commit 88b7fb7a0af5099b4d8b188c1316e416c58d3cfd Author: dekun Date: Wed Oct 14 00:18:40 2026 +0800 Initial CoinOX instance: coin-margined options + USDT hedge. Forked from crypto_monitor OKX instance (copy-only). Keeps standalone options, perp-options and options-options hedge; drops standalone perp entrypoints; forces coin margin; adds sim wallets; default port 5010. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5681359 --- /dev/null +++ b/.env.example @@ -0,0 +1,338 @@ +# ============================================================================= +# 环境配置模板(可提交 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=5010 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=admin +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=admin123 +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token +# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) +# APP_ALLOW_HUB_EMBED=true +# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com +# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None +# APP_COOKIE_SECURE=true +# 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=coinox + +# 训练总资金(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 +# ============================================================================= +# OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示) +# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理) +# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填 +# ============================================================================= +OKX_API_KEY= +OKX_API_SECRET= +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=CoinOX +# 企业微信推送里展示的账户备注 +# OKX_ACCOUNT_LABEL= +# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧 +OKX_SHOW_PERP_FUNDS=true + +# ============================================================================= +# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2) +# 详见 docs/期权方案.md 与 docs/期权用法.md +# ============================================================================= +OKX_OPTIONS_ENABLED=false +# 以下 OKX_OPTIONS_API_* 已废弃,请勿再配置(仅兼容旧部署回填) +# OKX_OPTIONS_API_KEY= +# OKX_OPTIONS_API_SECRET= +# OKX_OPTIONS_API_PASSPHRASE= +OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 +# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC) +OKX_OPTIONS_MARGIN_MODE=coin +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_BUDGET_BUFFER=0.95 +# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走) +OKX_OPTIONS_COIN_COMPOUND=true +OKX_OPTIONS_COIN_BUDGET_USDT=10 +OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false +OKX_OPTIONS_COIN_MAX_USDT=50 +# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换 +OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10 +# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 +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=perp_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 +# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值) +OKX_OPTIONS_CLOSE_GATE_MODE=premium +# 全局倍数可选;留空则按本位用下方 COIN/USDC +OKX_OPTIONS_CLOSE_RECYCLE_MULT= +OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05 +OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2 +OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0 +OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 +# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 +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 联动,修改后须重启 PM2) +# ============================================================================= +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) +# +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# false → 不执行触价自动单 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. +KEY_AUTO_ORDER_ENABLED=false + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _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) +KEY_AUTO_MIN_PLANNED_RR=1.5 +# 止损:突破 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 +# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数 +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +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 + +# ============================================================================= +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# ============================================================================= +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 + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# 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 +# 详见上文 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) +# 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 + +# --- CoinOX 模拟资金 --- +# sim=本地模拟资金撮合; live=实盘账户 +SIM_DEFAULT_MODE=sim +SIM_INITIAL_EQUITY_USDT=10000 +SIM_INITIAL_USDC=0 +SIM_FEE_RATE=0.0005 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..2f7b2ea --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# CoinOX(币本对冲) + +OKX **币本位期权** + **U 本位永续对冲** 独立实例。 + +远程仓库: + +## 产品范围 + +| 保留 | 说明 | +|------|------| +| 单独期权 | 仅币本位(USDT→买币→开期权) | +| 永期对冲 | 币本位期权腿 + USDT 永续腿 | +| 期期对冲 | 双腿币本位期权 | +| 模拟资金 | 默认 `sim`,本地钱包撮合 | + +| 不提供 | 说明 | +|--------|------| +| 单独永续下单 | `/trade` 已重定向 | +| 关键位自动开仓 | `/key_monitor` 已重定向 | +| 策略趋势/滚仓 | 未安装 | +| USDC 期权 | 已固定币本位 | + +源仓 `crypto_monitor` **只读复制**,不回改源仓。中控二期再做。 + +## 快速开始 + +```bash +cd /path/to/CoinOX # 或 E:\仓库\okx对冲 +python -m venv .venv +# Windows: .venv\Scripts\activate +pip install -r requirements.txt +copy .env.example .env # 编辑密钥;默认 SIM_DEFAULT_MODE=sim +python app.py +``` + +默认端口见 `.env` 的 `APP_PORT`(模板为 `5010`,避免与旧 OKX 实例冲突)。 + +## 环境要点 + +- `OKX_OPTIONS_MARGIN_MODE=coin`(固定) +- `OKX_TRADE_MODE=options|perp_options|options_options` +- `SIM_DEFAULT_MODE=sim` / `live` +- `SIM_INITIAL_EQUITY_USDT=10000` + +## 风险 + +实盘须自行确认 API 权限与资金;默认模拟盘,切 `live` 前请核对。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..214949d --- /dev/null +++ b/app.py @@ -0,0 +1,9929 @@ +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 # CoinOX 独立仓: 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.exchange.api_credentials_lib import ( + is_exchange_auth_error, + load_markets_public_fallback, + normalize_api_credential, +) +from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice +from lib.ai.ai_review_lib import ( + build_journal_ai_chart_path, + collect_images_for_ai_review, + journal_row_lines_for_ai, +) +from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from lib.key_monitor.fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + backfill_missing_key_signal_types, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + FALSE_BREAKOUT_VALIDITY_HOURS, + calc_false_breakout_plan, + expires_at_text, + false_breakout_gate_preview, + is_false_breakout_expired, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + apply_order_monitor_source_labels, + entry_reason_for_monitor_type, + handoff_trade_miss_reason, + normalize_journal_order_type, + order_monitor_source_type, + trade_record_monitor_type as resolve_trade_record_monitor_type, + trend_plan_id_from_monitor_row, +) +from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason +from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders +from lib.instance.journal_images_lib import ( + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + normalize_journal_draft_id, + primary_journal_image, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + JOURNAL_CHART_TF_CHOICES, + compose_chart_panels, + marker_points_for_timeframe, + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + JOURNAL_CHART_DEFAULT_ANCHOR, + price_levels_from_marker_payload, + render_candles_subplot, + trade_review_fetch_window, + trim_rows_for_trade_review, +) +from lib.key_monitor.key_sl_tp_lib import ( + breakeven_enabled_from_row, + normalize_sl_tp_mode, + parse_breakeven_enabled_form, + plan_key_sl_tp, + sl_tp_mode_from_row, + sl_tp_mode_label, + sl_tp_plan_summary_text, +) +from lib.trade.time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) +from lib.trade.force_close_lib import ( + apply_force_close_display_result, + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) +from lib.trade.manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema +from lib.key_monitor.trigger_entry_key_monitor_lib import ( + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_SL_INVALIDATE, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_MONITOR_TYPES, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + acquire_trigger_entry_exec_lock, + is_trigger_entry_in_flight_row, + release_trigger_entry_exec_lock, + is_breakout_trigger_entry_key_monitor_type, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate, + trigger_should_fire, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +from lib.trade.position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + format_risk_display_text, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, + risk_percent_for_storage, +) +from lib.trade.trade_policy_lib import load_trade_policy +from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_journal_entry_reason_options, + enrich_entry_model_display, + hub_meta_entry_context, + migrate_entry_model_columns, + order_entry_template_context, + open_position_button_label, + parse_manual_order_style_fields, + resolve_effective_trade_entry_reason, + format_entry_type_display, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_app_lib import ( + check_direction_policy, + check_open_policy, + check_symbol_policy, + default_symbol_for_policy, + trade_policy_template_context, +) +from lib.key_monitor.key_auto_order_lib import ( + check_monitor_type_add_allowed, + effective_entry_reason_options, + effective_stats_segment_defs, + load_key_auto_order_enabled, +) +from lib.key_monitor.key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day +from lib.key_monitor.key_monitor_lib import ( + KEY_DIRECTION_WATCH, + KEY_MONITOR_ALERT_ONLY_TYPES, + KEY_MONITOR_AUTO_TYPES, + KEY_MONITOR_RS_TYPE, + KEY_MONITOR_RS_TYPES, + auto_amp_ok, + auto_confirm_ok, + box_breakout_invalidate_by_mark, + box_breakout_invalidate_edge_label, + claim_rs_level_notify, + detect_rs_box_break, + format_auto_amp_line, + format_auto_confirm_line, + key_monitor_rule_template_context, + notify_interval_elapsed, + resolve_rs_break_for_alert, + rs_break_from_direction, + run_rs_level_alert_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, + order_monitor_tpsl_needs_sync, + stale_breakeven_armed, +) +from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook +from lib.hub.hub_auth import request_allowed as hub_request_allowed +from lib.instance.instance_nav_lib import request_is_hub_soft_nav +from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_CUSTOM, + PRESET_DEFAULT, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST3M, + PRESET_UTC_LAST6M, + PRESET_UTC_LAST7D, + PRESET_UTC_THIS_MONTH, + PRESET_UTC_TODAY, + list_window_redirect_query, + normalize_bj_datetime_storage, + resolve_list_window, + resolve_window, + sql_list_time_field, + utc_window_to_bj_sql_strings, + utc_window_to_utc_sql_strings, +) +from lib.trade.trade_result_lib import ( + 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", "coinox_2026_secret_key") +from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled + +attach_embed_templates(app, _REPO_ROOT) + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +TOTAL_CAPITAL = float(os.getenv("TOTAL_CAPITAL", "100")) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +RUNTIME_KEY_OPEN_GUARD = "trading_day_reset_open_guard_enabled" +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" + + +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 = normalize_api_credential(os.getenv("OKX_API_KEY")) +OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET")) +OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE")) +OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").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 "CoinOX").strip() or "CoinOX" +BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60")) +PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5")) +KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3")) +KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5")) +KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5")) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日(与 OKX 日界一致便于对账) +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +POSITION_SIZING_MODE = load_position_sizing_mode() +KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled() +TRADE_POLICY = load_trade_policy() +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90")) +RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3"))) +_APP_STARTED_AT = time.time() +_RECONCILE_FLAT_STREAK = {} +BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max( + 15, int(os.getenv("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", "60")) +) +_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {} +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") 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_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib +KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5")) +KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5")) +KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) + +MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) +MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) +KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20"))) +KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3")) +KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03")) +KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5")) +KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2")) +KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1")) +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in ( + "1", + "true", + "yes", + "on", +) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() + +OKX_SOCKS_PROXY = (os.getenv("OKX_SOCKS_PROXY") or "").strip() +OKX_HTTP_PROXY = (os.getenv("OKX_HTTP_PROXY") or "").strip() +OKX_HTTPS_PROXY = (os.getenv("OKX_HTTPS_PROXY") or "").strip() + + +def build_okx_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于:本地网络对 OKX TLS/SNI 不稳定,通过 SSH 动态转发 SOCKS5 出口). + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = OKX_SOCKS_PROXY.strip() + http = OKX_HTTP_PROXY.strip() + https = OKX_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +OKX_CCXT_PROXIES = build_okx_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# 同一套 OKX_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 +EXCHANGE_AUTH_DISABLED_MSG = "" +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "version": 0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + send_wechat_webhook( + WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS + ) + + +_BREAKEVEN_EXCHANGE_WARNED_IDS = set() + + +def _send_breakeven_exchange_warn_once(order_id, message): + """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏.""" + oid = int(order_id) + if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: + return + _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) + send_wechat_msg(message) + + +def _clear_breakeven_exchange_warn(order_id): + _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) + + +def _wechat_account_label(): + return (os.getenv("OKX_ACCOUNT_LABEL") or "okx实盘子账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 2)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 2)}U" + except Exception: + pass + return "-" + + +def format_wechat_scalar_2dp(value): + """企业微信推送:数值统一两位小数(与交易所 tick 无关).""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_wechat_scalar_2dp(stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{format_wechat_scalar_2dp(new_sl)}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + try: + if not end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + else: + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 10) + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 + ) + except Exception: + return [] + rows = _ohlcv_to_rows(ohlcv) + if not rows: + return [] + if not end_ts_ms: + return _ohlcv_dict_rows_to_lists(rows, lim) + filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] + if len(filtered) >= 2: + return _ohlcv_dict_rows_to_lists(filtered, lim) + return _ohlcv_dict_rows_to_lists(rows, lim) + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, + layout="grid", +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = list(timeframes or ORDER_CHART_TFS) + limit = limit or ORDER_CHART_LIMIT + if layout == "vertical": + timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + else: + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None + default_marker_tfs = {str(t).strip().lower() for t in timeframes} + price_levels = price_levels_from_marker_payload(marker_payload) + for tf in timeframes: + rows = [] + try: + if layout == "vertical" and marker_payload: + win = trade_review_fetch_window( + marker_payload.get("entry_ts_ms"), + marker_payload.get("exit_ts_ms"), + tf, + limit, + anchor=marker_payload.get("chart_anchor"), + now_ms=marker_payload.get("now_ts_ms"), + ) + if win: + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, + timeframe=tf, + since=max(0, int(win["since_ms"])), + limit=int(win["fetch_limit"]), + ) + rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) + if not rows: + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) + if not ohlcv and end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + rows = _ohlcv_to_rows(ohlcv)[-limit:] + except Exception: + rows = [] + title = f"{title_prefix} | {tf} x{len(rows)}" + tf_key = str(tf).strip().lower() + if marker_payload: + if marker_timeframes: + marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} + else: + marker_tfs = default_marker_tfs + else: + marker_tfs = set() + points = ( + marker_points_for_timeframe(rows, marker_payload) + if marker_payload and tf_key in marker_tfs + else [] + ) + panels.append( + render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + price_levels=price_levels, + ) + ) + + if not panels: + return None + + out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) + if out is None: + return None + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + opened_at_ms=None, + entry_price=None, +): + marker_payload = None + if opened_at_ms: + marker_payload = { + "entry_ts_ms": opened_at_ms, + "exit_ts_ms": None, + "entry_price": entry_price, + "exit_price": None, + } + marker_tfs = ( + {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} + or {"5m", "15m", "1h", "4h"} + ) + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + marker_payload=marker_payload, + marker_timeframes=marker_tfs, + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + TIME_CLOSE_RESULT, + "强制清仓", + "手动平仓", + "止损", + "其他", +) + +# 趋势户:复盘开仓类型仅 entry model;策略/风格项已拆至下单类型 +ENTRY_REASON_OPTIONS = build_journal_entry_reason_options() + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), + ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), +) +def normalize_entry_reason(raw, custom_text=None): + del custom_text + return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""" + ) + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + for ddl in ( + "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN direction TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '下单监控'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + ensure_time_close_schema(c) + ensure_key_monitor_schema(c) + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + from lib.strategy.strategy_db import init_strategy_tables +from lib.options.options_db import init_options_tables +from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables +from lib.sim.db_lib import init_sim_tables + +init_strategy_tables(conn) +init_options_tables(conn) +init_hedge_plan_tables(conn) +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(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + purge_disallowed_key_monitors( + conn, + sizing_mode=POSITION_SIZING_MODE, + select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), + cancel_fib_limit=_cancel_fib_monitor_limit, + delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), + send_wechat=send_wechat_msg, + ) + conn.commit() + except Exception as e: + print(f"[full_margin] purge key monitors: {e}", flush=True) + finally: + conn.close() + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def hub_account_risk_status(conn): + from lib.trade.account_risk_lib import ( + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + ) + + ensure_account_risk_schema(conn) + now = app_now() + st = compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + return apply_position_limit_risk( + st, + count_position_limit_active_monitors(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) + + +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较).""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware).""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算).""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」.""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_DEFAULT) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + if segment_key == "key_false_breakout": + return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst in TRIGGER_ENTRY_MONITOR_TYPES + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": None, # 见 _count_opens_for_segment 多类型 + } + if segment_key == "key_trigger": + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + return conn.execute( + f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + f"AND key_signal_type IN ({placeholders})", + (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone()[0] + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + loss_sum_raw = sum(p for p, _, _ in trades if p < 0) + loss_sum_u = round(abs(loss_sum_raw), 4) if loss_sum_raw < 0 else 0.0 + neg_pnls = [p for p, _, _ in trades if p < 0] + pos_pnls = [p for p, _, _ in trades if p > 0] + max_single_loss = round(min(neg_pnls), 4) if neg_pnls else None + max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + cum = peak = max_dd = 0.0 + for p, _, _ in trades: + cum += p + peak = max(peak, cum) + max_dd = max(max_dd, peak - cum) + max_dd = round(max_dd, 4) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def _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 = effective_stats_segment_defs( + STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + for seg_key, seg_title, _meta in seg_defs: + dm, wm, mm, 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 EXCHANGE_AUTH_DISABLED_MSG: + return False, EXCHANGE_AUTH_DISABLED_MSG + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return False, "缺少 OKX API 密钥配置" + return True, "" + + +def order_row_monitor_type(row): + return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def trade_record_monitor_type(conn, row): + return resolve_trade_record_monitor_type( + conn, row, default_manual=ORDER_MONITOR_TYPE_MANUAL + ) + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst) or is_false_breakout_key_monitor_type(kst): + return kst + return None + + +def _disable_private_api_after_auth_error(exc): + global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE + from lib.exchange.api_credentials_lib import strip_ccxt_credentials + + strip_ccxt_credentials(exchange) + try: + strip_ccxt_credentials(exchange_options) + except Exception: + pass + OKX_API_KEY = "" + OKX_API_SECRET = "" + OKX_API_PASSPHRASE = "" + EXCHANGE_AUTH_DISABLED_MSG = ( + f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}" + ) + + +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 as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) + 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: + try: + exchange.load_markets(reload=force) + except Exception as e: + if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)): + _disable_private_api_after_auth_error(e) + load_markets_public_fallback(exchange, reload=True) + else: + raise + 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(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False + return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) + + +def _position_row_effective_contracts(p): + """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致).""" + from lib.hub.hub_position_metrics import normalize_contracts_qty + + if not p: + return 0.0 + info = p.get("info", {}) or {} + for val in (info.get("pos"), p.get("contracts"), info.get("positionAmt"), info.get("size")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return normalize_contracts_qty(x) + except (TypeError, ValueError): + continue + return 0.0 + + +def _position_matches_wanted_contract(exchange_symbol, position): + if not position: + return False + sym = position.get("symbol") + if sym == exchange_symbol: + return True + try: + if normalize_okx_symbol(sym or "") == normalize_okx_symbol(exchange_symbol or ""): + return True + except Exception: + pass + info = position.get("info") or {} + inst = (info.get("instId") or "").strip().upper() + if not inst: + return False + try: + ensure_markets_loaded() + want = exchange.market(exchange_symbol) + mid = (want.get("id") or "").strip().upper() + if mid and inst == mid: + return True + base = (want.get("base") or "").strip().upper() + quote = (want.get("quote") or "").strip().upper() + if base and quote and inst == f"{base}-{quote}-SWAP": + return True + except Exception: + pass + return False + + +def _okx_position_direction(position): + info = position.get("info") or {} + side = (position.get("side") or info.get("posSide") or "").strip().lower() + if side in ("long", "short"): + return side + try: + raw = float(info.get("pos") or position.get("contracts") or 0) + except (TypeError, ValueError): + raw = 0.0 + if raw > 0: + return "long" + if raw < 0: + return "short" + return "" + + +def _fetch_okx_swap_position_rows(): + """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配.""" + ensure_markets_loaded() + rows = None + for fetcher in ( + lambda: exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}), + lambda: exchange.fetch_positions(), + ): + try: + rows = fetcher() or [] + break + except Exception: + continue + if rows is None: + return None + return rows + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + exchange_symbol = normalize_okx_symbol(exchange_symbol or "") + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + want_dir = (direction or "").lower() + if OKX_POS_MODE == "net" or side == "net": + pos_dir = _okx_position_direction(p) + if pos_dir and pos_dir != want_dir: + continue + elif (not relax_hedge) and OKX_POS_MODE == "hedge": + if side and side != want_dir: + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and OKX_POS_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def parse_ccxt_position_metrics(position, order_leverage=None): + if not position: + return None + p = position + info = p.get("info", {}) or {} + initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("margin"), + info.get("imr"), + info.get("initial_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) + if notional is not None: + notional = abs(notional) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float_signed( + p.get("unrealizedPnl"), + info.get("upl"), + info.get("uplLast"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, FUNDS_DECIMALS) + if notional is not None and notional > 0: + out["notional"] = round(notional, FUNDS_DECIMALS) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + if out: + sym = (p.get("symbol") or "").strip() + try: + cs = float(get_contract_size(sym)) if sym else 1.0 + except Exception: + cs = 1.0 + from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out + + enrich_ccxt_position_metrics_out( + p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS + ) + return out or None + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) + + +def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type=""): + if trigger_price is None or order_id is None: + return None + sym = exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", "") + return { + "order_id": str(order_id), + "trigger_price": float(trigger_price), + "trigger_display": format_price_for_symbol(sym, trigger_price), + "type": str(order_type or ""), + } + + +def _okx_tpsl_slots_from_order(order, exchange_symbol): + """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx).""" + if not isinstance(order, dict): + return None, None + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + oid = order.get("id") or info.get("algoId") or info.get("ordId") + if oid is None: + return None, None + ord_type = str(order.get("type") or info.get("ordType") or "") + sl_px = _coerce_float( + order.get("stopLossPrice"), + info.get("slTriggerPx"), + info.get("slOrdPx"), + ) + tp_px = _coerce_float( + order.get("takeProfitPrice"), + info.get("tpTriggerPx"), + info.get("tpOrdPx"), + ) + sl_slot = _okx_tpsl_slot_build(exchange_symbol, oid, sl_px, ord_type) if sl_px is not None else None + tp_slot = _okx_tpsl_slot_build(exchange_symbol, oid, tp_px, ord_type) if tp_px is not None else None + if sl_slot or tp_slot: + return sl_slot, tp_slot + trig = _coerce_float( + info.get("triggerPx"), + order.get("triggerPrice"), + order.get("stopPrice"), + ) + if trig is None: + return None, None + one = _okx_tpsl_slot_build(exchange_symbol, oid, trig, ord_type) + return one, None + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_okx_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + plan_sl_f = plan_tp_f = None + try: + if plan_sl is not None: + plan_sl_f = float(plan_sl) + if plan_tp is not None: + plan_tp_f = float(plan_tp) + except Exception: + plan_sl_f = plan_tp_f = None + + def assign_role(trig, slot): + if trig is None or slot is None: + return + if plan_sl_f is not None and plan_tp_f is not None: + role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" + elif plan_sl_f is not None: + role = "sl" + elif plan_tp_f is not None: + role = "tp" + else: + return + if slots[role] is None: + slots[role] = slot + + for order in fetch_okx_all_open_orders(exchange, exchange_symbol): + sl_slot, tp_slot = _okx_tpsl_slots_from_order(order, exchange_symbol) + if sl_slot and slots["sl"] is None: + slots["sl"] = sl_slot + if tp_slot and slots["tp"] is None: + slots["tp"] = tp_slot + if sl_slot or tp_slot: + continue + info = order.get("info") or {} + oid = order.get("id") or info.get("algoId") + trig = _coerce_float(info.get("triggerPx"), order.get("triggerPrice")) + if oid is None or trig is None: + continue + slot = _okx_tpsl_slot_build( + exchange_symbol, + oid, + trig, + str(order.get("type") or info.get("ordType") or ""), + ) + assign_role(trig, slot) + except Exception: + pass + return slots + + +def cancel_okx_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + oid = slot.get("order_id") + if not oid: + return + ensure_markets_loaded() + cancel_id = str(oid).split(":", 1)[0] + try: + exchange.cancel_order(cancel_id, exchange_symbol, {"stop": True}) + except Exception: + exchange.cancel_order(str(oid), exchange_symbol, {"stop": True}) + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约挂单/条件单,再按新价重挂 TP/SL.""" + ok, reason = ensure_okx_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancelled = cancel_okx_all_open_orders(exchange, ex_sym) + if cancelled > 0: + time.sleep(0.12) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + try: + pos_amt = float(order_row["order_amount"] or 0) + except (TypeError, ValueError): + pos_amt = 0 + if float(pos_amt or 0) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + +def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控. + + 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], + 后者会当成立即市价平仓(开仓后约 1 秒内全平). + """ + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_okx_swap_open_orders(exchange_symbol) + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) + if amt <= 0: + raise RuntimeError("止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=True) + sl_px = float(stop_loss) + last_err = None + for attempt in range(6): + try: + exchange.create_order( + exchange_symbol, + "market", + close_side, + amt, + None, + {**base, "stopLossPrice": sl_px}, + ) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008", + "empty position", "increase_position", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ex_sym = normalize_okx_symbol(exchange_symbol or "") + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, ex_sym, direction) + if not prow: + return 0.0 + return _position_row_effective_contracts(prow) + + +def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): + """趋势回调/下单监控:从交易所持仓读标记价与未实现盈亏.""" + if not exchange_private_api_configured() or not exchange_symbol: + return None + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(prow, order_leverage=order_leverage) + + +def opened_at_str_to_ms(opened_at_str): + if not opened_at_str: + return None + try: + dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades, min_ts=None): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + if min_ts and ts_i < int(min_ts): + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + return pick_from_trades(trades, since_ms) + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填). + 返回按时间排序的成交列表. + """ + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + return filter_position_lifecycle_fills( + trades or [], + direction, + since_ms, + closed_ms, + hedge_mode=(OKX_POS_MODE == "hedge"), + close_buffer_ms=0, + ) + + +def _attach_okx_trade_exchange_stats( + conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return + open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + contract_size = 1.0 + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + pass + + def _fetch(): + return fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms + ) + + try: + attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) + except Exception: + pass + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_okx_symbol(sym) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + try: + ts_i = int(ts) + except (TypeError, ValueError): + ts_i = None + if ts_i is not None and open_ms and ts_i < int(open_ms): + exit_px = None + elif ts_i is not None: + closed_at_str = ms_to_app_local_str(ts_i) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + normalize_result_with_pnl(guessed, pnl), + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + normalize_result_with_pnl(result, pnl), + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def _finalize_hub_flat_monitor_okx(conn, r, *, result, pnl_amount, closed_at, miss_reason): + opened_at = get_opened_at_value(r) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio( + r["direction"], + r["trigger_price"], + r["initial_stop_loss"] or r["stop_loss"], + r["take_profit"], + ), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + + +def reconcile_hub_external_close(conn, symbol, direction): + from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl + from lib.hub.hub_symbol_lib import symbols_match + + global _RECONCILE_FLAT_STREAK + + return reconcile_hub_external_close_impl( + conn, + symbol, + direction, + exchange_configured=exchange_private_api_configured, + not_configured_msg="未配置 OKX_API_KEY / OKX_API_SECRET", + symbols_match=symbols_match, + get_opened_at_value=get_opened_at_value, + resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, + get_live_position_contracts=get_live_position_contracts, + cancel_conditional_orders=cancel_okx_swap_open_orders, + resolve_synced_flat_close=resolve_synced_flat_close, + finalize_stopped_monitor=_finalize_hub_flat_monitor_okx, + sync_trade_records=sync_trade_records_from_exchange, + reconcile_flat_streak=_RECONCILE_FLAT_STREAK, + to_ms_with_fallback=_to_ms_with_fallback, + prefer_manual_resolve=False, + order_row_monitor_type=order_row_monitor_type, + ) + + +def reconcile_external_closes(conn, days=None): + global _RECONCILE_FLAT_STREAK + if not exchange_private_api_configured(): + return 0 + if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: + return 0 + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced_count += 1 + continue + exchange_symbol = r["exchange_symbol"] or normalize_okx_symbol(r["symbol"]) + live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) + if live_contracts is None: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if live_contracts > 0: + _RECONCILE_FLAT_STREAK.pop(oid, None) + continue + if r["status"] != "error": + streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1 + _RECONCILE_FLAT_STREAK[oid] = streak + if streak < RECONCILE_FLAT_CONFIRM_POLLS: + continue + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] {r['symbol']} id={oid} " + f"flat x{streak} polls -> sync close" + ) + else: + _RECONCILE_FLAT_STREAK.pop(oid, None) + print( + f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close" + ) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + + +def _coerce_ts_ms(val): + if val is None or val == "": + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if v > 1e12: + return int(v) + if v > 1e9: + return int(v * 1000.0) + return int(v * 1000.0) + + +def _unified_symbol_for_match(symbol_str): + """统一 ETH/USDT:USDT,ETH-USDT-SWAP 便于与 trade_records 比对.""" + s = (symbol_str or "").strip().upper() + if not s: + return "" + if ":" in s: + s = s.split(":")[0] + if "-" in s and "/" not in s: + parts = s.split("-") + if len(parts) >= 2 and parts[-1] in ("SWAP", "FUTURES", "FUTURE"): + s = f"{parts[0]}/{parts[1]}" + else: + s = s.replace("-", "/") + if "_" in s and "/" not in s: + s = s.replace("_", "/") + if s.endswith("USDT") and "/" not in s and len(s) > 4: + s = f"{s[:-4]}/USDT" + return s + + +def exchange_position_sync_since_ms(): + s = EXCHANGE_POSITION_SYNC_FROM_BJ + if s: + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)): + try: + chunk = s[:ln] if len(s) >= ln else s[:10] + dt = datetime.strptime(chunk, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + dt0 = app_now() - timedelta(days=90) + try: + aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ) + except Exception: + aware0 = datetime.now(APP_TZ) + return int(aware0.timestamp() * 1000) + + +def _normalize_okx_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + sym = p.get("symbol") or "" + if not sym: + inst = str(info.get("instId") or "").strip() + if inst: + try: + ensure_markets_loaded() + sym = exchange.market(inst).get("symbol") or "" + except Exception: + parts = inst.split("-") + if len(parts) >= 2: + sym = f"{parts[0]}/{parts[1]}" + side = (p.get("side") or info.get("direction") or info.get("posSide") or "").strip().lower() + if side not in ("long", "short"): + try: + pos_val = float(info.get("pos") or 0) + if pos_val > 0: + side = "long" + elif pos_val < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.get("realizedPnl") + if rp is None: + rp = info.get("pnl") + try: + rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None + except (TypeError, ValueError): + rp_f = None + close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if close_ms is None: + close_ms = _coerce_ts_ms(info.get("uTime")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("cTime")) + pos_id = str(info.get("posId") or "").strip() + inst_id = str(info.get("instId") or "").strip() + u_raw = info.get("uTime") + sync_key = pos_id or f"{inst_id}|{u_raw}|{side}" + return { + "symbol_u": _unified_symbol_for_match(sym), + "side": side, + "close_ms": close_ms, + "open_ms": open_ms, + "pnl": rp_f, + "sync_key": sync_key, + } + + +def fetch_okx_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + out = [] + page_limit = 100 + max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) + before = None + while len(out) < max_total: + params = {"instType": OKX_POSITION_INST_TYPE} + if before is not None: + params["before"] = str(before) + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=page_limit, + params=params, + ) + except Exception: + break + if not rows: + break + batch_min_u = None + for p in rows: + h = _normalize_okx_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + info = p.get("info") or {} + u = _coerce_ts_ms(info.get("uTime")) or _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if u and (batch_min_u is None or u < batch_min_u): + batch_min_u = u + if len(rows) < page_limit or batch_min_u is None: + break + if before is not None and batch_min_u >= before: + break + before = batch_min_u + return out[:max_total] + + +def sync_trade_records_from_exchange(conn, force=False): + """为未同步的 trade_records 回填 OKX 历史仓位中的已实现盈亏.返回统计 dict.""" + global _LAST_EXCHANGE_PNL_SYNC_AT + stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False} + if not exchange_private_api_configured(): + stats["reason"] = "未配置 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE" + return stats + now = time.time() + if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0: + stats["ok"] = True + stats["skipped"] = True + return stats + try: + hist = fetch_okx_positions_close_history() + except Exception as e: + stats["reason"] = str(e) + return stats + stats["hist_count"] = len(hist) + if not hist: + stats["ok"] = True + stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)" + return stats + candidates = conn.execute( + """ + SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms + FROM trade_records + WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') + OR exchange_realized_pnl IS NULL + ORDER BY id DESC + LIMIT 200 + """ + ).fetchall() + stats["pending"] = len(candidates) + if not candidates: + stats["ok"] = True + _LAST_EXCHANGE_PNL_SYNC_AT = now + return stats + used = set() + matched = 0 + for tr in candidates: + close_ms_trade = _to_ms_with_fallback( + tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"] + ) or opened_at_str_to_ms(tr["closed_at"]) + open_ms_trade = _to_ms_with_fallback( + tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"] + ) or opened_at_str_to_ms(tr["opened_at"]) + if close_ms_trade is None: + continue + best = None + best_d = None + for h in hist: + sk = h["sync_key"] + if not sk or sk in used: + continue + if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]): + continue + if h["side"] != (tr["direction"] or "long").strip().lower(): + continue + cm = h["close_ms"] + if cm is None: + continue + if open_ms_trade is not None: + if cm < open_ms_trade - 15 * 60 * 1000: + continue + if cm > open_ms_trade + 15 * 86400 * 1000: + continue + else: + if abs(cm - close_ms_trade) > 3 * 86400 * 1000: + continue + d = abs(cm - close_ms_trade) + if best_d is None or d < best_d: + best_d = d + best = h + if best is None or best_d is None or best_d > 90 * 60 * 1000: + continue + sk = best["sync_key"] + if sk in used: + continue + eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None + ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None + pnl_val = best.get("pnl") + if pnl_val is None: + pnl_val = 0.0 + conn.execute( + """ + UPDATE trade_records + SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ? + WHERE id = ? + """, + (float(pnl_val), eo, ec, sk, int(tr["id"])), + ) + used.add(sk) + matched += 1 + stats["matched"] = matched + stats["ok"] = True + # 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」 + 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 cancel_fib_limit_order(exchange_symbol, order_id): + if not order_id: + return False + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return False + ensure_markets_loaded() + oid = str(order_id) + try: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + try: + for o in exchange.fetch_open_orders(exchange_symbol) or []: + if str(o.get("id")) == oid: + exchange.cancel_order(oid, exchange_symbol) + return True + except Exception: + pass + return False + + +def fib_limit_order_status(exchange_symbol, order_id): + if not order_id: + return "missing" + ensure_markets_loaded() + oid = str(order_id) + try: + o = exchange.fetch_order(oid, exchange_symbol) + st = (o.get("status") or "").lower() + if st in ("closed", "filled"): + filled = float(o.get("filled") or 0) + if filled > 0 or st == "filled": + return "filled" + if st in ("canceled", "cancelled", "expired", "rejected"): + return "canceled" + if st in ("open", "new", "partially_filled", "live"): + return "open" + except Exception: + pass + try: + for o in fetch_okx_all_open_orders(exchange, exchange_symbol): + if str(o.get("id")) == oid: + return "open" + except Exception: + pass + return "unknown" + + +def place_fib_limit_order( + exchange_symbol, + direction, + amount, + leverage, + limit_price, + stop_loss=None, + take_profit=None, +): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + price = round_price_to_exchange(exchange_symbol, float(limit_price)) + if price is None or price <= 0: + raise ValueError("挂单价无效") + params = build_okx_order_params(direction, reduce_only=False) + if stop_loss and take_profit: + params["attachAlgoOrds"] = [ + { + "tpTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "tpOrdPx": "-1", + "slTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "slOrdPx": "-1", + } + ] + return exchange.create_order(exchange_symbol, "limit", side, amount, price, params) + + +def _fib_key_exists_for_symbol(conn, symbol): + ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})", + (symbol, *tuple(FIB_KEY_MONITOR_TYPES)), + ).fetchone() + return row is not None + + +def _fib_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + ratio = fib_ratio_from_type(typ) + if ratio is None: + return None + return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio) + + +def _limit_key_plan_for_row(row): + typ = (row["monitor_type"] or "").strip() + if is_fib_key_monitor_type(typ): + return _fib_plan_for_row(row) + if is_false_breakout_key_monitor_type(typ): + direction = (row["direction"] or "long").lower() + key_px = key_price_from_row(direction, row["upper"], row["lower"]) + if key_px is None: + return None + return calc_false_breakout_plan(direction, key_px) + return None + + +def _cancel_fib_monitor_limit(row): + ex_sym = normalize_okx_symbol(row["symbol"]) + oid = _sqlite_row_val(row, "fib_limit_order_id") + if oid: + cancel_fib_limit_order(ex_sym, oid) + + +def _fib_has_live_position(exchange_symbol, direction): + live = get_live_position_contracts(exchange_symbol, direction) + return live is not None and float(live) > 0 + + +def _insert_order_monitor_from_fib_fill( + conn, + row, + trigger_price, + stop_loss, + take_profit, + amount, + leverage, + margin_capital, + notional_value, + position_ratio, + base_amount, + exchange_order_id, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_okx_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + tc_en, tc_h, _ = time_close_settings_from_row(row) + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + tc_en, + tc_h, + tc_at, + ), + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +def _finalize_fib_key_fill(conn, row): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + typ = (row["monitor_type"] or "").strip() + kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波" + ex_sym = normalize_okx_symbol(symbol) + plan = _limit_key_plan_for_row(row) + if not plan: + _finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "fib_plan_invalid") + return + entry_plan, sl_plan, tp_plan = plan + sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan) + tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan) + sl_adj = round_price_to_exchange(ex_sym, sl) + tp_adj = round_price_to_exchange(ex_sym, tp) + if sl_adj is not None: + sl = float(sl_adj) + if tp_adj is not None: + tp = float(tp_adj) + amount = float(_sqlite_row_val(row, "fib_order_amount") or 0) + leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5) + margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0) + oid = _sqlite_row_val(row, "fib_limit_order_id") + entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan) + trigger_price = entry_px + if oid: + try: + o = exchange.fetch_order(str(oid), ex_sym) + trigger_price = resolve_order_entry_price(o, ex_sym, entry_px) + except Exception: + pass + tr_adj = round_price_to_exchange(ex_sym, trigger_price) + if tr_adj is not None: + trigger_price = float(tr_adj) + if amount <= 0: + live_amt = get_live_position_contracts(ex_sym, direction) + amount = float(live_amt or 0) + if amount <= 0: + msg = ( + f"# ❌ {symbol} {kind}成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_fill_no_amount") + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + msg = ( + f"# ❌ {symbol} {kind}成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_risk_rejected") + return + tpsl_attached = False + try: + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp) + if slots.get("sl") and slots.get("tp"): + tpsl_attached = True + else: + _okx_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + slots2 = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp) + tpsl_attached = bool(slots2.get("sl") and slots2.get("tp")) + except Exception as e: + msg = ( + f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_okx_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_tpsl_failed") + return + contract_size = get_contract_size(ex_sym) + base_amount = round(float(amount) * contract_size, 8) + notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0 + session_row = ensure_session(conn, get_trading_day(app_now())) + capital_base = float(session_row["current_capital"] or 0) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0 + planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp) + new_order_id = _insert_order_monitor_from_fib_fill( + conn, + row, + trigger_price, + sl, + tp, + amount, + leverage, + margin_capital, + notional_value, + position_ratio, + base_amount, + oid, + ) + rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" + succ = ( + f"# ✅ {symbol} {kind}限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, close_reason) + + +def _trigger_entry_exists_for_symbol(conn, symbol): + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})", + (symbol, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + if mt not in TRIGGER_ENTRY_MONITOR_TYPES: + mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致.""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_symbol_mark_price(symbol) or get_price(symbol) + if live_price is None: + return False, "获取标记价/实时价失败", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = float(lp_r) + + entry_price = float(entry_price) + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + None, + ) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(monitor_type), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + if not acquire_trigger_entry_exec_lock(conn, kid): + return False, "触价开仓进行中" + conn.commit() + + try: + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE), + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + except Exception as e: + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = friendly_exchange_error(e) + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + if ok and det: + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + rows = conn.execute( + f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})", + tuple(TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + kid = int(r["id"]) + if is_trigger_entry_in_flight_row(r): + continue + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + prev_mark = _sqlite_row_val(r, "last_mark_price") + prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + inv = trigger_entry_invalidate(mt, direction, mark, sl, tp) + if inv == "tp": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if inv == "sl": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE) + continue + if trigger_should_fire(mt, direction, mark, entry, prev_mark_f): + _execute_trigger_entry_cross(conn, r) + continue + conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid)) + conn.commit() + conn.close() + + +def check_fib_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + typ = (r["monitor_type"] or "").strip() + if not is_limit_key_monitor_type(typ): + continue + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + ex_sym = normalize_okx_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + if is_false_breakout_key_monitor_type(typ): + now_dt = app_now() + if is_false_breakout_expired(r["created_at"], now_dt): + _cancel_fib_monitor_limit(r) + exp_txt = expires_at_text(r["created_at"]) + msg = ( + f"# ⚠️ {symbol} 假突破监控已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" + f"- 已撤销限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if is_fib_key_monitor_type(typ) and status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if is_fib_key_monitor_type(typ) and status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _false_breakout_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, FALSE_BREAKOUT_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _false_breakout_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + plan = calc_false_breakout_plan(direction_sel, key_px) + if not plan: + return False, "假突破价位无效,请核对方向与关键价位" + entry, sl, tp = plan + ex_sym = normalize_okx_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "假突破价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价);请核对方向与关键价位" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_okx_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +def _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_okx_symbol(symbol) + entry = round_price_to_exchange(ex_sym, entry) + sl = round_price_to_exchange(ex_sym, sl) + tp = round_price_to_exchange(ex_sym, tp) + if entry is None or sl is None or tp is None: + return False, "斐波价位经交易所精度舍入后无效" + entry, sl, tp = float(entry), float(sl), float(tp) + planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)" + ok, reason = precheck_risk(conn, symbol, direction_sel) + if not ok: + return False, reason + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + available_usdt = get_available_trading_usdt() + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + order_resp = place_fib_limit_order( + ex_sym, direction_sel, amount, leverage, entry, stop_loss=sl, take_profit=tp + ) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_okx_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + oid, + entry, + sl, + tp, + float(amount), + margin_capital, + leverage, + be_flag, + ), + ) + return True, None + + +def _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(OKX 永续). + 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) + """ + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + return False, "获取交易所实时价格失败(以损定仓需要当前价)", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = lp_r + + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + None, + ) + + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_okx_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, tc_at = time_close_insert_values( + time_close_enabled, time_close_hours, opened_at_ms + ) + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "base_amount": base_amount, + "notional_value": notional_value, + "position_ratio": position_ratio, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "risk_percent": risk_percent, + "breakeven_rr_trigger": breakeven_rr_trigger, + "breakeven_price": breakeven_price, + "capital_base_at_open": capital_base, + } + + +def can_notify_key_monitor(row, now_dt): + max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) + if int(row["notification_count"] or 0) >= max_notify: + return False + last_at = row["last_notified_at"] + if not last_at: + return True + try: + last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") + except Exception: + return True + interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) + return (now_dt - last_dt).total_seconds() >= interval_min * 60 + + +def breakout_too_far(p, edge_price, limit_pct): + try: + if edge_price is None or float(edge_price) <= 0: + return False + diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 + return diff_pct > float(limit_pct) + except Exception: + return False + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_limit_key_monitor_type(typ): + continue + if typ in KEY_MONITOR_RS_TYPES: + try: + _process_key_rs_level_alert(conn, r) + except Exception as e: + print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") + continue + + if not KEY_AUTO_ORDER_ENABLED: + continue + + direction = (r["direction"] or "long").lower() + if direction == KEY_DIRECTION_WATCH: + continue + if typ in KEY_MONITOR_AUTO_TYPES: + mark = get_symbol_mark_price(sym) + if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low): + edge = float(low) if direction == "long" else float(up) + edge_label = box_breakout_invalidate_edge_label(direction) + msg = ( + f"# ⚠️ {sym} 关键位监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} " + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") + continue + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损." + + key_price = float(low) if direction == "long" else float(up) + hard_lines = _key_hard_lines_from_checks(checks) + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + + if typ not in KEY_MONITOR_AUTO_TYPES: + continue + + plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + if not plan_tuple: + fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划无效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" + f"- **{fmt_rr}**(未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + E, sl_raw, tp_raw, box_h = plan_tuple + exchange_symbol = normalize_okx_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{plan_line}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None + be_on = breakeven_enabled_from_row(r, 0) + tc_en, tc_h, _ = time_close_settings_from_row(r) + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, + sym, + direction, + exchange_symbol, + sl_raw, + tp_raw, + key_signal_type=key_sig, + breakeven_enabled=1 if be_on else 0, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂止盈/止损触发单(OKX 条件单)" + if det.get("tpsl_attached") + else "⚠️ 条件单挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if should_send_daily_open_alert( + det.get("opens_today_before", 0), + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + det["trading_day"], + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", + ) + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 0 + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + was_armed = breakeven_armed + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_okx_live_ready() + synced_ex = False + last_ex_sync = float(_BREAKEVEN_LAST_EX_SYNC.get(pid, 0)) + interval_ok = ( + time.time() - last_ex_sync + ) >= BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC + if ok_live and tp_ex > 0 and interval_ok: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _BREAKEVEN_LAST_EX_SYNC[pid] = time.time() + _clear_breakeven_exchange_warn(pid) + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + _send_breakeven_exchange_warn_once( + pid, + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}", + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + breakeven_armed = 1 + if not was_armed: + arm_txt = "保本止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + if should_trigger_time_close(r): + res = TIME_CLOSE_RESULT + # 做多 + if not res and direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif not res and direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细. + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判. + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason=handoff_trade_miss_reason( + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + r, + ), + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + ex_sym_fail = r["exchange_symbol"] or normalize_okx_symbol(sym) + live_contracts = get_live_position_contracts(ex_sym_fail, direction) + if live_contracts is not None and live_contracts <= 0: + record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms + ) + record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" + monitor_status = "stopped" + else: + record_res, record_pnl, record_closed = res, pnl_amount, closed_at + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" + monitor_status = "error" + record_hold = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(record_closed) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=record_pnl, + hold_seconds=record_hold, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(record_pnl, r["risk_amount"]), + result=record_res, + miss_reason=handoff_trade_miss_reason(record_miss, r), + opened_at=opened_at, + closed_at=record_closed, + ) + session_capital = update_session_capital(conn, session_date, record_pnl) + conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + if monitor_status == "stopped": + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{record_res}(已补记入交易记录)", + pnl_amount=record_pnl, + hold_seconds=record_hold, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note=record_miss, + session_capital_fallback=session_capital, + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason=handoff_trade_miss_reason(None, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点起 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_fib_key_monitors() + check_trigger_entry_key_monitors() + _roll_cfg = app.extensions.get("strategy_roll_cfg") + if _roll_cfg: + from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors + + check_roll_monitors(_roll_cfg) + check_key_monitors() + check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from lib.strategy.strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) + except Exception as e: + print(f"[monitor_loop] {e}", flush=True) + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template( + "login.html", + exchange_display=EXCHANGE_DISPLAY_NAME, + pwa_app_name="OKX 交易系统", + ) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): + return f(*args, **kwargs) + return redirect("/login") + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="trade", embed_mode=None): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + from lib.instance.instance_embed_context_lib import ( + embed_render_plan, + minimal_stats_bundle, + 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 + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + if ( + OKX_OPTIONS_ENABLED + and exchange_options.apiKey + and embed_mode != "fragment" + ): + try: + from lib.exchange.okx_options_lib import options_header_balance_pack + + _op = options_header_balance_pack(exchange_options) + options_trading_usdc = _op.get("trading_usdc") + options_funding_usdc = _op.get("funding_usdc") + options_funding_usdt = _op.get("funding_usdt") + options_trading_usdt = _op.get("trading_usdt") + options_funding_eth = _op.get("funding_eth") + options_trading_eth = _op.get("trading_eth") + options_trading_btc = _op.get("trading_btc") + options_margin_mode = _op.get("options_margin_mode") or "coin" + options_underly = _op.get("options_underly") or "ETH" + except Exception: + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + recommended_capital = get_recommended_capital(current_capital) + key_list = ( + conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] + ) + key_history = ( + conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + if plan.key_history + else [] + ) + stats_bundle = ( + compute_stats_bundle(conn, trading_day, now) + if plan.stats_bundle + else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) + ) + order_list = [] + if plan.orders: + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) + exchange_pnl_sync = {} + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): + try: + exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} + except Exception as e: + exchange_pnl_sync = {"ok": False, "reason": str(e)} + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + if plan.records_rows: + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = filter_trade_records_excluding_miss( + [to_effective_trade_dict(r) for r in raw_records] + ) + total = len(records) + win = count_winning_trades(records) + rate = round(win / total * 100, 2) if total else 0 + profit_loss_ratio = profit_loss_ratio_from_trades(records) + elif plan.records_summary: + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + records = summary["records"] + total = summary["total"] + rate = summary["rate"] + profit_loss_ratio = summary.get("profit_loss_ratio") + else: + records = [] + total = rate = 0 + profit_loss_ratio = None + active_count = len(order_list) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + 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", "trade") or page in ( + "strategy", + "strategy_trend", + "strategy_roll", + "strategy_records", + ): + key_rule_ctx = key_monitor_rule_template_context( + kline_timeframe=KLINE_TIMEFRAME, + key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, + key_volume_ma_bars=KEY_VOLUME_MA_BARS, + key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, + key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, + key_confirm_bar=KEY_CONFIRM_BAR, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, + key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + strategy_extra = {} + if plan.strategy: + from lib.strategy.strategy_ui import strategy_render_extras + + strategy_extra = strategy_render_extras( + conn, + page, + default_risk_percent=float(RISK_PERCENT), + request_obj=request, + trend_cfg=app.extensions.get("strategy_trend_cfg"), + ) + conn.close() + from lib.instance.instance_embed_lib import embed_context_extras + from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context + + _display_ctx = display_prefs_template_context(get_db) + 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, + options_funding_eth=options_funding_eth, + options_trading_eth=options_trading_eth, + options_trading_btc=options_trading_btc, + options_margin_mode=options_margin_mode, + options_underly=options_underly, + trading_day=trading_day, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + open_guard_enabled=open_guard_enabled, + open_guard_blocks_now=open_guard_blocks_now, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=position_limit_count, + can_trade=can_trade, + open_block_note=open_block_note, + opens_today=opens_today, + daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, + daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_this_month": PRESET_UTC_THIS_MONTH, + "utc_last3m": PRESET_UTC_LAST3M, + "utc_last6m": PRESET_UTC_LAST6M, + "all": PRESET_ALL, + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + trade_policy=trade_policy_template_context(TRADE_POLICY), + **order_entry_template_context(TRADE_POLICY), + open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + price_fmt=format_price_for_symbol, + entry_reason_options=list( + effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY), + ) + ), + order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), + key_auto_order_enabled=KEY_AUTO_ORDER_ENABLED, + journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, + journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, + journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, + journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, + journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, + key_rule_ctx=key_rule_ctx, + funds_fmt=format_funds_u, + # options_funding_label / trading_account_label 由 embed_context_extras 注入,勿重复写进 dict + exchange_display=EXCHANGE_DISPLAY_NAME, + options_enabled=OKX_OPTIONS_ENABLED, + show_perp_funds=_show_perp_funds or (options_margin_mode == "coin"), + 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("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + # CoinOX 去掉单独永续关键位开仓入口 + flash("CoinOX 不提供关键位自动开仓,请使用期权或对冲计划") + return redirect("/hedge-plan") + + +@app.route("/trade") +@login_required +def trade_page(): + # CoinOX 去掉单独永续下单入口 + flash("CoinOX 不提供单独永续下单,请使用期权或对冲计划") + return redirect("/hedge-plan") + + +@app.route("/records") +@login_required +def records_page(): + redir = redirect_to_embed_shell_if_enabled("records") + if redir is not None: + return redir + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + redir = redirect_to_embed_shell_if_enabled("stats") + if redir is not None: + return redir + return render_main_page("stats") + + +@app.route("/dashboard") +@login_required +def dashboard_page(): + redir = redirect_to_embed_shell_if_enabled("dashboard") + if redir is not None: + return redir + return render_main_page("dashboard") + + +@app.route("/risk_policy") +@login_required +def risk_policy_page(): + redir = redirect_to_embed_shell_if_enabled("risk_policy") + if redir is not None: + return redir + return render_main_page("risk_policy") + + +@app.route("/system_guide") +@login_required +def system_guide_page(): + redir = redirect_to_embed_shell_if_enabled("system_guide") + if redir is not None: + return redir + return render_main_page("system_guide") + + +@app.route("/env_config") +@login_required +def env_config_page(): + redir = redirect_to_embed_shell_if_enabled("env_config") + if redir is not None: + return redir + return render_main_page("env_config") + + +@app.route("/settings") +@login_required +def settings_page(): + redir = redirect_to_embed_shell_if_enabled("settings") + if redir is not None: + return redir + return render_main_page("settings") + + +@app.route("/options") +@login_required +def options_main_page(): + redir = redirect_to_embed_shell_if_enabled("options") + if redir is not None: + return redir + return render_main_page("options") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + force_refresh = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + funding_capital, trading_capital = get_exchange_capitals(force=force_refresh) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + options_index_px = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack + + _op = options_header_balance_pack(exchange_options, force=force_refresh) + options_trading_usdc = _op.get("trading_usdc") + options_funding_usdc = _op.get("funding_usdc") + options_funding_usdt = _op.get("funding_usdt") + options_trading_usdt = _op.get("trading_usdt") + options_funding_eth = _op.get("funding_eth") + options_trading_eth = _op.get("trading_eth") + options_trading_btc = _op.get("trading_btc") + options_margin_mode = _op.get("options_margin_mode") or "coin" + options_underly = _op.get("options_underly") or "ETH" + options_index_px = fetch_index_price(exchange_options, options_underly) + except Exception: + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + options_index_px = None + recommended_capital = get_recommended_capital(current_capital) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + from lib.instance.instance_embed_context_lib import ( + header_trade_stats_for_window, + 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) + # 币本位期权盈亏单位为币,禁止与永续 U 混加(且 merge 只保留 2 位会把 0.0019 抹成 0) + if options_margin_mode != "coin": + unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) + except Exception: + options_unrealized_pnl = None + _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin") + 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, + "options_funding_eth": options_funding_eth, + "options_trading_eth": options_trading_eth, + "options_trading_btc": options_trading_btc, + "options_margin_mode": options_margin_mode, + "options_underly": options_underly, + "options_index_px": options_index_px, + "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.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + guard_on = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + conn.close() + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + ) + return jsonify( + { + "ok": True, + "open_guard_enabled": guard_on, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "reset_hour": TRADING_DAY_RESET_HOUR, + } + ) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,order_amount," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" + ).fetchall() + + try: + ensure_markets_loaded() + except Exception: + pass + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: + price = get_symbol_mark_price(r["symbol"]) + else: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + fb_gate_ok = True + te_gate_ok = True + box_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + elif is_fb: + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + prev = false_breakout_gate_preview( + entry_display=entry_txt, + limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"), + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + sl_v = _sqlite_row_val(r, "fib_stop_loss") + inv = ( + trigger_entry_invalidate( + r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0) + ) + if tp_v + else None + ) + prev = trigger_entry_gate_preview( + monitor_type=r["monitor_type"], + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=inv == "tp", + sl_invalidated=inv == "sl", + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + te_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: + try: + prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + except Exception: + gate_summary = "-" + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES: + direction = (r["direction"] or "long").lower() + if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]): + edge_label = box_breakout_invalidate_edge_label(direction) + gate_summary = f"反向突破{edge_label}·将撤销" + box_gate_ok = False + else: + try: + gate = _key_hard_checks( + r["symbol"], + direction, + r["upper"], + r["lower"], + r["monitor_type"], + ) + except Exception: + gate = None + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + px_disp = format_price_for_symbol(r["symbol"], price) + try: + price_num = float(px_disp) if px_disp != "-" else float(price) + except Exception: + price_num = float(price) + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": price_num, + "price_display": px_disp, + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": ( + fib_gate_ok if is_fib + else fb_gate_ok if is_fb + else te_gate_ok if is_te + else box_gate_ok and bool(gate and gate.get("ok")) + ), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + from lib.hub.price_snapshot_lib import resolve_order_snapshot_price + + for r in order_rows: + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + exchange_tpsl = {"sl": None, "tp": None} + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + price = resolve_order_snapshot_price( + r["symbol"], + prices, + position_row=prow, + order_leverage=lev_row, + parse_position_metrics_fn=parse_ccxt_position_metrics, + get_mark_price_fn=get_symbol_mark_price, + fallback_entry=entry if entry > 0 else None, + ) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "plan_margin": round(margin, 2) if margin else None, + "order_amount": float(r["order_amount"]) if r["order_amount"] not in (None, "") else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + px_for_fmt = None + if price is not None: + try: + px_for_fmt = float(price) + except (TypeError, ValueError): + px_for_fmt = None + if ex_metrics and ex_metrics.get("mark_price") is not None: + try: + px_for_fmt = float(ex_metrics["mark_price"]) + except (TypeError, ValueError): + pass + if px_for_fmt is not None: + px_disp = format_price_for_symbol(r["symbol"], px_for_fmt) + try: + payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt + except Exception: + payload["price"] = px_for_fmt + payload["price_display"] = px_disp + else: + payload["price"] = None + payload["price_display"] = "-" + if exchange_private_api_configured(): + try: + exchange_tpsl = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + avg_entry = None + if ex_metrics and ex_metrics.get("entry_price") is not None: + avg_entry = ex_metrics["entry_price"] + elif prow: + from lib.hub.hub_position_metrics import parse_position_entry_price + + avg_entry = parse_position_entry_price(prow) + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_for_symbol, + symbol=r["symbol"], + margin_capital=margin, + leverage=leverage, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contracts=abs(_position_row_effective_contracts(prow)) if prow else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=ex_metrics.get("mark_price") if ex_metrics else price, + avg_entry_price=avg_entry, + funds_decimals=FUNDS_DECIMALS, + ) + apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None + open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None + payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + r["stop_loss"], r["take_profit"], exchange_tpsl + ) + if changed: + try: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (new_sl, new_tp, int(r["id"])), + ) + except Exception: + pass + order_prices.append(payload) + + try: + conn.commit() + except Exception: + pass + conn.close() + + from lib.hub.hub_position_metrics import build_position_marks_list + + position_marks = build_position_marks_list( + all_swap_positions, + format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), + ) + + options_unrealized_pnl = None + options_index_px = None + options_margin_mode = None + options_underly = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + from lib.exchange.okx_options_lib import fetch_index_price + + 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) + options_margin_mode = normalize_options_margin_mode() + options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + options_index_px = fetch_index_price(exchange_options, options_underly) + 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, + "options_index_px": options_index_px, + "options_margin_mode": options_margin_mode, + "options_underly": options_underly, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + base = journal_coin_from_symbol(symbol) + vol_24h = (LIQUIDITY_RANK_CACHE.get("volumes") or {}).get(base) + if total <= 0: + return jsonify({"ok": False, "msg": "24h成交额排名读取失败"}), 502 + if rank is None: + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": None, + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": False, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + in_top = bool(rank <= KEY_DAILY_VOLUME_RANK_MAX) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": in_top, + "in_top": in_top, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_okx_symbol(symbol) + leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + available = get_available_trading_usdt() + last_price = get_price(symbol) + return jsonify({ + "ok": True, + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "available_trading_usdt": round(available, 4) if available is not None else None, + "last_price": round(float(last_price), 8) if last_price is not None else None, + "price": round(float(last_price), 8) if last_price is not None else None, + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_okx_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + from lib.instance.focus_chart_lib import ( + build_order_kline_order_payload, + load_swap_positions_for_order_kline, + metrics_for_order_item, + ) + + current_price = get_price(order_item["symbol"]) + positions = load_swap_positions_for_order_kline( + exchange, + private_configured=exchange_private_api_configured(), + ensure_markets_fn=ensure_markets_loaded, + ) + ex_metrics = metrics_for_order_item( + order_item, + positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + select_live_fn=_select_live_position_row, + parse_metrics_fn=parse_ccxt_position_metrics, + ) + order_payload = build_order_kline_order_payload( + order_item, + ticker_price=current_price, + format_price_fn=format_price_for_symbol, + calc_pnl_fn=calc_pnl, + calc_rr_ratio_fn=calc_rr_ratio, + ex_metrics=ex_metrics, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": order_payload, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = default_symbol_for_policy( + TRADE_POLICY, + symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT", + ) + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + trade_policy=trade_policy_template_context(TRADE_POLICY), + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + from lib.instance.focus_chart_lib import enrich_key_kline_response + + price_display, key_info = enrich_key_kline_response( + symbol=symbol, + current_price=current_price, + key_info=key_info, + format_price_fn=format_price_for_symbol, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "current_price_display": price_display, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/api/order//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 in KEY_MONITOR_RS_TYPES: + direction_sel = KEY_DIRECTION_WATCH + mt = KEY_MONITOR_RS_TYPE + elif direction_sel not in ("long", "short"): + flash("箱体/收敛突破请选择做多或做空") + return redirect("/key_monitor") + ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) + if not ok_dir: + flash(dir_msg) + return redirect("/key_monitor") + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + + (FALSE_BREAKOUT_MONITOR_TYPE,) + + tuple(TRIGGER_ENTRY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + ok_mt, mt_msg = check_monitor_type_add_allowed( + mt, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + if not ok_mt: + flash(mt_msg) + return redirect("/key_monitor") + skip_volume_rank = is_false_breakout_key_monitor_type(mt) + rank, total = None, None + if not skip_volume_rank: + rank, total = _daily_volume_rank(symbol) + if rank is None: + flash("日成交量排名读取失败,请稍后重试") + return redirect("/key_monitor") + if rank > KEY_DAILY_VOLUME_RANK_MAX: + flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") + return redirect("/key_monitor") + conn = get_db() + if mt in KEY_MONITOR_AUTO_TYPES: + occupied = get_active_position_count(conn) + if occupied >= MAX_ACTIVE_POSITIONS: + conn.close() + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」." + "请平仓后再试,或使用「关键支撑阻力」(仅提醒)." + ) + return redirect("/key_monitor") + ex_sym_key = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + flash("触价请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + flash("触价须填写有效的入场价,止损价,止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry_px, + sl_px, + tp_px, + monitor_type=mt, + breakeven_enabled=be_flag, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + trigger_hint = ( + "标记价穿越入场价后立即市价开仓" + if is_breakout_trigger_entry_key_monitor_type(mt) + else "标记价回调触达入场价后下一轮询市价开仓" + ) + flash( + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|{trigger_hint}" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + if is_false_breakout_key_monitor_type(mt): + fb_sym = normalize_false_breakout_symbol(symbol) + if not fb_sym: + conn.close() + flash("假突破仅支持 BTC / ETH") + return redirect("/key_monitor") + symbol = fb_sym + if direction_sel not in ("long", "short"): + conn.close() + flash("假突破请选择做多或做空") + return redirect("/key_monitor") + try: + key_px = float(d.get("key_price") or 0) + except (TypeError, ValueError): + key_px = 0 + if key_px <= 0: + conn.close() + flash("请填写关键价位(做空填高点,做多填低点)") + return redirect("/key_monitor") + ex_sym_key = normalize_okx_symbol(symbol) + key_adj = round_price_to_exchange(ex_sym_key, key_px) + key_px = float(key_adj) if key_adj is not None else float(key_px) + try: + upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px) + except ValueError as e: + conn.close() + flash(str(e)) + return redirect("/key_monitor") + ok_fb, err_fb = _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_fb: + flash(err_fb or "假突破监控添加失败") + return redirect("/key_monitor") + flash( + f"假突破监控已添加,限价单已挂出({symbol})" + f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + if upper_px <= lower_px: + conn.close() + flash("上沿必须大于下沿") + return redirect("/key_monitor") + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + if mt in KEY_MONITOR_RS_TYPES: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + tc_en, + tc_h, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), + ) + conn.commit() + conn.close() + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + if tc_en: + extra += f"|{time_close_label(tc_h)}" + ctr = False + try: + coin4h_status, _, _ = _status_by_ema55(symbol, "4h") + ctr = (direction_sel == "long" and coin4h_status == "空头") or ( + direction_sel == "short" and coin4h_status == "多头" + ) + except Exception: + pass + if mt in KEY_MONITOR_RS_TYPES: + flash( + f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿," + f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)" + ) + else: + flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}") + if ctr and mt in KEY_MONITOR_AUTO_TYPES: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位." + ) + return redirect("/key_monitor") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/trade") + ok_pol, pol_msg = validate_trade_policy_open(symbol, direction) + if not ok_pol: + conn.close() + flash(f"账户限制:{pol_msg}") + return redirect("/trade") + dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) + if dup_msg: + conn.close() + flash(dup_msg) + return redirect("/trade") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/trade") + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/trade") + exchange_symbol = normalize_okx_symbol(symbol) + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style, entry_model, style_err = parse_manual_order_style_fields( + TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend" + ) + if style_err: + conn.close() + flash(style_err) + return redirect("/trade") + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/trade") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/trade") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/trade") + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") + return redirect("/trade") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/trade") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + conn.close() + flash(flat_msg) + return redirect("/trade") + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=FUNDS_DECIMALS, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/trade") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/trade") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/trade") + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/trade") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") + return redirect("/trade") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_okx_error(e, available_usdt=available_usdt)) + return redirect("/trade") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + risk_display = format_risk_display_text( + POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=FUNDS_DECIMALS + ) + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, entry_model, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price, + breakeven_enabled, + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, "下单监控", + tc_en, tc_h, tc_at, + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart( + exchange_symbol, + title_prefix, + opened_at_ms=opened_at_ms, + entry_price=trigger_price, + ) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 2) + if trading_capital_after is not None + else round(float(capital_base), 2) + ) + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈,止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_wechat_scalar_2dp(stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_display}", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{format_wechat_scalar_2dp(amount)} 张", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + format_daily_open_counter_line( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," + f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + format_daily_open_summary_short( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if should_send_daily_open_alert( + opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + trading_day, + opens_today_after, + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U.", + ) + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/trade") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_limit_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", 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: + if is_limit_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = format_entry_type_display( + er1 or er0 or entry_reason_from_key_signal(kst) or "", + entry_model=r["entry_model"] if "entry_model" in r.keys() else None, + trade_style=r["trade_style"] if "trade_style" in r.keys() else None, + ) + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "images_json", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=row["symbol"], + monitor_type=trade_record_monitor_type(conn, row), + trend_plan_id=trend_plan_id_from_monitor_row(row), + key_signal_type=order_row_key_signal_type(row), + direction=row["direction"], + trigger_price=row["trigger_price"], + stop_loss=row["stop_loss"], + initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"], + take_profit=row["take_profit"], + margin_capital=row["margin_capital"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error + + d = request.form + order_type_norm = normalize_journal_order_type(d.get("order_type")) + if not order_type_norm: + return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records) + direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) + if not direction_norm: + return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records) + entry_reason_norm = normalize_journal_entry_reason( + d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False + ) + if not entry_reason_norm: + return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_redirect_records) + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records) + if early_exit_trigger == "手动平仓" and not early_exit_note: + return journal_ajax_or_flash_error( + request, "手工平仓必须填写补充说明", redirect_fn=_redirect_records + ) + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.2f}" + except Exception: + pass + + want_exchange_chart = ( + not has_manual_uploads + and ORDER_CHART_ENABLED + and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + ) + chart_job = None + if want_exchange_chart: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_okx_symbol(symbol_guess) + journal_tfs = parse_journal_chart_timeframes( + d.get("journal_chart_tf1"), + d.get("journal_chart_tf2"), + ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, + ) + journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) + chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) + chart_job = { + "exchange_symbol": exchange_symbol, + "title_prefix": f"{symbol_guess} journal {entry_id[:8]}", + "journal_tfs": journal_tfs, + "journal_limit": journal_limit, + "marker_payload": { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": d.get("exit_price_hint"), + "stop_loss_price": d.get("stop_loss_hint"), + "chart_anchor": chart_anchor, + "now_ts_ms": int(app_now().timestamp() * 1000), + }, + } + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, + normalize_bj_datetime_storage(d.get("open_datetime")), + normalize_bj_datetime_storage(d.get("close_datetime")), + hold_duration, + d.get("coin"), + d.get("tf"), + direction_norm, + d.get("pnl"), order_type_norm, entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), None, d.get("note"), image_filename, + images_json_str, + ) + ) + from lib.trade.account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + if chart_job: + from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart + + schedule_journal_exchange_chart( + entry_id=entry_id, + exchange_symbol=chart_job["exchange_symbol"], + title_prefix=chart_job["title_prefix"], + journal_tfs=chart_job["journal_tfs"], + journal_limit=chart_job["journal_limit"], + marker_payload=chart_job["marker_payload"], + upload_folder=app.config["UPLOAD_FOLDER"], + generate_chart_fn=generate_multi_timeframe_chart_png, + get_db_fn=get_db, + ) + msg = ( + "交易复盘记录已保存.K 线图后台生成中" + f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见." + ) + else: + msg = "交易复盘记录已保存" + from lib.instance.journal_chart_async_lib import request_wants_journal_ajax + + if request_wants_journal_ajax(request): + return jsonify( + { + "ok": True, + "id": entry_id, + "msg": msg, + "chart_pending": bool(chart_job), + } + ) + flash(msg) + return _redirect_records() + + +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, + format_price_fn=format_price_for_symbol, + sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False), +) + + +def _dashboard_fetch_options_positions(): + if not OKX_OPTIONS_ENABLED: + return [] + cfg = app.extensions.get("options_cfg") + if not isinstance(cfg, dict) or not cfg.get("enabled"): + return [] + try: + from lib.options.options_dashboard_lib import fetch_light_option_positions_for_dashboard + + return fetch_light_option_positions_for_dashboard(cfg) + except Exception: + return [] + + +def _dashboard_enrich_orders(items): + from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks + + return enrich_order_items_with_marks( + items, get_price=get_price, get_contract_size=get_contract_size + ) + + +from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + fetch_options_positions=_dashboard_fetch_options_positions, + enrich_orders=_dashboard_enrich_orders, + hedge_enabled=hedge_module_enabled, +) + +from lib.account_ledger.account_ledger_register import install_account_ledger + +install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx") + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + f"SELECT * FROM journal_entries WHERE {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? " + f"AND {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} <= ? ORDER BY created_at DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = enrich_journal_api_item(row_to_dict(r)) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + win = _list_window_from_request() + start_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"]) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_sql, end_sql), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR)) +_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") +_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") +_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") +_OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") +_OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") +_OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") +_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.js") + + +@app.route("/static/ai_review_render.js") +def static_ai_review_render_js(): + if not os.path.isfile(_AI_REVIEW_RENDER_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/form_submit_guard.js") +def static_form_submit_guard_js(): + if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/manual_order_rr_preview.js") +def static_manual_order_rr_preview_js(): + if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_panel.js") +def static_options_panel_js(): + if not os.path.isfile(_OPTIONS_PANEL_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_expiry_countdown.js") +def static_options_expiry_countdown_js(): + if not os.path.isfile(_OPTIONS_EXPIRY_COUNTDOWN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_EXPIRY_COUNTDOWN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_settings.js") +def static_options_settings_js(): + if not os.path.isfile(_OPTIONS_SETTINGS_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/hedge_plan.js") +def static_hedge_plan_js(): + if not os.path.isfile(_HEDGE_PLAN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_HEDGE_PLAN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", 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") + + +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "key_gate_rule_text": ( + f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + "trade_policy": trade_policy_template_context(TRADE_POLICY), + **hub_meta_entry_context(TRADE_POLICY), + "options_enabled": OKX_OPTIONS_ENABLED, + } + + +def _hub_account_bundle(): + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None + available = get_available_trading_usdt() + return { + "funding_usdt": funding_usdt, + "trading_usdt": trading_usdt, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "trading_day": get_trading_day(app_now()), + } + + +def _hub_fetch_market(base=""): + from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="okx", + ) + + +def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): + from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub + + return fetch_ohlcv_for_hub( + symbol=symbol, + timeframe=timeframe, + since_ms=since_ms, + limit=limit, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + friendly_error=friendly_okx_error, + ) + + +def _hub_fetch_volume_rank(top_n=20): + from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank + + return fetch_usdt_swap_volume_rank( + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + top_n=top_n, + exchange_id="okx", + ) + + +try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + from lib.hub.hub_bridge import install_on_app + + install_on_app( + app, + exchange="okx", + capabilities=["order", "key"], + has_trend=True, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + account_fn=_hub_account_bundle, + views={"add_order": add_order, "add_key": add_key}, + ohlcv_fn=_hub_fetch_ohlcv, + volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, + reconcile_hub_flat_fn=reconcile_hub_external_close, + risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, + ) +except Exception as _hub_err: + print(f"[hub_bridge] okx: {_hub_err}") + +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="okx", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] okx: {_settings_err}") + + +@app.route("/strategy") +@login_required +def strategy_trading_page(): + return render_main_page("strategy") + + +@app.route("/strategy/trend") +@login_required +def strategy_trend_page(): + qs = request.query_string.decode() + return redirect(f"/strategy?{qs}" if qs else "/strategy") + + +@app.route("/strategy/roll") +@login_required +def strategy_roll_page(): + return redirect("/strategy") + + +# CoinOX: 不做单独永续策略; 永续仅作对冲腿 +normalize_exchange_symbol = normalize_okx_symbol +ensure_exchange_live_ready = ensure_okx_live_ready + +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__]) + +try: + from lib.sim.hooks import patch_options_cfg + + _opt_cfg = app.extensions.get("options_cfg") + if isinstance(_opt_cfg, dict): + patch_options_cfg(_opt_cfg) +except Exception as _sim_patch_err: + print(f"[sim] patch options_cfg: {_sim_patch_err}", flush=True) + +_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/brand/icon.svg b/brand/icon.svg new file mode 100644 index 0000000..2277788 --- /dev/null +++ b/brand/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/brand/icons/apple-touch-icon.png b/brand/icons/apple-touch-icon.png new file mode 100644 index 0000000..bd835ad Binary files /dev/null and b/brand/icons/apple-touch-icon.png differ diff --git a/brand/icons/binance/apple-touch-icon.png b/brand/icons/binance/apple-touch-icon.png new file mode 100644 index 0000000..702fc96 Binary files /dev/null and b/brand/icons/binance/apple-touch-icon.png differ diff --git a/brand/icons/binance/favicon.ico b/brand/icons/binance/favicon.ico new file mode 100644 index 0000000..26172c8 Binary files /dev/null and b/brand/icons/binance/favicon.ico differ diff --git a/brand/icons/binance/icon-16.png b/brand/icons/binance/icon-16.png new file mode 100644 index 0000000..c623509 Binary files /dev/null and b/brand/icons/binance/icon-16.png differ diff --git a/brand/icons/binance/icon-192.png b/brand/icons/binance/icon-192.png new file mode 100644 index 0000000..4cda818 Binary files /dev/null and b/brand/icons/binance/icon-192.png differ diff --git a/brand/icons/binance/icon-32.png b/brand/icons/binance/icon-32.png new file mode 100644 index 0000000..c706b7d Binary files /dev/null and b/brand/icons/binance/icon-32.png differ diff --git a/brand/icons/binance/icon-48.png b/brand/icons/binance/icon-48.png new file mode 100644 index 0000000..877ec15 Binary files /dev/null and b/brand/icons/binance/icon-48.png differ diff --git a/brand/icons/binance/icon-512.png b/brand/icons/binance/icon-512.png new file mode 100644 index 0000000..6f48a2b Binary files /dev/null and b/brand/icons/binance/icon-512.png differ diff --git a/brand/icons/binance/icon.svg b/brand/icons/binance/icon.svg new file mode 100644 index 0000000..70a7d51 --- /dev/null +++ b/brand/icons/binance/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand/icons/favicon.ico b/brand/icons/favicon.ico new file mode 100644 index 0000000..0af9b9c Binary files /dev/null and b/brand/icons/favicon.ico differ diff --git a/brand/icons/gate/apple-touch-icon.png b/brand/icons/gate/apple-touch-icon.png new file mode 100644 index 0000000..129f899 Binary files /dev/null and b/brand/icons/gate/apple-touch-icon.png differ diff --git a/brand/icons/gate/favicon.ico b/brand/icons/gate/favicon.ico new file mode 100644 index 0000000..dcb291e Binary files /dev/null and b/brand/icons/gate/favicon.ico differ diff --git a/brand/icons/gate/icon-16.png b/brand/icons/gate/icon-16.png new file mode 100644 index 0000000..8ebd22a Binary files /dev/null and b/brand/icons/gate/icon-16.png differ diff --git a/brand/icons/gate/icon-192.png b/brand/icons/gate/icon-192.png new file mode 100644 index 0000000..9039264 Binary files /dev/null and b/brand/icons/gate/icon-192.png differ diff --git a/brand/icons/gate/icon-32.png b/brand/icons/gate/icon-32.png new file mode 100644 index 0000000..4add43f Binary files /dev/null and b/brand/icons/gate/icon-32.png differ diff --git a/brand/icons/gate/icon-48.png b/brand/icons/gate/icon-48.png new file mode 100644 index 0000000..1e10957 Binary files /dev/null and b/brand/icons/gate/icon-48.png differ diff --git a/brand/icons/gate/icon-512.png b/brand/icons/gate/icon-512.png new file mode 100644 index 0000000..7c41ef9 Binary files /dev/null and b/brand/icons/gate/icon-512.png differ diff --git a/brand/icons/gate/icon.svg b/brand/icons/gate/icon.svg new file mode 100644 index 0000000..e0249d5 --- /dev/null +++ b/brand/icons/gate/icon.svg @@ -0,0 +1,6 @@ + + + + + G + diff --git a/brand/icons/icon-16.png b/brand/icons/icon-16.png new file mode 100644 index 0000000..b3a4ee1 Binary files /dev/null and b/brand/icons/icon-16.png differ diff --git a/brand/icons/icon-180.png b/brand/icons/icon-180.png new file mode 100644 index 0000000..b6b498f Binary files /dev/null and b/brand/icons/icon-180.png differ diff --git a/brand/icons/icon-192.png b/brand/icons/icon-192.png new file mode 100644 index 0000000..92351e1 Binary files /dev/null and b/brand/icons/icon-192.png differ diff --git a/brand/icons/icon-32.png b/brand/icons/icon-32.png new file mode 100644 index 0000000..dc2186f Binary files /dev/null and b/brand/icons/icon-32.png differ diff --git a/brand/icons/icon-48.png b/brand/icons/icon-48.png new file mode 100644 index 0000000..219285a Binary files /dev/null and b/brand/icons/icon-48.png differ diff --git a/brand/icons/icon-512.png b/brand/icons/icon-512.png new file mode 100644 index 0000000..a46fe93 Binary files /dev/null and b/brand/icons/icon-512.png differ diff --git a/brand/icons/icon.svg b/brand/icons/icon.svg new file mode 100644 index 0000000..2277788 --- /dev/null +++ b/brand/icons/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/brand/icons/okx/apple-touch-icon.png b/brand/icons/okx/apple-touch-icon.png new file mode 100644 index 0000000..82d1e82 Binary files /dev/null and b/brand/icons/okx/apple-touch-icon.png differ diff --git a/brand/icons/okx/favicon.ico b/brand/icons/okx/favicon.ico new file mode 100644 index 0000000..2dbb0dd Binary files /dev/null and b/brand/icons/okx/favicon.ico differ diff --git a/brand/icons/okx/icon-16.png b/brand/icons/okx/icon-16.png new file mode 100644 index 0000000..5ea0f54 Binary files /dev/null and b/brand/icons/okx/icon-16.png differ diff --git a/brand/icons/okx/icon-192.png b/brand/icons/okx/icon-192.png new file mode 100644 index 0000000..55ed1de Binary files /dev/null and b/brand/icons/okx/icon-192.png differ diff --git a/brand/icons/okx/icon-32.png b/brand/icons/okx/icon-32.png new file mode 100644 index 0000000..ea8a9a2 Binary files /dev/null and b/brand/icons/okx/icon-32.png differ diff --git a/brand/icons/okx/icon-48.png b/brand/icons/okx/icon-48.png new file mode 100644 index 0000000..16a80dc Binary files /dev/null and b/brand/icons/okx/icon-48.png differ diff --git a/brand/icons/okx/icon-512.png b/brand/icons/okx/icon-512.png new file mode 100644 index 0000000..e526b77 Binary files /dev/null and b/brand/icons/okx/icon-512.png differ diff --git a/brand/icons/okx/icon.svg b/brand/icons/okx/icon.svg new file mode 100644 index 0000000..b7eaa46 --- /dev/null +++ b/brand/icons/okx/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand/manifest.binance.webmanifest b/brand/manifest.binance.webmanifest new file mode 100644 index 0000000..eddc20d --- /dev/null +++ b/brand/manifest.binance.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Binance 交易系统", + "short_name": "Binance 交易系统", + "description": "Binance 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#F0B90B", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.exchange.webmanifest b/brand/manifest.exchange.webmanifest new file mode 100644 index 0000000..7464c0f --- /dev/null +++ b/brand/manifest.exchange.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "交易系统", + "short_name": "交易系统", + "description": "加密货币永续交易监控与复盘(请使用各所独立 manifest)", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#0b0d14", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.gate.webmanifest b/brand/manifest.gate.webmanifest new file mode 100644 index 0000000..6dbd346 --- /dev/null +++ b/brand/manifest.gate.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Gate 交易系统", + "short_name": "Gate 交易系统", + "description": "Gate 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#17E6A1", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.okx.webmanifest b/brand/manifest.okx.webmanifest new file mode 100644 index 0000000..bad9fb4 --- /dev/null +++ b/brand/manifest.okx.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "OKX 交易系统", + "short_name": "OKX 交易系统", + "description": "OKX 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#FFFFFF", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.webmanifest b/brand/manifest.webmanifest new file mode 100644 index 0000000..f63dbd9 --- /dev/null +++ b/brand/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "复盘系统中控", + "short_name": "中控", + "description": "三所交易监控与行情中控", + "start_url": "/monitor", + "display": "standalone", + "background_color": "#0b0e18", + "theme_color": "#0b0e18", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/docs/OKX单笔期权-币本位与USDT桥-开发方案.md b/docs/OKX单笔期权-币本位与USDT桥-开发方案.md new file mode 100644 index 0000000..4e26650 --- /dev/null +++ b/docs/OKX单笔期权-币本位与USDT桥-开发方案.md @@ -0,0 +1,279 @@ +# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案 + +> 状态:**已实现首版**(按本文落地;改需求先改本文). +> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**. +> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为). +> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md) + +--- + +## 1. 背景与动机 + +当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平. + +实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量. + +币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大. + +--- + +## 2. 目标(首版) + +1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥). +2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量). +3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT. +4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**. +5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`. +6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**. +7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持. +8. **中控不代下期权单**,但监控/快照/持仓卡片等 **只读字段须能识别币本位**(见 §7.5). +9. **不涉及 Gate** 任何业务改动. + +--- + +## 3. 不做(首版外) + +- 对冲计划(永期/期期)币本位腿或双模式混开 +- 盘中按单笔切换本位(必须 env + 重启/无仓校验) +- 按权利金精确计算后再买现货(明确不做;见 §5) +- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转) +- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则) +- 多笔并行单笔期权仓(维持「一次一仓」) +- **中控代下 / 中控内嵌开平仓按钮**触发币本位或 USDC 期权下单(开平仍只在 OKX 实例页) +- **任何 Gate 相关改动**(含为「顺便统一」去动 Gate 模板或共享路径上的 Gate 分支) + +--- + +## 4. 模式开关与互斥 + +### 4.1 env(草案) + +| 变量 | 含义 | 默认 | +|------|------|------| +| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `coin` | +| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` | +| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` | +| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) | +| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` | +| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) | +| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) | +| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` | + +开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。 + +说明: + +- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`. +- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`. +- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致). + +### 4.2 切换门禁 + +| 条件 | 行为 | +|------|------| +| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc`↔`coin` | +| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 | +| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC | +| 无仓且无半成品 | 允许改 env 并重启后生效 | + +启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价. + +--- + +## 5. 币本位资金桥与开平流水 + +### 5.1 开仓(先买满,再开满) + +``` +1. 读取交易账户 USDT 可用 +2. 计算 budget_usdt(§4.1) +3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致) +4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权 + - 受:最小张数、卖一深度、单笔一仓规则约束 + - 不要求「币数量精确等于权利金」;允许开满后仍残留部分币 +5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding +``` + +### 5.2 平仓(先平期权,再卖回 USDT) + +``` +1. 按现有纪律买一限价卖出期权(可分批深度) +2. 期权仓清零(或本轮目标完成)后: + 现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT +3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利 +``` + +### 5.3 失败回滚(必须) + +| 失败点 | 处理 | +|--------|------| +| 现货买入失败 | 不开期权;报错 | +| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 | +| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 | +| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 | + +--- + +## 6. 复利与「人工转走」 + +### 6.1 口径 + +- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算). +- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」. +- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`): + - **默认关闭** → 纯靠人工转走控规模. + - **开启** → `min(账户×0.95, N)`,防止单笔过大. + +### 6.2 与永续「全仓」的关系 + +思想同类(吃可用 × 缓冲),但资产不同: + +- 永续全仓:USDT 保证金 × 杠杆 → 合约名义 +- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金 + +**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合. + +### 6.3 一次一仓 + +复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品. + +--- + +## 7. 产品与 UI + +### 7.1 模式可见性 + +- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`. +- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态. +- USDC 模式保持现有 USDC 余额与预算展示. + +### 7.2 开仓按钮文案(示例) + +- 币本位:`买币并开仓(预算 ≈ xx USDT)` +- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT. + +### 7.3 对冲 + +- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算. +- 不在此模式自动把对冲预算改成 USDT 桥. + +### 7.4 复盘字段(建议) + +单笔 round-trip 尽量可拆: + +- 期权腿盈亏(币或折合 USDT) +- 桥兑换盈亏(买币成本 vs 卖币回收) +- 合计 USDT 变化(对复利最有意义) + +首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**. + +### 7.5 中控只读(要做)与不下单(不做) + +中控保持现有分工:**监控只读 + 点「期权」进 OKX 实例操作**;本方案**不**在中控增加开平仓/买币桥按钮. + +只读侧须能区分并展示币本位,避免仍按「一律 USDC 权利金」误读.实例上报快照/期权字段建议至少包含: + +| 字段(名可调) | 含义 | +|--------------|------| +| `options_margin_mode` | `usdc` \| `coin` | +| 持仓行可辨本位 | 合约族/结算币/标签,卡片上能看出「币本位」或「USDC」 | +| 币本位时预算口径 | 可选:交易户 USDT、本轮 `×0.95` 预估预算、是否触达 N 上限 | +| 桥状态(若有半成品) | 如 `holding` / `pending_sell_spot`(待卖回 USDT),中控只展示与告警,不代执行 | + +展示落点(与现网对齐即可,不新开中控交易页): + +- OKX 账户监控里的期权区块 / 期权持仓卡片 +- 推给教练等用的监控快照文案(若已注入期权行,须带本位标记,避免 AI/人工当成 USDC) + +**不做:**中控代下单、中控触发买 ETH/卖 ETH、中控改 env 切模式. + +--- + +## 8. 技术要点 + +### 8.1 合约与报价 + +- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径. +- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设). +- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套. + +### 8.2 模块建议 + +| 块 | 职责 | +|----|------| +| 模式读取 + 门禁 | env、有仓拒切、启动一致性 | +| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 | +| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 | +| 定价/张数 | 币本位分支 | +| UI/API | 预算预览、确认、半成品提示 | +| 中控只读 | 消费实例快照中的 `options_margin_mode` 等字段;卡片/文案可识别币本位;**无下单 API** | +| Gate | **不纳入**;禁止为本次需求修改 Gate 树 | + +现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同. + +共享 `lib/options*` / 快照序列化若调整:仅扩展 OKX 期权载荷;Binance/Gate 账户快照路径保持原样. + +### 8.3 权限与账户 + +- API 需具备:交易账户现货市价、期权开平. +- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划). + +### 8.4 测试 + +- 预算计算:复利开/关、上限开/关、余额边界. +- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功. +- 门禁:有仓切换拒绝;一次一仓. +- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC. +- 中控只读:快照含本位字段时卡片/文案可区分 `usdc`/`coin`. +- Gate:本次 diff **不应出现** `crypto_monitor_gate/` 业务文件变更. + +--- + +## 9. 验收标准 + +1. `usdc` 模式:单笔期权行为与现网一致. +2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警). +3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时). +4. 上限开关默认关;开启后预算不超过 N. +5. 有持仓或半成品时切换模式被拒绝. +6. 币本位下对冲不能误开币本位腿. +7. 开仓失败自动卖回 USDT,不留下无主现货. +8. 中控:**无**期权下单入口新增;监控/快照/持仓只读能看出当前为币本位或 USDC. +9. Gate:无相关代码改动;Gate 实例行为与改前一致. + +--- + +## 10. 实现顺序建议 + +1. 模式 env + 有仓/半成品门禁 + OKX 实例 UI 展示当前模式 +2. 现货桥(买/卖/回滚/待卖回) + 单测 +3. 币本位合约适配 + 卖一开/买一平接入编排 +4. 复利预算预览与开仓确认 +5. 上限开关 +6. 快照字段上报 + **中控只读识别币本位**(卡片/文案;不下单) +7. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔 + +--- + +## 11. 决策摘要(已拍板) + +| 决策 | 结论 | +|------|------| +| 对冲 | 暂不接币本位 | +| 单笔模式 | env:`usdc` ↔ `coin` | +| 有持仓切换 | **拒绝** | +| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) | +| 复利 | 交易账户 USDT × 0.95;人工转走控规模 | +| 单笔不超过 N U | **独立开关,默认关闭** | +| 中控 | **不下单**;只读字段/快照**能识别币本位** | +| Gate | **本次不改** | +| 动机 | 币本位流动性往往优于 USDC,利于成交 | + +--- + +## 12. 风险与说明 + +- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显. +- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待. +- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准. +- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族. diff --git a/docs/env配置说明.md b/docs/env配置说明.md new file mode 100644 index 0000000..8fa4002 --- /dev/null +++ b/docs/env配置说明.md @@ -0,0 +1,231 @@ +# env 配置页说明 + +本文档描述各交易实例 Web 端 **「env 配置」** 页展示项,含义,生效方式,以及与系统设置,中控密钥的分工. + +> **不在本页展示的配置**(服务端口,数据库路径,关键位门控,轮询间隔等)仍保存在实例目录 `.env` 中,需 SSH 编辑或部署脚本维护,见文末「隐藏项」. + +--- + +## 1. 设计原则 + +| 原则 | 说明 | +|------|------| +| **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | +| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | +| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | +| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | +| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` | +| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | +| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | + +--- + +## 2. 密钥分工(自动生成,本页不可见) + +部署时由脚本统一生成并写入对应 `.env`(已有值则跳过,避免覆盖生产环境). + +| 类型 | 环境变量 | 写入位置 | 用途 | +|------|----------|----------|------| +| **中控通信密钥** | `HUB_BRIDGE_TOKEN` | 中控 `manual_trading_hub/.env` + 三实例 `.env`(**相同值**) | 中控调用实例 API(`X-Hub-Token`),iframe SSO 签发与校验 | +| **登录会话密钥** | `FLASK_SECRET_KEY` | 三实例 `.env`(**三所相同**) | Flask Session 签名;与网页登录态相关,与中控密钥 **分离** | +| **中控会话密钥** | `HUB_SESSION_SECRET` | 中控 `.env` | 中控登录 Cookie 签名 | +| **登录账号** | `APP_USERNAME` / `APP_PASSWORD` | 三实例 `.env`(建议三所统一) | 直链实例 `/login` 使用;**仅在系统设置中修改** | +| **中控登录** | `HUB_USERNAME` / `HUB_PASSWORD` | 中控 `.env` | 中控网页登录;**仅在中控系统设置改密** | + +说明: + +- **长期密钥一次生成,不轮换**:`setup_env.sh` 末尾调用 `scripts/bootstrap_deploy_secrets.py`;已有非占位值不会被覆盖. +- **SSO 链接不变**:仍为中控每次签发,默认 2 小时有效,单次使用(`HUB_SSO_TTL_SEC`),与长期 `HUB_BRIDGE_TOKEN` 分离. +- 经中控 iframe / SSO 打开实例时,可免输实例密码;直链 IP/域名仍走 `/login`. +- 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页. + +--- + +## 3. 页面布局(三列卡片) + +| 列 1 | 列 2 | 列 3 | +|------|------|------| +| 交易所与实盘 | 企业微信 | 交易执行 | +| 交易风控 | 账户冷静期 | 自动划转 | +| 当日资金 | 期权账户(仅 OKX) | | + +> **AI 复盘**(OpenAI / Ollama)已移至中控 **系统设置 → AI 配置**,保存后强制同步三所 `.env`.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」. + +--- + +## 4. 各卡片字段(中文展示名) + +### 4.1 交易所与实盘 + +| 中文名 | 说明 | 重启 | +|--------|------|------| +| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | +| 保证金模式 | 全仓 / 逐仓 | 需重启 | +| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | +| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | +| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | +| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 | + +**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP. + +**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥. + +--- + +### 4.2 企业微信 + +| 中文名 | 说明 | +|--------|------| +| 机器人 Webhook | 行情,风控,提醒推送地址 | +| 推送超时(秒) | 可选,默认 10 | + +--- + +### 4.3 AI 复盘(已移至中控) + +AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT_SECONDS`)**不再在本页展示**. + +请在中控 **系统设置 → AI 配置** 修改;保存后写入中控 `.env` 并 **强制同步** 至 OKX / Binance / Gate 三实例.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +--- + +### 4.4 交易执行 + +| 中文名 | 说明 | +|--------|------| +| 计仓模式 | 以损定仓 / 全仓杠杆 | +| 以损定仓风险% | 单笔风险占资金比例 | +| 全仓资金缓冲比例 | 全仓模式下可用资金折扣 | +| BTC 默认杠杆 | | +| 山寨默认杠杆 | | +| 方向限制开关 | | +| 允许方向 | 多 / 空 / 双向 | +| 币种白名单开关 | | +| 白名单币种 | 逗号分隔 | +| 交易日切点(北京时间) | 默认 8 点 | +| 切点前禁止新开仓 | | +| 最大同时持仓 | | +| 人工最低盈亏比 | | +| 强制清仓开关 | `FORCE_CLOSE_ENABLED`;开启后在指定北京整点小时内,市价平掉本地 active 监控仓 | +| 强制清仓整点(北京) | `FORCE_CLOSE_BJ_HOUR`(0–23);例 `8` 表示 08:00~08:59;仅扫监控仓,不含交易所裸仓 | + +--- + +### 4.5 交易风控(日内开仓) + +| 中文名 | 说明 | +|--------|------| +| 单日开仓提醒阈值 | 达到次数后 AI 克制提醒(不拦单) | +| 单日开仓硬上限 | 0 表示不启用;达到后禁止新开仓 | + +详见 [daily-open-limit.md](./daily-open-limit.md). + +--- + +### 4.6 账户冷静期 + +| 中文名 | 说明 | +|--------|------| +| 冷静期总开关 | | +| 手动平仓冷静(小时) | | +| 复盘情绪冷静(小时) | | +| 日手动平仓次数上限 | | +| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 | +| 情绪标签日冻结 | | + +详见 [account-risk-cooldown.md](./account-risk-cooldown.md). + +--- + +### 4.7 自动划转 + +| 中文名 | 说明 | +|--------|------| +| 启用自动划转 | | +| 目标余额(U) | 交易账户目标 USDT | +| 划出账户 | funding / swap | +| 划入账户 | swap / funding | +| 执行整点(北京时间) | | +| 划转币种 | 默认 USDT | + +详见 [auto-transfer-daily.md](./auto-transfer-daily.md). + +--- + +### 4.8 当日资金 + +| 中文名 | 说明 | +|--------|------| +| 日起始基数(U) | | +| 回撤后基数(U) | | +| 盈利后基数(U) | | + +与自动划转目标余额相互独立;若需一致请手动对齐. + +--- + +### 4.9 期权账户(仅 OKX) + +| 中文名 | 说明 | +|--------|------| +| 启用期权模块 | 与永续共用 `OKX_API_*`;不再单独配置期权密钥 | +| 期权账户备注 | | +| 单笔预算(USDC) | | +| 预算缓冲比例 | | +| 默认标的 | 如 ETH | +| 最大到期天数 | 等常用策略参数 | + +高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md),[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)(线上 `/options/guide`). + +--- + +## 5. 操作说明 + +1. 修改后点 **保存**:即时生效项立即应用;需重启项写入 `.env` 但未重启进程. +2. 含需重启项时点 **保存并重启**:写 `.env` 后 PM2 重启当前实例. +3. **重新加载**:从磁盘重新读取 `.env` 刷新表单(放弃未保存修改). +4. 敏感项(API,密钥)显示为掩码;**留空提交表示不修改原值**. + +--- + +## 6. 隐藏项(本页不展示) + +以下仍存在于 `.env`,仅供运维或 SSH 修改: + +- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG` +- 数据:`DB_PATH`,`UPLOAD_DIR` +- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空) +- 关键位门控:全部 `KEY_*`,`KLINE_*` +- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*` +- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` 等 +- 备份:`BACKUP_*` +- 中控嵌入细节:`APP_ALLOW_HUB_EMBED`,`HUB_EMBED_*`,`APP_COOKIE_SECURE` +- 登录相关:`APP_AUTH_DISABLED`,`APP_USERNAME`,`APP_PASSWORD`,`FLASK_SECRET_KEY`,`HUB_BRIDGE_TOKEN` + +后续若需要可增加「高级模式」折叠区,默认关闭. + +--- + +## 7. 与系统设置的关系 + +| 能力 | env 配置 | 系统设置 | 中控系统设置 | +|------|----------|----------|--------------| +| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | +| 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ | +| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | +| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | +| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | +| 数据导出 | ❌ | ✅ 数据导出 | ❌ | +| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | ❌ | + +系统设置说明见 [系统设置说明.md](./系统设置说明.md);中控 AI 与部署密钥见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +--- + +## 8. 实现备注(开发用) + +- 白名单分组:`lib/env/env_ui_manifest.py`(按 `exchange_key` 过滤) +- 中文标签:`ENV_UI_LABELS` 映射,模板只渲染 `label` / `note` +- 全量校验仍基于 `.env.example`;POST 仅接受 manifest 内 key diff --git a/docs/lib-structure.md b/docs/lib-structure.md new file mode 100644 index 0000000..4b772bd --- /dev/null +++ b/docs/lib-structure.md @@ -0,0 +1,147 @@ +# lib/ 共用模块结构 + +三所实例与中控共用的 Python 库,模板与静态资源统一放在仓库根目录的 **`lib/`** 下.部署单元(`crypto_monitor_*`,`manual_trading_hub`)仍保持独立目录与 PM2 配置不变. + +**重构前快照 Git 标签**:`pre-lib-modularization`(可用 `git checkout pre-lib-modularization` 查看旧布局). +**移除 gate_bot 前快照 Git 标签**:`pre-remove-gate-bot`. + +--- + +## 顶层目录 + +``` +crypto_monitor/ +├── crypto_monitor_binance/ # 三所:各自 app + .env + PM2 +├── crypto_monitor_gate/ +├── crypto_monitor_okx/ +├── manual_trading_hub/ # 中控 + 子代理 agent +│ +├── lib/ # 共用模块(本说明) +│ ├── strategy/ +│ ├── key_monitor/ +│ ├── trade/ +│ ├── hub/ +│ ├── ai/ +│ ├── instance/ +│ ├── exchange/ +│ ├── common/ +│ └── paths.py +│ +├── brand/ # 各所共用图标 +├── docs/ +├── deploy/ +├── scripts/ +├── tests/ +├── requirements.txt +└── README.md +``` + +--- + +## lib/ 子包说明 + +| 子包 | 职责 | 主要模块 | +|------|------|----------| +| **`lib/strategy/`** | 策略交易(顺势加仓,趋势回调,快照与记录) | `strategy_register.py`,`strategy_trend_register.py`,`strategy_db.py`,`strategy_roll_*`,`strategy_trend_*` | +| **`lib/strategy/templates/`** | 策略页 Jinja 模板(原 `strategy_templates/`) | `strategy_trading_page.html`,`strategy_roll_panel.html` 等 | +| **`lib/key_monitor/`** | 关键位监控,斐波,假突破,止盈止损方案 | `key_monitor_lib.py`,`fib_key_monitor_lib.py`,`key_sl_tp_lib.py` 等 | +| **`lib/trade/`** | 下单监控展示,计仓,账户风控,手动 SL/TP | `order_monitor_display_lib.py`,`position_sizing_lib.py`,`account_risk_lib.py` 等 | +| **`lib/hub/`** | 中控 API,K 线,归档,计仓器,SSO/Bridge | `hub_bridge.py`,`hub_kline_store.py`,`hub_trades_lib.py` 等 | +| **`lib/ai/`** | AI 复盘与文本生成 | `ai_client.py`,`ai_review_lib.py` | +| **`lib/instance/`** | 中控 iframe 嵌入,导航,复盘图表 | `instance_embed_lib.py`,`focus_chart_lib.py`,`journal_chart_lib.py` | +| **`lib/instance/templates/`** | 嵌入页片段(原 `embed_templates/`) | `embed_page_fragment.html` | +| **`lib/exchange/`** | 特定交易所工具 | `gate_transfer_lib.py`,`okx_orders_lib.py` 等 | +| **`lib/common/`** | 跨功能小工具 | `form_submit_lib.py`,`wechat_notify_lib.py` 等 | +| **`lib/common/static/`** | 三所与中控共用的 JS/CSS(原根目录 `static/`) | `instance_theme.js`,`strategy_roll.js` 等 | + +> **说明**:`hub_*` 命名表示「中控侧能力或行情聚合」,但部分模块(如 `hub_volume_rank_lib`,`hub_market_info_lib`)三所 `app.py` 也会调用,并非中控独占. + +--- + +## 路径辅助函数 + +`lib/paths.py` 集中维护资源目录,避免硬编码: + +```python +from lib.paths import strategy_templates_dir, embed_templates_dir, common_static_dir + +strategy_templates_dir() # .../lib/strategy/templates +embed_templates_dir() # .../lib/instance/templates +common_static_dir() # .../lib/common/static +``` + +可选传入 `repo_root`(字符串或 `Path`),默认使用 `lib/` 的上级目录即仓库根. + +--- + +## Python 导入约定 + +各部署目录在启动时将 **仓库根** 加入 `sys.path`(与重构前相同): + +```python +_REPO_ROOT = os.path.dirname(BASE_DIR) # 或 Path(__file__).resolve().parent.parent +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +``` + +之后使用 **`lib.<子包>.<模块>`** 形式导入,例如: + +```python +from lib.strategy.strategy_db import init_strategy_tables +from lib.key_monitor.key_monitor_lib import check_key_monitors +from lib.hub.hub_bridge import install_on_app +from lib.ai.ai_client import ai_review +``` + +策略注册仍在各所 `app.py` 末尾: + +```python +from lib.strategy.strategy_register import install_strategy_trading +from lib.strategy.strategy_trend_register import install_strategy_trend + +install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) +``` + +--- + +## 静态资源与 URL + +- 三所页面仍通过 **`/static/...`** 访问共用脚本;`hub_bridge.install_instance_theme_static` 从 `lib/common/static/` 提供部分根级静态路由. +- 各所目录下 **`static/`**(图标,上传图片等)仍为实例私有,未迁入 `lib/`. +- 中控 `manual_trading_hub/hub.py` 通过 `_REPO_ROOT / "lib" / "common" / "static"` 挂载与三所共用的 badge,复盘 JS 等. + +--- + +## 测试 + +在仓库根执行(需将根目录置于 Python 路径,或从根目录运行): + +```bash +cd /opt/crypto_monitor +python -m unittest discover -s tests -p "test_*.py" +``` + +测试文件内统一 `from lib.<子包>.<模块> import ...`.使用 `@patch` 时目标写完整模块路径,例如 `lib.hub.hub_calculator_lib._resolve_market`. + +--- + +## 迁移脚本 + +一次性迁移由 `scripts/migrate_to_lib.py` 完成(移动文件 + 批量改写 import).**不要在已迁移后的仓库上重复执行**. + +--- + +## 后续可选整理 + +- 三所 `app.py` 体量接近,可逐步抽取公共 `exchange_app` 基座(改动面大,单独规划). +- `manual_trading_hub/okx_orders_lib.py` 为 agent 本地副本,可与 `lib/exchange/okx_orders_lib.py` 合并去重. +- 可引入 `pyproject.toml` + `pip install -e .`,替代 `sys.path.insert`(长期维护更规范). + +--- + +## 相关文档 + +- [README.md](../README.md) — 总览与部署 +- [策略交易说明.md](../策略交易说明.md) +- [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) diff --git a/docs/position-sizing-mode.md b/docs/position-sizing-mode.md new file mode 100644 index 0000000..22a964f --- /dev/null +++ b/docs/position-sizing-mode.md @@ -0,0 +1,73 @@ +# 计仓模式(三所统一) + +## 配置 + +在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**): + +```env +# risk(默认)= 以损定仓 +# full_margin = 全仓杠杆(合约可用保证金 × 比例) +POSITION_SIZING_MODE=risk +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 关键位程序自动单(默认 false,详见各所 关键位自动下单说明.md) +KEY_AUTO_ORDER_ENABLED=false +``` + +切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓). + +## 模式说明 + +| 模式 | 保证金计算 | 杠杆 | 允许入口 | +|------|------------|------|----------| +| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工,关键位自动(须 `KEY_AUTO_ORDER_ENABLED=true`),趋势回调,顺势加仓 | +| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**,**关键位触价**(须 `KEY_AUTO_ORDER_ENABLED=true`);阻力/支撑仅提醒 | + +全仓模式下: + +- **`KEY_AUTO_ORDER_ENABLED=false`(默认)** 时,触价程序自动单也不执行. +- **`KEY_AUTO_ORDER_ENABLED=true`** 时,仅触价可程序自动开仓;箱体/斐波等仍禁止. +- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`). +- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定. +- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准. +- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知. + +## 不允许(全仓模式) + +- 关键位:箱体突破,收敛突破,斐波,假突破(添加时拒绝;已存在则启动时撤销). +- 趋势回调,顺势加仓(策略入口返回明确错误). + +**允许(须 `KEY_AUTO_ORDER_ENABLED=true`):** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价,触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发). + +## `KEY_AUTO_ORDER_ENABLED`(三所统一,默认 `false`) + +| 计仓 | 开关 | 效果 | +|------|------|------| +| `risk` | `false` | 关闭全部关键位程序自动单(含触价);顺势加仓不受影响 | +| `risk` | `true` | 关键位全套自动(旧行为) | +| `full_margin` | `false` | 关闭触价自动 | +| `full_margin` | `true` | 仅触价自动 | + +详见各实例目录 `关键位自动下单说明.md`. + +## 用脚本更新三所 `.env` + +详见 **[env-sync-scripts.md](./env-sync-scripts.md)**.常用命令: + +```bash +git pull + +# 仅补全计仓相关项(缺省 risk,缓冲 0.98) +python scripts/sync_four_exchange_position_sizing_env.py + +# 无仓后切换全仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin + +# 无仓后切回以损定仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk + +# 计仓 + 划转一并补全 +python scripts/sync_four_exchange_env.py + +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate +``` diff --git a/docs/对冲计划-以期权为主.md b/docs/对冲计划-以期权为主.md new file mode 100644 index 0000000..562e13f --- /dev/null +++ b/docs/对冲计划-以期权为主.md @@ -0,0 +1,71 @@ +# 对冲计划 · 永期「以期权为主」 + +> 实现日:2026-08-09 · 在现有永期**保险模式**上增加子模式,不新增 `OKX_TRADE_MODE`。 +> 模式由 env `HEDGE_PLAN_OPTION_PRIMARY` 切换(默认 `true`),页面标题前显示标识,不可在页内切换。 + +## 1. 模式对照 + +| | 保险模式(`OPTION_PRIMARY=false`) | 以期权为主(`true`) | +|--|------------------|-------------------| +| UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 | +| UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 | +| 左卡 | 开仓价 / 张数 / TP / SL | 资金与杠杆 / 选约条件 / 出场条件 三组 | +| 右卡 | 上永续行情 · 下期权链 | 同上;仅展示间隔+类型+杠杆达标候选 | +| 选约 | 仅实值/平值 | 类型下拉(默认虚值)+间隔+杠杆门槛 | +| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **策略启动=盯盘**(status=`watching`),达标后才先期权后市价永续(**不挂**交易所 TP/SL) | +| 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 | + +## 2. 左卡默认 + +| 字段 | 默认 | +|------|------| +| 权利金 | 用户填(USDC 预算) | +| 永续杠杆 | 100 | +| 期权杠杆 | 实/平 100;虚 200 | +| 期权:永续比例 | 实/平 2;虚 4 | +| 到期时间(最短 h) | 36 | +| 期权间隔(点) | 15 | +| 期权/永续目标位 | 相对 K 点数,须 **>0** | + +## 3. 定仓 + +``` +usable = 权利金 × 0.95 +eth_qty = floor2(usable / ask) # ETH 名义,两位小数 +sheets = floor(eth_qty / ct_mult) # 整张 +perp_eth = eth_qty / 比例 +contracts = perp_eth / contract_size +``` + +启动前再拉卖一重算;卖一深度不足则缩量。 + +## 4. 出场 + +触达任一目标位(做多 `index ≥ K+N`,做空 `index ≤ K−N`)后立即执行: + +| 触达 | 规则 | +|------|------| +| **期权目标** | 验买一流动性 + **扣费净利 > 0** → 先平期权再平永续 | +| **永续目标** | 市价平永续;期权 `hold_to_expiry` 至到期结算 | + +净利:平仓/卖出手续费**按买入费率**估算(`HEDGE_PLAN_FEE_RATE` / `OKX_TAKER_FEE`,默认 0.0005)。 + +若期权目标因买一/净利未过、但永续目标已触达 → 改走永续目标。 +期权已平永续失败 → `opt_target_perp_pending` 下轮只补平永续。 +两腿仍开但期权到期 → 结算期权并平永续,避免裸奔。 + +## 5. 代码落点 + +| 文件 | 作用 | +|------|------| +| `lib/hedge_plan/hedge_plan_option_primary_lib.py` | 定仓/方向/目标/净利/校验 | +| `hedge_plan_orders_lib.py` | 路径、开平永续、启动前定仓刷新 | +| `hedge_plan_monitor_lib.py` | `_tick_po_option_primary*` | +| `hedge_plan_register.py` / `hedge_plan_db.py` | preview/start/persist;`options-chain?option_primary&min_hours&strike_interval` | +| `hedge_plan.js` + `hedge_plan_panel.html` | env 模式标识、左三组参数、右上永续/右下期权 | + +## 6. 测试 + +```bash +python -m unittest tests.test_hedge_plan_option_primary -v +``` diff --git a/docs/对冲计划-选约与虚实值.md b/docs/对冲计划-选约与虚实值.md new file mode 100644 index 0000000..7ffdcf9 --- /dev/null +++ b/docs/对冲计划-选约与虚实值.md @@ -0,0 +1,76 @@ +# 对冲计划 · 选约与虚实值 + +> 实现日:2026-08-05 · 吸收 `eth_hedge_sim`(比特骆驼)选约几何;退出仍用本仓 TP/SL/S*,**不**移植仿真「净盈亏 15U 离场」。 + +## 1. 冻结规则 + +| 计划类型 | 允许虚实值 | 禁止 | 推荐模板 | +|----------|------------|------|----------| +| **永期保险** `perp_options`(开关关) | 实值、平值 | **虚值** | 距指数最近的实值/平值(做多 Put / 做空 Call) | +| **永期以期权为主** `option_primary=1` | 实值、平值、**虚值** | —(间隔+杠杆门) | 做多 Call+永续空 / 做空 Put+永续多;详见 `docs/对冲计划-以期权为主.md` | +| **期期** `options_options` | 平值、虚值 | **实值** | 平值跨式(ATM C+P);双虚值(OTM C+P) | + +口径与 `lib/options/options_pricing_lib.option_moneyness` 一致:ATM 带 = `max(指数×0.2%, 2U)`。 + +永期几何兜底(与仿真一致): + +- Call 实值/平值:`K ≤ S` +- Put 实值/平值:`K ≥ S` + +## 2. 代码落点 + +| 层 | 文件 | 作用 | +|----|------|------| +| 选约/校验库 | `lib/hedge_plan/hedge_plan_moneyness_lib.py` | `is_itm_or_atm` / `is_atm_or_otm` / `pick_*` / `recommend_oo_legs` / `validate_*` | +| 启动门禁 | `hedge_plan_orders_lib.validate_start_body` | 测算外再拦一遍;防绕过 UI 直 POST | +| 测算 | `hedge_plan_register._preview_po/_preview_oo` | 预览同样拒绝违规腿 | +| UI | `hedge_plan.js` + `hedge_plan_panel.html` | 筛选锁定、推荐按钮、选用前校验 | +| Env | `env_ui_manifest` 永期分组 | `HEDGE_PLAN_ITM_MAX_DIST_USD` / `MIN_OPTION_HOURS` / `MIN_OPTION_LEVERAGE` | + +## 3. Env + +| 键 | 默认 | 说明 | +|----|------|------| +| `HEDGE_PLAN_ITM_MAX_DIST_USD` | 空→沿用 `OKX_OPTIONS_ITM_MAX_DIST_USD`(常 30) | 永期过深实值上限;0=不限 | +| `HEDGE_PLAN_MIN_OPTION_HOURS` | 8 | 仅当请求带 `hours_to_expiry` 时生效 | +| `HEDGE_PLAN_MIN_OPTION_LEVERAGE` | 0 | `指数/卖一`;0=关闭 | + +## 4. 可用性审计 + +| 项 | 结论 | +|----|------| +| 默认筛选 | 永期默认「实值/平值」;期期默认「平/虚」—减少误选 | +| 推荐一键 | 永期「推荐」;期期「推荐跨式 / 推荐双虚」—降低手选成本 | +| 文案 | 规则说明与 alert 明确禁虚(永期)/禁实(期期) | +| 服务端一致 | UI 过滤可绕过时,preview/start 仍会 400 | +| 兼容旧 API | 未传 `strike` 时从 `inst_id` 解析;未传 `index_px` 时永期用 `entry`、期期用上下破中点 | +| 以期权为主 | 见 `docs/对冲计划-以期权为主.md`:点数目标+扣费净利出场(非仿真 15U 固定);保险模式仍不接仿真净盈亏离场 | + +**已知局限:** + +- 链上 `moneyness` 依赖刷新时指数;剧烈跳动后需「刷新链」再选。 +- `MIN_OPTION_HOURS` 需前端/调用方传入 `hours_to_expiry` 才校验(当前链行未必带该字段)。 +- 期期「推荐跨式」优先 ATM,若无 ATM 会回退到最近允许档(含 OTM)。 + +## 5. 安全性审计 + +| 风险 | 控制 | +|------|------| +| 客户端改包选虚值永期保险 | `validate_start_body` + preview 服务端拒绝 | +| 客户端选实值期期腿 | 同上 | +| 过深实值权利金过贵 / 杠杆过低 | `ITM_MAX_DIST` + 可选 `MIN_OPTION_LEVERAGE` | +| 误开实盘 | 既有 `HEDGE_PLAN_LIVE_ORDER` ∩ `LIVE_TRADING_ENABLED` ∩ 全仓(永期)门禁不变 | +| 保险模式平仓 | 不变:交易所 TP/SL | +| 以期权为主平仓 | 独立监控分支;不改保险模式路径 | + +## 6. 测试 + +```bash +python -m unittest tests.test_hedge_plan_moneyness tests.test_hedge_plan_orders -v +``` + +覆盖:虚实值几何、永期拒 OTM、期期拒 ITM、`validate_start_body` 集成。 + +## 7. 与开发方案对齐 + +更新 `docs/对冲计划开发方案.md` §3.2 / §4.1 选约约束,与本文件一致。 diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md new file mode 100644 index 0000000..3b0df28 --- /dev/null +++ b/docs/对冲计划开发方案.md @@ -0,0 +1,695 @@ +# OKX 对冲计划 — 开发方案 + +> 状态:**方案冻结**(实现前对照本页;改需求先改本文). +> 范围:**仅 `crypto_monitor_okx` 实例**;中控不做对冲开平. +> 相关策略说明:[期权对冲方案分析.md](./期权对冲方案分析.md) + +--- + +## 1. 目标与命名 + +在 OKX 实例增加独立模块 **「对冲计划」**,把「行情选腿 → 情景测算 →(条件满足时)自动开仓 → 规则退出 → 独立复盘统计」串成一套可状态跟踪的计划. + +| 产品名 | 英文键 | 含义 | +|--------|--------|------| +| **永期对冲** | `perp_options` | 同账户永续 + 买方期权 | +| **期期对冲** | `options_options` | 同账户内两条买方期权腿 | + +页面/导航展示用中文名;API/DB 用英文键. + +**不做(本方案外):** + +- 卖方期权、组合单原子成交 +- 中控代下单 +- 跨所对冲 +- 替代现有关键位/策略/期权页(可共存,但同标的限制见 §9) + +--- + +## 2. 计仓模式门禁 + +沿用 `[docs/position-sizing-mode.md](./position-sizing-mode.md)`: + +| `POSITION_SIZING_MODE` | 永期对冲 | 期期对冲 | +|------------------------|----------|----------| +| `risk`(以损定仓 / **非全仓**) | **仅测算**(左右行情 + 情景表);**禁止开仓/启动计划** | **允许开仓**(测算 + 启动计划) | +| `full_margin`(全仓杠杆) | **允许开仓** | **允许开仓** | + +说明: + +- **永期**依赖永续全仓名义与保证金节奏,故开仓仅限全仓. +- **期期**不占永续保证金,非全仓也允许开期权腿;非全仓下 UI 隐藏/禁用「启动永期」,仍可做永期**只读测算**. +- 切模式后若存在 `active` 永期计划,禁止切到 `risk`,或强制要求先结束计划(实现时二选一并写死校验). + +额外硬门: + +- 须 `OKX_OPTIONS_ENABLED=true` 且期权 API 可用. +- 实际开仓须实例允许实盘(`LIVE_ORDER` 等与永续/期权现有开关一致),并对冲计划自有开关见 §10. + +--- + +## 3. 永期对冲 — 仓位与选期权 + +### 3.1 永续开仓量(全仓) + +与现网全仓逻辑一致,对冲计划固定用于 **BTC / ETH**: + +``` +可用保证金 = 合约账户可用 USDT +占用保证金 = 可用 × FULL_MARGIN_BUFFER_RATIO(默认 0.98) +杠杆 = BTC_LEVERAGE / ETH 对应杠杆(默认 10x) +名义 ≈ 占用保证金 × 杠杆 +张数 = amount_to_precision(名义 / 价格 / 合约面值) +``` + +页面左侧展示:**建议张数、名义、止损亏损额、止盈盈利额**(用户填止盈价/止损价后按该仓位即时算). + +用户流程: + +1. 选标的 ETH/BTC、方向(多/空). +2. 系统按全仓 ×0.98×10x 算出永续张数与到止盈/止损的 U 盈亏. +3. **再据此挑选右侧期权**(保费、张数、行权价),使「止损时保险腿」与「止盈保费损耗」可接受. +4. 通过情景表确认后启动计划. + +### 3.2 期权腿选取 + +- 行情自动拉 OKX 期权链(复用 `build_option_chain`). +- **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**. +- **虚实值(冻结):**仅允许 **实值或平值**,**禁止虚值**(保险腿须有内在价值或贴近平值).详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md). +- 页面默认筛「实值/平值」,提供「推荐」取距指数最近档;服务端 `validate_start_body` / preview 二次校验. +- 权利金默认按 **卖一 ask** 估算;开仓限价买入. + +### 3.3 左右布局 + +``` +左:永续列表行情(标记/买卖一) + 方向/建议张数/开仓价/止盈/止损 +右:期权列表(可「选用」一条腿) +下:情景测算 → [保存草稿] [启动计划] +``` + +--- + +## 4. 期期对冲 — 仓位与选腿 + +### 4.1 报价与选腿 + +- **T 型报价链**(复用期权页 T 型样式/数据结构). +- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). +- **虚实值(冻结):**两腿仅允许 **平值或虚值**,**禁止实值**;推荐模板:平值跨式 / 双虚值.详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md). +- 预算:`B = min(交易户 USDC × 对冲缓冲 HEDGE_PLAN_BUDGET_BUFFER, 单笔预算)`(默认 buffer=0.95;与期权页 buffer 独立). +- 自动张数(选齐两腿后写入,可手改): + - **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n` + - **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改) + - 做多:主腿=Call;做空:主腿=Put + - 拆分口径 `HEDGE_PLAN_OO_BIAS_SPLIT_BY`:`budget`(默认,按权利金预算拆) / `sheets`(先按同张数得每腿 `n`,总张数 `2n` 再按比例拆到 Call/Put) + - 另受各自卖一深度上限约束 + - 已移除页面「均分」;后端仍兼容旧 `split_budget` 入参(预算对半) + +### 4.2 目标价 + +用户填 **预判价格 S\*** (「价格能到的位置」): + +- 系统标明在 S\* 时哪条腿为 **盈利方**、哪条为 **亏损方**. +- 到达规则见 §5.2. + +### 4.3 左右布局 + +``` +左:指数价 + 到期日 + 预算 + 目标价 S* +右:T 型链,依次选用两腿 +下:情景测算 → [保存草稿] [启动计划] +``` + +非全仓模式下期期布局同上(无永续区). + +--- + +## 5. 退出规则(冻结) + +### 5.1 永期对冲 + +| 事件 | 永续 | 期权 | 计划是否结束 | 设计意图 | +|------|------|------|--------------|----------| +| **永续止盈触发** | 交易所 TP 平仓 | **不强制平**(保险腿可自生自灭/人工) | **算结束** | 对冲计划以永续兑现目标收口 | +| **永续止损触发** | 交易所 SL 平仓 | **必须强制平仓** | **算结束** | 保护机制 | +| 期权单独到期 | — | 结算 | 若计划已因止盈结束则只更新腿快照,不再改计划合计 | | +| 人工结束计划 | 可选平永续 | 按选项 | **算结束** | | + +**计划结束时盈亏口径(写入 `realized_pnl_*`,推送与统计共用):** + +| 结束原因 | 公式(≈U,1:1) | 字段落库 | +|----------|--------------|----------| +| **止盈** `perp_tp` | **永续止盈已实现盈利 − 期权已付权利金** | `realized_pnl_perp` = 止盈盈利;`realized_pnl_options` = **−premium_total**(按权利金全额计成本,不论期权是否仍持仓);`realized_pnl_total` = 上两式之和 | +| **止损** `perp_sl` | **期权平仓盈利 − 永续止损亏损额** | `realized_pnl_options` = 期权强制平后已实现;`realized_pnl_perp` = 永续止损已实现(为负或记亏损额);`realized_pnl_total` = 期权盈利 − \|永续亏损\|(即有符号相加) | + +说明: + +- 止盈时期权**物理上可不平**,但 **计划账** 已按「保费打掉」收口,后续期权 thrift/到期盈亏 **不再回写计划合计**(可在腿上另记备注/浮盈,不进 `realized_pnl_total`). +- 止损时期权必须先强平再结账,用真实平仓盈亏,不是只扣权利金. + +永续侧 TP/SL:沿用实例 **交易所条件单**,监控识别成交后触发计划结束逻辑 + 微信推送. + +### 5.2 期期对冲 + +| 事件 | 盈利方 | 另一腿(残腿) | 计划是否结束 | +|------|--------|--------------|--------------| +| **标的价到达目标 + 平仓模式=到期平** | **自动平仓** | **不平**,持有至到期(`hold_expiry`) | 平盈利腿后仍 `active`;残腿到期后结账 | +| **标的价到达目标 + 平仓模式=全平**(默认) | **自动平仓** | **随即买一清残腿**(无 2×门控,失败则每轮重试) | 两腿都平完后 `closed` | +| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损** | +| **到期时组合合计仍盈利** | — | 到期结算 | **算结束**;按实际结算盈亏入账 | + +- 界面「平仓模式」仅控制**盈利腿已平之后**另一腿的处理;须 `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true`(默认开)才显示,页面默认选 **全平**. +- 关闭方案C开关时行为固定为 **到期平**. +- 判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). +- 盈利方判定:触达上破/下破时按浮盈较大一侧平仓;皆亏则等到期. + +### 5.2.1 期权腿实盘平仓执行(与期权页共用) + +对冲计划凡**必须物理平掉期权腿**时(如永期止损联动、期期平盈利腿),执行口径与独立期权模块一致: + +| 规则 | 说明 | +|------|------| +| 禁市价 | 代码硬关闭,无市价兜底 | +| 只锁买一 | 本轮 `min(仓位, 买一深度)` × 买一限价;`reduceOnly` | +| 分批 | 买一不够则剩余下一轮再平再锁新买一 | +| 有效流动性 | 残档买一禁止按买盘平 | +| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 | +| 平仓挂单 TTL | 卖出限价超 `OKX_OPTIONS_PENDING_TTL_SECONDS`(默认 10 分钟)自动撤;UI「委托」可见 | + +完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`. + +--- + +### 5.3 企业微信推送(起止必发) + +| 时机 | 是否必发 | 内容要点 | +|------|----------|----------| +| **计划开始**(开仓成功 → `active`) | **必发** | 类型(永期/期期)、标的方向、关键价位、张数/保费、计划 id | +| **计划结束** | **必发** | 结束原因、`realized_pnl_total`、分项(永续/期权)、是否止盈/止损/到期亏损 | +| 半腿失败 / 强平失败 | 必发告警 | 便于人工介入 | +| 目标价平掉盈利腿(期期中间态) | 建议发 | 注明亏损腿仍持有 | + +结束推送触发点与「算结束」一致:永期止盈、永期止损、期期到期收口(含无盈利总亏)、人工结束等. + +--- + +## 6. 情景测算(开仓前必显) + +### 6.1 永期 + +| 情景 | 含义 | +|------|------| +| 止盈 | 永续到 TP 的盈利 − 期权保费(期权按不强制平时的损耗估算) | +| 宽止损 | 永续到 SL 的亏损 + 期权平仓估值(强制平,用 mark/买一估算) | +| 到期横盘 | 永续≈0 + 期权权利金全损 | +| 价格扫描 | index ± 若干档合计 | + +核心输出:**宽止损合计亏损**(人工评估是否开仓). + +### 6.2 期期 + +| 情景 | 含义 | +|------|------| +| 到达 S\* | 盈利腿兑现估值 − 已付总保费中亏损腿残留 | +| 到期横盘 | 双腿权利金近似全损 | +| 到期大涨/大跌 | 结算内在价值 | + +--- + +## 7. 数据来源(行情自动) + +| 侧 | 来源 | +|----|------| +| 永续行情/规格 | OKX 账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 | +| 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) | +| 指数价 | 期权 `index_px`,左右对齐 | + +报价刷新:页面手动刷新 + 计划编辑态可选 10~30s 自动刷新. + +--- + +## 8. 自动开仓编排 + +### 8.1 永期(仅全仓) + +建议默认顺序:**先期权、后永续**(期权失败成本低;永续失败则提示处理刚开的期权). + +``` +校验 full_margin + LIVE + 无冲突计划 +→ 期权限价买入(卖一) +→ 永续全仓张数市价开仓 + 挂交易所 TP/SL +→ 写 hedge_plans / legs,状态 active +``` + +部分失败补偿(最小集): + +| 情况 | 动作 | +|------|------| +| 期权成、永续败 | 告警;建议自动平期权(可配置)或转人工 | +| 永续成、期权败 | 告警;可选撤永续或重试期权;计划标 `partial` | + +### 8.2 期期(全仓与非全仓均可开) + +``` +校验 LIVE + 期权资金 +→ 腿1 限价买 +→ 腿2 限价买 +→ active;记录 target_price S* +``` + +两腿间勿留长时间单腿敞口;第二腿失败则标 `partial` 并告警. + +--- + +## 9. 状态机与冲突 + +``` +draft → opening → active → closing → closed + ↘ partial / failed +draft → cancelled +``` + +冲突规则: + +- **同标的同时至多 1 条 active 永期计划**(全局可 `MAX_ACTIVE_HEDGE_PLANS`). +- 永期 active 时与全仓「单仓」一致:**不与额外永续仓并存**(启动前校验无其它持仓,或本计划即为该仓). +- 期权页对手动平「计划绑定 inst」应提示归属对冲计划. + +--- + +## 10. 历史记录、统计与复盘(独立) + +**入口:** OKX 顶栏 **「对冲计划」** 页内三个 Tab(不单开顶栏项): + +| Tab | 路由建议 | 内容 | +|-----|----------|------| +| **计划** | `/hedge-plan` | 新建 / 草稿 / 进行中 | +| **历史** | `/hedge-plan?tab=history` | 已结束与取消的计划列表 + 详情复盘 | +| **统计** | `/hedge-plan?tab=stats` | 独立统计看板 | + +**不得**并入普通「交易记录与复盘」`/records`、全站「统计分析」`/stats`、策略交易记录. +腿可可选关联 `options_trades` / 监控 id,但 **计划合计盈亏与胜率只读本模块表**. + +币种展示约定:永续腿 USDT、期权腿 USDC;合计列标注 **「≈U(1:1)」**,不做实时汇率换算. + +--- + +### 10.1 数据落库 + +库文件:OKX 实例 `crypto.db`(与其它表同库). + +#### `hedge_plans`(一条计划) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_type` | TEXT | `perp_options` 永期 / `options_options` 期期 | +| `status` | TEXT | draft / opening / active / closing / closed / partial / failed / cancelled | +| `underlying` | TEXT | BTC / ETH | +| `direction` | TEXT | 永期:long/short;期期可空 | +| `entry_mark` | REAL | 开仓参考价(标记/指数快照) | +| `tp` | REAL | 永期止盈价;可空 | +| `sl` | REAL | 永期止损价;可空 | +| `target_price` | REAL | 期期目标价 S\*;可空 | +| `sizing_mode_at_open` | TEXT | 开仓时 `risk`/`full_margin` 快照 | +| `perp_size` | REAL | 永期张数/币量快照;期期空 | +| `margin` | REAL | 占用保证金快照 | +| `leverage` | REAL | 杠杆快照 | +| `premium_total` | REAL | 期权已付权利金合计(USDC) | +| `realized_pnl_perp` | REAL | 永续已实现(USDT);止盈为正,止损为负 | +| `realized_pnl_options` | REAL | 期权账:止盈场景记 **−权利金**;止损场景记 **强平真实盈亏** | +| `realized_pnl_total` | REAL | 见 §5.1 / §5.2 公式;统计与微信共用此值 | +| `stats_bucket` | TEXT | 可选冗余:`tp` / `sl` / `oo_expiry_loss` / `oo_target` / `other` 便于统计筛选 | +| `close_reason` | TEXT | 见下表枚举 | +| `wechat_start_sent` | INTEGER | 开仓推送是否已发 | +| `wechat_end_sent` | INTEGER | 结束推送是否已发 | +| `note` | TEXT | 人工复盘短评 | +| `created_at` | TEXT | | +| `opened_at` | TEXT | 首次腿成交时间 | +| `closed_at` | TEXT | **计划结束时间**(止盈/止损/到期收口等) | +| `preview_json` | TEXT | 开仓前情景测算快照(可选) | + +**`close_reason` 枚举** + +| 值 | 含义 | 是否算计划结束 | 合计口径 | +|----|------|----------------|----------| +| `perp_tp` | 永续止盈 | **是**(立刻 closed) | **止盈盈利 − 权利金** | +| `perp_sl` | 永续止损 + 期权强制平 | **是** | **期权盈利 − 永续亏损** | +| `target_win_leg` | 期期已平盈利腿(中间态可暂不 closed) | 腿未齐前可不结束 | 待亏损腿到期后定合计 | +| `oo_expiry_loss` | 期期到期且合计无盈利 | **是** | **总亏损**(settled ≤ 0,常 ≈ −保费) | +| `oo_expiry_win` | 期期到期合计仍盈利 | **是** | 实际到期合计 | +| `expiry` | 其它到期收口 | **是** | 实际结算 | +| `manual` | 人工结束 | **是** | 按当时已实现 | +| `partial_fail` | 半腿失败收尾 | **是** | 按补偿结果 | +| `cancelled` | 未真正开仓取消 | 是(无盈亏) | 0 | + +说明:止盈结束时期权腿可标 `hold_to_expiry`/`orphaned_after_tp`,**计划已 closed**,后续期权盈亏不回写 `realized_pnl_total`. + +#### `hedge_plan_legs`(一条腿) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_id` | INTEGER FK | | +| `leg_role` | TEXT | `perp` / `option_hedge` / `option_a` / `option_b` | +| `symbol` 或 `inst_id` | TEXT | 永续符号或期权合约 id | +| `opt_type` | TEXT | C/P;永续空 | +| `strike` | REAL | 期权行权价 | +| `side` | TEXT | long/short 或 buy | +| `size` | REAL | 张数或币量 | +| `avg_open` | REAL | 开仓均价/权利金单价 | +| `premium` | REAL | 该腿已付权利金(期权) | +| `status` | TEXT | open / closed / hold_to_expiry | +| `linked_monitor_id` | INTEGER | 可选,永续监控 | +| `options_trade_id` | INTEGER | 可选,期权成交表 | +| `realized_pnl` | REAL | 该腿已实现 | +| `close_reason` | TEXT | 腿级原因 | +| `opened_at` / `closed_at` | TEXT | | + +--- + +### 10.2 历史列表(列定义) + +筛选:**类型**(全部/永期/期期)、**状态**、**标的**、**日期**(按 `opened_at` 或 `closed_at`). + +| 列 | 来源 | 展示 | +|----|------|------| +| ID | id | `#12` | +| 类型 | plan_type | 永期对冲 / 期期对冲 | +| 标的 | underlying + direction | 如 `ETH 多` / `ETH 双买` | +| 状态 | status | 中文标签 | +| 开仓时间 | opened_at | | +| 结束时间 | closed_at | 进行中显示 — | +| 平仓原因 | close_reason | 中文(止盈离场/止损联动平/目标价平盈利腿/到期…) | +| 保费 | premium_total | `x.xx USDC` | +| 合计盈亏 | realized_pnl_total | 着色 +/- ,单位 ≈U | +| 腿摘要 | legs | 如 `永续✓ · Put持仓` / `Call已平 · Put到期` | +| 操作 | | 详情 | + +行操作:**详情**(主)、可选「补写短评」. + +--- + +### 10.3 计划详情 / 复盘页(字段) + +从历史点进去的详情页 = **主复盘面**,分块如下. + +#### A. 计划摘要 + +| 项 | 字段 | +|----|------| +| 类型 / 标的 / 方向 | plan_type, underlying, direction | +| 状态 / 平仓原因 | status, close_reason | +| 时间线 | created_at → opened_at → closed_at | +| 开仓时计仓 | sizing_mode_at_open, margin, leverage, perp_size | +| 关键价位 | entry_mark, tp, sl(永期), target_price(期期) | +| 盈亏 | realized_pnl_perp / realized_pnl_options / realized_pnl_total | +| 开仓情景快照 | preview_json 折叠展示(止盈合计/宽止损合计等) | + +#### B. 腿明细表 + +| 列 | 说明 | +|----|------| +| 角色 | 永续 / 保险期权 / 期期腿A/B | +| 合约 | symbol / inst_id | +| 数量 | size | +| 开仓价/保费 | avg_open, premium | +| 状态 | open / closed / 持有至到期 | +| 盈亏 | realized_pnl | +| 平仓原因 | close_reason | +| 关联 | 链到期权成交或监控(有则显示) | + +#### C. 复盘短评(必做入口) + +| 项 | 说明 | +|----|------| +| `note` | 多行文本,可空;保存 `PATCH /api/hedge-plan//note` | +| 提示文案 | 建议写:开仓理由、结果是否符合情景测算、下次调整 | + +**不做(本期):** 复盘截图上传、填入「交易记录与复盘」表单、纳入 AI 日/周复盘. +**可后置:** 以计划摘要生成 AI 点评(独立按钮,不写进 trade_records). + +#### D. 终态示例文案(便于复盘理解) + +| 场景 | 详情页状态说明 | 合计 | +|------|----------------|------| +| 永期止盈 | 「计划已结束(止盈);期权腿可不强平,账上已扣全部权利金」 | 止盈盈利 − 权利金 | +| 永期止损 + 期权已强平 | 「计划已结束(止损保护:期权已联动平仓)」 | 期权盈利 − 永续亏损 | +| 期期到期无盈利 | 「计划已结束(到期无盈利)」 | 总亏损(计入统计) | +| 期期达 S\* 后亏损腿仍持有 | 「盈利腿已平;待亏损腿到期后结账」 | 暂不入 closed 统计,或单独「收尾中」 | + +--- + +### 10.4 统计页(独立看板) + +**筛选:** 日期区间、类型(全部/永期/期期)、标的、结束桶(`tp`/`sl`/`oo_expiry_loss`/…). + +**聚合规则:** 仅 `status=closed`;胜场 = `realized_pnl_total > 0`. + +#### 永期口径(冻结) + +| 统计桶 | `close_reason` | 单笔盈亏公式 | 汇总 | +|--------|----------------|--------------|------| +| **止盈统计** | `perp_tp` | **止盈盈利 − 期权权利金** | sum / 笔数 / 胜率 | +| **止损统计** | `perp_sl` | **期权盈利 − 永续亏损** | sum / 笔数 /「保护后净亏」均值 | + +实现校验示例: + +``` +# 止盈 +realized_pnl_total = pnl_perp_tp - premium_total + +# 止损(亏损额取绝对值) +realized_pnl_total = pnl_option_close - abs(pnl_perp_sl) +# 等价有符号: pnl_option_close + pnl_perp_sl(后者为负) +``` + +#### 期期口径(冻结) + +| 统计桶 | 条件 | 单笔盈亏 | +|--------|------|----------| +| **到期无盈利** | 到期收口且合计 ≤ 0 | **总亏损**写入 `realized_pnl_total`(负值),计入区间净亏与「到期亏损」汇总 | +| 目标价路径 | 盈利腿已平 + 亏损腿到期后 | 两腿 realized 之和 | +| 到期仍盈利 | 合计 > 0 | 实际结算合计 | + +#### 总览卡片 + +| 指标 | 计算 | +|------|------| +| 计划笔数 | count(closed) | +| 胜率 | 胜场 / 笔数 | +| 区间净盈亏 | sum(realized_pnl_total) | +| 止盈桶净盈亏 | sum where stats_bucket=tp | +| 止损桶净盈亏 | sum where stats_bucket=sl | +| 期期到期亏损合计 | sum where oo_expiry_loss(绝对值或带符号合计) | +| 总保费支出 | sum(premium_total) | +| 平均持仓时长 | avg(closed_at − opened_at) | + +#### 分类型卡片 + +永期、期期各一套:笔数、胜率、净盈亏、平均保费;永期再拆 **止盈桶 / 止损桶**. + +#### 退出结构 + +| 指标 | 过滤 | +|------|------| +| 止盈结束笔数 | `perp_tp` | +| 止损结束笔数 | `perp_sl` | +| 期期到期无盈利笔数 | `oo_expiry_loss` | +| 目标价路径完结 | 含 `target_win_leg` 后收尾 | +| 半腿/失败 | `partial_fail` / failed | + +#### 简易表(可选) + +最近 N 条已结束计划迷你列表,点击跳详情. + +**导出(P5 可选):** CSV. + +--- + +### 10.5 与现有页面关系 + +| 现有页 | 关系 | +|--------|------| +| 交易记录与复盘 | **不写入**;永续腿若系统仍落 `trade_records`,可标记来源「对冲计划#id」,但人工复盘以对冲详情为准 | +| 统计分析 | **不合并**对冲净盈亏到全站数字(避免重复或口径混乱) | +| 期权页成交/持仓 | 腿 `options_trade_id` 可跳转对照;期权页仍可看单腿 | +| 中控 | V1 不聚合;V2 可选只读摘要 | + +--- + +### 10.6 API(历史 / 统计 / 复盘) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/hedge-plan/list` | 进行中+草稿;`status` 过滤 | +| GET | `/api/hedge-plan/history` | 已结束列表;类型/日期/标的 | +| GET | `/api/hedge-plan/` | 详情 = 计划 + legs + note + preview | +| PATCH | `/api/hedge-plan//note` | 保存复盘短评 | +| GET | `/api/hedge-plan/stats` | query:`from`,`to`,`plan_type`,`underlying` → 总览+分类型+退出结构 | + +--- + +## 11. 监控线程 + +在 `crypto_okx` 同进程内新增 **`hedge_plan_monitor_loop`**(可与 `options_monitor_loop` 并列): + +| 职责 | | +|------|--| +| 永期 | 侦测永续 TP/SL → 按 §5.1 结束计划并结账;SL 时强制平期权 | +| 期期 | 侦测价触 S\* → 平盈利腿;到期无盈利 → 结束并记总亏损 | +| 微信 | **开始必推、结束必推**(§5.3);半腿/强平失败告警 | +| 幂等 | `wechat_start_sent` / `wechat_end_sent` 防重复推送 | + +--- + +## 12. 配置项与前端 env 页 + +对冲相关开关 **一律在 OKX 实例「env 配置」页维护**,不要求 SSH 改 `.env`. +实现对齐现有白名单模式(`lib/env/env_ui_manifest.py` 的「期权账户」分组). + +### 12.1 前端分组 + +在 env 配置页新增独立卡片,标题 **「对冲计划」**: + +- **仅 OKX** 展示(Binance/Gate 不出现) +- 放在 **「期权账户」下方**(依赖期权模块) +- 卡片说明文案建议: + - 永期开仓还要求「交易执行」里计仓模式为 **全仓**(`POSITION_SIZING_MODE=full_margin`) + - 真实下单还与「交易所与实盘」→ `LIVE_TRADING_ENABLED`、本卡片 `HEDGE_PLAN_LIVE_ORDER` 同时开启 + - `HEDGE_PLAN_ENABLED` 关闭时隐藏顶栏「对冲计划」并拒绝启动计划 + +### 12.2 本分组字段(前端可配) + +| 变量 | 前端标签 | 默认 | 控件 | 热更新 | 说明 | +|------|----------|------|------|--------|------| +| `HEDGE_PLAN_ENABLED` | 启用对冲计划 | false | bool | 热更优先 | 总开关:导航 + API | +| `HEDGE_PLAN_SHOW_PERP_OPTIONS` | 显示永期对冲 | true | bool | 热更 | 关则隐藏永期 Tab,不可测算/开仓 | +| `HEDGE_PLAN_SHOW_OPTIONS_OPTIONS` | 显示期期对冲 | true | bool | 热更 | 关则隐藏期期 Tab,不可测算/开仓 | +| `HEDGE_PLAN_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 | +| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 | +| `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** | +| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** | +| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 | +| `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED` | 期期平仓模式(方案C) | true | bool | 热更 | 开:页面可选到期平/全平;关:固定到期平 | +| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 | +| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 | +| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 | + +**不放入本分组、沿用已有卡片:** + +| 已有位置 | 键 | 对冲用途 | +|----------|-----|----------| +| 交易执行 | `POSITION_SIZING_MODE` | 全仓才允许永期开仓 | +| 交易执行 | `FULL_MARGIN_BUFFER_RATIO` | 默认 0.98 | +| 交易执行 | `BTC_LEVERAGE` | BTC/ETH 档(默认 10) | +| 交易所与实盘 | `LIVE_TRADING_ENABLED` | 总实盘门 | +| 期权账户 | `OKX_OPTIONS_*` | 期权 API、预算、标的 | + +### 12.3 `.env.example` 片段(实现时写入 OKX) + +```env +# --- 对冲计划(仅 OKX;前端 env「对冲计划」) --- +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_SHOW_PERP_OPTIONS=true +HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true +HEDGE_PLAN_LIVE_ORDER=false +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 +HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true +``` + +### 12.4 编码触点 + +| 文件 | 改动 | +|------|------| +| `lib/env/env_ui_manifest.py` | `_HEDGE_PLAN_SECTION`,`exchanges={"okx"}`,接入 `ui_sections_for_exchange` | +| `crypto_monitor_okx/.env.example` | 增加上表键与分组注释 | +| `lib/env/env_schema.py` | 纳入 `HOT_RELOAD_EXACT`(及必要时重启列表) | +| `docs/env配置说明.md` | 上线时补「对冲计划」小节 | + +推荐分期:**P0 先做 env 白名单 + 开关可读**,页面按 `HEDGE_PLAN_ENABLED` 显隐导航. + +--- + +## 13. 前端与路由(实现要点) + +- 导航:OKX 实例顶栏 **「对冲计划」**(display prefs 可加 `show_nav_hedge_plan`). +- 路由建议:`/hedge-plan`(主)、`/hedge-plan/history`、`/hedge-plan/stats`(或单页 Tab). +- 模板/静态:`lib/options` 旁新增 `lib/hedge_plan/`(或 `lib/hedge/`),复用: + - 期权链 CSS/T 型渲染思路(`options_panel.js` / `options-strike-table--t`) + - `compute_full_margin_sizing` / `position_sizing_lib` + - 永续下单 + TPSL、期权限价开平 API + +API 草图: + +- `GET /api/hedge-plan/market` — 永续行情 + 全仓试算 +- `GET /api/hedge-plan/options-chain` — 期权链 +- `POST /api/hedge-plan/preview` — 情景测算 +- `GET /api/hedge-plan/list` / `history` / `` / `stats` +- `PATCH /api/hedge-plan//note` — 复盘短评 +- `POST /api/hedge-plan` — 保存草稿/启动 +- `POST /api/hedge-plan//cancel|close` + +非全仓请求启动永期 → `400` 明确:「永期对冲开仓仅全仓模式可用」. + +--- + +## 14. 分期实施 + +| 阶段 | 交付 | 验收 | +|------|------|------| +| **P0** | env「对冲计划」分组 + 页框 + 行情 + 永期列表/期期 T + 情景测算 + 门禁 | env 可改开关;非全仓无法启动永期;全仓可看建议张数 | +| **P1** | 表结构 + 草稿/列表 | DB 可查 | +| **P2** | 永期自动开仓(全仓) | 双腿成交入计划 | +| **P3** | 监控:止损强制平期权;止盈不平期权 | 用例测 TP/SL 分支 | +| **P4** | 期期开仓 + 目标价只平盈利腿 | 达价仅平一侧 | +| **P5** | 历史列表列 + 详情复盘(note) + 统计看板 + 微信 | 与 /records /stats 隔离;止损/止盈文案可区分 | + +建议顺序严格;P3 规则错误会误平保险腿,上线前用 dry-run / paper flags. + +--- + +## 15. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 | +| [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 | +| [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 | +| [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) | 买一平仓、门控、监控与风险;线上 `/options/guide` | +| [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 | + +本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文. + +--- + +## 16. 已拍板规则摘要(校验清单) + +- [x] 放在 **OKX 实例**,名 **永期对冲 / 期期对冲** +- [x] 永期止盈 → **期权不强制平**,但 **计划算结束**;统计 = **止盈盈利 − 权利金** +- [x] 永期止损 → **期权必须强制平**,计划结束;统计 = **期权盈利 − 永续亏损** +- [x] 期期按目标价 → **只平盈利方**;到期无盈利 → **算结束并统计总亏损** +- [x] 对冲计划 **开始与结束均企业微信推送** +- [x] **独立历史 + 独立统计 + 计划详情复盘**(短评 note;不进普通交易复盘) +- [x] 永期开仓 **仅全仓**;非全仓永期只算账 +- [x] 非全仓允许 **期期**开仓;全仓永期+期期均可 +- [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权 +- [x] 行情自动;永期期权 **列表式**;期期 **T 型** +- [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组 +- [x] 期权腿实盘平仓:**禁市价、只锁买一、分批;流动性/2×门控见开平仓说明** + +--- + +## 17. 免责与边界 + +- 双账户非原子成交,存在半腿风险. +- 止盈结束账上按权利金全额计成本;期权若仍持仓,后续行情 **不再改计划合计**,属设计意图. +- 止盈后期权可能继续损耗直至到期,与「计划已结束」并存. +- 买方权利金可能全部损失;期期到期无盈利记总亏损,属设计意图. +- 本文不构成投资建议. diff --git a/docs/对冲计划策略与P0校验.md b/docs/对冲计划策略与P0校验.md new file mode 100644 index 0000000..3506fe7 --- /dev/null +++ b/docs/对冲计划策略与P0校验.md @@ -0,0 +1,109 @@ +# 对冲计划策略说明与 P0 校验 + +> 配套实现方案:[对冲计划开发方案.md](./对冲计划开发方案.md) +> 本文记录 **策略口径** 与 **P0 代码校验**,确认可继续 P1+. + +--- + +## 1. 策略摘要 + +| 类型 | 账户 | 作用 | +|------|------|------| +| **永期对冲** | 同账户永续 + 期权买方 | 全仓做方向,期权买保险 | +| **期期对冲** | 同账户双买方期权 | 目标价兑现盈利腿,亏损腿到期 | + +### 永期结束与统计 + +| 事件 | 期权处理 | 计划 | 统计公式 | +|------|----------|------|----------| +| 止盈 | 不强平 | **结束** | **止盈盈利 − 权利金** | +| 止损 | **强制平** | **结束** | **期权盈利 − 永续亏损**(有符号相加) | + +### 期期结束与统计 + +| 事件 | 处理 | 统计 | +|------|------|------| +| 达目标价 | 只平盈利腿 | 待亏损腿到期后结账 | +| 到期无盈利 | **结束** | **总亏损** | + +起止均企业微信推送(P2+ 监控落地后再接). + +### 门禁 + +- 永期开仓:**仅** `POSITION_SIZING_MODE=full_margin` +- 非全仓:永期可测算不可开;期期 P0 起可测算(开仓后续版本) +- env:OKX「对冲计划」分组;须 `HEDGE_PLAN_ENABLED=true` 才显示导航 + +--- + +## 2. P0 已交付 + +| 项 | 状态 | +|----|------| +| env 白名单「对冲计划」9 字段 | 有 | +| OKX `.env.example` 字段 | 有 | +| `/hedge-plan` 页(永期列表 / 期期 T) | 有 | +| `/api/hedge-plan/market|options-chain|preview|gates` | 有 | +| 全仓建议张数(可用×0.98×10x) | 有 | +| 情景测算止盈/止损口径 | 有 | +| 启动开仓 | **禁用**(明示 P0) | +| 历史/统计/监控/微信 | **未做**(P1–P5) | + +关键文件: + +- `lib/hedge_plan/hedge_plan_calc_lib.py` +- `lib/hedge_plan/hedge_plan_register.py` +- `lib/hedge_plan/templates/hedge_plan_panel.html` +- `lib/common/static/hedge_plan.js` +- `tests/test_hedge_plan_calc.py` + +--- + +## 3. 校验清单(可行性) + +### 计算口径 + +``` +止盈: perp_pnl(tp) - premium +止损: option_expiry_pnl(spot=sl) + perp_pnl(sl) +期期到期无盈利: expiry_flat_total <= 0 → 记总亏损 +``` + +单测覆盖:`tests/test_hedge_plan_calc.py`. + +### 门禁 + +- `risk` + 永期 → `can_start=false`,文案含「全仓」 +- `HEDGE_PLAN_ENABLED=false` → 导航隐藏(服务端 template 读 env) + +### 行情 + +- 永续:`exchange.fetch_ticker` + `get_available_trading_usdt` + `compute_full_margin_sizing` +- 期权:`build_option_chain`(与期权页同源) + +### 已知边界(非 P0 bug) + +1. 建议张数未强制 `amount_to_precision`(开仓阶段再对齐交易所精度). +2. 止盈账扣全额权利金,与期权是否仍持仓无关(策略如此). +3. 热更新 `HEDGE_PLAN_ENABLED` 后需刷新页面才显隐导航. +4. 嵌入壳 Tab 已注册 `hedge_plan`;中控能力勾选若需显式「对冲」可后续加. + +--- + +## 4. 服务器启用步骤 + +```bash +# env 配置页 → 对冲计划 → HEDGE_PLAN_ENABLED=true → 保存 +# 或服务器: +cd /opt/crypto_monitor/crypto_monitor_okx +# 确保 .env 含 HEDGE_PLAN_* 字段后 +pm2 restart crypto_okx --update-env +``` + +验收: + +1. 顶栏出现「对冲计划」 +2. 永期可见建议张数(全仓时) +3. 点「计算」得到止盈/止损合计 +4. 「启动计划」禁用 +5. env 页可见「对冲计划」分组 diff --git a/docs/期权开平仓与监控说明.md b/docs/期权开平仓与监控说明.md new file mode 100644 index 0000000..b6129e7 --- /dev/null +++ b/docs/期权开平仓与监控说明.md @@ -0,0 +1,104 @@ +# OKX 期权 — 开平仓与监控说明 + +> 独立页查看(登录后):`/options/guide` +> 对冲计划侧同步见 [对冲计划开发方案.md](./对冲计划开发方案.md) §期权腿开平仓. + +--- + +## 1. 开仓方式 + +| 项 | 规则 | +|----|------| +| 方向 | **仅买方**(Call / Put 限价买入) | +| 价格 | **卖一 ask** 限价;无卖一时可用标记估算展示,实下单仍以可挂限价为准 | +| 张数 | 1 张 = 0.01 ETH/BTC;可按预算打满或指定数量 | +| 资金 | 交易账户 **USDC**(或 USDG);不自动兑划 | +| 入口 | OKX 实例 **期权** 页列表 / T 型;对冲计划可带期权腿开仓 | + +开仓后写入 `options_trades`(open),并在持仓卡展示权利金、买盘深度、按买一可回收等. + +**同合约加仓**:每次买入再插一条 open 记录;展示权利金 / 平仓门控 / 翻倍提醒按同合约 **SUM(premium_paid)** 汇总,不再只取最新一笔. + +--- + +## 2. 平仓方式 + +### 2.1 统一规则(手动 / 目标自动共用执行核) + +1. **禁止市价平仓**(代码硬关闭,忽略 `OKX_OPTIONS_ALLOW_MARKET_CLOSE`). +2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价. +3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一. +4. 全程 `reduceOnly` 限价卖. +5. **平仓限价挂单超时自动撤**:卖出/平仓委托未成交超过默认 **10 分钟**(`OKX_OPTIONS_PENDING_TTL_SECONDS`,默认 600)由监控自动撤销,并可微信通知;UI「委托」面板实时展示挂单与剩余自动撤倒计时. + +示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发. + +### 2.2 手动「买一平仓」 + +- 入口:持仓卡按钮. +- **只校验有效流动性**(买一非残档、有深度). +- **不卡**「回收 ≥ 2×权利金」门控(用户主动平仓). + +### 2.3 目标位自动平仓 + +- 设置目标指数后由监控轮询;Call 指数 ≥ 目标 / Put 指数 ≤ 目标触发. +- 触发后走同一买一执行核. +- **额外门控**(不是独立自动平):仅当目标已触达时才检查;买一可回收 ≥ **权利金 × 2**,且连续约 **120 秒**(env:`OKX_OPTIONS_CLOSE_RECYCLE_MULT` / `OKX_OPTIONS_CLOSE_HOLD_SECONDS`).**到 2× 本身不会自动平仓**. +- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. +- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. + +### 2.4 翻倍出场(可选) + +- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金). +- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件). +- 可随时关闭;与目标位监控并行,谁先达标谁平. +- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单. + +--- + +## 3. 监控逻辑 + +| 监控 | 行为 | +|------|------| +| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** | +| 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 | +| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | +| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | +| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | +| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 | +| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) | +| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | + +--- + +## 4. 平仓校验(门控) + +| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 | +|------|------------|------------|----------|------| +| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | +| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 | +| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 | +| 锁定买一价 | ✅ | ✅ | ✅ | 下单价 = 通过校验时的买一 | +| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 | + +--- + +## 5. 风险点 + +1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤档,限价可能挂着 — 超 TTL 会自动撤,之后需再次点平或等目标触发. +2. **开仓挂单**:买入委托不在超时自动撤范围(仅平仓卖单);可在「委托」面板手动撤销. +3. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价. +4. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期. +5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. +6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. +7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. +8. **期期自动张数**:对冲计划页为「同张数 / 做多 / 做空」(已无均分);做多/做空按 Call·Put 比例拆,口径与比例见 env `HEDGE_PLAN_OO_BIAS_SPLIT_BY`、`HEDGE_PLAN_OO_BIAS_RATIO`. + +--- + +## 6. 相关文档 + +- [期权用法.md](./期权用法.md) — 资金兑划与页面操作 +- [期权方案.md](./期权方案.md) — env 与架构 +- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿 +- [系统说明.md](./系统说明.md) — 实例操作与门禁总手册 diff --git a/docs/期权方案.md b/docs/期权方案.md new file mode 100644 index 0000000..8c4f278 --- /dev/null +++ b/docs/期权方案.md @@ -0,0 +1,78 @@ +# OKX 期权模块 — 技术方案 + +> 适用范围:`crypto_monitor_okx` 实例;永续与期权共用同一套 `OKX_API_*`,不新增 PM2 进程. + +## 1. 目标 + +在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力: + +- 永续/关键位与期权:**同一账户 API**(`OKX_API_*`) +- 两个 ccxt 客户端:`exchange`(defaultType=swap)与 `exchange_options`(defaultType=option),身份相同 +- 顶栏资金:**资金账户/交易账户=USDT**;**期权资金/期权交易=USDC** +- 支持 **手动 USDT→USDC 兑换** 与 **账户内划转**(无主↔子划转) +- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC) + +## 2. 交易规则(硬约束) + +| 规则 | 说明 | +|------|------| +| 仅买方 | 开仓 `buy`,平仓 `sell`;禁止卖方开仓 | +| 产品 | `BTC-USD_UM` / `ETH-USD_UM`(线性,USDC/USDG 结算) | +| 到期 | 仅展示 ≤2 日到期合约(可配置 `OKX_OPTIONS_MAX_DTE_DAYS`) | +| 虚实 | 仅 **轻度实值**(`OKX_OPTIONS_ITM_ONLY`) | +| 合约规格 | **1 张 = 0.01 ETH/BTC**(`ctMult=0.01`,以接口为准) | +| 报价单位 | 盘口 ask/bid = **每 1 ETH/BTC** 的 USD 价 | +| 权利金 | `总权利金 = 报价 × ETH数量`;`张数 = ETH数量 / 0.01` | +| 单笔预算 | `≤ OKX_OPTIONS_TRADE_BUDGET_USDC`(默认 10),算张数 × `OKX_OPTIONS_BUDGET_BUFFER`(默认 0.95) | +| 开仓 | 限价买单,价格 = 卖一 | +| 平仓 | 限价卖单,价格 = 买一(市价需显式开启且二次确认) | +| 监控 | 浮盈 / 已付权利金 ≥ 100% → 企业微信推送一次 | + +## 3. 架构 + +``` +crypto_okx(单 PM2) +├── exchange (swap) ← OKX_API_* +└── exchange_options (option)← 同一套 OKX_API_* + +lib/options/ +├── okx_options_lib.py # 封装于 lib/exchange/ +├── options_pricing_lib.py +├── options_db.py +├── options_monitor_lib.py +└── options_register.py # 路由 + 监控线程 +``` + +**说明:** `defaultType` 分离避免错路由;密钥身份唯一.旧 `OKX_OPTIONS_API_*` 已废弃. + +## 4. 资金与兑换 + +### 4.1 展示(实例顶栏) + +- **资金账户 / 交易账户**:USDT(永续侧) +- **期权资金账户 / 期权交易账户**:USDC +- 总资金:USDT + USDC(1:1),同账户 USDT 不重复累加期权侧 USDT + +### 4.2 兑换与划转 + +- 系统设置「币种兑换」:资金账户内 USDT ↔ USDC +- 「期权划转」:同账户 funding ↔ trading(USDC/USDT) +- **已移除**主↔子账户划转 + +## 5. 环境变量(要点) + +```bash +OKX_API_KEY= +OKX_API_SECRET= +OKX_API_PASSPHRASE= +OKX_OPTIONS_ENABLED=true +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 +``` + +详见 [env配置说明.md](./env配置说明.md) 与 `.env.example`. + +## 6. 相关文档 + +- [期权用法.md](./期权用法.md) +- [对冲计划开发方案.md](./对冲计划开发方案.md) diff --git a/docs/期权用法.md b/docs/期权用法.md new file mode 100644 index 0000000..d682679 --- /dev/null +++ b/docs/期权用法.md @@ -0,0 +1,107 @@ +# OKX 期权 — 使用说明 + +## 1. 前置条件 + +1. OKX 账户已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`. +2. 在 `crypto_monitor_okx/.env` 配置 **唯一账户 API**(永续与期权共用): + +```bash +OKX_API_KEY=你的账户Key +OKX_API_SECRET=... +OKX_API_PASSPHRASE=... +OKX_OPTIONS_ENABLED=true +``` + +3. 重启实例:`pm2 restart crypto_okx --update-env` + +> 旧 `OKX_OPTIONS_API_*` 已废弃.若仅残留 OPTIONS 键而 `OKX_API_*` 为空,启动会自动回填. + +## 2. 资金准备 + +期权权利金使用 **USDC**,不能直接用 USDT 买入. + +### 推荐步骤 + +1. 查看顶栏: + - **资金账户 / 交易账户**:USDT + - **期权资金账户 / 期权交易账户**:USDC +2. **币种兑换**(资金账户内) + - 从 USDT 兑换为 USDC + - 先点 **询价**,确认预估获得量后点 **确认兑换** +3. **账户内划转** + - 从:资金账户 → 到:交易账户 + - 币种:USDC + - 将兑换得到的 USDC 划到交易账户 +4. 确认 **期权交易账户 USDC** 足够支付本笔权利金 + +系统 **不会** 自动兑换或划转,避免误动资金. + +## 3. 下单流程 + +1. 顶栏进入 **期权** +2. 选择 **ETH** 或 **BTC** +3. 选择 **到期日**(默认仅 1~2 日) +4. 选择 **看涨 Call** 或 **看跌 Put** +5. 在行权价列表中选 **轻度实值** 合约 +6. 查看: + - **卖一价**(每 1 ETH/BTC 的报价) + - **张数 / ETH 数量** + - **预估权利金**(USDC) +7. 选择 **按预算打满**(默认 10U×0.95)或 **指定 ETH 数量** +8. 点击 **限价买入**(价格 = 卖一) + +### 张数说明 + +- **1 张 = 0.01 ETH**(或 0.01 BTC)— 与 OKX App「合约价值」一致 +- 盘口报价是 **每 1 ETH** 的价格 + 例:报价 15.6,买 0.5 ETH(50 张)→ 权利金 ≈ 15.6 × 0.5 = **7.8 USDC** + +## 4. 持仓与平仓 + +持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,净盈亏,收益率,到期等. + +**买一平仓:** + +1. 在持仓卡点击 **买一平仓** +2. 系统重读盘口并校验有效买一(非残档) +3. 本轮只按买一深度限价卖出;买一不够则剩余下次再平 +4. **市价平仓已禁用**(代码硬关闭) + +目标位自动平另需「可回收 ≥ 2×权利金并持续约 2 分钟」;细则见独立说明: + +- 仓库文档:[期权开平仓与监控说明.md](./期权开平仓与监控说明.md) +- 线上(登录后):`/options/guide` + +## 5. 微信提醒 + +需已配置 `WECHAT_WEBHOOK`. + +| 场景 | 标题 | 说明 | +|------|------|------| +| **开仓** | 【OKX期权·开仓】 | 下单成功并写入本地后必发(幂等) | +| **平仓** | 【OKX期权·平仓】 | 手动全平 / 目标位全平 / 到期或交易所平仓同步后必发(幂等) | +| 浮盈翻倍 | 【OKX期权·翻倍提醒】 | 未实现盈亏 ≥ 已付权利金约 100%,同一笔只提醒一次 | +| 挂单超时撤销 | 【OKX期权·挂单超时撤销】 | 平仓挂单超时被系统撤销 | + +## 6. 与永续 / 对冲计划的关系 + +| | 永续 | 期权 | +|--|------|------| +| API | `OKX_API_*`(同一套) | `OKX_API_*`(同一套) | +| 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 | +| 顶栏 | USDT 资金户+交易户 | USDC 期权资金+期权交易 | + +**期期对冲张数**(对冲计划页,与单独开期权共用预算算法): + +| 模式 | 说明 | +|------|------| +| 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 | +| 按比例 | 主腿/次腿按 `HEDGE_PLAN_OO_BIAS_*` | + +## 7. 常见问题 + +**Q:以前的期权专用密钥还要配吗?** +- 不需要.统一写到 `OKX_API_*`. + +**Q:还能主↔子划转吗?** +- 已移除.只保留同账户内划转与币种兑换. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..91fe5cc --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,25 @@ +/** + * CoinOX PM2 进程定义 (Ubuntu / Linux). + * + * 启动: pm2 start ecosystem.config.cjs + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "coinox", + 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/ai/__init__.py b/lib/ai/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/ai/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/ai/ai_client.py b/lib/ai/ai_client.py new file mode 100644 index 0000000..454bffd --- /dev/null +++ b/lib/ai/ai_client.py @@ -0,0 +1,540 @@ +"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一. + +配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env), +若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空. +""" +from __future__ import annotations + +import base64 +import os +import re +from typing import List, Optional, Sequence, Tuple + +import requests + + +def _env_str(name: str, default: str = "") -> str: + v = os.getenv(name) + if v is None: + return default + return str(v).strip() + + +def _ai_timeout_seconds(*, image_count: int = 0, chat: bool = False) -> int: + if chat: + try: + return max(30, int(_env_str("CHAT_AI_TIMEOUT_SECONDS", "300") or "300")) + except ValueError: + return 300 + if image_count > 0: + try: + return max(30, int(_env_str("AI_REVIEW_TIMEOUT_SECONDS", "300") or "300")) + except ValueError: + return 300 + try: + return max(10, int(_env_str("AI_TIMEOUT_SECONDS", "120") or "120")) + except ValueError: + return 120 + + +def _ai_provider() -> str: + return (_env_str("AI_PROVIDER", "openai") or "openai").lower() + + +def _openai_api_base() -> str: + base = _env_str("OPENAI_API_BASE", "https://op.bz121.com/v1") or "https://op.bz121.com/v1" + return base.rstrip("/") + + +def _openai_api_key() -> str: + return _env_str("OPENAI_API_KEY") or _env_str("AI_API_KEY") + + +def _openai_model() -> str: + return _env_str("OPENAI_MODEL", "gemma4:e4b") or "gemma4:e4b" + + +def _ollama_api() -> str: + return _env_str("OLLAMA_API", "http://127.0.0.1:11434/api/generate") or "http://127.0.0.1:11434/api/generate" + + +def _ollama_model() -> str: + return _env_str("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") or "huihui_ai/deepseek-r1-abliterated:latest" + + +def _use_openai() -> bool: + return _ai_provider() in ("openai", "openai_compatible", "gateway") + + +def _image_mime_for_path(path: str) -> str: + ext = os.path.splitext(str(path or ""))[1].lower() + if ext == ".png": + return "image/png" + if ext in (".jpg", ".jpeg"): + return "image/jpeg" + if ext == ".webp": + return "image/webp" + if ext == ".gif": + return "image/gif" + return "image/jpeg" + + +def _read_image_base64(image_path: str) -> Optional[tuple]: + try: + with open(image_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + return b64, _image_mime_for_path(image_path) + except Exception: + return None + + +def _collect_images( + image_paths: Optional[Sequence[str]] = None, + images_b64: Optional[Sequence[str]] = None, +) -> List[tuple]: + out: List[tuple] = [] + for p in image_paths or []: + item = _read_image_base64(p) + if item: + out.append(item) + for b in images_b64 or []: + if b: + out.append((str(b), "image/jpeg")) + return out + + +def _openai_chat_url() -> str: + base = _openai_api_base() + if base.endswith("/chat/completions"): + return base + return f"{base}/chat/completions" + + +def _openai_message_text(msg: dict) -> str: + content = msg.get("content") + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(str(part.get("text") or "")) + content = "".join(parts) + text = str(content or "").strip() + if text: + return text + # 部分网关/模型把正文放在 reasoning_content;gemma 系则常写在 reasoning + for key in ("reasoning_content", "reasoning"): + alt = str(msg.get(key) or "").strip() + if not alt: + continue + # 英文链式思考不算可交付正文,留给上层按 finish=length 重试 + low = alt[:80].lower() + if low.startswith("here's a thinking process") or low.startswith("here is a thinking process"): + continue + if low.startswith("thinking process") or "analyze the request" in low: + continue + return alt + return "" + + +def _apply_max_tokens(body: dict, max_tokens: int | None, *, chat: bool = False) -> None: + if max_tokens is not None and max_tokens > 0: + mt = int(max_tokens) + body["max_tokens"] = mt + # 部分 OpenAI 兼容网关对 max_tokens + max_completion_tokens 双写不友好 + if chat: + body["max_completion_tokens"] = mt + + +def _openai_chat_completion( + messages: list[dict], + *, + temperature: float, + max_tokens: int | None = None, + image_count: int = 0, + chat: bool = False, +) -> Tuple[str, str]: + api_key = _openai_api_key() + if not api_key: + return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + body: dict = { + "model": _openai_model(), + "messages": messages, + "temperature": temperature, + "stream": False, + } + _apply_max_tokens(body, max_tokens, chat=chat) + r = requests.post( + _openai_chat_url(), + headers=headers, + json=body, + timeout=_ai_timeout_seconds(image_count=image_count, chat=chat), + ) + r.raise_for_status() + data = r.json() + choices = data.get("choices") or [] + if not choices: + return "AI 生成失败:响应无 choices", "error" + choice = choices[0] or {} + msg = choice.get("message") or {} + text = _openai_message_text(msg) + finish = str(choice.get("finish_reason") or "") + + # gemma 等会先把 token 花在 reasoning 上:过小 max_tokens 时 content 为空且 finish=length + if not text: + retry_body = dict(body) + retry_body.pop("max_completion_tokens", None) + cur = int(retry_body.get("max_tokens") or 0) + retry_body["max_tokens"] = max(cur, 4096 if chat else 8192) + r2 = requests.post( + _openai_chat_url(), + headers=headers, + json=retry_body, + timeout=_ai_timeout_seconds(image_count=image_count, chat=chat), + ) + r2.raise_for_status() + data2 = r2.json() + choices2 = data2.get("choices") or [] + if choices2: + choice2 = choices2[0] or {} + msg2 = choice2.get("message") or {} + text2 = _openai_message_text(msg2) + finish2 = str(choice2.get("finish_reason") or finish) + if text2: + return text2, finish2 + finish = finish2 or finish + if not text: + return f"AI 生成失败:空内容(finish={finish or '?'})", finish or "error" + return text, finish + + +def _generate_openai( + prompt: str, + images: List[tuple], + temperature: float, + *, + max_tokens: int | None = None, +) -> str: + if images: + content: List[dict] = [{"type": "text", "text": prompt}] + for b64, mime in images: + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } + ) + messages = [{"role": "user", "content": content}] + else: + messages = [{"role": "user", "content": prompt}] + text, _reason = _openai_chat_completion( + messages, + temperature=temperature, + max_tokens=max_tokens, + image_count=len(images), + ) + return text + + +def _generate_ollama( + prompt: str, + images: List[tuple], + temperature: float, + *, + max_tokens: int | None = None, + chat: bool = False, +) -> Tuple[str, str]: + options: dict = {"temperature": temperature} + if max_tokens is not None and max_tokens > 0: + options["num_predict"] = int(max_tokens) + payload = { + "model": _ollama_model(), + "prompt": prompt, + "stream": False, + "options": options, + } + if images: + payload["images"] = [b64 for b64, _mime in images] + r = requests.post( + _ollama_api(), + json=payload, + timeout=_ai_timeout_seconds(image_count=len(images), chat=chat), + ) + r.raise_for_status() + data = r.json() + text = (data.get("response") or "").strip() or "AI 生成失败" + return text, str(data.get("done_reason") or "") + + +def ai_generate( + prompt: str, + *, + image_paths: Optional[Sequence[str]] = None, + images_b64: Optional[Sequence[str]] = None, + temperature: float = 0.2, + max_tokens: int | None = None, +) -> str: + """统一文本生成;失败时返回以「AI 调用失败」开头的说明.""" + images = _collect_images(image_paths, images_b64) + try: + if _use_openai(): + out = _generate_openai(prompt, images, temperature, max_tokens=max_tokens) + else: + out, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens) + # 附图导致空正文时,降级为纯文本再试一次(复盘仍可用) + if ( + images + and isinstance(out, str) + and (out.startswith("AI 生成失败:空内容") or out.startswith("AI 调用失败")) + ): + if _use_openai(): + return _generate_openai(prompt, [], temperature, max_tokens=max_tokens or 8192) + text, _reason = _generate_ollama(prompt, [], temperature, max_tokens=max_tokens or 8192) + return text + return out + except requests.HTTPError as e: + detail = "" + try: + detail = (e.response.text or "")[:500] + except Exception: + pass + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}" + except Exception as e: + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov}):{str(e)}" + + +_CHAT_CONTINUE_USER = ( + "你上一条回复在中途截断了.请从断点处继续写完,不要重复已写内容," + "保持同一语气;编号列表每条单独一行." +) +_CHAT_END_CHARS = ".!?.!?\"」』))>】" +_INCOMPLETE_TAIL_RE = re.compile( + r"(不会|不能|没有|会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|" + r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$" +) + + +def _looks_truncated(text: str) -> bool: + t = (text or "").rstrip() + if len(t) < 16: + return False + if t[-1] in _CHAT_END_CHARS: + return False + if _INCOMPLETE_TAIL_RE.search(t): + return True + if t.endswith("…") or t.endswith("..."): + return True + if re.search(r"\d+\.\s*$", t): + return True + return t[-1] not in ",,,;;::\n" + + +def _should_continue(reason: str, full_text: str) -> bool: + if reason in ("length", "max_tokens", "model_length"): + return True + return _looks_truncated(full_text) + + +def _chat_continue_message(full_text: str) -> str: + tail = full_text[-500:] if len(full_text) > 500 else full_text + return ( + f"{_CHAT_CONTINUE_USER}\n\n" + f"已写到最后这几句:\n「{tail}」\n\n" + f"请从断点接着写完.不要重复前文;最后一句话必须以句号,问号或感叹号结束." + ) + + +def _chat_continue_system(system: str) -> str: + return ( + f"{system.strip()}\n\n" + "【续写模式】只输出断点后的剩余内容,不要重复前文;" + "列表每条单独一行;必须以句号,问号或感叹号收尾." + ) + + +def ai_generate_chat( + *, + system: str, + user: str, + temperature: float = 0.5, + images_b64: Optional[Sequence[str]] = None, + max_tokens: int = 8192, + max_continuations: int = 4, +) -> str: + """聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文).""" + images = _collect_images(None, images_b64) + max_rounds = max(1, int(max_continuations) + 1) + try: + if _use_openai(): + if images: + user_content: List[dict] | str = [{"type": "text", "text": user.strip()}] + for b64, mime in images: + user_content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } + ) + else: + user_content = user.strip() + base_user_msg = {"role": "user", "content": user_content} + messages: list[dict] = [ + {"role": "system", "content": system.strip()}, + base_user_msg, + ] + + parts: list[str] = [] + for attempt in range(max_rounds): + chunk, reason = _openai_chat_completion( + messages, + temperature=temperature, + max_tokens=max_tokens, + image_count=len(images) if attempt == 0 else 0, + chat=True, + ) + if chunk.startswith("AI 调用失败") or chunk.startswith("AI 生成失败"): + return chunk if not parts else "".join(parts).strip() + parts.append(chunk) + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: + break + messages = [ + {"role": "system", "content": _chat_continue_system(system)}, + {"role": "assistant", "content": full}, + {"role": "user", "content": _chat_continue_message(full)}, + ] + return "".join(parts).strip() or "AI 生成失败:空内容" + + prompt = f"{system.strip()}\n\n---\n\n{user.strip()}" + parts: list[str] = [] + for attempt in range(max_rounds): + if parts: + full = "".join(parts) + current_prompt = ( + f"{_chat_continue_system(system)}\n\n" + f"【你已写道】\n{full}\n\n{_chat_continue_message(full)}" + ) + else: + current_prompt = prompt + chunk, reason = _generate_ollama( + current_prompt, + images if not parts else [], + temperature, + max_tokens=max_tokens, + chat=True, + ) + if chunk.startswith("AI 生成失败") and not parts: + return chunk + if chunk.startswith("AI 生成失败"): + break + parts.append(chunk) + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: + break + return "".join(parts).strip() or "AI 生成失败:空内容" + except requests.HTTPError as e: + detail = "" + try: + detail = (e.response.text or "")[:500] + except Exception: + pass + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}" + except Exception as e: + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov}):{str(e)}" + + +def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: + # 附图过多时网关易超时/空回复;保留前几张即可支撑第5节 + raw_paths = [p for p in (image_paths or []) if p] + try: + max_imgs = max(0, int(_env_str("AI_REVIEW_MAX_IMAGES", "4") or "4")) + except ValueError: + max_imgs = 4 + capped_paths = raw_paths[:max_imgs] if max_imgs else [] + n_img = len(capped_paths) + n_skipped = max(0, len(raw_paths) - n_img) + period_label = "周" if "周" in str(period_title) else "日" + attach_note = ( + f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图)" + + (f",另跳过 {n_skipped} 张以控制体积" if n_skipped else "") + + ",请结合附图分析第5节.】\n\n" + if n_img + else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n" + ) + prompt = f""" +你是一位专业交易教练.下面是用户的{period_title}交易记录,请做简洁,可执行的复盘(中文). + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损,是否扛单,亏损是否扩大,图上具体结构/进出场点位等记录里**没有**的信息. +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价,是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」. +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等. +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据. +- 禁止用语:人身攻击,夸张定性(如「致命伤」「灾难」);语气克制,对事不对人. +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事. + +【输出格式 — Markdown,必须严格遵守】 +- 第一行:**交易复盘报告({period_label}度)** +- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名): + **1. 📊 总体盈亏结构** + **2. 🧠 心态与执行** + **3. 🏷️ 行为标签** + **4. ✅ 改进建议** + **5. 📈 图表分析** +- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.` +- 第1节至少包含:**笔数/盈亏**,**风险回报比**,**总结** +- 第2节至少包含:**得分**(1–10),**依据**(对应记录字段) +- 第5节至少包含:**趋势确认**,**执行路径**(记录不足则写明) +- 语气简洁,少形容词;不要输出代码块,不要表格 + +交易记录: +{trades_text} +""".strip() + try: + review_max = max(1024, int(_env_str("AI_REVIEW_MAX_TOKENS", "8192") or "8192")) + except ValueError: + review_max = 8192 + return attach_note + ai_generate( + prompt, + image_paths=capped_paths, + temperature=0.2, + max_tokens=review_max, + ) + + +def ai_short_advice(prompt_text: str) -> str: + prompt = f""" +你是交易风控助理.请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制,具体,可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + return ai_generate(prompt, temperature=0.2) + + +def ai_provider_label() -> str: + if _use_openai(): + return f"OpenAI 兼容 · {_openai_model()} @ {_openai_api_base()}" + return f"Ollama · {_ollama_model()}" + + +def ai_config_status() -> dict: + """调试用:当前进程内读到的 AI 配置(不含密钥明文).""" + key = _openai_api_key() + return { + "provider": _ai_provider(), + "openai_base": _openai_api_base(), + "openai_model": _openai_model(), + "openai_key_configured": bool(key), + "ollama_api": _ollama_api(), + "ollama_model": _ollama_model(), + } diff --git a/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py new file mode 100644 index 0000000..c81f443 --- /dev/null +++ b/lib/ai/ai_review_lib.py @@ -0,0 +1,178 @@ +"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用).""" +from __future__ import annotations + +import os +import uuid +from typing import Any, Callable, List, Mapping, Optional, Sequence + +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_ANCHOR_CLOSE, + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + normalize_chart_timeframe, +) +from lib.instance.journal_images_lib import journal_image_paths + + +def _journal_nz(v: Any, default: str = "无") -> str: + if v is None: + return default + s = str(v).strip() + return s if s else default + + +def _row_get(row: Any, key: str, default: Any = None) -> Any: + """兼容 dict 与 sqlite3.Row(Row 无 .get 方法).""" + if row is None: + return default + getter = getattr(row, "get", None) + if callable(getter): + return getter(key, default) + try: + keys = row.keys() if hasattr(row, "keys") else () + if key in keys: + return row[key] + except Exception: + pass + try: + return row[key] + except (KeyError, TypeError, IndexError): + return default + + +def journal_row_lines_for_ai( + idx: int, + row: Any, + *, + include_hold_duration: bool = True, +) -> str: + """把 journal 字段拼成给 AI 的文本;三所日复盘/周复盘共用.""" + lines = [ + ( + f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} " + f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U " + f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} " + f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}" + ), + f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}", + f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}") + ee_bits = [ + _journal_nz(_row_get(row, "early_exit")), + _journal_nz(_row_get(row, "early_exit_reason")), + _journal_nz(_row_get(row, "early_exit_trigger")), + _journal_nz(_row_get(row, "early_exit_note")), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}" + mood_score = _row_get(row, "mood_score") + if mood_score is not None: + mood_bits += f" | 自评心态分:{mood_score}" + lines.append(f" {mood_bits}") + if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无": + lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}") + if _journal_nz(_row_get(row, "note")) != "无": + lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}") + return "\n".join(lines) + "\n" + + +def collect_images_for_ai_review( + rows: Sequence, + upload_folder: str, + *, + build_chart_if_missing: Optional[Callable] = None, +) -> List[str]: + """ + 收集传给视觉模型的本地图片路径. + - 优先 journal_entries.images_json / image 已存附图(含多周期手动上传); + - 若无附图且提供 build_chart_if_missing,则临时生成 K 线图. + """ + paths: List[str] = [] + seen = set() + upload_folder = os.path.abspath(upload_folder or "") + for row in rows or []: + row_paths = journal_image_paths(row, upload_folder) + if row_paths: + for candidate in row_paths: + if candidate not in seen: + seen.add(candidate) + paths.append(candidate) + continue + if build_chart_if_missing: + try: + candidate = build_chart_if_missing(row) + except Exception: + candidate = None + if not candidate: + continue + candidate = os.path.abspath(candidate) + if os.path.isfile(candidate) and candidate not in seen: + seen.add(candidate) + paths.append(candidate) + return paths + + +def build_journal_ai_chart_path( + row, + upload_folder: str, + *, + order_chart_enabled: bool, + normalize_exchange_symbol_fn: Callable[[str], str], + generate_chart_fn: Callable, + local_datetime_to_ms_fn: Callable[[str], Optional[int]], + now_ts_ms_fn: Callable[[], int], +) -> Optional[str]: + """无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径.""" + if not order_chart_enabled: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + return None + coin = (row["coin"] if "coin" in keys else "") or "" + coin = str(coin).strip() + if not coin: + return None + try: + symbol = normalize_exchange_symbol_fn(coin) + except Exception: + return None + open_dt = row["open_datetime"] if "open_datetime" in keys else "" + close_dt = row["close_datetime"] if "close_datetime" in keys else "" + entry_ms = local_datetime_to_ms_fn(open_dt) + exit_ms = local_datetime_to_ms_fn(close_dt) + if not entry_ms: + return None + row_tf = row["tf"] if "tf" in keys else "" + tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1 + tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h" + row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8] + marker = { + "entry_ts_ms": entry_ms, + "exit_ts_ms": exit_ms, + "chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE, + "now_ts_ms": int(now_ts_ms_fn()), + } + fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png" + saved = generate_chart_fn( + symbol, + f"AI复盘 {coin}", + timeframes=[tf1, tf2], + limit=JOURNAL_CHART_DEFAULT_LIMIT, + out_dir=upload_folder, + filename=fname, + marker_payload=marker, + marker_timeframes={tf1, tf2}, + layout="vertical", + ) + if not saved: + return None + path = os.path.join(upload_folder, saved) + return path if os.path.isfile(path) else None 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/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/ai_review_render.js b/lib/common/static/ai_review_render.js new file mode 100644 index 0000000..8330aba --- /dev/null +++ b/lib/common/static/ai_review_render.js @@ -0,0 +1,223 @@ +/** + * AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底 + */ +(function (global) { + "use strict"; + + var SECTION_FIXES = [ + { re: /^\*\*1\.\s*(?!📊)总体盈亏结构\*\*/m, rep: "**1. 📊 总体盈亏结构**" }, + { re: /^\*\*2\.\s*(?!🧠)心态与执行\*\*/m, rep: "**2. 🧠 心态与执行**" }, + { re: /^\*\*3\.\s*(?!🏷️)行为标签\*\*/m, rep: "**3. 🏷️ 行为标签**" }, + { re: /^\*\*4\.\s*(?!✅)改进建议\*\*/m, rep: "**4. ✅ 改进建议**" }, + { re: /^\*\*5\.\s*(?!📈)图表(?:分析)?\*\*/m, rep: "**5. 📈 图表分析**" }, + { re: /^1\.\s*(?!📊)总体盈亏结构/m, rep: "**1. 📊 总体盈亏结构**" }, + { re: /^2\.\s*(?!🧠)心态与执行/m, rep: "**2. 🧠 心态与执行**" }, + { re: /^3\.\s*(?!🏷️)行为标签/m, rep: "**3. 🏷️ 行为标签**" }, + { re: /^4\.\s*(?!✅)改进建议/m, rep: "**4. ✅ 改进建议**" }, + { re: /^5\.\s*(?!📈)图表/m, rep: "**5. 📈 图表分析**" }, + ]; + + function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function parseInline(raw) { + var s = escapeHtml(raw); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/`([^`]+)`/g, "$1"); + return s; + } + + function enhanceReviewHeadings(text) { + var out = String(text || ""); + SECTION_FIXES.forEach(function (item) { + out = out.replace(item.re, item.rep); + }); + if (/^【系统说明/m.test(out) && !/^ℹ️/m.test(out)) { + out = out.replace(/^【系统说明/gm, "ℹ️ 【系统说明"); + } + if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) { + out = out.replace(/^原始记录:/gm, "📎 **原始记录**"); + } + return out; + } + + function isNumberedListLine(trimmed) { + if (!trimmed) return false; + if (/^\d+\.\s+/.test(trimmed)) return true; + if (/^\*\*\d+\.\s*.+\*\*$/.test(trimmed)) return true; + return false; + } + + /** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */ + function preprocessListBlanks(text) { + var lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); + var out = []; + for (var i = 0; i < lines.length; i++) { + var trimmed = lines[i].trim(); + if (!trimmed) { + var prevTrim = out.length ? String(out[out.length - 1]).trim() : ""; + var nextTrim = ""; + for (var j = i + 1; j < lines.length; j++) { + var t = lines[j].trim(); + if (t) { + nextTrim = t; + break; + } + } + if (isNumberedListLine(prevTrim) && isNumberedListLine(nextTrim)) { + continue; + } + } + out.push(lines[i]); + } + return out.join("\n"); + } + + function renderMarkdown(text) { + var src = enhanceReviewHeadings(preprocessListBlanks(text)); + var lines = src.replace(/\r\n/g, "\n").split("\n"); + var html = []; + var inUl = false; + var inOl = false; + + function closeLists() { + if (inUl) { + html.push(""); + inUl = false; + } + if (inOl) { + html.push(""); + inOl = false; + } + } + + lines.forEach(function (line) { + var trimmed = line.trim(); + if (!trimmed) { + closeLists(); + return; + } + var hm = trimmed.match(/^(#{1,3})\s+(.+)$/); + if (hm) { + closeLists(); + var level = hm[1].length + 1; + if (level > 4) level = 4; + html.push("" + parseInline(hm[2]) + ""); + return; + } + var ulm = trimmed.match(/^[-*]\s+(.+)$/); + if (ulm) { + if (!inUl) { + closeLists(); + html.push("
    "); + inUl = true; + } + html.push("
  • " + parseInline(ulm[1]) + "
  • "); + return; + } + var boldOl = trimmed.match(/^\*\*(\d+)\.\s*(.+)\*\*$/); + if (boldOl) { + if (!inOl) { + closeLists(); + html.push("
      "); + inOl = true; + } + html.push("
    1. " + parseInline(trimmed) + "
    2. "); + return; + } + var olm = trimmed.match(/^\d+\.\s+(.+)$/); + if (olm) { + if (!inOl) { + closeLists(); + html.push("
        "); + inOl = true; + } + html.push("
      1. " + parseInline(olm[1]) + "
      2. "); + return; + } + closeLists(); + if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) { + html.push('
        ' + parseInline(trimmed) + "
        "); + return; + } + html.push("

        " + parseInline(trimmed) + "

        "); + }); + closeLists(); + return html.join("\n"); + } + + var _genBusy = false; + + function setGenerating(opts) { + opts = opts || {}; + _genBusy = true; + var wrap = document.getElementById(opts.wrapId); + var el = document.getElementById(opts.elId); + var btn = opts.btnId ? document.getElementById(opts.btnId) : null; + if (wrap) wrap.style.display = "block"; + if (el) { + el.classList.remove("ai-result-md"); + el.classList.add("is-loading"); + el.innerHTML = ""; + el.innerText = opts.message || "生成复盘中,请稍候…"; + } + if (btn) { + btn.disabled = true; + if (!btn.dataset.aiOrigText) btn.dataset.aiOrigText = btn.textContent; + btn.textContent = opts.btnLabel || "生成中…"; + } + if (wrap && wrap.scrollIntoView) { + try { + wrap.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch (e) { /* ignore */ } + } + } + + function clearGenerating(btnId) { + _genBusy = false; + var btn = btnId ? document.getElementById(btnId) : null; + if (btn) { + btn.disabled = false; + if (btn.dataset.aiOrigText) { + btn.textContent = btn.dataset.aiOrigText; + delete btn.dataset.aiOrigText; + } + } + } + + function isGenerating() { + return _genBusy; + } + + function setElementMarkdown(el, rawText) { + if (!el) return; + var raw = String(rawText || ""); + el.dataset.markdownRaw = raw; + el.classList.remove("is-loading"); + el.classList.add("ai-result-md"); + el.innerHTML = renderMarkdown(raw); + } + + function getElementMarkdown(el) { + if (!el) return ""; + if (el.dataset && el.dataset.markdownRaw != null) { + return el.dataset.markdownRaw; + } + return el.innerText || ""; + } + + global.AiReviewRender = { + enhanceReviewHeadings: enhanceReviewHeadings, + renderMarkdown: renderMarkdown, + setElementMarkdown: setElementMarkdown, + getElementMarkdown: getElementMarkdown, + setGenerating: setGenerating, + clearGenerating: clearGenerating, + isGenerating: isGenerating, + }; +})(typeof window !== "undefined" ? window : this); 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..ee87c25 --- /dev/null +++ b/lib/common/static/instance_settings_prefs.js @@ -0,0 +1,632 @@ +/** + * 实例:导航显示,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); + bindMarginModeGateVisibility(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 setEnvRowHidden(row, hidden) { + if (!row) return; + row.hidden = !!hidden; + row.style.display = hidden ? "none" : ""; + } + + 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"; + setEnvRowHidden(budgetRow, compoundOn); + } + + function syncMarginModeGateVisibility(body) { + if (!body) return; + const modeSel = body.querySelector( + '.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]' + ); + const mode = String((modeSel && modeSel.value) || "coin").toLowerCase(); + const coinMode = mode !== "usdc"; + setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC"), coinMode); + setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), !coinMode); + } + + 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 bindMarginModeGateVisibility(body) { + if (!body) return; + syncMarginModeGateVisibility(body); + const modeSel = body.querySelector( + '.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]' + ); + if (!modeSel || modeSel.dataset.marginGateBound === "1") return; + modeSel.dataset.marginGateBound = "1"; + modeSel.addEventListener("change", () => syncMarginModeGateVisibility(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..5a35936 --- /dev/null +++ b/lib/common/static/instance_theme.css @@ -0,0 +1,6365 @@ +/* 实例页手机端:与中控一致,桌面专属区块隐藏;下载仅电脑端 */ +: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; +} + +#current-capital, +.stat-strip-item [data-funds-field="current-capital"], +.inst-phone-chip [data-funds-field="current-capital"] { + white-space: pre-line; + line-height: 1.2; + font-size: 0.82rem; +} + +.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; + max-width: 100%; + font-size: 0.72rem; +} +.opt-history-table th:nth-child(1), +.opt-history-table td:nth-child(1) { + width: 22%; +} +.opt-history-table th:nth-child(2), +.opt-history-table td:nth-child(2) { + width: 6%; +} +.opt-history-table th:nth-child(3), +.opt-history-table td:nth-child(3) { + width: 14%; + white-space: nowrap; +} +.opt-history-table th:nth-child(4), +.opt-history-table td:nth-child(4) { + width: 8%; +} +.opt-history-table th:nth-child(5), +.opt-history-table td:nth-child(5) { + width: 16%; + white-space: nowrap; +} +.opt-history-table th:nth-child(6), +.opt-history-table td:nth-child(6) { + width: 22%; +} +.opt-history-table th:nth-child(7), +.opt-history-table td:nth-child(7) { + width: 12%; + text-align: center; +} +.opt-history-table .opt-hist-pnl { + white-space: nowrap; +} +.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; +} +.options-pos-head-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; +} +.opt-bridge-sell-hint { + font-size: 0.78rem; + line-height: 1.35; + max-width: 280px; + text-align: right; +} +.opt-retry-sell-coin-btn--pending { + border-color: #ffb347; + color: #ffb347; +} +.options-page-wrap .opt-pos-transfer { + margin-top: 10px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.options-page-wrap .opt-pos-transfer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + list-style: none; + font-weight: 600; + font-size: 0.88rem; + margin: 0 0 8px; +} +.options-page-wrap .opt-pos-transfer-head::-webkit-details-marker { + display: none; +} +.options-page-wrap .opt-pos-transfer[open] .opt-pos-transfer-closed-hint { + display: none; +} +.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-open-hint { + display: none; +} +.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-body { + display: none; +} +.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-head { + margin-bottom: 0; +} +.options-page-wrap .opt-pos-transfer-body .options-settings-subtitle { + margin-bottom: 6px; +} +.options-page-wrap .opt-pos-transfer-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0; +} +.options-page-wrap .opt-pos-transfer-form select, +.options-page-wrap .opt-pos-transfer-form input[type="number"] { + font-size: 0.74rem; +} +.options-page-wrap .opt-pos-transfer-form input[type="number"] { + width: 96px; + max-width: 30vw; +} +.options-page-wrap .opt-pos-xfer-msg { + margin-top: 6px; + font-size: 0.76rem; + min-height: 1.1em; +} +.options-page-wrap .opt-pos-xfer-msg.opt-error { + color: #ff7b72; +} +.options-page-wrap .opt-pos-xfer-msg.opt-success { + color: #3dd68c; +} +.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..1d1e1e7 --- /dev/null +++ b/lib/common/static/options_panel.js @@ -0,0 +1,3109 @@ +(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(); + void refreshBridgeSellUi(true); + } 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); + } + } + + let lastBalancesPayload = null; + + function tradingCoinAvailFromPayload(d, uly) { + if (!d) return null; + const u = String(uly || state.underlying || "ETH").toUpperCase(); + const key = u === "BTC" ? "trading_btc_avail" : "trading_eth_avail"; + const v = d[key]; + if (v == null) return d[u === "BTC" ? "trading_btc" : "trading_eth"]; + return v; + } + + function coinMarginModeFromPayload(d) { + if (d && d.options_margin_mode === "coin") return true; + if (d && d.options_margin_mode === "usdc") return false; + return isCoinMarginMode(); + } + + function updateRetrySellCoinUi(d) { + const btn = document.getElementById("opt-retry-sell-coin"); + const hint = document.getElementById("opt-bridge-sell-hint"); + if (!btn) return; + const payload = d || lastBalancesPayload; + const coinOn = coinMarginModeFromPayload(payload); + const uly = String(state.underlying || "ETH").toUpperCase(); + btn.hidden = !coinOn; + if (!coinOn) { + if (hint) hint.hidden = true; + btn.classList.remove("opt-retry-sell-coin-btn--pending"); + return; + } + btn.textContent = "重试卖回 " + uly; + const avail = tradingCoinAvailFromPayload(payload, uly); + const pending = payload && payload.bridge_status === "pending_sell_spot"; + btn.classList.toggle("opt-retry-sell-coin-btn--pending", !!pending); + if (hint) { + const parts = []; + if (pending) parts.push("平仓后卖币未成功,可点此重试"); + if (avail != null && Number(avail) > 0) { + parts.push("交易户可用 " + fmt(Number(avail), 6) + " " + uly); + } + if (parts.length) { + hint.textContent = parts.join(" · "); + hint.hidden = false; + } else { + hint.hidden = true; + } + } + } + + async function refreshBridgeSellUi(force) { + try { + const qs = force ? "?force=1" : ""; + const d = await apiJson("/api/options/balances" + qs); + lastBalancesPayload = d; + syncCompoundFlagsFromPayload(d); + if (d && d.options_margin_mode === "coin") { + if (!state.chain) state.chain = {}; + state.chain.options_margin_mode = "coin"; + state.chain.margin_mode = "coin"; + } + if (d && d.trade_budget != null && root) { + root.dataset.tradeBudget = String(d.trade_budget); + } + updateRetrySellCoinUi(d); + return d; + } catch (_) { + updateRetrySellCoinUi(null); + return null; + } + } + + function setXferMsg(text, isErr) { + const el = document.getElementById("opt-pos-xfer-msg"); + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("opt-error", !!isErr); + el.classList.toggle("opt-success", !!text && !isErr); + } + + function xferRoundAvail(v) { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.round(n * 100) / 100; + } + + function xferPickBalance(bal, account, ccy) { + if (!bal) return null; + const acct = account === "trading" ? "trading" : "funding"; + const c = String(ccy || "").toLowerCase(); + const availKey = acct + "_" + c + "_avail"; + const totalKey = acct + "_" + c; + return xferRoundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]); + } + + function xferAccountLabel(acct) { + return acct === "trading" ? "交易账户" : "资金账户"; + } + + function setPosXferBusy(busy) { + ["opt-pos-xfer-btn", "opt-pos-xfer-all-btn"].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; + } else { + btn.disabled = false; + if (btn.dataset.origText) { + btn.textContent = btn.dataset.origText; + delete btn.dataset.origText; + } + } + }); + const input = document.getElementById("opt-pos-xfer-amount"); + if (input) input.disabled = !!busy; + } + + async function submitPosTransfer(amount) { + const ccyEl = document.getElementById("opt-pos-xfer-ccy"); + const fromEl = document.getElementById("opt-pos-xfer-from"); + const toEl = document.getElementById("opt-pos-xfer-to"); + if (!ccyEl || !fromEl || !toEl) return { ok: false }; + setPosXferBusy(true); + setXferMsg("划转中…", false); + try { + const r = await apiJson("/api/options/transfer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ccy: ccyEl.value, + from: fromEl.value, + to: toEl.value, + amount: amount, + }), + }); + if (r.ok) { + setXferMsg("划转成功", false); + const amtEl = document.getElementById("opt-pos-xfer-amount"); + if (amtEl) amtEl.value = ""; + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot({ force: true }); + await refreshBridgeSellUi(true); + } else { + setXferMsg("划转失败:" + (r.msg || "未知错误"), true); + } + return r; + } catch (e) { + setXferMsg("划转失败:" + (e.message || "网络错误"), true); + return { ok: false }; + } finally { + setPosXferBusy(false); + } + } + + function bindPosTransferControls() { + const xferBtn = document.getElementById("opt-pos-xfer-btn"); + const xferAllBtn = document.getElementById("opt-pos-xfer-all-btn"); + const amtEl = document.getElementById("opt-pos-xfer-amount"); + if (!xferBtn && !xferAllBtn) return; + + function hardenXferAmountInput() { + if (!amtEl) return; + function wipe() { + const v = String(amtEl.value || "").trim(); + if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) amtEl.value = ""; + } + wipe(); + amtEl.setAttribute("readonly", "readonly"); + amtEl.addEventListener("focus", function () { + amtEl.removeAttribute("readonly"); + }); + amtEl.addEventListener("blur", function () { + if (!amtEl.value) amtEl.setAttribute("readonly", "readonly"); + }); + setTimeout(wipe, 200); + setTimeout(wipe, 800); + } + hardenXferAmountInput(); + + if (xferBtn) { + xferBtn.addEventListener("click", async function () { + const amount = parseFloat((amtEl || {}).value); + if (!amount || amount <= 0) { + setXferMsg("请输入有效数量", true); + return; + } + await submitPosTransfer(amount); + }); + } + + if (xferAllBtn) { + xferAllBtn.addEventListener( + "click", + function () { + if (amtEl) amtEl.removeAttribute("readonly"); + }, + true + ); + xferAllBtn.addEventListener("click", async function () { + try { + const ccyEl = document.getElementById("opt-pos-xfer-ccy"); + const fromEl = document.getElementById("opt-pos-xfer-from"); + const toEl = document.getElementById("opt-pos-xfer-to"); + const bal = await refreshBridgeSellUi(true); + const ccy = ccyEl ? ccyEl.value : "USDT"; + const from = fromEl ? fromEl.value : "funding"; + const to = toEl ? toEl.value : "trading"; + const amount = xferPickBalance(bal, from, ccy); + if (!amount) { + setXferMsg("划出账户可用余额不足", true); + return; + } + const msg = + "确认全部划转?\n\n" + + "币种:" + ccy + "\n" + + "划出:" + xferAccountLabel(from) + "\n" + + "划入:" + xferAccountLabel(to) + "\n" + + "金额:" + amount.toFixed(2) + " " + ccy + "\n\n" + + "将划转该账户全部可用余额。"; + if (!confirm(msg)) return; + if (amtEl) amtEl.value = String(amount); + await submitPosTransfer(amount); + } catch (e) { + setXferMsg("划转失败:" + (e.message || "余额拉取失败"), true); + } + }); + } + } + + async function retrySellBackCoin() { + const uly = String(state.underlying || "ETH").toUpperCase(); + const d = await refreshBridgeSellUi(true); + const avail = tradingCoinAvailFromPayload(d, uly); + const availTxt = + avail != null && Number(avail) > 0 + ? ("\n交易账户可用: " + fmt(Number(avail), 6) + " " + uly) + : ""; + const msg = + "确认市价卖出交易账户中的全部可用 " + uly + " 换回 USDT?" + + availTxt + + "\n\n将按可用余额全额卖出(非 bridge 记账量)。"; + if (!confirm(msg)) return; + const btn = document.getElementById("opt-retry-sell-coin"); + if (btn) btn.disabled = true; + try { + const r = await apiJson("/api/options/spot-bridge/retry-sell", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ underlying: uly }), + }); + if (r.ok) { + if (r.skipped) { + alert(r.msg || "无残留币需卖回"); + } else { + const sold = r.sell && r.sell.coin_sold; + alert("卖回已提交" + (sold != null ? (" " + fmt(Number(sold), 6) + " " + uly) : "")); + } + } else { + alert(r.msg || "卖回失败"); + } + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + await refreshBridgeSellUi(true); + } finally { + if (btn) btn.disabled = false; + } + } + + 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, callAsk, putAsk) { + const k = Number(strike); + if (!Number.isFinite(k)) return "—"; + if (isCoinMarginMode()) { + const pc = Number(callAsk); + const pp = Number(putAsk); + if (!Number.isFinite(pc) || !Number.isFinite(pp) || pc <= 0 || pp <= 0 || pc >= 1) return "—"; + const lo = Math.round((k / (1 + pp)) * 10) / 10; + const hi = Math.round((k / (1 - pc)) * 10) / 10; + return lo.toFixed(0) + " ~ " + hi.toFixed(0); + } + const per = combinedAsk; + if (per == null) return "—"; + const d = Number(per); + if (!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 || "门控未过(见 env 目标平仓门控)"); + } + 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 posPremiumCcy(p) { + const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase(); + if (ccy) return ccy; + const mode = String((p && p.margin_mode) || "").toLowerCase(); + const inst = String((p && p.inst_id) || ""); + if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) { + return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH"; + } + if (isCoinMarginMode && isCoinMarginMode()) { + return ((inst.split("-")[0]) || "ETH").toUpperCase() || "ETH"; + } + return "USDC"; + } + + function isCoinPos(p) { + return posPremiumCcy(p) !== "USDC"; + } + + function fmtPremiumAmt(v, ccy) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const unit = String(ccy || "USDC").toUpperCase(); + if (unit === "ETH" || unit === "BTC") { + let s = n.toFixed(8).replace(/\.?0+$/, ""); + return s || "0"; + } + return fmtUsdc(n); + } + + /** 币本位盈亏双显:0.0018 ETH / 4.09U(按指数/现货价换算). */ + function spotPxOf(p) { + const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px)); + return Number.isFinite(n) && n > 0 ? n : null; + } + + function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) { + if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—"; + const n = Number(coinAmt); + const unit = String(ccy || "ETH").toUpperCase(); + if (unit !== "ETH" && unit !== "BTC") { + const sign = signed && n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + "U"; + } + const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; + const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : ""; + const coinTxt = coinSign + absCoin + " " + unit; + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return coinTxt; + const u = n * px; + const absU = Math.abs(u).toFixed(2); + const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : ""; + return coinTxt + " / " + uSign + absU + "U"; + } + + function fmtPremiumAmtSigned(v, ccy) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const sign = n > 0 ? "+" : ""; + return sign + fmtPremiumAmt(n, ccy) + " " + (String(ccy || "USDC").toUpperCase()); + } + + function fmtNetPnlDual(net, p) { + const ccy = posPremiumCcy(p); + if (ccy === "USDC") { + if (net == null || Number.isNaN(Number(net))) return "—"; + return fmtUsdc(Number(net)) + "U"; + } + return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true); + } + + function fmtClosePreview(preview, premiumPaid, p) { + if (!preview || preview.total_received == null) return "—"; + const ccy = posPremiumCcy(p); + const recvTxt = fmtPremiumAmt(preview.total_received, ccy); + 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 + " " + ccy + ""; + } + + function fmtClosePreviewText(preview, p) { + if (!preview || preview.total_received == null) return "—"; + const ccy = posPremiumCcy(p); + let text = fmtPremiumAmt(preview.total_received, ccy) + " " + ccy; + if (preview.covered_sheets != null) { + text += " · 覆盖 " + preview.covered_sheets + "张"; + } + if (preview.uncovered_sheets > 0) { + text += " · 缺 " + preview.uncovered_sheets + "张"; + } + return text; + } + + function fmtPreviewLevels(preview, p) { + const levels = (preview && preview.levels) || []; + if (!levels.length) return "暂无可用买盘深度"; + const ccy = posPremiumCcy(p); + return levels.map(function (x) { + return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " + + fmtPremiumAmt(x.received, ccy) + " " + ccy; + }).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, indexPx) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + let prem = Number(totalPremium); + if (value == null || !Number.isFinite(prem)) return null; + // 币本位权利金为币:与到期美元实值对比时先×指数 + if (isCoinMarginMode()) { + const idx = Number(indexPx); + if (!Number.isFinite(idx) || idx <= 0) return null; + prem = prem * idx; + } + return Math.round((value - prem) * 100) / 100; + } + + /** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */ + function estimateProfitRr(profit, totalPremium, indexPx) { + const pnl = Number(profit); + let prem = Number(totalPremium); + if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null; + if (isCoinMarginMode()) { + const idx = Number(indexPx); + if (!Number.isFinite(idx) || idx <= 0) return null; + prem = prem * idx; + } + 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 isCoinMarginMode() { + const ch = state.chain || {}; + return ch.margin_mode === "coin" || ch.options_margin_mode === "coin"; + } + + 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; + } + // USDC: 名义(U)/权利金(U)=指数×币数/权利金; 币本位权利金为币: 名义(U)/(权利金币×指数)=币数/权利金币 + if (isCoinMarginMode()) { + return Math.round((amt / prem) * 10) / 10; + } + 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) + "×"; + } + + /** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 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; + if (isCoinMarginMode()) { + return Math.round((1 / ask) * 10) / 10; + } + 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, q.index_px); + const rr = estimateProfitRr(profit, premium, q.index_px); + if (value == null || Number.isNaN(value)) { + valueEl.textContent = "—"; + } else { + valueEl.textContent = isCoinMarginMode() + ? (fmtUsdc(value) + " U(估)") + : (fmtUsdc(value) + " USDC"); + } + if (profit == null || Number.isNaN(profit)) { + profitEl.textContent = "—"; + profitEl.className = "v"; + } else { + profitEl.textContent = isCoinMarginMode() + ? ((Number(profit) > 0 ? "+" : "") + fmtUsdc(profit) + " U(估)") + : 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); + if (n < 0) return n.toFixed(1); + return 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"; + 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, call && call.ask, put && put.ask) + "" + + '' + (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(); + const coinMode = isCoinMarginMode() || (d && d.options_margin_mode === "coin"); + const premCcy = (sz.premium_ccy || (coinMode ? ((d.inst_id || "").split("-")[0] || "ETH") : "USDC")).toUpperCase(); + document.getElementById("opt-order-premium").textContent = + canOpen && sz.total_premium != null + ? (coinMode ? (fmt(sz.total_premium, 6) + " " + premCcy) : (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; + const bud = (d && d.coin_budget && d.coin_budget.budget_usdt) || + (state.chain && state.chain.coin_budget && state.chain.coin_budget.budget_usdt); + if (!canOpen || sz.ok === false) { + openBtn.textContent = coinMode + ? ((d && d.msg) || (sz && sz.msg) || "无法开仓") + : "暂无卖一深度,无法开仓"; + } else if (coinMode) { + const buyU = sz && sz.buy_usdt != null ? sz.buy_usdt : null; + openBtn.textContent = + buyU != null + ? ("买币并开仓(约 " + Number(buyU).toFixed(2) + " USDT)") + : (bud != null ? "买币并开仓(预算上限 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一"); + } else { + openBtn.textContent = "限价买入 @ 卖一"; + } + } + 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 if (coinMode && sz.est_note) { + msgEl.textContent = sz.est_note; + 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(); + updateRetrySellCoinUi(lastBalancesPayload); + 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; + const coinMode = isCoinMarginMode() || (latest && latest.options_margin_mode === "coin"); + const bud = (latest && latest.coin_budget && latest.coin_budget.budget_usdt) || + (state.chain && state.chain.coin_budget && state.chain.coin_budget.budget_usdt); + btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); + if (!(latest && latest.can_open) || (latest && latest.sizing && latest.sizing.ok === false)) { + btn.textContent = coinMode + ? ((latest && (latest.msg || (latest.sizing && latest.sizing.msg))) || "无法开仓") + : "暂无卖一深度,无法开仓"; + } else if (coinMode) { + const buyU = latest && latest.sizing && latest.sizing.buy_usdt != null + ? latest.sizing.buy_usdt + : null; + btn.textContent = + buyU != null + ? ("买币并开仓(约 " + Number(buyU).toFixed(2) + " USDT)") + : (bud != null ? "买币并开仓(预算上限 ≈ " + Number(bud).toFixed(2) + " USDT)" : "买币并开仓 @ 卖一"); + } else { + btn.textContent = "限价买入 @ 卖一"; + } + } + } + + 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 premCcy = posPremiumCcy(p); + const coinPos = isCoinPos(p); + const premTxt = fmtDisplay( + p.premium_paid_fmt, + p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : 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); + const netTxt = closePreview.bid_invalid || net == null + ? "—" + : fmtNetPnlDual(net, p); + 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 + " " + premCcy + "
        " + + '
        开仓均价' + avgTxt + "
        " + + '
        标记价' + markTxt + "
        " + + '
        指数价' + fmt(p.idx_px, 0) + "
        " + + '
        到期平衡' + fmt(p.expiry_be_px, 0) + "
        " + + '
        平掉回本' + fmt(p.close_be_px, 0) + "
        " + + '
        净盈亏' + + netTxt + "
        " + + '
        收益率' + + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
        " + + '
        买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
        " + + '
        按买盘回收' + + (closePreview.bid_invalid + ? '暂无有效买盘' + : fmtClosePreview(closePreview, p.premium_paid, p)) + "
        " + + "
        " + + (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 profitExitHint(mult, req, premCcy) { + const n = Number(mult); + const m = Number.isFinite(n) && n > 0 ? n : 1; + const needFactor = 1 + m; + const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9 + ? String(Math.round(needFactor)) + : fmt(needFactor, 2); + let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2) + + " → 需回收≥权利金×" + factorTxt; + if (req != null) { + hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy; + } + return hint; + } + + 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; + const premCcy = posPremiumCcy(p); + const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult; + let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; + if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; + return ( + '
        ' + + '翻倍' + + '" + + '' + + '" + + '' + statusTxt + "" + + '' + + (enabled + ? profitExitHint(hintMult, req, premCcy) + : "开启后自选倍数;达标按买一限价平;可随时关闭") + + "" + + "
        " + ); + } + + 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, indexPx, p) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid, indexPx); + const rr = estimateProfitRr(profit, premiumPaid, indexPx); + if (value == null && profit == null && rr == null) return ""; + const coinPos = isCoinPos(p); + const valueUnit = coinPos ? " U(估)" : " USDC"; + const profitTxt = profit == null + ? "—" + : (coinPos + ? ((Number(profit) > 0 ? "+" : "") + fmtUsdc(profit) + " U(估)") + : fmtUsdcSigned(profit)); + let html = ''; + html += '价值' + + (value == null ? "—" : fmtUsdc(value) + valueUnit) + ""; + html += '预估盈利' + + profitTxt + ""; + 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, p.idx_px, p) + : ''; + 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"), + row.getAttribute("data-idx"), + { premium_ccy: row.getAttribute("data-prem-ccy"), margin_mode: row.getAttribute("data-prem-ccy") === "USDC" ? "usdc" : "coin", inst_id: row.getAttribute("data-inst") } + ); + 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 posLike = { + inst_id: inst, + premium_ccy: q.premium_ccy || (preview.close_gate && preview.close_gate.premium_ccy) || null, + margin_mode: q.options_margin_mode || q.margin_mode || null, + }; + const premCcy = posPremiumCcy(posLike); + const msg = [ + "按买一限价卖出本轮可平张数?", + "合约: " + inst, + "锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张", + "预计收回: " + fmtClosePreviewText(preview, posLike), + preview.estimated_pnl != null + ? ("预估盈亏: " + fmtPremiumAmtSigned(preview.estimated_pnl, premCcy)) + : "", + 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预估收回: " + fmtPremiumAmt(r.premium_received, premCcy) + " " + premCcy; + } + if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)"; + if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason; + const spotSell = r.spot_sell; + if (spotSell && spotSell.ok === false) { + okMsg += "\n卖回 " + premCcy + " 失败: " + (spotSell.msg || "未知错误"); + okMsg += "\n可点「重试卖回 " + premCcy + "」手动卖出交易户全部可用币。"; + } else if (spotSell && spotSell.ok && spotSell.sell && spotSell.sell.coin_sold != null) { + okMsg += "\n已卖回: " + fmt(Number(spotSell.sell.coin_sold), 6) + " " + premCcy; + } + alert(okMsg); + } else { + alert(r.msg || "平仓失败"); + } + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + void refreshBridgeSellUi(true); + } 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 statsPnlUnit(d) { + const u = String((d && d.pnl_unit) || "").trim().toUpperCase(); + if (u === "U" || u === "USDT") return "U"; + if (u === "ETH" || u === "BTC") return u; + return "USDC"; + } + + function paintPnlStat(el, value, unit) { + 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); + const label = unit || "USDC"; + const decimals = label === "ETH" || label === "BTC" ? 6 : 2; + el.textContent = (n > 0 ? "+" : "") + fmt(n, decimals) + " " + label; + 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; + } + const unit = statsPnlUnit(d); + paintPnlStat(totalPnlEl, d.total_pnl, unit); + paintPnlStat(netRealizedEl, d.net_realized_pnl, unit); + paintPnlStat(openFloatEl, d.open_float_pnl, unit); + 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) + " " + unit : (d.win_count ? "0 " + unit : "—"); + } + if (lossEl) { + lossEl.textContent = d.avg_loss != null && d.avg_loss > 0 + ? fmt(d.avg_loss, 2) + " " + unit : (d.loss_count ? "0 " + unit : "—"); + } + 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 unit = statsPnlUnit(d); + const pnlTotal = profit + loss; + if (pnlTotal > 0) { + setBarFill(profitBar, (profit / pnlTotal) * 100); + setBarFill(lossBar, (loss / pnlTotal) * 100); + if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " " + unit; + if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " " + unit; + } else { + setBarFill(profitBar, 0); + setBarFill(lossBar, 0); + if (profitBarLabel) profitBarLabel.textContent = d.win_count ? "0 " + unit : "—"; + if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? "0 " + unit : "—"; + } + + 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 ccy = posPremiumCcy(h); + const premTxt = + h.premium_paid != null && !Number.isNaN(Number(h.premium_paid)) + ? fmtPremiumAmt(h.premium_paid, ccy) + (ccy !== "USDC" ? " " + ccy : "") + : "—"; + const isOpen = h.status === "open"; + const pnl = isOpen ? null : h.realized_pnl; + let pnlTxt = "—"; + let pnlCls = ""; + if (pnl != null && !Number.isNaN(Number(pnl))) { + const n = Number(pnl); + pnlCls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; + if (ccy === "ETH" || ccy === "BTC") { + const sign = n > 0 ? "+" : n < 0 ? "-" : ""; + pnlTxt = sign + fmtPremiumAmt(Math.abs(n), ccy) + " " + ccy; + } else { + pnlTxt = (n > 0 ? "+" : "") + fmt(n, 2); + } + } + 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() { + await refreshBridgeSellUi(false); + })(); + 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"); + updateRetrySellCoinUi(lastBalancesPayload); + 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); + const retrySellBtn = document.getElementById("opt-retry-sell-coin"); + if (retrySellBtn) retrySellBtn.addEventListener("click", retrySellBackCoin); + document.getElementById("opt-open-btn").addEventListener("click", openPosition); + const pendingRefreshBtn = document.getElementById("opt-pending-refresh"); + if (pendingRefreshBtn) { + pendingRefreshBtn.addEventListener("click", function () { + refreshPendingOrders(); + }); + } + bindOptionsPosTabs(); + bindPosTransferControls(); + 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..3e45b4a --- /dev/null +++ b/lib/common/static/options_position_cards.js @@ -0,0 +1,344 @@ +(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 posPremiumCcy(p) { + const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase(); + if (ccy) return ccy; + const mode = String((p && p.margin_mode) || "").toLowerCase(); + const inst = String((p && p.inst_id) || ""); + if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) { + return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH"; + } + return "USDC"; + } + + function fmtPremiumAmt(v, ccy) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + const unit = String(ccy || "USDC").toUpperCase(); + if (unit === "ETH" || unit === "BTC") { + let s = n.toFixed(8).replace(/\.?0+$/, ""); + return s || "0"; + } + return fmtUsdc(n); + } + + function spotPxOf(p) { + const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px)); + return Number.isFinite(n) && n > 0 ? n : null; + } + + function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) { + if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—"; + const n = Number(coinAmt); + const unit = String(ccy || "ETH").toUpperCase(); + if (unit !== "ETH" && unit !== "BTC") { + const sign = signed && n > 0 ? "+" : ""; + return sign + fmtUsdc(n) + "U"; + } + const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0"; + const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : ""; + const coinTxt = coinSign + absCoin + " " + unit; + const px = Number(spotPx); + if (!Number.isFinite(px) || !(px > 0)) return coinTxt; + const u = n * px; + const absU = Math.abs(u).toFixed(2); + const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : ""; + return coinTxt + " / " + uSign + absU + "U"; + } + + function fmtNetPnlDual(net, p) { + const ccy = posPremiumCcy(p); + if (ccy === "USDC") { + if (net == null || Number.isNaN(Number(net))) return "—"; + return fmtUsdc(Number(net)) + "U"; + } + return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true); + } + + 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 || "门控未过(见 env 目标平仓门控)"); + } + 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, p) { + if (!preview || preview.total_received == null) return "—"; + const ccy = posPremiumCcy(p); + const recvTxt = fmtPremiumAmt(preview.total_received, ccy); + 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 + " " + ccy + ""; + } + + 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 premCcy = posPremiumCcy(p); + const premTxt = fmtDisplay( + p.premium_paid_fmt, + p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : 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 ? "—" : fmtNetPnlDual(net, p)) + "
        " + + '
        收益率' + + (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 + " " + premCcy + "
        " + + '
        开仓均价' + 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, p)) + "
        " + + "
        " + + (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); + const idx = Number(p.idx_px); + 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)) { + let premUsd = prem; + if (premCcy !== "USDC" && Number.isFinite(idx) && idx > 0) premUsd = prem * idx; + profit = Math.round((value - premUsd) * 100) / 100; + } + } + } + const valueUnit = premCcy !== "USDC" ? " U(估)" : " USDC"; + const profitTxt = profit == null + ? "—" + : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + (premCcy !== "USDC" ? " U(估)" : " 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) + valueUnit) + "" + + 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..c7cf7f3 --- /dev/null +++ b/lib/common/static/options_review.js @@ -0,0 +1,1484 @@ +/** + * 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) + "U"; + } + + 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"); + } + } + + var detailChartPollTimer = null; + var detailChartPollTradeId = 0; + + function hideDetail() { + hideLightbox(); + stopDetailChartPoll(); + var backdrop = $("or-detail-backdrop"); + if (backdrop) backdrop.hidden = true; + } + + function stopDetailChartPoll() { + if (detailChartPollTimer) { + clearTimeout(detailChartPollTimer); + detailChartPollTimer = null; + } + detailChartPollTradeId = 0; + } + + function tradeHasAutoChart(t) { + var images = ((t && t.entry) || {}).images || []; + for (var i = 0; i < images.length; i++) { + var tf = String((images[i] && images[i].tf) || "").trim(); + var file = String((images[i] && images[i].file) || "").trim(); + if (tf === "auto" && file) return true; + } + return false; + } + + function openDetail(tradeId, opts) { + opts = opts || {}; + var waitAuto = !!opts.waitAutoChart; + var backdrop = $("or-detail-backdrop"); + if (!backdrop) return; + stopDetailChartPoll(); + backdrop.hidden = false; + ($("or-detail-title") || {}).textContent = "加载中…"; + ($("or-detail-meta") || {}).innerHTML = ""; + ($("or-detail-text") || {}).innerHTML = ""; + ($("or-detail-images") || {}).innerHTML = waitAuto + ? '
        自动K线
        生成中…
        ' + : ""; + + function applyTrade(trade, pendingAuto) { + renderDetail(trade, { pendingAuto: !!pendingAuto }); + } + + function fetchOnce() { + return fetch("/api/options/review/trades/" + tradeId, { + credentials: "same-origin", + }).then(function (r) { + return r.json(); + }); + } + + fetchOnce() + .then(function (data) { + if (!data.ok || !data.trade) { + ($("or-detail-title") || {}).textContent = "加载失败"; + return; + } + if (!waitAuto || tradeHasAutoChart(data.trade)) { + applyTrade(data.trade, false); + return; + } + applyTrade(data.trade, true); + detailChartPollTradeId = tradeId; + var tries = 0; + var maxTries = 20; + function poll() { + if (detailChartPollTradeId !== tradeId) return; + tries += 1; + fetchOnce() + .then(function (again) { + if (detailChartPollTradeId !== tradeId) return; + if (again.ok && again.trade && tradeHasAutoChart(again.trade)) { + stopDetailChartPoll(); + applyTrade(again.trade, false); + loadReviewed({ sync: false, soft: true }); + return; + } + if (tries >= maxTries) { + stopDetailChartPoll(); + applyTrade((again && again.trade) || data.trade, false); + return; + } + detailChartPollTimer = setTimeout(poll, 1500); + }) + .catch(function () { + if (detailChartPollTradeId !== tradeId) return; + if (tries >= maxTries) { + stopDetailChartPoll(); + return; + } + detailChartPollTimer = setTimeout(poll, 1500); + }); + } + detailChartPollTimer = setTimeout(poll, 1200); + }) + .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 chartTfLabel(tf) { + if (tf === "auto") return "自动K线"; + if (tf === "_") return "截图"; + return tf; + } + + function setChartOptionsVisible(visible) { + var box = $("or-chart-options"); + var hint = $("or-chart-hint"); + if (box) box.classList.toggle("hidden", !visible); + if (hint) hint.classList.toggle("hidden", !visible); + } + + function resetChartOptions() { + var cb = $("or-exchange-chart"); + if (cb) cb.checked = false; + } + + function renderDetailImages(images, opts) { + var imagesHost = $("or-detail-images"); + if (!imagesHost) return; + opts = opts || {}; + var pendingAuto = !!opts.pendingAuto; + 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; + }); + // 互斥:有自动K线只显示自动;否则只显示手动四周期 + var hasAuto = !!byTf.auto; + if (pendingAuto && !hasAuto) { + imagesHost.innerHTML = + '
        ' + + '自动K线' + + '
        生成中…
        ' + + "
        "; + return; + } + var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"]; + var keys = order.slice(); + if (!hasAuto) { + Object.keys(byTf).forEach(function (k) { + if (k === "auto") return; + if (keys.indexOf(k) < 0) keys.push(k); + }); + } + var cells = keys + .map(function (tf) { + var file = byTf[tf]; + if (!file) { + if (hasAuto || order.indexOf(tf) < 0) return ""; + return ( + '
        ' + + '' + + escapeHtml(tf) + + "" + + '
        未上传
        ' + + "
        " + ); + } + var src = optionsJournalImgSrc(file); + var label = escapeHtml(chartTfLabel(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, opts) { + 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 || [], opts || {}); + } + } + + 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(); + resetChartOptions(); + setChartOptionsVisible(false); + 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); + resetChartOptions(); + setChartOptionsVisible(t.source_type === "option_spot"); + + 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 tf = String((img && img.tf) || "").trim(); + if (tf === "auto") return; + 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(), + }; + var chartBox = $("or-chart-options"); + var chartVisible = chartBox && !chartBox.classList.contains("hidden"); + if (chartVisible && ($("or-exchange-chart") || {}).checked) { + payload.journal_exchange_chart = true; + payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || ""; + payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || ""; + payload.journal_chart_limit = ($("or-chart-limit") || {}).value || ""; + payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close"; + } + ($("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 = data.chart_queued + ? "已保存,K线图后台生成中" + : "已保存"; + hideJournalForm(); + reloadAll(); + openDetail(tradeId, { waitAutoChart: !!data.chart_queued }); + }) + .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/strategy_roll.js b/lib/common/static/strategy_roll.js new file mode 100644 index 0000000..388983a --- /dev/null +++ b/lib/common/static/strategy_roll.js @@ -0,0 +1,318 @@ +(function () { + "use strict"; + + function syncRollFormMode(form, mode) { + if (!form) return; + const m = mode || "market"; + form.setAttribute("data-add-mode", m); + const showFib = m === "fib_618" || m === "fib_786"; + const showBreakout = m === "breakout"; + const fibWrap = form.querySelector(".roll-field-fib"); + const breakoutWrap = form.querySelector(".roll-field-breakout"); + const fibUpper = form.querySelector("#roll-fib-upper"); + const fibLower = form.querySelector("#roll-fib-lower"); + const breakoutInput = form.querySelector("#roll-breakout"); + + function tuneInput(inp, active, required) { + if (!inp) return; + inp.disabled = !active; + inp.required = !!required && active; + inp.tabIndex = active ? 0 : -1; + if (!active) inp.value = ""; + } + + if (fibWrap) fibWrap.setAttribute("aria-hidden", showFib ? "false" : "true"); + if (breakoutWrap) breakoutWrap.setAttribute("aria-hidden", showBreakout ? "false" : "true"); + tuneInput(fibUpper, showFib, showFib); + tuneInput(fibLower, showFib, showFib); + tuneInput(breakoutInput, showBreakout, showBreakout); + } + + window.syncRollFormMode = syncRollFormMode; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function submitRollForm(form) { + if (isEmbedShell() && window.InstanceEmbed && typeof window.InstanceEmbed.postFormAndReload === "function") { + window.InstanceEmbed.postFormAndReload(form, "执行中…"); + return; + } + if (window.FormSubmitGuard && typeof window.FormSubmitGuard.nativeSubmitOnce === "function") { + window.FormSubmitGuard.nativeSubmitOnce(form, "执行中…"); + return; + } + form.submit(); + } + + function initStrategyRollForm() { + const form = document.getElementById("roll-form"); + if (!form) return; + if (form.dataset.rollJsInit === "1") return; + form.dataset.rollJsInit = "1"; + + const symbolSel = document.getElementById("roll-symbol"); + const dirInput = document.getElementById("roll-direction"); + const modeSel = document.getElementById("roll-add-mode"); + const riskBanner = document.getElementById("roll-risk-banner"); + const previewBtn = document.getElementById("roll-preview-btn"); + const submitBtn = document.getElementById("roll-submit-btn"); + const previewBox = document.getElementById("roll-preview-box"); + const previewText = document.getElementById("roll-preview-text"); + const countdownEl = document.getElementById("roll-countdown"); + const trendLocked = submitBtn && submitBtn.getAttribute("data-trend-locked") === "1"; + + let countdownTimer = null; + let previewOk = false; + let lastPreviewMode = ""; + let monitorSubmitting = false; + + function isMarketMode() { + return (modeSel.value || "market") === "market"; + } + + function isMonitorMode() { + const m = modeSel.value || "market"; + return m === "fib_618" || m === "fib_786" || m === "breakout"; + } + + function selectedOption() { + return symbolSel.options[symbolSel.selectedIndex]; + } + + function syncDirectionLock() { + const opt = selectedOption(); + if (!opt || !opt.value) { + riskBanner.textContent = "当前风险:请选择持仓币种"; + return; + } + const dir = opt.getAttribute("data-direction") || "long"; + const rp = opt.getAttribute("data-risk-percent") || "—"; + dirInput.value = dir; + riskBanner.textContent = + "当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")"; + } + + function syncSubmitButton() { + if (!submitBtn || trendLocked) return; + if (isMonitorMode()) { + submitBtn.disabled = false; + submitBtn.removeAttribute("disabled"); + return; + } + const blocked = !previewOk || !!countdownTimer; + submitBtn.disabled = blocked; + if (!blocked) submitBtn.removeAttribute("disabled"); + } + + function clearMessageBox() { + if (!previewBox) return; + previewBox.style.display = "none"; + previewBox.classList.remove("is-error", "is-preview"); + if (previewText) previewText.textContent = ""; + if (countdownEl) countdownEl.style.display = "none"; + } + + function showReject(msg) { + if (!previewBox || !previewText) return; + previewBox.style.display = "block"; + previewBox.classList.remove("is-preview"); + previewBox.classList.add("is-error"); + previewText.textContent = msg || "无法执行"; + if (countdownEl) countdownEl.style.display = "none"; + previewBox.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + function showPreviewResult(p) { + if (!previewBox || !previewText) return; + previewBox.style.display = "block"; + previewBox.classList.remove("is-error"); + previewBox.classList.add("is-preview"); + previewText.innerHTML = + "" + + (p.add_mode_label || "") + + " · 约 " + + (p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) + + " 张
        " + + "加仓参考价 " + + (p.add_price_display != null ? p.add_price_display : p.add_price) + + " · 新止损 " + + (p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) + + "
        " + + "合并均价 " + + p.avg_entry_after + + " · 打到止损约 " + + p.loss_at_sl_usdt + + "U(风险预算 " + + (p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") + + "U)"; + } + + function syncFieldVisibility() { + syncRollFormMode(form, modeSel.value || "market"); + resetPreview(); + } + + function resetPreview() { + previewOk = false; + monitorSubmitting = false; + clearMessageBox(); + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + syncSubmitButton(); + } + + function formPayload() { + const fd = new FormData(form); + const obj = {}; + fd.forEach(function (v, k) { + if (v !== "") obj[k] = v; + }); + return obj; + } + + function requestPreview() { + return fetch("/strategy/roll/preview", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(formPayload()), + credentials: "same-origin", + }).then(function (r) { + return r.json(); + }); + } + + function runPreview() { + resetPreview(); + if (!symbolSel.value) { + showReject("请先选择持仓币种"); + return; + } + if (previewBtn) previewBtn.disabled = true; + requestPreview() + .then(function (data) { + if (previewBtn) previewBtn.disabled = false; + if (!data.ok) { + showReject(data.msg || "预览失败"); + return; + } + const p = data.preview || {}; + lastPreviewMode = p.add_mode || modeSel.value; + showPreviewResult(p); + previewOk = true; + if (lastPreviewMode === "market") { + startCountdown(10); + } else { + syncSubmitButton(); + } + }) + .catch(function () { + if (previewBtn) previewBtn.disabled = false; + showReject("预览请求失败,请稍后重试"); + }); + } + + function runMonitorSubmit() { + if (monitorSubmitting) return; + if (!symbolSel.value) { + showReject("请先选择持仓币种"); + return; + } + monitorSubmitting = true; + if (submitBtn) submitBtn.disabled = true; + requestPreview() + .then(function (data) { + monitorSubmitting = false; + if (submitBtn && !trendLocked) { + submitBtn.disabled = false; + submitBtn.removeAttribute("disabled"); + } + if (!data.ok) { + showReject(data.msg || "无法提交监控"); + return; + } + const p = data.preview || {}; + const modeLabel = modeSel.options[modeSel.selectedIndex].text; + const summary = + "约 " + + (p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) + + " 张 · 触发参考价 " + + (p.add_price_display != null ? p.add_price_display : p.add_price) + + " · 新止损 " + + (p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss); + if (!confirm("确认提交「" + modeLabel + "」?\n" + summary)) { + return; + } + submitRollForm(form); + }) + .catch(function () { + monitorSubmitting = false; + if (submitBtn && !trendLocked) { + submitBtn.disabled = false; + submitBtn.removeAttribute("disabled"); + } + showReject("校验请求失败,请稍后重试"); + }); + } + + function startCountdown(sec) { + let left = sec; + if (submitBtn) submitBtn.disabled = true; + if (countdownEl) { + countdownEl.style.display = "block"; + countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(修改表单将取消预览)"; + } + countdownTimer = setInterval(function () { + left -= 1; + if (left <= 0) { + clearInterval(countdownTimer); + countdownTimer = null; + if (countdownEl) countdownEl.textContent = "可以执行市价加仓"; + syncSubmitButton(); + return; + } + if (countdownEl) countdownEl.textContent = "市价加仓:" + left + " 秒后可执行"; + }, 1000); + } + + symbolSel.addEventListener("change", function () { + syncDirectionLock(); + resetPreview(); + }); + modeSel.addEventListener("change", syncFieldVisibility); + form.addEventListener("input", resetPreview); + form.addEventListener("change", function (e) { + if (e.target !== previewBtn) resetPreview(); + }); + if (previewBtn) previewBtn.addEventListener("click", runPreview); + form.addEventListener("submit", function (e) { + e.preventDefault(); + if (isMonitorMode()) { + runMonitorSubmit(); + return; + } + if (!previewOk) { + showReject("请先点击「预览」并通过校验"); + return; + } + if (submitBtn && submitBtn.disabled) { + showReject("请等待 10 秒确认倒计时结束后再执行市价加仓"); + return; + } + const modeLabel = modeSel.options[modeSel.selectedIndex].text; + if (!confirm("确认提交「" + modeLabel + "」?")) { + return; + } + submitRollForm(form); + }); + + syncDirectionLock(); + syncFieldVisibility(); + } + + window.initStrategyRollForm = initStrategyRollForm; + initStrategyRollForm(); +})(); 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..db209cd --- /dev/null +++ b/lib/env/env_schema.py @@ -0,0 +1,434 @@ +"""从 .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", + "KEY_AUTO_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", + "KEY_AUTO_ORDER_ENABLED", + "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_OPTIONS_COIN_COMPOUND", + "OKX_OPTIONS_COIN_BUDGET_USDT", + "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", + "OKX_OPTIONS_COIN_MAX_USDT", + "OKX_OPTIONS_COIN_SPOT_BUY_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", + "HUB_BRIDGE_TOKEN", + "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", "期期对冲"), + ), + "OKX_OPTIONS_MARGIN_MODE": ( + ("coin", "币本位(USDT买币桥)"), + ("usdc", "USDC(USDⓈ权利金)"), + ), + "OKX_OPTIONS_CLOSE_GATE_MODE": ( + ("premium", "权利金×倍数"), + ("net_pnl", "净盈亏(U)阈值"), + ), + "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..ea58a5a --- /dev/null +++ b/lib/env/env_ui_manifest.py @@ -0,0 +1,636 @@ +"""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", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"), + ("OKX_TD_MODE", "保证金模式", ""), + ("OKX_POS_MODE", "持仓模式", ""), + ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), + ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ( + "OKX_SHOW_PERP_FUNDS", + "显示永续资金", + "默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧", + ), + ], + "binance": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"), + ("BINANCE_MARGIN_MODE", "保证金模式", ""), + ("BINANCE_POSITION_MODE", "持仓模式", ""), + ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), + ], + "gate": [ + ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"), + ("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"), + ("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"), + ("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"), + ("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_MARGIN_MODE", + "单笔期权本位", + "usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启", + ), + ( + "OKX_OPTIONS_TRADE_BUDGET_USDC", + "单笔预算(USDC)", + "仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限", + ), + ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"), + ( + "OKX_OPTIONS_COIN_COMPOUND", + "币本位按交易户USDT复利", + "默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲", + ), + ( + "OKX_OPTIONS_COIN_BUDGET_USDT", + "币本位固定预算(USDT)", + "仅币本位且复利关闭时生效", + ), + ( + "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", + "币本位单笔上限开关", + "默认 false=靠人工转走控规模;true 时预算不超过下方 N U", + ), + ( + "OKX_OPTIONS_COIN_MAX_USDT", + "币本位单笔上限(USDT)", + "仅上限开关开启时生效", + ), + ( + "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", + "币本位现货买入缓冲", + "相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换", + ), + ( + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", + "全仓复利开关", + "默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利", + ), + ( + "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_OPTIONS_CLOSE_GATE_MODE", + "目标平仓门控模式", + "premium=可回收(U)≥权利金(U)×倍数;net_pnl=净盈亏(U)大于阈值。币本位按指数换算为U", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "门控权利金倍数(全局)", + "可选;填写则覆盖下方分本位默认值", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", + "门控权利金倍数(币本位)", + "默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值", + ), + ( + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", + "门控权利金倍数(USDC)", + "默认 2;premium 模式下 recyclable(U)≥premium(U)×本值", + ), + ( + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", + "门控净盈亏下限(U)", + "net_pnl 模式;净盈亏(估)须大于本值,如 0 或 1", + ), + ( + "OKX_OPTIONS_CLOSE_HOLD_SECONDS", + "门控持续秒数", + "达标后须持续本秒数才通过,默认 120", + ), + ], +} + +# 对冲公共字段(不含已由 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", + "OKX_OPTIONS_CLOSE_GATE_MODE": "premium", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC": "2", + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_U": "0", + "OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120", +} + + +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_options_env_field_visibility(fields) + groups.append({ + "title": sec["title"], + "fields": fields, + "has_restart": any(f.get("restart_required") for f in fields), + }) + return groups + + +def _mark_options_env_field_visibility(fields: list[dict[str, Any]]) -> list[dict[str, Any]]: + """按本位/全仓复利隐藏无关项(供 SSR/前端;切换开关仍可再显示).""" + compound_on = True + margin_mode = "coin" + for f in fields: + key = f.get("key") + cur = str(f.get("current") or f.get("default") or "").strip() + if key == "OKX_OPTIONS_COMPOUND_FULL_ENABLED": + compound_on = _env_truthy(cur or "true") + elif key == "OKX_OPTIONS_MARGIN_MODE": + margin_mode = (cur or "coin").lower() + if margin_mode not in ("coin", "usdc"): + margin_mode = "coin" + out: list[dict[str, Any]] = [] + for f in fields: + item = dict(f) + key = item.get("key") + hide = False + if key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on: + hide = True + if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC" and margin_mode == "coin": + hide = True + if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and margin_mode == "usdc": + hide = True + if hide: + item["hidden"] = True + out.append(item) + return out + + +def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]: + """兼容旧调用名;实际走 _mark_options_env_field_visibility.""" + return _mark_options_env_field_visibility(fields) + +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..bb8efa0 --- /dev/null +++ b/lib/env/shared_env_lib.py @@ -0,0 +1,237 @@ +"""中控统一 AI 环境变量:字段定义,读写,同步三实例.""" +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) + +INSTANCE_ENV_DIRS: dict[str, Path] = { + "okx": REPO_ROOT / "crypto_monitor_okx", + "binance": REPO_ROOT / "crypto_monitor_binance", + "gate": REPO_ROOT / "crypto_monitor_gate", +} + + +def hub_env_path() -> str: + return str(REPO_ROOT / "manual_trading_hub" / ".env") + + +def hub_example_path() -> str: + return str(REPO_ROOT / "manual_trading_hub" / ".env.example") + + +def instance_example_path(exchange_key: str = "okx") -> str: + ex = (exchange_key or "okx").strip().lower() + base = INSTANCE_ENV_DIRS.get(ex, INSTANCE_ENV_DIRS["okx"]) + return str(base / ".env.example") + + +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 hub_env_path() + example_path = example_path or hub_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]: + """比较 hub 与三实例 AI 键是否一致(用于 UI 提示).""" + hub_vals = env_get_all(read_env_lines(hub_env_path())) + per_instance: dict[str, dict[str, Any]] = {} + all_ok = True + for ex, inst_dir in INSTANCE_ENV_DIRS.items(): + path = str(inst_dir / ".env") + if not os.path.isfile(path): + per_instance[ex] = {"ok": False, "msg": "缺少 .env"} + all_ok = False + continue + inst_vals = env_get_all(read_env_lines(path)) + mismatched = [ + k + for k in AI_ENV_KEYS + if (hub_vals.get(k) or "") != (inst_vals.get(k) or "") + ] + ok = not mismatched + if not ok: + all_ok = False + per_instance[ex] = { + "ok": ok, + "mismatched_keys": mismatched, + } + return {"all_synced": all_ok, "instances": per_instance} + + +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 hub_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]: + """写入 hub .env 并强制同步三实例相同键.""" + 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} + + changed: dict[str, list[str]] = {} + targets = [("hub", hub_env_path())] + for ex, inst_dir in INSTANCE_ENV_DIRS.items(): + targets.append((ex, str(inst_dir / ".env"))) + + for name, path in targets: + if not os.path.isfile(path): + if name == "hub": + return {"ok": False, "errors": [f"缺少 {path}"], "changed": {}} + continue + keys = apply_env_updates(path, clean) + if keys: + changed[name] = keys + load_env_file_into_environ(path) + + return { + "ok": True, + "changed": changed, + "restart_required": True, + "errors": [], + } + + +def restart_instances_then_hub_pm2() -> dict[str, Any]: + """先重启三实例,最后重启中控(避免当前请求被中断).""" + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "results": []} + from lib.instance.instance_pm2_lib import restart_instance_pm2 + + results: list[dict[str, Any]] = [] + for ex in ("okx", "binance", "gate"): + r = restart_instance_pm2(ex) + results.append({"exchange": ex, **r}) + hub_result = _restart_pm2_app("manual-trading-hub") + results.append({"app": "manual-trading-hub", **hub_result}) + ok = all(r.get("ok") for r in results) + return {"ok": ok, "results": results} + + +def restart_hub_and_instances_pm2() -> dict[str, Any]: + """兼容旧调用:与 restart_instances_then_hub_pm2 相同顺序.""" + return restart_instances_then_hub_pm2() + + +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/api_credentials_lib.py b/lib/exchange/api_credentials_lib.py new file mode 100644 index 0000000..82d754f --- /dev/null +++ b/lib/exchange/api_credentials_lib.py @@ -0,0 +1,94 @@ +"""交易所 API 凭证规范化. + +新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且 +(尤其 Gate)反复签名请求易触发 IP 封禁. +""" +from __future__ import annotations + +from typing import Any, Optional + + +_PLACEHOLDER_EXACT = frozenset( + { + "你的密钥", + "your-api-key", + "your_api_key", + "your-api-secret", + "your_api_secret", + "todo", + "xxx", + "changeme", + } +) + + +def normalize_api_credential(value: Optional[str]) -> str: + """去空白;空/占位符一律视为未配置.""" + s = (value or "").strip().strip('"').strip("'") + if not s: + return "" + upper = s.upper() + if upper.startswith("REPLACE_WITH"): + return "" + if upper.startswith("CHANGE_TO"): + return "" + if s.lower() in _PLACEHOLDER_EXACT: + return "" + return s + + +def credentials_configured(*parts: Optional[str]) -> bool: + return all(bool(normalize_api_credential(p)) for p in parts) + + +def is_exchange_auth_error(exc: BaseException) -> bool: + """鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP).""" + name = type(exc).__name__ + if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"): + return True + msg = str(exc) + markers = ( + "Invalid Api-Key", + "Invalid API-key", + "Invalid API Key", + "INVALID_KEY", + "Invalid key", + "API key is invalid", + "api key not found", + "Signature", + "INVALID_SIGNATURE", + "401", + "-2008", + "-2014", + "-2015", + "10003", # Gate: invalid key often + "INVALID_KEY", + ) + low = msg.lower() + if "api" in low and ("key" in low or "sign" in low) and ( + "invalid" in low or "incorrect" in low or "not found" in low + ): + return True + return any(m in msg for m in markers) + + +def strip_ccxt_credentials(exchange: Any) -> None: + """内存中清空密钥,后续只走公开接口,避免继续带坏钥签名.""" + try: + exchange.apiKey = "" + except Exception: + pass + try: + exchange.secret = "" + except Exception: + pass + try: + exchange.password = "" + except Exception: + pass + + +def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None: + """鉴权失败后去掉密钥再拉公开 markets(最多再请求一次).""" + strip_ccxt_credentials(exchange) + exchange.load_markets(reload=reload) 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..6b84c30 --- /dev/null +++ b/lib/exchange/okx_options_lib.py @@ -0,0 +1,1877 @@ +"""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, + intrinsic_px_per_unit, + is_shallow_itm, + option_moneyness, + option_moneyness_label, + strike_distance_to_be, +) + +_OKX_OPTION_ERR_ZH: dict[str, str] = { + "51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 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 模式常因交易户 USDC 不足;币本位则是标的币不足 + if "usdc" in low: + return "交易账户 USDC 可用余额不足" + if "usdt" in low: + return "USDT 可用余额不足" + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + if is_coin_margin_mode(): + return "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够,或减少张数)" + except Exception: + pass + 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 format_premium_amount(v: float | None, *, ccy: str | None = "USDC") -> str | None: + """权利金/回收金额文案:USDC 2 位;币本位 ETH/BTC 最多 8 位去尾零.""" + if v is None: + return None + try: + n = float(v) + except (TypeError, ValueError): + return None + unit = (ccy or "USDC").strip().upper() or "USDC" + if unit in ("ETH", "BTC"): + txt = f"{n:.8f}".rstrip("0").rstrip(".") + return txt or "0" + return f"{n:.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: + """OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区.""" + from lib.options.options_time_lib import ms_to_options_app_str + + return ms_to_options_app_str(ms) + + +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 _resolve_chain_quote( + *, + ticker: dict[str, Any], + meta: dict[str, Any], + opt_type: str, + strike: float, + index_px: float, + inst_id: str | None = None, +) -> 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 + iid = (inst_id or str(meta.get("instId") or "")).strip() + + 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, inst_id=iid or None) + 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, inst_id=iid or None) + 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, "ETH": None, "BTC": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": 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, "ETH": None, "BTC": None} + avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": 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") + # OKX 统一账户:option 客户端拉 type=trading 常缺 USDT/币;用 swap 补齐缺失项 + if any(trading.get(c) is None for c in ("USDT", "USDC", "ETH", "BTC")): + swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") + for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"): + if trading.get(ccy) is None and swap_bal.get(ccy) is not None: + trading[ccy] = swap_bal[ccy] + if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None: + trading_avail[ccy] = swap_avail[ccy] + result = { + "scope": "main", + "funding_usdt": funding.get("USDT"), + "funding_usdc": funding.get("USDC"), + "funding_usdg": funding.get("USDG"), + "funding_eth": funding.get("ETH"), + "funding_btc": funding.get("BTC"), + "funding_usdt_avail": funding_avail.get("USDT"), + "funding_usdc_avail": funding_avail.get("USDC"), + "funding_eth_avail": funding_avail.get("ETH"), + "funding_btc_avail": funding_avail.get("BTC"), + "trading_usdt": trading.get("USDT"), + "trading_usdc": trading.get("USDC"), + "trading_usdg": trading.get("USDG"), + "trading_eth": trading.get("ETH"), + "trading_btc": trading.get("BTC"), + "trading_usdt_avail": trading_avail.get("USDT"), + "trading_usdc_avail": trading_avail.get("USDC"), + "trading_eth_avail": trading_avail.get("ETH"), + "trading_btc_avail": trading_avail.get("BTC"), + } + _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) + """ + pack = options_header_balance_pack(ex, force=force) + return ( + pack.get("trading_usdc"), + pack.get("funding_usdc"), + pack.get("funding_usdt"), + pack.get("trading_usdt"), + ) + + +def options_header_balance_pack( + ex: ccxt.okx, + *, + force: bool = False, +) -> dict[str, Any]: + """顶栏/快照用期权资金包(含币本位 ETH/BTC).""" + import os + + bal = fetch_options_balances(ex, force=force) + + def _round(v: Any, nd: int = 2) -> float | None: + if v is None: + return None + try: + return round(float(v), nd) + except (TypeError, ValueError): + return None + + def _round_coin(v: Any) -> float | None: + if v is None: + return None + try: + return round(float(v), 8) + except (TypeError, ValueError): + return None + + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() + except Exception: + margin_mode = "usdc" + underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + coin_key = "btc" if underly == "BTC" else "eth" + return { + "trading_usdc": _round(bal.get("trading_usdc")), + "funding_usdc": _round(bal.get("funding_usdc")), + "funding_usdt": _round(bal.get("funding_usdt")), + "trading_usdt": _round(bal.get("trading_usdt")), + "funding_eth": _round_coin(bal.get("funding_eth")), + "trading_eth": _round_coin(bal.get("trading_eth")), + "funding_btc": _round_coin(bal.get("funding_btc")), + "trading_btc": _round_coin(bal.get("trading_btc")), + "options_margin_mode": margin_mode, + "options_underly": underly, + "funding_coin": _round_coin(bal.get(f"funding_{coin_key}")), + "trading_coin": _round_coin(bal.get(f"trading_{coin_key}")), + } + + +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, + margin_mode: str | None = None, + inst_family: str | None = None, +) -> dict[str, Any]: + u = (underlying or "ETH").upper() + if inst_family: + family = str(inst_family).strip() + else: + try: + from lib.options.options_margin_mode_lib import inst_family_for_underlying + + family = inst_family_for_underlying(u, margin_mode=margin_mode) + except Exception: + family = f"{u}-USD_UM" + uly = f"{u}-USD" + idx = index_px if index_px is not None else fetch_index_price(ex, uly) + chain_margin = "usdc" if "_UM" in family.upper() else "coin" + 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, + inst_id=inst_id, + ) + 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, + inst_id=inst_id, + margin_mode=chain_margin, + ) + 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": strike_distance_to_be(strike, expiry_be, opt_type=opt_type), + "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, + "margin_mode": "usdc" if "_UM" in family.upper() else "coin", + "premium_ccy": "USDC" if "_UM" in family.upper() else u, + "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, + inst_id=inst_id, + ) + 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": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")), + "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", "") + try: + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc" + premium_ccy = premium_ccy_for_mode(row_mode, uly or "ETH") + except Exception: + row_mode = "usdc" + premium_ccy = "USDC" + idx_px = _safe_float(raw.get("idxPx") or raw.get("idx_px")) + 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, + "ct_mult": ct_mult, + "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_premium_amount(premium_paid, ccy=premium_ccy), + "premium_ccy": premium_ccy, + "margin_mode": row_mode, + "margin_mode_label": "币本位" if row_mode == "coin" else "USDC", + "idx_px": idx_px, + "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 + premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" + 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"), + "premium_ccy": premium_ccy, + "margin_mode": row.get("margin_mode"), + "margin_mode_label": row.get("margin_mode_label"), + "idx_px": row.get("idx_px"), + "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, + strike_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 + try: + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc" + underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH" + premium_ccy = premium_ccy_for_mode(row_mode, underly) + except Exception: + row_mode = "usdc" + underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH" + premium_ccy = "USDC" + 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")), + inst_id=inst_id, + margin_mode=row_mode, + ) + 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_premium_amount(premium_paid, ccy=premium_ccy), + "tick_sz": tick_sz, + "ct_mult": ct_mult, + "idx_px": idx_px, + "premium_paid": premium_paid, + "margin_mode": row_mode, + "premium_ccy": premium_ccy, + "underlying": underly, + "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": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")), + "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..304263a --- /dev/null +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -0,0 +1,1413 @@ +"""对冲计划开仓/平仓编排(可 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 + underlying = str(inst_id).split("-")[0].upper() or "ETH" + spot_bridge = None + 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, + "premium_ccy": underlying, + "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, + "margin_mode": "coin", + } + if not callable(place_fn): + return {"ok": False, "msg": "期权限价下单未注入"} + # 币本位:开仓前按权利金×缓冲用 USDT 买足标的币 + try: + from lib.options.options_margin_mode_lib import ( + normalize_coin_spot_buy_buffer, + plan_coin_open_by_budget, + ) + from lib.options.options_spot_bridge_lib import ( + fetch_trading_coin_available, + spot_market_buy_coin_with_usdt, + ) + + idx = float( + q.get("index_px") + or q.get("idx_px") + or (q.get("meta") or {}).get("idxPx") + or q.get("mark") + or 0 + ) + if idx <= 0: + return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "quote": q} + buf = normalize_coin_spot_buy_buffer(None) + need_coin = float(premium) * float(buf) + avail = float(fetch_trading_coin_available(ex, underlying) or 0) + if avail + 1e-12 < need_coin: + plan = plan_coin_open_by_budget( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=1, + budget_usdt=need_coin * idx * 1.05, + index_px=idx, + ask_sz=float(ask_sz) if ask_sz is not None else None, + spot_buy_buffer=buf, + target_sheets=sheets_i, + ) + if not plan.get("ok"): + return { + "ok": False, + "msg": plan.get("msg") or "币本位买币计划失败", + "quote": q, + "plan": plan, + } + buy_usdt = float(plan.get("buy_usdt") or 0) + if buy_usdt > 0: + buy = spot_market_buy_coin_with_usdt( + ex, underlying=underlying, usdt_amount=buy_usdt + ) + spot_bridge = {"buy_usdt": buy_usdt, "buy": buy, "plan": plan} + if not buy.get("ok"): + return { + "ok": False, + "msg": buy.get("msg") or "买币失败", + "quote": q, + "spot_bridge": spot_bridge, + } + except Exception as e: + return {"ok": False, "msg": f"币本位买币准备失败: {e}", "quote": q} + 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, + "premium_ccy": underlying, + "margin_mode": "coin", + "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, + "spot_bridge": spot_bridge, + "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_balances, 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, + } + + # CoinOX 期期预算改用交易户 USDT(resolve 函数名仍 usdc,数值按 USDT) + bal = fetch_options_balances(ex, force=True) or {} + trading = bal.get("trading_usdt_avail") + if trading is None: + trading = bal.get("trading_usdt") + 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..f770383 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_register.py @@ -0,0 +1,1434 @@ +"""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.hub.hub_calculator_market_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)) + + return { + "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), + } + + +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) + # CoinOX: 对冲期权腿固定币本位,不再拦截 + 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/hub/__init__.py b/lib/hub/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/hub/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py new file mode 100644 index 0000000..d4de313 --- /dev/null +++ b/lib/hub/amp_stats_lib.py @@ -0,0 +1,888 @@ +"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径. + +仅只读行情;不触及下单链路. +""" +from __future__ import annotations + +import csv +import io +import statistics +import time +from datetime import date, datetime, timedelta +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +import httpx + +APP_TZ = ZoneInfo("Asia/Shanghai") +END_HOUR = 16 +EXCHANGE = "okx" +TIMEFRAME = "1H" + +SYMBOLS: dict[str, dict[str, str]] = { + "eth": { + "label": "ETH", + "index_inst": "ETH-USD", + "swap_inst": "ETH-USDT-SWAP", + }, + "btc": { + "label": "BTC", + "index_inst": "BTC-USD", + "swap_inst": "BTC-USDT-SWAP", + }, +} + +PERIOD_DAYS: dict[str, int] = { + "1m": 30, + "2m": 60, + "3m": 90, + "6m": 180, + "1y": 365, +} + +OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles" +OKX_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles" +OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles" +OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-candles" + + +def normalize_symbol(raw: str) -> str: + s = (raw or "").strip().lower() + if s in ("eth", "ethereum"): + return "eth" + if s in ("btc", "bitcoin"): + return "btc" + raise ValueError("symbol 仅支持 eth / btc") + + +def resolve_sample_days(period: str, custom_days: Any = None) -> int: + p = (period or "2m").strip().lower() + if p == "custom": + try: + n = int(custom_days) + except (TypeError, ValueError): + raise ValueError("自定义天数无效") from None + return max(7, min(400, n)) + if p not in PERIOD_DAYS: + raise ValueError("周期无效") + return PERIOD_DAYS[p] + + +def window_bounds_for_settlement( + settlement: date, + start_hour: int, + *, + span_days: int = 1, +) -> tuple[datetime, datetime]: + """返回 [start, end) 的本地时刻;end 为结算日 16:00. + + span_days=1: 与现口径相同(如 26日16:00→27日16:00) + span_days=2: 再往前推 1 天(如 25日16:00→27日16:00) + """ + if not (0 <= int(start_hour) <= 23): + raise ValueError("起点须为 0-23 整点") + span = max(1, int(span_days or 1)) + end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ) + sh = int(start_hour) + if sh >= END_HOUR: + prev = settlement - timedelta(days=1) + start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ) + else: + start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ) + if span > 1: + start = start - timedelta(days=span - 1) + return start, end + + +def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]: + """最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00).""" + now = now or datetime.now(APP_TZ) + if now.tzinfo is None: + now = now.replace(tzinfo=APP_TZ) + else: + now = now.astimezone(APP_TZ) + today = now.date() + today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ) + latest = today if now >= today_end else today - timedelta(days=1) + return [latest - timedelta(days=i) for i in range(int(sample_days))] + + +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 bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]: + """open_time_ms -> {o,h,l,c}.""" + m: dict[int, dict[str, float]] = {} + for b in bars or []: + if not isinstance(b, dict): + continue + ts = b.get("ts") + if ts is None: + ts = b.get("open_time_ms") + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + o = _safe_float(b.get("o") if "o" in b else b.get("open")) + h = _safe_float(b.get("h") if "h" in b else b.get("high")) + l = _safe_float(b.get("l") if "l" in b else b.get("low")) + c = _safe_float(b.get("c") if "c" in b else b.get("close")) + if None in (o, h, l, c): + continue + m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)} + return m + + +def _ohlc_window_metrics( + start: datetime, + end: datetime, + bar_map: dict[int, dict[str, float]], +) -> Optional[dict[str, Any]]: + """在 [start, end) 上算开高低收与开→高/开→低/振幅/涨跌.""" + start_ms = int(start.timestamp() * 1000) + # 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close + last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000) + if start_ms not in bar_map or last_bar_ms not in bar_map: + return None + opens = bar_map[start_ms]["o"] + close = bar_map[last_bar_ms]["c"] + hi = bar_map[start_ms]["h"] + lo = bar_map[start_ms]["l"] + t = start_ms + while t <= last_bar_ms: + b = bar_map.get(t) + if b: + hi = max(hi, b["h"]) + lo = min(lo, b["l"]) + t += 3600 * 1000 + up = hi - opens + down = opens - lo + amp = hi - lo + change = close - opens + return { + "window_start": start.strftime("%Y-%m-%d %H:%M"), + "window_end": end.strftime("%Y-%m-%d %H:%M"), + "open": round(opens, 4), + "high": round(hi, 4), + "low": round(lo, 4), + "close": round(close, 4), + "up_points": round(up, 4), + "down_points": round(down, 4), + "amplitude": round(amp, 4), + "change": round(change, 4), + } + + +def compute_day_row( + settlement: date, + start_hour: int, + bar_map: dict[int, dict[str, float]], +) -> Optional[dict[str, Any]]: + start, end = window_bounds_for_settlement(settlement, start_hour, span_days=1) + m1 = _ohlc_window_metrics(start, end, bar_map) + if m1 is None: + return None + start2, end2 = window_bounds_for_settlement(settlement, start_hour, span_days=2) + m2 = _ohlc_window_metrics(start2, end2, bar_map) + wd = settlement.weekday() # Mon=0 … Sun=6 + is_we = wd >= 5 + row: dict[str, Any] = { + "settlement_day": settlement.isoformat(), + "weekday": wd, + "weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""), + "is_weekend": is_we, + **m1, + } + if m2 is None: + row.update( + { + "window2_start": start2.strftime("%Y-%m-%d %H:%M"), + "window2_end": end2.strftime("%Y-%m-%d %H:%M"), + "open_2d": None, + "high_2d": None, + "low_2d": None, + "close_2d": None, + "up_points_2d": None, + "down_points_2d": None, + "amplitude_2d": None, + "change_2d": None, + } + ) + else: + row.update( + { + "window2_start": m2["window_start"], + "window2_end": m2["window_end"], + "open_2d": m2["open"], + "high_2d": m2["high"], + "low_2d": m2["low"], + "close_2d": m2["close"], + "up_points_2d": m2["up_points"], + "down_points_2d": m2["down_points"], + "amplitude_2d": m2["amplitude"], + "change_2d": m2["change"], + } + ) + return row + + +def normalize_move_points(raw: Any) -> Optional[float]: + """对照波动点数.空/≤0 表示不做点数达标对照.""" + if raw is None or raw == "": + return None + try: + v = float(raw) + except (TypeError, ValueError): + raise ValueError("波动点数须为数字") from None + if v <= 0: + return None + return v + + +def normalize_weekend_filter(raw: Any) -> str: + """all | exclude | only;默认全部.""" + s = (str(raw) if raw is not None else "all").strip().lower() + if s in ("", "all", "全部"): + return "all" + if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"): + return "exclude" + if s in ("only", "weekend_only", "only_weekend", "仅周末"): + return "only" + raise ValueError("周末筛选须为 all / exclude / only") + + +def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]: + mode = normalize_weekend_filter(weekend_filter) + if mode == "all": + return list(rows or []) + out: list[dict[str, Any]] = [] + for r in rows or []: + is_we = bool(r.get("is_weekend")) + if "is_weekend" not in r and r.get("settlement_day"): + try: + is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5 + except ValueError: + is_we = False + if mode == "exclude" and is_we: + continue + if mode == "only" and not is_we: + continue + out.append(r) + return out + + +def _ensure_weekend_flags(item: dict[str, Any]) -> None: + if "is_weekend" in item: + return + if not item.get("settlement_day"): + item.setdefault("weekday_label", "") + item.setdefault("is_weekend", False) + return + try: + wd = date.fromisoformat(str(item["settlement_day"])).weekday() + item["weekday"] = wd + item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "") + item["is_weekend"] = wd >= 5 + except ValueError: + item.setdefault("weekday_label", "") + item.setdefault("is_weekend", False) + + +def enrich_rows( + rows: list[dict[str, Any]], + *, + move_points: Any = None, +) -> list[dict[str, Any]]: + """为日表附加周末标注,以及相对波动点数的两边达标.""" + mp = normalize_move_points(move_points) + out: list[dict[str, Any]] = [] + for r in rows or []: + item = dict(r) + _ensure_weekend_flags(item) + up = float(item.get("up_points") or 0) + down = float(item.get("down_points") or 0) + amp = float(item.get("amplitude") or 0) + hit_up = bool(mp is not None and up >= mp) + hit_down = bool(mp is not None and down >= mp) + amp_hit = bool(mp is not None and amp >= mp) + amp2 = item.get("amplitude_2d") + amp2_v = float(amp2) if amp2 is not None and amp2 != "" else None + amp_hit_2d = bool(mp is not None and amp2_v is not None and amp2_v >= mp) + item["move_points"] = mp + item["hit_up"] = hit_up + item["hit_down"] = hit_down + item["hit_either"] = hit_up or hit_down + item["hit_both"] = hit_up and hit_down + item["amp_hit"] = amp_hit + item["amp_hit_2d"] = amp_hit_2d + out.append(item) + return out + + +# 兼容旧调用名 +def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]: + return enrich_rows(rows, move_points=kwargs.get("move_points")) + + +def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]: + """波动点数达标汇总:开→高/开→低两边.""" + mp = float(move_points) + if mp <= 0: + raise ValueError("波动点数须 > 0") + work = enrich_rows(rows, move_points=mp) + n = len(work) + empty = { + "move_points": round(mp, 4), + "sample_count": n, + "up_hit_days": 0, + "up_hit_ratio": None, + "down_hit_days": 0, + "down_hit_ratio": None, + "either_hit_days": 0, + "either_hit_ratio": None, + "both_hit_days": 0, + "both_hit_ratio": None, + "amp_hit_days": 0, + "amp_hit_ratio": None, + "amp_2d_hit_days": 0, + "amp_2d_hit_ratio": None, + "abs_change_hit_days": 0, + "abs_change_hit_ratio": None, + } + if n <= 0: + return empty + up_hit = sum(1 for r in work if r.get("hit_up")) + down_hit = sum(1 for r in work if r.get("hit_down")) + either = sum(1 for r in work if r.get("hit_either")) + both = sum(1 for r in work if r.get("hit_both")) + amp_hit = sum(1 for r in work if r.get("amp_hit")) + amp2_rows = [r for r in work if r.get("amplitude_2d") is not None] + amp2_hit = sum(1 for r in work if r.get("amp_hit_2d")) + n2 = len(amp2_rows) + abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp) + empty.update( + { + "up_hit_days": up_hit, + "up_hit_ratio": round(up_hit / n, 4), + "down_hit_days": down_hit, + "down_hit_ratio": round(down_hit / n, 4), + "either_hit_days": either, + "either_hit_ratio": round(either / n, 4), + "both_hit_days": both, + "both_hit_ratio": round(both / n, 4), + "amp_hit_days": amp_hit, + "amp_hit_ratio": round(amp_hit / n, 4), + "amp_2d_hit_days": amp2_hit, + "amp_2d_hit_ratio": round(amp2_hit / n2, 4) if n2 else None, + "abs_change_hit_days": abs_hit, + "abs_change_hit_ratio": round(abs_hit / n, 4), + } + ) + return empty + + +def summarize_rows( + rows: list[dict[str, Any]], + *, + move_points: Any = None, +) -> dict[str, Any]: + mp = normalize_move_points(move_points) + empty_2d = { + "max_amplitude_2d": None, + "max_amplitude_2d_day": None, + "avg_amplitude_2d": None, + "median_amplitude_2d": None, + } + if not rows: + out = { + "sample_count": 0, + "max_amplitude": None, + "max_amplitude_day": None, + "avg_amplitude": None, + "median_amplitude": None, + "max_up_points": None, + "avg_up_points": None, + "max_down_points": None, + "avg_down_points": None, + "up_day_ratio": None, + "down_day_ratio": None, + **empty_2d, + "move_points_stats": None, + } + if mp is not None: + out["move_points_stats"] = move_points_stats([], mp) + return out + amps = [float(r["amplitude"]) for r in rows] + ups = [float(r["up_points"]) for r in rows] + downs = [float(r["down_points"]) for r in rows] + max_amp = max(amps) + max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp) + up_days = sum(1 for r in rows if float(r["change"]) > 0) + down_days = sum(1 for r in rows if float(r["change"]) < 0) + n = len(rows) + amps2 = [float(r["amplitude_2d"]) for r in rows if r.get("amplitude_2d") is not None] + out: dict[str, Any] = { + "sample_count": n, + "max_amplitude": round(max_amp, 4), + "max_amplitude_day": max_amp_day, + "avg_amplitude": round(statistics.fmean(amps), 4), + "median_amplitude": round(statistics.median(amps), 4), + "max_up_points": round(max(ups), 4), + "avg_up_points": round(statistics.fmean(ups), 4), + "max_down_points": round(max(downs), 4), + "avg_down_points": round(statistics.fmean(downs), 4), + "up_day_ratio": round(up_days / n, 4), + "down_day_ratio": round(down_days / n, 4), + **empty_2d, + "move_points_stats": None, + } + if amps2: + max_a2 = max(amps2) + out["max_amplitude_2d"] = round(max_a2, 4) + out["max_amplitude_2d_day"] = next( + r["settlement_day"] for r in rows if r.get("amplitude_2d") is not None and float(r["amplitude_2d"]) == max_a2 + ) + out["avg_amplitude_2d"] = round(statistics.fmean(amps2), 4) + out["median_amplitude_2d"] = round(statistics.median(amps2), 4) + if mp is not None: + out["move_points_stats"] = move_points_stats(rows, mp) + return out + + +def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]: + if not row or len(row) < 5: + return None + try: + ts = int(row[0]) + o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4]) + except (TypeError, ValueError, IndexError): + return None + return {"ts": ts, "o": o, "h": h, "l": l, "c": c} + + +def _okx_get_json( + client: httpx.Client, + url: str, + params: dict[str, str], + *, + retries: int = 8, +) -> dict[str, Any]: + """GET OKX 公共行情;遇 429 指数退避重试.""" + last_err: Optional[BaseException] = None + for attempt in range(max(1, int(retries))): + try: + r = client.get(url, params=params) + if r.status_code == 429: + wait = min(12.0, 0.7 * (2**attempt)) + time.sleep(wait) + last_err = httpx.HTTPStatusError( + f"429 Too Many Requests for url '{r.url}'", + request=r.request, + response=r, + ) + continue + r.raise_for_status() + body = r.json() + if not isinstance(body, dict): + raise RuntimeError("OKX 返回非对象 JSON") + return body + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else None + if status == 429 and attempt + 1 < retries: + wait = min(12.0, 0.7 * (2**attempt)) + time.sleep(wait) + last_err = exc + continue + raise + except httpx.TransportError as exc: + if attempt + 1 < retries: + time.sleep(min(8.0, 0.5 * (2**attempt))) + last_err = exc + continue + raise + if last_err is not None: + raise last_err + raise RuntimeError("OKX 请求失败") + + +def fetch_okx_candles( + *, + url: str, + inst_id: str, + since_ms: int, + until_ms: int, + bar: str = "1H", + client: Optional[httpx.Client] = None, + timeout: float = 30.0, + history_url: Optional[str] = None, + max_pages: int = 200, + page_pause_sec: float = 0.12, + history_page_pause_sec: float = 0.22, +) -> list[dict[str, Any]]: + """拉取 [since_ms, until_ms] 覆盖的 K 线(含边界). + + OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉. + 分页带间隔,429 自动退避重试. + """ + own = client is None + client = client or httpx.Client( + timeout=timeout, + trust_env=False, + headers={"User-Agent": "crypto_monitor-amp-stats/1.0"}, + ) + try: + out: dict[int, dict[str, Any]] = {} + after: Optional[str] = None + active_url = url + switched_history = False + for page_i in range(max(20, int(max_pages))): + if page_i > 0: + pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec + if pause > 0: + time.sleep(pause) + params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"} + if after: + params["after"] = after + body = _okx_get_json(client, active_url, params) + if str(body.get("code") or "") not in ("0", "0.0", ""): + raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}") + data = body.get("data") or [] + if not data: + # 近期接口到头 → 切历史端点再试 + if history_url and not switched_history and after is not None: + active_url = history_url + switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) + continue + break + oldest_ts = None + for row in data: + parsed = _parse_okx_candle_row(row) + if not parsed: + continue + ts = int(parsed["ts"]) + oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts) + if ts < since_ms - 3600 * 1000: + continue + if ts > until_ms + 3600 * 1000: + continue + out[ts] = parsed + if oldest_ts is None: + break + if oldest_ts <= since_ms: + break + # 无新进度时避免死循环 + if after is not None and str(oldest_ts) == after: + if history_url and not switched_history: + active_url = history_url + switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) + continue + break + after = str(oldest_ts) + # 近期接口返回变少且仍未覆盖 since → 切历史 + if ( + history_url + and not switched_history + and len(data) < 100 + and oldest_ts > since_ms + ): + active_url = history_url + switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) + return [out[k] for k in sorted(out.keys())] + finally: + if own: + client.close() + + +def fetch_symbol_bars( + symbol: str, + *, + since_ms: int, + until_ms: int, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, +) -> tuple[list[dict[str, Any]], str, str]: + """返回 (bars, price_source_label, inst_id).""" + key = normalize_symbol(symbol) + meta = SYMBOLS[key] + if fetch_fn: + bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms) + return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] + + index_err: Optional[BaseException] = None + try: + bars = fetch_okx_candles( + url=OKX_INDEX_CANDLES, + history_url=OKX_HISTORY_INDEX_CANDLES, + inst_id=meta["index_inst"], + since_ms=since_ms, + until_ms=until_ms, + ) + if bars: + return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"] + except Exception as exc: + index_err = exc + # 指数侧已触发限频时先冷却,再降级永续,避免连环 429 + time.sleep(1.2) + + try: + bars = fetch_okx_candles( + url=OKX_SWAP_CANDLES, + history_url=OKX_HISTORY_SWAP_CANDLES, + inst_id=meta["swap_inst"], + since_ms=since_ms, + until_ms=until_ms, + ) + except Exception as exc: + detail = f"index={index_err}; swap={exc}" if index_err else str(exc) + raise RuntimeError(f"OKX K线拉取失败({detail})") from exc + if not bars: + detail = f"index={index_err}" if index_err else "empty" + raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})") + return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"] + + +def compute_amp_stats( + *, + symbol: str = "eth", + start_hour: int = 16, + period: str = "2m", + custom_days: Any = None, + move_points: Any = None, + weekend_filter: Any = "all", + now: Optional[datetime] = None, + fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, +) -> dict[str, Any]: + key = normalize_symbol(symbol) + sh = int(start_hour) + if sh < 0 or sh > 23: + raise ValueError("起点须为 0-23 整点") + mp = normalize_move_points(move_points) + we_mode = normalize_weekend_filter(weekend_filter) + sample_days = resolve_sample_days(period, custom_days) + settlements = list_settlement_dates(sample_days=sample_days, now=now) + if not settlements: + raise RuntimeError("无可用结算日") + # 最远窗起点(含两日振幅,多拉 1 天) + oldest = settlements[-1] + newest = settlements[0] + start0, _ = window_bounds_for_settlement(oldest, sh, span_days=2) + _, end1 = window_bounds_for_settlement(newest, sh, span_days=1) + since_ms = int(start0.timestamp() * 1000) + until_ms = int(end1.timestamp() * 1000) + bars, price_source, inst_id = fetch_symbol_bars( + key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn + ) + bar_map = bars_to_map(bars) + rows_all: list[dict[str, Any]] = [] + missing: list[str] = [] + for d in settlements: + row = compute_day_row(d, sh, bar_map) + if row is None: + missing.append(d.isoformat()) + continue + rows_all.append(row) + return build_amp_result( + rows_all=rows_all, + symbol_key=key, + start_hour=sh, + period=period, + sample_days=sample_days, + move_points=mp, + weekend_filter=we_mode, + price_source=price_source, + inst_id=inst_id, + missing=missing, + ) + + +def build_amp_result( + *, + rows_all: list[dict[str, Any]], + symbol_key: str, + start_hour: int, + period: str, + sample_days: int, + move_points: Any = None, + weekend_filter: Any = "all", + price_source: str = "", + inst_id: str = "", + missing: Optional[list[str]] = None, +) -> dict[str, Any]: + mp = normalize_move_points(move_points) + we_mode = normalize_weekend_filter(weekend_filter) + filtered = filter_weekend_rows(rows_all, we_mode) + rows = enrich_rows(filtered, move_points=mp) + summary = summarize_rows(rows, move_points=mp) + if period == "custom" or str(period).startswith("custom:"): + period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}" + else: + period_label = str(period) + miss = missing or [] + return { + "ok": True, + "exchange": EXCHANGE, + "symbol": symbol_key, + "symbol_label": SYMBOLS[symbol_key]["label"], + "start_hour": start_hour, + "end_hour": END_HOUR, + "period": period_label, + "sample_days_requested": sample_days, + "move_points": mp, + "weekend_filter": we_mode, + "timeframe": TIMEFRAME, + "price_source": price_source, + "inst_id": inst_id, + "timezone": "Asia/Shanghai", + "rows_all": rows_all, + "rows": rows, + "summary": summary, + "missing_days": miss[:30], + "missing_count": len(miss), + } + + +def reframe_amp_stats( + *, + rows_all: list[dict[str, Any]], + symbol: str = "eth", + start_hour: int = 16, + period: str = "2m", + sample_days: int = 60, + move_points: Any = None, + weekend_filter: Any = "all", + price_source: str = "", + inst_id: str = "", + missing: Optional[list[str]] = None, +) -> dict[str, Any]: + """已有日表上改周末/波动点数,不拉 K 线.""" + key = normalize_symbol(symbol) + return build_amp_result( + rows_all=list(rows_all or []), + symbol_key=key, + start_hour=int(start_hour), + period=period, + sample_days=int(sample_days or 60), + move_points=move_points, + weekend_filter=weekend_filter, + price_source=price_source, + inst_id=inst_id, + missing=missing, + ) + + +def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]: + page = max(1, int(page or 1)) + page_size = max(5, min(100, int(page_size or 20))) + total = len(rows) + start = (page - 1) * page_size + chunk = rows[start : start + page_size] + return { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": max(1, (total + page_size - 1) // page_size) if total else 1, + "rows": chunk, + } + + +def build_export_csv(payload: dict[str, Any]) -> str: + buf = io.StringIO() + # Excel 友好 BOM + buf.write("\ufeff") + w = csv.writer(buf) + s = payload.get("summary") or {} + w.writerow(["【统计摘要】"]) + w.writerow(["交易所", payload.get("exchange")]) + w.writerow(["标的", payload.get("symbol_label")]) + w.writerow(["价源", payload.get("price_source")]) + w.writerow(["起点整点", f"{payload.get('start_hour')}:00"]) + w.writerow(["终点", f"{payload.get('end_hour')}:00"]) + w.writerow(["周期", payload.get("period")]) + w.writerow(["周末筛选", payload.get("weekend_filter")]) + w.writerow(["样本数", s.get("sample_count")]) + w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")]) + w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")]) + w.writerow(["两日最大振幅", s.get("max_amplitude_2d"), "日期", s.get("max_amplitude_2d_day")]) + w.writerow(["两日振幅均值", s.get("avg_amplitude_2d"), "中位数", s.get("median_amplitude_2d")]) + w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")]) + w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")]) + w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")]) + mp = s.get("move_points_stats") or {} + if mp: + w.writerow([]) + w.writerow(["【波动点数·振幅占比】", mp.get("move_points")]) + w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")]) + w.writerow(["两日振幅≥点数天数", mp.get("amp_2d_hit_days"), "占比", mp.get("amp_2d_hit_ratio")]) + w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")]) + w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")]) + w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")]) + w.writerow([]) + w.writerow(["【日表明细】"]) + w.writerow( + [ + "结算日", + "星期", + "周末", + "窗起点", + "窗终点", + "开盘", + "最高", + "最低", + "收盘", + "开→高", + "开→低", + "振幅", + "涨跌值", + "两日窗起点", + "两日窗终点", + "两日振幅", + "两日开→高", + "两日开→低", + "对照点数", + "振幅达标", + "两日振幅达标", + ] + ) + for r in payload.get("rows") or []: + w.writerow( + [ + r.get("settlement_day"), + r.get("weekday_label") or "", + "是" if r.get("is_weekend") else "否", + r.get("window_start"), + r.get("window_end"), + r.get("open"), + r.get("high"), + r.get("low"), + r.get("close"), + r.get("up_points"), + r.get("down_points"), + r.get("amplitude"), + r.get("change"), + r.get("window2_start"), + r.get("window2_end"), + r.get("amplitude_2d"), + r.get("up_points_2d"), + r.get("down_points_2d"), + r.get("move_points") if r.get("move_points") is not None else "", + "是" if r.get("amp_hit") else ("否" if r.get("move_points") is not None else ""), + "是" if r.get("amp_hit_2d") else ("否" if r.get("move_points") is not None and r.get("amplitude_2d") is not None else ""), + ] + ) + return buf.getvalue() + + +def export_filename(payload: dict[str, Any]) -> str: + sym = (payload.get("symbol") or "eth").lower() + sh = int(payload.get("start_hour") or 16) + period = str(payload.get("period") or "2m").replace(":", "") + day = datetime.now(APP_TZ).strftime("%Y%m%d") + return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv" diff --git a/lib/hub/hub_auth.py b/lib/hub/hub_auth.py new file mode 100644 index 0000000..a9015ab --- /dev/null +++ b/lib/hub/hub_auth.py @@ -0,0 +1,36 @@ +"""中控调用实例 API 时的鉴权(Flask request 头 X-Hub-Token).SSO 见 hub_sso.py.""" +from __future__ import annotations + +import os + +from lib.hub.hub_sso import ( + HUB_SSO_TTL_SEC, + hub_bridge_token, + mint_hub_sso_token, + safe_next_path, + verify_hub_sso_token, +) + +__all__ = [ + "HUB_SSO_TTL_SEC", + "hub_bridge_token", + "mint_hub_sso_token", + "safe_next_path", + "verify_hub_sso_token", + "request_allowed", +] + + +def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool: + if auth_disabled or session_logged_in: + return True + tok = hub_bridge_token() + if not tok: + return False + try: + from flask import request + except ImportError: + return False + if request.headers.get("X-Hub-Token") == tok: + return True + return False diff --git a/lib/hub/hub_backup_lib.py b/lib/hub/hub_backup_lib.py new file mode 100644 index 0000000..97aef76 --- /dev/null +++ b/lib/hub/hub_backup_lib.py @@ -0,0 +1,447 @@ +"""中控备份与恢复:三所 SQLite,K 线库,env,hub JSON.""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +import zipfile +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir + +HUB_DIR = manual_trading_hub_dir() +TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai" + +EXCHANGE_DIRS: list[tuple[str, str]] = [ + ("binance", "crypto_monitor_binance"), + ("okx", "crypto_monitor_okx"), + ("gate", "crypto_monitor_gate"), +] + +HUB_JSON_FILES = ( + "hub_settings.json", + "hub_fund_history.json", + "hub_ai_summaries.json", + "hub_ai_chat.json", + "hub_supervisor_state.json", +) + +HUB_DATA_FILES = ( + "hub_kline.db", + "hub_symbol_archive.db", + "hub_entry_plans.db", + "hub_macro_calendar.db", + "hub_volume_rank.json", + "hub_divergence_scan.json", +) + +DEFAULT_BACKUP_SETTINGS = { + "auto_enabled": True, + "auto_hour": 0, + "retention_days": 30, + "include_env": True, + "include_exchange_images": False, + "backup_root": "", +} + +BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json" + + +def normalize_backup_settings(raw: dict | None) -> dict: + out = dict(DEFAULT_BACKUP_SETTINGS) + if isinstance(raw, dict): + for key in DEFAULT_BACKUP_SETTINGS: + if key in raw: + out[key] = raw[key] + try: + out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0)))) + except (TypeError, ValueError): + out["auto_hour"] = 0 + try: + out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30)))) + except (TypeError, ValueError): + out["retention_days"] = 30 + out["auto_enabled"] = bool(out.get("auto_enabled")) + out["include_env"] = bool(out.get("include_env", True)) + out["include_exchange_images"] = bool(out.get("include_exchange_images")) + out["backup_root"] = str(out.get("backup_root") or "").strip() + return out + + +def backup_root(settings: dict | None = None) -> Path: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip() + if not raw: + raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip() + root = Path(raw).expanduser() + if not root.is_absolute(): + root = REPO_ROOT / root + portal = root / "crypto_monitor_portal" + portal.mkdir(parents=True, exist_ok=True) + return portal + + +def _now_local() -> datetime: + try: + return datetime.now(ZoneInfo(TZ_NAME)) + except Exception: + return datetime.now() + + +def _read_env_var(env_path: Path, key: str, default: str = "") -> str: + if not env_path.is_file(): + return default + try: + for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + k, v = raw.split("=", 1) + if k.strip() == key: + return v.strip().strip('"').strip("'") + except Exception: + pass + return default + + +def _resolve_project_path(project_dir: Path, rel: str) -> Path: + p = Path(rel or "") + if p.is_absolute(): + return p + return project_dir / p + + +def _load_backup_state() -> dict: + if not BACKUP_STATE_PATH.is_file(): + return {} + try: + data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_backup_state(state: dict) -> None: + BACKUP_STATE_PATH.write_text( + json.dumps(state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def _safe_archive_name(name: str) -> bool: + return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or "")) + + +def _collect_targets( + *, + include_env: bool, + include_exchange_images: bool, +) -> list[tuple[str, Path, str]]: + """Return list of (archive_rel_path, source_path, kind).""" + items: list[tuple[str, Path, str]] = [] + + if include_env: + hub_env = HUB_DIR / ".env" + if hub_env.is_file(): + items.append(("hub/.env", hub_env, "env")) + + for name in HUB_JSON_FILES: + src = HUB_DIR / name + if src.is_file(): + items.append((f"hub/{name}", src, "json")) + + data_dir = hub_data_dir() + for name in HUB_DATA_FILES: + src = data_dir / name + if src.is_file(): + items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json")) + + for key, dirname in EXCHANGE_DIRS: + proj = REPO_ROOT / dirname + prefix = dirname + env_path = proj / ".env" + db_rel = "crypto.db" + upload_rel = "static/images" + if env_path.is_file(): + db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db" + upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images" + if include_env: + items.append((f"{prefix}/.env", env_path, "env")) + db_path = _resolve_project_path(proj, db_rel) + if db_path.is_file(): + items.append((f"{prefix}/{db_rel}", db_path, "sqlite")) + if include_exchange_images: + img_dir = _resolve_project_path(proj, upload_rel) + if img_dir.is_dir(): + for fp in sorted(img_dir.rglob("*")): + if fp.is_file(): + rel = fp.relative_to(proj).as_posix() + items.append((f"{prefix}/{rel}", fp, "image")) + return items + + +def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None: + manifest = { + "version": 1, + "created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"), + "timezone": TZ_NAME, + "trigger": trigger, + "repo_root": str(REPO_ROOT), + "files": files, + } + (staging / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def run_backup( + *, + trigger: str = "manual", + settings: dict | None = None, + log_fn: Callable[[str], None] | None = None, +) -> dict[str, Any]: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + root = backup_root(settings) + ts = _now_local().strftime("%Y-%m-%d_%H%M%S") + archive_name = f"backup_{ts}.zip" + archive_path = root / archive_name + + def log(msg: str) -> None: + if log_fn: + log_fn(msg) + + targets = _collect_targets( + include_env=cfg["include_env"], + include_exchange_images=cfg["include_exchange_images"], + ) + if not targets: + return {"ok": False, "error": "没有可备份的文件"} + + file_meta: list[dict] = [] + with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp: + staging = Path(tmp) + for arc_rel, src, kind in targets: + dest = staging / arc_rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + file_meta.append( + { + "path": arc_rel.replace("\\", "/"), + "size": src.stat().st_size, + "kind": kind, + } + ) + _write_manifest(staging, trigger, file_meta) + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for fp in sorted(staging.rglob("*")): + if fp.is_file(): + zf.write(fp, fp.relative_to(staging).as_posix()) + + size = archive_path.stat().st_size + prune_old_backups(root, cfg["retention_days"]) + state = _load_backup_state() + if trigger == "auto": + state["last_auto_day"] = _now_local().strftime("%Y-%m-%d") + state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_backup_file"] = archive_name + state["last_trigger"] = trigger + _save_backup_state(state) + log(f"backup written: {archive_path}") + return { + "ok": True, + "file": archive_name, + "path": str(archive_path), + "size": size, + "file_count": len(file_meta), + "trigger": trigger, + } + + +def prune_old_backups(root: Path, retention_days: int) -> int: + if not root.is_dir(): + return 0 + cutoff = _now_local() - timedelta(days=max(1, retention_days)) + removed = 0 + for fp in root.glob("backup_*.zip"): + try: + mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo) + except Exception: + continue + if mtime < cutoff: + fp.unlink(missing_ok=True) + removed += 1 + return removed + + +def list_backups(settings: dict | None = None) -> list[dict[str, Any]]: + root = backup_root(settings) + rows: list[dict[str, Any]] = [] + if not root.is_dir(): + return rows + for fp in sorted(root.glob("backup_*.zip"), reverse=True): + try: + st = fp.stat() + except OSError: + continue + rows.append( + { + "name": fp.name, + "size": st.st_size, + "modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), + } + ) + return rows + + +def backup_status(settings: dict | None = None) -> dict[str, Any]: + cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None) + state = _load_backup_state() + root = backup_root(settings) + return { + "ok": True, + "settings": cfg, + "backup_root": str(root), + "state": state, + "backups": list_backups(settings)[:50], + "timezone": TZ_NAME, + } + + +def _pm2_restart_all() -> dict[str, Any]: + if os.name != "posix": + return {"ok": False, "skipped": True, "reason": "non-posix"} + try: + proc = subprocess.run( + ["pm2", "restart", "all"], + capture_output=True, + text=True, + timeout=120, + ) + return { + "ok": proc.returncode == 0, + "returncode": proc.returncode, + "stdout": (proc.stdout or "")[-2000:], + "stderr": (proc.stderr or "")[-2000:], + } + except Exception as e: + return {"ok": False, "error": str(e)} + + +def restore_backup_archive( + archive_path: Path, + *, + settings: dict | None = None, + pre_backup: bool = True, + restart_pm2: bool = True, +) -> dict[str, Any]: + if not archive_path.is_file(): + return {"ok": False, "error": "备份文件不存在"} + + pre = None + if pre_backup: + pre = run_backup(trigger="pre_restore", settings=settings) + + restored: list[str] = [] + skipped: list[str] = [] + with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp: + extract_dir = Path(tmp) + with zipfile.ZipFile(archive_path, "r") as zf: + zf.extractall(extract_dir) + manifest_path = extract_dir / "manifest.json" + if not manifest_path.is_file(): + return {"ok": False, "error": "无效的备份包:缺少 manifest.json"} + + for fp in extract_dir.rglob("*"): + if not fp.is_file() or fp.name == "manifest.json": + continue + rel = fp.relative_to(extract_dir).as_posix() + parts = Path(rel).parts + if parts[0] == "hub": + if len(parts) >= 3 and parts[1] == "data": + dest = hub_data_dir() / parts[-1] + else: + dest = HUB_DIR.joinpath(*parts[1:]) + else: + matched = False + for _key, dirname in EXCHANGE_DIRS: + if rel.startswith(dirname + "/"): + dest = REPO_ROOT / rel + matched = True + break + if not matched: + skipped.append(rel) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(fp, dest) + restored.append(rel) + + pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True} + state = _load_backup_state() + state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + state["last_restore_from"] = archive_path.name + _save_backup_state(state) + return { + "ok": True, + "restored": restored, + "skipped": skipped, + "pre_backup": pre, + "pm2": pm2, + } + + +def restore_backup_upload( + content: bytes, + filename: str, + *, + settings: dict | None = None, +) -> dict[str, Any]: + if not content: + return {"ok": False, "error": "空文件"} + suffix = Path(filename or "").suffix.lower() + if suffix != ".zip": + return {"ok": False, "error": "仅支持 .zip 备份包"} + with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf: + tf.write(content) + temp_path = Path(tf.name) + try: + return restore_backup_archive(temp_path, settings=settings) + finally: + temp_path.unlink(missing_ok=True) + + +def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]: + if not _safe_archive_name(name): + return None + fp = backup_root(settings) / name + if fp.is_file(): + return fp + return None + + +def should_run_auto_backup(settings: dict) -> bool: + cfg = normalize_backup_settings(settings.get("backup")) + if not cfg.get("auto_enabled"): + return False + now = _now_local() + today = now.strftime("%Y-%m-%d") + state = _load_backup_state() + if state.get("last_auto_day") == today: + return False + if now.hour < int(cfg.get("auto_hour", 0)): + return False + return True + + +def mark_auto_backup_done() -> None: + state = _load_backup_state() + state["last_auto_day"] = _now_local().strftime("%Y-%m-%d") + state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S") + _save_backup_state(state) diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py new file mode 100644 index 0000000..2c3ffa4 --- /dev/null +++ b/lib/hub/hub_bridge.py @@ -0,0 +1,1187 @@ +""" +各 crypto_monitor_* 注册 /api/hub/* JSON 接口,供 manual_trading_hub 调用. +实例末尾:app.config["HUB_CTX"] = {...}; register_hub_routes(app) +""" + +from __future__ import annotations + +import json +import time +from functools import wraps + +from flask import ( + current_app, + flash, + get_flashed_messages, + jsonify, + redirect, + request, + session, +) + +from lib.hub.hub_auth import request_allowed +from lib.hub.hub_sso import ( + mint_hub_embed_bootstrap, + safe_next_path, + verify_hub_embed_bootstrap, + verify_hub_sso_token, +) + + +def _merge_query_into_path(path: str, **params: str) -> str: + from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + + split = urlsplit(path or "/") + q = list(parse_qsl(split.query, keep_blank_values=True)) + keys = {k for k, _ in q} + for k, v in params.items(): + if not v or k in keys: + continue + q.append((k, str(v))) + return urlunsplit((split.scheme, split.netloc, split.path, urlencode(q), split.fragment)) + + +def install_instance_theme_static(app) -> None: + """仓库 lib/common/static 下 instance_theme.* 等供三所页面共用.""" + import os + + from flask import Response, send_file + + from lib.paths import common_static_dir + + repo_static = common_static_dir() + assets = { + "instance_theme.js": "application/javascript; charset=utf-8", + "instance_theme_early.css": "text/css; charset=utf-8", + "instance_theme.css": "text/css; charset=utf-8", + "account_risk_badge.css": "text/css; charset=utf-8", + "account_risk_badge.js": "application/javascript; charset=utf-8", + "instance_ui.js": "application/javascript; charset=utf-8", + "instance_records_mobile.js": "application/javascript; charset=utf-8", + "records_review_page.js": "application/javascript; charset=utf-8", + "ai_review_render.js": "application/javascript; charset=utf-8", + "form_submit_guard.js": "application/javascript; charset=utf-8", + "autofill_guard.js": "application/javascript; charset=utf-8", + "key_monitor_form.js": "application/javascript; charset=utf-8", + "time_close_ui.js": "application/javascript; charset=utf-8", + "manual_order_rr_preview.js": "application/javascript; charset=utf-8", + "symbol_live_price.js": "application/javascript; charset=utf-8", + "journal_upload_slots.js": "application/javascript; charset=utf-8", + "journal_form_save.js": "application/javascript; charset=utf-8", + "strategy_roll.js": "application/javascript; charset=utf-8", + "instance_page.css": "text/css; charset=utf-8", + "instance_embed.js": "application/javascript; charset=utf-8", + "instance_mobile_nav.js": "application/javascript; charset=utf-8", + "instance_stats.js": "application/javascript; charset=utf-8", + "instance_live.js": "application/javascript; charset=utf-8", + "instance_settings_prefs.js": "application/javascript; charset=utf-8", + "instance_dashboard.js": "application/javascript; charset=utf-8", + "account_ledger.js": "application/javascript; charset=utf-8", + "options_expiry_countdown.js": "application/javascript; charset=utf-8", + "options_panel.js": "application/javascript; charset=utf-8", + "order_entry_model.js": "application/javascript; charset=utf-8", + "focus_chart_page.js": "application/javascript; charset=utf-8", + "focus_chart_page.css": "text/css; charset=utf-8", + "trade_stats_calendar.js": "application/javascript; charset=utf-8", + "trade_stats_calendar.css": "text/css; charset=utf-8", + } + + for name, mime in assets.items(): + path = os.path.join(repo_static, name) + + def _view(p=path, m=mime): + if not os.path.isfile(p): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(p, mimetype=m) + + app.add_url_rule( + f"/static/{name}", + endpoint=f"repo_static_{name.replace('.', '_')}", + view_func=_view, + ) + + +def register_trade_stats_calendar_route( + app, + *, + login_required_fn, + load_pnls_fn, + row_matches_segment_fn, + reset_hour: int, + get_db_fn=None, +): + """三所统计分析页:按月返回各交易日盈亏/笔数.""" + from flask import jsonify, request + + from lib.trade.trade_stats_calendar_lib import build_trade_stats_calendar + + @app.route("/api/stats/calendar") + @login_required_fn + def api_stats_calendar(): + year = request.args.get("year", type=int) + month = request.args.get("month", type=int) + segment = (request.args.get("segment") or "all").strip() or "all" + if not year or not month: + from datetime import datetime + + now = datetime.now() + year = year or now.year + month = month or now.month + get_db = get_db_fn or (app.config.get("HUB_CTX") or {}).get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "未配置数据库"}), 500 + conn = get_db() + try: + pnls = load_pnls_fn(conn) + finally: + conn.close() + try: + payload = build_trade_stats_calendar( + pnls, + year, + month, + segment, + row_matches_segment_fn, + reset_hour=int(reset_hour), + ) + except ValueError as exc: + return jsonify({"ok": False, "msg": str(exc)}), 400 + return jsonify({"ok": True, **payload}) + + +def _hub_auth_required(f): + @wraps(f) + def wrapped(*args, **kwargs): + from flask import current_app as cap + + auth_disabled = bool(cap.config.get("HUB_AUTH_DISABLED")) + if not request_allowed(bool(session.get("logged_in")), auth_disabled): + return jsonify({"ok": False, "msg": "未授权(登录或 HUB_BRIDGE_TOKEN)"}), 401 + return f(*args, **kwargs) + + return wrapped + + +def _ctx(): + return current_app.config.get("HUB_CTX") or {} + + +def _row_to_dict(row): + fn = _ctx().get("row_to_dict") + if fn and row is not None: + return fn(row) + return dict(row) if row is not None else {} + + +def build_hub_monitor_payload( + *, + keys, + orders, + trends, + rolls, + hedges=None, + enrich=None, + risk_status=None, +) -> dict: + """合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders.""" + payload = { + "ok": True, + "keys": keys, + "orders": orders, + "trends": trends, + "rolls": rolls, + "hedges": hedges if isinstance(hedges, list) else [], + "key_prices": [], + } + if isinstance(risk_status, dict): + payload["risk_status"] = risk_status + if callable(enrich): + extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls) + if isinstance(extra, dict): + payload.update(extra) + # enrich 可能不返回 hedges,保留本地组装的对冲列表. + if "hedges" not in extra: + payload["hedges"] = hedges if isinstance(hedges, list) else [] + return payload + + +_FAIL_HINTS = ( + "失败", + "错误", + "拒绝", + "无效", + "缺少", + "无法", + "过期", + "未达", + "不能为空", + "已有", + "不允许", + "异常", +) + + +def _invoke_view(view_name: str, path: str, form=None) -> dict: + views = _ctx().get("views") or {} + view = views.get(view_name) + if not view: + return {"ok": False, "messages": [f"未配置视图 {view_name}"]} + data = form if form is not None else request.form + if hasattr(data, "items") and not isinstance(data, dict): + data = {k: v for k, v in data.items()} + with current_app.test_request_context(path, method="POST", data=data): + session["logged_in"] = True + try: + view() + except Exception as e: + return {"ok": False, "messages": [str(e)]} + try: + msgs = [str(x) for x in get_flashed_messages()] + except Exception as e: + return {"ok": False, "messages": [f"读取提示信息失败: {e}"]} + ok = True + for m in msgs: + if any(k in m for k in _FAIL_HINTS): + ok = False + break + return {"ok": ok, "messages": msgs} + + +def _invoke_view_get(view_name: str, path: str) -> dict: + views = _ctx().get("views") or {} + view = views.get(view_name) + if not view: + return {"ok": False, "messages": [f"未配置视图 {view_name}"]} + with current_app.test_request_context(path, method="GET"): + session["logged_in"] = True + try: + view() + except Exception as e: + return {"ok": False, "messages": [str(e)]} + try: + msgs = [str(x) for x in get_flashed_messages()] + except Exception as e: + return {"ok": False, "messages": [f"读取提示信息失败: {e}"]} + ok = True + for m in msgs: + if any(k in m for k in _FAIL_HINTS): + ok = False + break + return {"ok": ok, "messages": msgs} + + +def _hub_json(view_name: str, path: str, form=None): + try: + return jsonify(_invoke_view(view_name, path, form=form)) + except Exception as e: + return jsonify({"ok": False, "messages": [str(e)]}) + + +def _embed_login_dest(next_path: str) -> str: + """embed=1 时把 /trade 等映射到 /embed?tab=…""" + ht = (request.args.get("hub_theme") or "").strip().lower() + hub_theme = ht if ht in ("light", "dark") else None + if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"): + from lib.instance.instance_embed_lib import rewrite_embed_dest + + return rewrite_embed_dest(next_path, hub_theme=hub_theme) + if hub_theme: + return _merge_query_into_path(next_path, hub_theme=hub_theme) + return next_path + + +def install_on_app( + app, + *, + exchange: str, + capabilities: list, + has_trend: bool, + get_db, + row_to_dict, + meta_fn, + views: dict, + ohlcv_fn=None, + account_fn=None, + volume_rank_fn=None, + market_fn=None, + reconcile_hub_flat_fn=None, + risk_status_fn=None, + user_close_fn=None, + render_main_page_fn=None, + login_required_fn=None, +): + app.config["HUB_CTX"] = { + "exchange": exchange, + "capabilities": list(capabilities), + "has_trend": bool(has_trend), + "get_db": get_db, + "row_to_dict": row_to_dict, + "meta_fn": meta_fn, + "account_fn": account_fn, + "views": views, + "ohlcv_fn": ohlcv_fn, + "volume_rank_fn": volume_rank_fn, + "market_fn": market_fn, + "reconcile_hub_flat_fn": reconcile_hub_flat_fn, + "risk_status_fn": risk_status_fn, + "user_close_fn": user_close_fn, + } + install_hub_embed_headers(app) + configure_hub_embed_session(app) + install_instance_theme_static(app) + register_hub_routes(app) + if render_main_page_fn and login_required_fn: + from lib.instance.instance_embed_lib import attach_embed_templates, register_embed_routes + from lib.paths import REPO_ROOT + + attach_embed_templates(app, str(REPO_ROOT)) + register_embed_routes(app, login_required_fn, render_main_page_fn) + + +def configure_hub_embed_session(app): + """HTTPS iframe 内嵌须 SameSite=None + Secure;hub-sso / hub-embed-auth 自动启用.""" + import os + + allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if not allowed: + return + + secure_env = (os.getenv("APP_COOKIE_SECURE") or "auto").strip().lower() + if secure_env in ("1", "true", "yes", "on"): + app.config.update( + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SAMESITE="None", + SESSION_COOKIE_HTTPONLY=True, + ) + return + + @app.before_request + def _hub_embed_session_cookie(): + if request.path not in ("/hub-sso", "/hub-embed-auth"): + return + embed = (request.args.get("embed") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe" + if not embed and not in_iframe: + return + if not request.is_secure: + return + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "None" + app.config["SESSION_COOKIE_HTTPONLY"] = True + + +def _sso_wants_embed_auth() -> bool: + embed = (request.args.get("embed") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe" + return bool(embed or in_iframe) + + +def install_hub_embed_headers(app): + """允许复盘中控 iframe 内嵌打开本实例(须与 hub 的 HUB_EMBED_ORIGINS 或域名一致).""" + import os + + allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + if not allowed: + return + origins = ( + (os.getenv("HUB_EMBED_PARENT_ORIGINS") or os.getenv("HUB_EMBED_ORIGINS") or "*") + .strip() + ) + + @app.after_request + def _hub_embed_frame_headers(response): + if origins == "*": + response.headers["Content-Security-Policy"] = "frame-ancestors *" + else: + response.headers["Content-Security-Policy"] = ( + f"frame-ancestors 'self' {origins}" + ) + return response + + +def register_hub_routes(app): + auth_disabled = False + try: + import os + + auth_disabled = os.getenv("APP_AUTH_DISABLED", "false").lower() in ( + "1", + "true", + "yes", + "on", + ) + except Exception: + pass + app.config.setdefault("HUB_AUTH_DISABLED", auth_disabled) + + @app.route("/api/hub/ping") + @_hub_auth_required + def api_hub_ping(): + c = _ctx() + return jsonify( + { + "ok": True, + "exchange": c.get("exchange"), + "capabilities": c.get("capabilities") or [], + } + ) + + @app.route("/api/hub/meta") + @_hub_auth_required + def api_hub_meta(): + c = _ctx() + meta_fn = c.get("meta_fn") + meta = meta_fn() if callable(meta_fn) else {} + return jsonify({"ok": True, "meta": meta}) + + @app.route("/api/hub/account") + @_hub_auth_required + def api_hub_account(): + """中控 AI:资金账户 / 交易账户余额(无需浏览器登录).""" + fn = _ctx().get("account_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "未配置 account_fn"}), 501 + try: + data = fn() + if not isinstance(data, dict): + data = {} + return jsonify({"ok": True, **data}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/options/snapshot") + @_hub_auth_required + def api_hub_options_snapshot(): + """中控监控:期权持仓 / 资金 / 本地统计(只读).""" + fn = _ctx().get("options_snapshot_fn") + if not callable(fn): + return jsonify({"ok": True, "enabled": False}) + try: + data = fn() + if not isinstance(data, dict): + data = {"ok": False, "enabled": True, "msg": "invalid snapshot"} + return jsonify(data) + except Exception as e: + return jsonify({"ok": False, "enabled": True, "msg": str(e)}), 500 + + @app.route("/api/account_risk_status") + @_hub_auth_required + def api_account_risk_status(): + c = _ctx() + get_db = c.get("get_db") + risk_fn = c.get("risk_status_fn") + if not callable(get_db) or not callable(risk_fn): + return jsonify({"ok": False, "msg": "未配置风控"}), 501 + conn = get_db() + try: + payload = risk_fn(conn) + return jsonify({"ok": True, **(payload if isinstance(payload, dict) else {})}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/account-risk/user-close", methods=["POST"]) + @_hub_auth_required + def api_hub_account_risk_user_close(): + """中控/实例:登记用户主动平仓(计入冷静期与日冻结).""" + c = _ctx() + get_db = c.get("get_db") + user_close_fn = c.get("user_close_fn") + if not callable(get_db) or not callable(user_close_fn): + return jsonify({"ok": False, "msg": "未配置 user_close_fn"}), 501 + body = request.get_json(silent=True) or {} + source = (body.get("source") or request.form.get("source") or "").strip() + try: + count = max(0, int(body.get("count") if body.get("count") is not None else 1)) + except (TypeError, ValueError): + count = 1 + trade_record_id = body.get("trade_record_id") + closed_at_ms = body.get("closed_at_ms") + if count <= 0: + return jsonify({"ok": True, "skipped": True, "count": 0}) + conn = get_db() + try: + user_close_fn( + conn, + source=source, + count=count, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + ) + conn.commit() + return jsonify({"ok": True, "count": count, "source": source}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/monitor") + @_hub_auth_required + def api_hub_monitor(): + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + conn = get_db() + keys = [] + for row in conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall(): + keys.append(_row_to_dict(row)) + orders = [] + for row in conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + od = _row_to_dict(row) + try: + from lib.strategy.strategy_trade_labels 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 + orders.append(od) + trends = [] + if c.get("has_trend"): + for row in conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall(): + trends.append(_row_to_dict(row)) + rolls = [] + try: + for row in 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(): + rolls.append(_row_to_dict(row)) + except Exception: + pass + hedges = [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + hedge_rows: list = [] + for st in ("opening", "active", "partial"): + hedge_rows.extend(list_plans(conn, status=st, limit=80)) + hedge_rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + hedges = attach_legs_to_plans(conn, hedge_rows) + except Exception: + hedges = [] + risk_status = None + risk_fn = c.get("risk_status_fn") + if callable(risk_fn): + try: + risk_status = risk_fn(conn) + except Exception: + risk_status = None + conn.close() + enrich = c.get("enrich_monitor") + if callable(enrich): + try: + return jsonify( + build_hub_monitor_payload( + keys=keys, + orders=orders, + trends=trends, + rolls=rolls, + hedges=hedges, + enrich=enrich, + risk_status=risk_status, + ) + ) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + return jsonify( + build_hub_monitor_payload( + keys=keys, + orders=orders, + trends=trends, + rolls=rolls, + hedges=hedges, + risk_status=risk_status, + ) + ) + + @app.route("/api/hub/trades/archive") + @_hub_auth_required + def api_hub_trades_archive(): + """中控币种档案:近 N 天已平仓记录.""" + from lib.hub.hub_trades_lib import fetch_trades_for_archive, summarize_trades + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + try: + days = int(request.args.get("days") or "365") + except ValueError: + days = 365 + try: + limit = int(request.args.get("limit") or "2000") + except ValueError: + limit = 2000 + try: + import os + + reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + reset_hour = 8 + conn = get_db() + try: + trades = fetch_trades_for_archive( + conn, + exchange_key=str(c.get("exchange") or ""), + days=days, + row_to_dict_fn=c.get("row_to_dict"), + reset_hour=reset_hour, + limit=limit, + ) + finally: + conn.close() + stats = summarize_trades(trades) + return jsonify( + { + "ok": True, + "days": max(1, min(days, 3650)), + "trading_day_reset_hour": reset_hour, + "trades": trades, + "stats": stats, + } + ) + + @app.route("/api/hub/options/review/archive") + @_hub_auth_required + def api_hub_options_review_archive(): + """中控期权档案:近 N 天已平仓复盘记录(默认排除对冲腿).""" + from datetime import datetime, timedelta + from zoneinfo import ZoneInfo + + from flask import current_app + + from lib.options.options_review_lib import ( + compute_review_stats, + ensure_local_review_synced, + list_review_trades, + ) + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + try: + days = int(request.args.get("days") or "365") + except ValueError: + days = 365 + days = max(1, min(days, 3650)) + try: + limit = int(request.args.get("limit") or "2000") + except ValueError: + limit = 2000 + limit = max(1, min(limit, 5000)) + include_hedge_legs = str(request.args.get("include_hedge_legs") or "").strip() in ( + "1", + "true", + "yes", + ) + tz = ZoneInfo("Asia/Shanghai") + closed_from = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d") + cfg = (current_app.extensions or {}).get("options_cfg") or {} + ex = cfg.get("exchange_options") + conn = get_db() + try: + ensure_local_review_synced(conn, ex=ex, backfill_exchange_pnl=bool(ex)) + trades = list_review_trades( + conn, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + limit=limit, + offset=0, + ) + stats = compute_review_stats( + conn, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + ) + finally: + conn.close() + return jsonify( + { + "ok": True, + "days": days, + "limit": limit, + "product": "options", + "trades": trades, + "stats": stats, + } + ) + + @app.route("/api/hub/trades/today") + @_hub_auth_required + def api_hub_trades_today(): + """中控 AI:当日已平仓记录(按实例交易日).""" + from lib.hub.hub_trades_lib import ( + current_trading_day, + fetch_trades_for_trading_day, + summarize_trades, + ) + from lib.trade.daily_open_limit_lib import count_opens_for_trading_day + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + day_arg = (request.args.get("trading_day") or request.args.get("date") or "").strip()[:10] + try: + import os + + reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + reset_hour = 8 + trading_day = day_arg or current_trading_day(reset_hour=reset_hour) + conn = get_db() + try: + trades = fetch_trades_for_trading_day( + conn, + trading_day, + row_to_dict_fn=c.get("row_to_dict"), + reset_hour=reset_hour, + ) + opens_today = count_opens_for_trading_day(conn, trading_day) + finally: + conn.close() + stats = summarize_trades(trades) + return jsonify( + { + "ok": True, + "trading_day": trading_day, + "trading_day_reset_hour": reset_hour, + "opens_today": opens_today, + "trades": trades, + "stats": stats, + } + ) + + @app.route("/api/hub/volume-rank") + @_hub_auth_required + def api_hub_volume_rank(): + fn = _ctx().get("volume_rank_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置成交量排名接口"}), 501 + top_raw = (request.args.get("top") or "").strip() + top_n = 20 + if top_raw.isdigit(): + top_n = int(top_raw) + try: + result = fn(top_n=top_n) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "成交量排名返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/market") + @_hub_auth_required + def api_hub_market(): + fn = _ctx().get("market_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置合约信息接口"}), 501 + base = (request.args.get("base") or request.args.get("symbol") or "").strip() + try: + result = fn(base=base) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "合约信息返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/ohlcv") + @_hub_auth_required + def api_hub_ohlcv(): + fn = _ctx().get("ohlcv_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置 OHLCV 接口"}), 501 + symbol = (request.args.get("symbol") or "").strip() + timeframe = (request.args.get("timeframe") or "5m").strip() + since_raw = (request.args.get("since_ms") or "").strip() + limit_raw = (request.args.get("limit") or "").strip() + since_ms = None + if since_raw.isdigit(): + since_ms = int(since_raw) + limit = 500 + if limit_raw.isdigit(): + limit = int(limit_raw) + try: + result = fn(symbol=symbol, timeframe=timeframe, since_ms=since_ms, limit=limit) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "OHLCV 返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/api/hub/add_order", methods=["POST"]) + @_hub_auth_required + def api_hub_add_order(): + return _hub_json("add_order", "/add_order") + + @app.route("/api/hub/add_key", methods=["POST"]) + @_hub_auth_required + def api_hub_add_key(): + return _hub_json("add_key", "/add_key") + + @app.route("/api/hub/trend/preview", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_preview(): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + data = _invoke_view("preview_trend_pullback", "/trade") + pid = _latest_preview_id() + preview = _fetch_preview(pid) if pid else None + return jsonify( + { + "ok": bool(data.get("ok")), + "messages": data.get("messages") or [], + "preview_id": pid, + "preview": preview, + } + ) + + @app.route("/api/hub/trend/execute", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_execute(): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + pid = (request.form.get("preview_id") or "").strip() + if not pid: + body = request.get_json(silent=True) or {} + pid = str(body.get("preview_id") or "").strip() + form = {"preview_id": pid} if pid else {} + return jsonify(_invoke_view("execute_trend_pullback", "/trade", form=form)) + + @app.route("/api/hub/trend/preview/") + @_hub_auth_required + def api_hub_trend_preview_get(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + preview = _fetch_preview(pid) + if not preview: + return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404 + return jsonify({"ok": True, "preview": preview}) + + @app.route("/api/hub/trend/stop/", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_stop(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + return jsonify(_invoke_view_get("stop_trend_pullback", f"/stop_trend_pullback/{pid}")) + + @app.route("/api/hub/order/sync-tpsl", methods=["POST"]) + @_hub_auth_required + def api_hub_order_sync_tpsl(): + """中控 agent 已挂 TP/SL 后:同步 order_monitors 计划价,避免刷新仍显示旧止损止盈.""" + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + try: + sl = float(body.get("stop_loss")) + tp = float(body.get("take_profit")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "stop_loss / take_profit 须为数字"}), 400 + get_db = _ctx().get("get_db") + if not callable(get_db): + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + from lib.hub.hub_symbol_lib import symbols_match + from lib.hub.hub_order_sync_lib import sync_active_monitor_tpsl_prices + + conn = get_db() + try: + out = sync_active_monitor_tpsl_prices( + conn, symbol, side, sl, tp, symbols_match=symbols_match + ) + if out.get("ok"): + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/order/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_order_sync_flat(): + """中控市价全平后:同步 order_monitors 并读 Gate 平仓历史写交易记录.""" + fn = _ctx().get("reconcile_hub_flat_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置 order sync-flat"}), 400 + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + get_db = _ctx().get("get_db") + if not callable(get_db): + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + conn = get_db() + try: + out = fn(conn, symbol, side) + if not isinstance(out, dict): + out = {"ok": True, "synced": int(out or 0)} + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/trend/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_sync_flat(): + """中控市价全平后:结束仍 active 的同币种同向趋势计划.""" + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + cfg = current_app.extensions.get("strategy_trend_cfg") + get_db = _ctx().get("get_db") + if not cfg or not callable(get_db): + return jsonify({"ok": False, "msg": "趋势配置未就绪"}), 500 + from lib.strategy.strategy_trend_register import sync_trend_plans_after_external_close + + conn = get_db() + try: + return jsonify(sync_trend_plans_after_external_close(cfg, conn, symbol, side)) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/roll/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_roll_sync_flat(): + """中控/实例手动平仓后:取消滚仓 pending 并关闭 active 滚仓组.""" + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + cfg = current_app.extensions.get("strategy_roll_cfg") + get_db = _ctx().get("get_db") + if not cfg or not callable(get_db): + return jsonify({"ok": False, "msg": "滚仓配置未就绪"}), 500 + from lib.strategy.strategy_register import roll_sync_after_external_close + + conn = get_db() + try: + out = roll_sync_after_external_close(cfg, conn, symbol, side) + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + + @app.route("/api/hub/trend/breakeven/", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_breakeven(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + body = request.get_json(silent=True) or {} + raw = (request.form.get("breakeven_offset_pct") or body.get("breakeven_offset_pct") or "").strip() + form = {} + if raw != "": + form["breakeven_offset_pct"] = raw + return jsonify( + _invoke_view( + "trend_pullback_breakeven", + f"/trend_pullback_breakeven/{pid}", + form=form, + ) + ) + + @app.route("/hub-sso") + def hub_sso_login(): + """中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login.""" + from urllib.parse import urlencode + + auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) + next_arg = request.args.get("next") + if auth_disabled: + session["logged_in"] = True + return redirect(safe_next_path(next_arg)) + ex = str((_ctx().get("exchange") or "")).strip().lower() + token = (request.args.get("token") or "").strip() + ok, next_path, err = verify_hub_sso_token(token, ex) + if ok: + embed_on = request.args.get("embed", "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + dest_next = _embed_login_dest(next_path) if embed_on else next_path + if not embed_on: + ht = (request.args.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + dest_next = _merge_query_into_path(next_path, hub_theme=ht) + if embed_on and _sso_wants_embed_auth() and request.is_secure: + boot = mint_hub_embed_bootstrap(ex, dest_next) + if boot: + from urllib.parse import urlencode as _ue + + qdict = {"t": boot, "next": dest_next, "embed": "1"} + ht0 = (request.args.get("hub_theme") or "").strip().lower() + if ht0 in ("light", "dark"): + qdict["hub_theme"] = ht0 + return redirect(f"/hub-embed-auth?{_ue(qdict)}") + session["logged_in"] = True + session.modified = True + return redirect(dest_next) + hint = err or "校验失败" + flash( + f"中控 SSO 未生效({hint})." + "请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致," + f"且中控设置里该账户 key 为「{ex}」." + "经本地导航 iframe 打开时,实例须 HTTPS 且可设 APP_COOKIE_SECURE=true." + ) + return redirect("/login") + + @app.route("/hub-embed-auth") + def hub_embed_auth_login(): + """LocalNav 等 iframe 内嵌:单独写入 SameSite=None 会话后跳转.""" + auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED")) + next_arg = request.args.get("next") + if auth_disabled: + session["logged_in"] = True + return redirect(safe_next_path(next_arg)) + ex = str((_ctx().get("exchange") or "")).strip().lower() + boot = (request.args.get("t") or "").strip() + ok, next_path, err = verify_hub_embed_bootstrap(boot, ex) + if ok: + session["logged_in"] = True + session.modified = True + return redirect(_embed_login_dest(next_path)) + hint = err or "校验失败" + flash(f"iframe 登录未生效({hint}).可点本地导航工具栏「实例免密」重试.") + return redirect("/login") + + +def _latest_preview_id(): + get_db = _ctx().get("get_db") + if not get_db: + return None + conn = get_db() + row = conn.execute( + "SELECT id FROM trend_pullback_previews ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + return row["id"] if row else None + + +def _fetch_preview(pid): + get_db = _ctx().get("get_db") + if not get_db or not pid: + return None + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid,) + ).fetchone() + conn.close() + if not row: + return None + d = _row_to_dict(row) + now_ms = int(time.time() * 1000) + d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000)) + try: + from lib.strategy.strategy_trend_lib import build_trend_preview_level_rows + + enriched, level_rows = build_trend_preview_level_rows(d) + for key in ( + "preview_target_rr", + "preview_first_take_profit", + "preview_unified_stop_loss", + "preview_risk_amount_u", + "preview_first_profit_u", + "preview_take_profit_price", + ): + if key in enriched: + d[key] = enriched[key] + d["preview_level_rows"] = level_rows + d["grid_levels"] = [ + { + "i": row.get("i"), + "label": row.get("label"), + "price": row.get("price"), + "contracts": row.get("contracts"), + "cum_contracts": row.get("cum_contracts"), + "avg_entry": row.get("avg_entry"), + "take_profit_price": row.get("take_profit_price"), + "profit_u": row.get("profit_u"), + "risk_u": row.get("risk_u"), + "rr": row.get("rr"), + "stop_loss_price": row.get("stop_loss_price"), + "take_profit": row.get("profit_u"), + "stop_loss": row.get("risk_u"), + } + for row in level_rows + ] + except Exception: + d["grid_levels"] = [] + d["preview_level_rows"] = [] + return d diff --git a/lib/hub/hub_calculator_lib.py b/lib/hub/hub_calculator_lib.py new file mode 100644 index 0000000..524ebcf --- /dev/null +++ b/lib/hub/hub_calculator_lib.py @@ -0,0 +1,514 @@ +"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则).""" +from __future__ import annotations + +from typing import Any, Callable, Optional, Tuple + +from lib.strategy.strategy_roll_lib import max_roll_legs +from lib.strategy.strategy_trend_lib import ( + build_trend_preview_level_rows, + calc_risk_fraction, + compute_trend_plan_core, + validate_trend_bounds, +) + +DEFAULT_DCA_LEGS = 5 +MARGIN_BUFFER = 0.95 + + +def _resolve_market( + exchange_id: str, + base: str, +) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]: + from lib.hub.hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market + + market, err = get_calculator_market(exchange_id, base) + if err or not market: + return None, None, err or "无法解析合约" + amount_precise = make_amount_precise_fn_from_market(market) + return market, amount_precise, None + + +def calc_trend_calculator( + *, + direction: str, + capital_usdt: float, + risk_percent: float, + leverage: int, + entry_price: float, + stop_loss: float, + add_upper: float, + take_profit: float, + dca_legs: int = DEFAULT_DCA_LEGS, + exchange_id: str = "0", + base: str = "ETH", +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + market, amount_precise, merr = _resolve_market(exchange_id, base) + if merr or not market or not amount_precise: + return None, merr or "无法解析合约" + contract_size = float(market.get("contract_size") or 1.0) + exchange_symbol = market["exchange_symbol"] + + direction = (direction or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向须为 long 或 short" + try: + capital = float(capital_usdt) + rp = float(risk_percent) + lev = int(leverage) + entry = float(entry_price) + sl = float(stop_loss) + upper = float(add_upper) + tp = float(take_profit) + legs = max(1, int(dca_legs)) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0: + return None, "资金,风险,杠杆与价格须大于 0" + + bound_err = validate_trend_bounds(direction, sl, upper) + if bound_err: + return None, bound_err + + rf = calc_risk_fraction(direction, upper, sl) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + + risk_budget = capital * (rp / 100.0) + notional = risk_budget / rf + margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER) + if margin_plan <= 0: + return None, "计划保证金过小" + + target_amt = _amount_from_margin(margin_plan, lev, entry, cs) + if target_amt is None or target_amt <= 0: + return None, "无法计算计划张数,请检查入场价与杠杆" + target_amt = amount_precise(target_amt) + if target_amt is None or target_amt <= 0: + return None, "计划张数低于交易所最小精度" + + def _amount_precise(_symbol: str, amount: float) -> Optional[float]: + return amount_precise(amount) + + payload, err = compute_trend_plan_core( + direction=direction, + stop_loss=sl, + add_upper=upper, + risk_percent=rp, + snapshot_usdt=capital, + leverage=lev, + live_price=entry, + target_order_amount=target_amt, + exchange_symbol=exchange_symbol, + dca_legs=legs, + amount_precise=_amount_precise, + min_amount=float(market.get("min_amount") or 0.0), + full_margin_buffer_ratio=MARGIN_BUFFER, + ) + if err: + return None, err + + payload["take_profit"] = tp + payload["leverage"] = lev + payload["contract_size"] = cs + preview, rows = build_trend_preview_level_rows(payload) + + px_dec = int(market.get("price_decimals") or 4) + amt_dec = int(market.get("amount_decimals") or 4) + + def _f(v: Any, nd: int | None = None) -> Any: + if v is None: + return None + try: + return round(float(v), nd if nd is not None else 8) + except (TypeError, ValueError): + return v + + table = [] + for row in rows: + table.append( + { + "label": row.get("label"), + "price": _f(row.get("price"), px_dec), + "contracts": _f(row.get("contracts"), amt_dec), + "avg_entry": _f(row.get("avg_entry"), px_dec), + "profit_u": _f(row.get("profit_u")), + "risk_u": _f(row.get("risk_u")), + "rr": _f(row.get("rr"), 4), + } + ) + + return { + "direction": direction, + "capital_usdt": _f(capital), + "risk_percent": _f(rp, 2), + "risk_budget_u": _f(preview.get("preview_risk_amount_u")), + "leverage": lev, + "entry_price": _f(entry, px_dec), + "stop_loss": _f(sl, px_dec), + "add_upper": _f(upper, px_dec), + "take_profit": _f(tp, px_dec), + "plan_margin_u": _f(preview.get("plan_margin_capital")), + "target_contracts": _f(preview.get("target_order_amount"), amt_dec), + "first_contracts": _f(preview.get("first_order_amount"), amt_dec), + "dca_legs": int(preview.get("dca_legs") or legs), + "first_profit_u": _f(preview.get("preview_first_profit_u")), + "first_rr": _f(preview.get("preview_target_rr"), 4), + "market": market, + "rows": table, + }, None + + +def _amount_from_margin( + margin_capital: float, + leverage: int, + price: float, + contract_size: float, +) -> Optional[float]: + try: + margin = float(margin_capital) + lev = int(leverage) + px = float(price) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None + if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0: + return None + notional = margin * lev + return notional / (px * cs) + + +def _round(v: Any, nd: int = 4) -> Any: + if v is None: + return None + try: + return round(float(v), nd) + except (TypeError, ValueError): + return v + + +def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]: + try: + if risk_u is None or float(risk_u) <= 0 or profit_u is None: + return None + return round(float(profit_u) / float(risk_u), 4) + except (TypeError, ValueError): + return None + + +def calc_initial_roll_qty( + direction: str, + entry_price: float, + stop_loss: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """首仓以损定仓:打到初始止损亏损 = 风险预算.""" + try: + entry = float(entry_price) + sl = float(stop_loss) + budget = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0: + return None, "入场价,止损与风险预算须大于 0" + direction = (direction or "long").strip().lower() + if direction == "short": + per_unit = (sl - entry) * cs + if per_unit <= 0: + return None, "做空:止损价须高于首仓入场价" + else: + per_unit = (entry - sl) * cs + if per_unit <= 0: + return None, "做多:止损价须低于首仓入场价" + return budget / per_unit, None + + +def solve_add_amount_for_total_risk( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + new_stop: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数.""" + try: + q1 = float(qty_existing) + e1 = float(entry_existing) + e2 = float(add_price) + sl = float(new_stop) + b = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0: + return None, "持仓或风险预算无效" + direction = (direction or "long").strip().lower() + if direction == "short": + denom = sl - e2 + numer = b / cs - q1 * (sl - e1) + if denom <= 0: + return None, "做空:新止损须高于限价加仓价" + else: + denom = e2 - sl + numer = b / cs - q1 * (e1 - sl) + if denom <= 0: + return None, "做多:新止损须低于限价/市价加仓价" + q2 = numer / denom + if q2 <= 0: + return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)" + return q2, None + + +def _roll_leg_preview( + *, + direction: str, + qty_existing: float, + entry_existing: float, + take_profit: float, + add_price: float, + new_stop_loss: float, + risk_budget: float, + contract_size: float, + amount_precise: Callable[[float], Optional[float]], +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + direction = (direction or "long").strip().lower() + try: + tp = float(take_profit) + sl = float(new_stop_loss) + entry_add = float(add_price) + e1 = float(entry_existing) + except (TypeError, ValueError): + return None, "止损/止盈格式错误" + if sl <= 0 or tp <= 0 or entry_add <= 0: + return None, "止损与首仓止盈须大于0" + if direction == "long": + if sl >= entry_add: + return None, "做多:新止损须低于加仓价" + if tp <= e1: + return None, "做多:首仓止盈须高于当前持仓均价参考" + else: + if sl <= entry_add: + return None, "做空:新止损须高于加仓价" + if tp >= e1: + return None, "做空:首仓止盈须低于当前持仓均价参考" + + q2_raw, err = solve_add_amount_for_total_risk( + direction, + qty_existing, + entry_existing, + entry_add, + sl, + risk_budget, + contract_size, + ) + if err: + return None, err + q2 = amount_precise(float(q2_raw)) + if q2 is None or q2 <= 0: + return None, "加仓张数低于交易所最小精度" + new_qty = float(qty_existing) + float(q2) + new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty + cs = float(contract_size) if contract_size else 1.0 + if direction == "long": + loss_at_sl = (new_avg - sl) * new_qty * cs + reward_gross = (tp - new_avg) * new_qty * cs + else: + loss_at_sl = (sl - new_avg) * new_qty * cs + reward_gross = (new_avg - tp) * new_qty * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + reward_at_tp = net_pnl_after_fee(reward_gross, new_avg, tp, new_qty, cs) + if reward_at_tp is None: + reward_at_tp = reward_gross + except Exception: + reward_at_tp = reward_gross + return { + "add_amount_raw": q2, + "qty_after": new_qty, + "avg_entry_after": new_avg, + "add_price": entry_add, + "new_stop_loss": sl, + "loss_at_sl_usdt": loss_at_sl, + "reward_at_tp_usdt": reward_at_tp, + }, None + + +def calc_roll_calculator( + *, + direction: str, + capital_usdt: float, + risk_percent: float, + entry_price: float, + stop_loss: float, + take_profit: float, + add_legs: list[dict[str, float]] | None = None, + legs_done: int = 0, + exchange_id: str = "0", + base: str = "ETH", +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """ + 滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓. + add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算. + legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进). + """ + market, amount_precise, merr = _resolve_market(exchange_id, base) + if merr or not market or not amount_precise: + return None, merr or "无法解析合约" + contract_size = float(market.get("contract_size") or 1.0) + px_dec = int(market.get("price_decimals") or 4) + amt_dec = int(market.get("amount_decimals") or 4) + + direction = (direction or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向须为 long 或 short" + try: + capital = float(capital_usdt) + rp = float(risk_percent) + entry = float(entry_price) + initial_sl = float(stop_loss) + tp = float(take_profit) + done = max(0, int(legs_done)) + except (TypeError, ValueError): + return None, "参数格式错误" + if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0: + return None, "资金,风险与价格须大于 0" + if done > max_roll_legs(direction): + return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)} 次" + + legs_in: list[dict[str, float]] = [] + for raw in add_legs or []: + if not isinstance(raw, dict): + continue + try: + ap = float(raw.get("add_price")) + nsl = float(raw.get("new_stop_loss")) + except (TypeError, ValueError): + return None, "加仓价与新止损须为有效数字" + if ap <= 0 or nsl <= 0: + return None, "加仓价与新止损须大于 0" + legs_in.append({"add_price": ap, "new_stop_loss": nsl}) + + if done + len(legs_in) > max_roll_legs(direction): + return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓" + + if direction == "long": + if tp <= entry: + return None, "做多:止盈价须高于首仓入场价" + else: + if tp >= entry: + return None, "做空:止盈价须低于首仓入场价" + + risk_budget = capital * (rp / 100.0) + qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size) + if err: + return None, err + if qty is None or qty <= 0: + return None, "无法计算首仓张数" + qty_p = amount_precise(float(qty)) + if qty_p is None or qty_p <= 0: + return None, "首仓张数低于交易所最小精度" + + qty_f = float(qty_p) + avg = entry + rows: list[dict[str, Any]] = [] + cs = contract_size + + if direction == "long": + first_loss = (avg - initial_sl) * qty_f * cs + first_profit_gross = (tp - avg) * qty_f * cs + else: + first_loss = (initial_sl - avg) * qty_f * cs + first_profit_gross = (avg - tp) * qty_f * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + first_profit = net_pnl_after_fee(first_profit_gross, avg, tp, qty_f, cs) + if first_profit is None: + first_profit = first_profit_gross + except Exception: + first_profit = first_profit_gross + + rows.append( + { + "label": "首仓", + "leg_index": 0, + "already_done": False, + "entry_or_add_price": _round(entry, px_dec), + "stop_loss": _round(initial_sl, px_dec), + "add_contracts": _round(qty_f, amt_dec), + "total_contracts": _round(qty_f, amt_dec), + "avg_entry": _round(avg, px_dec), + "take_profit": _round(tp, px_dec), + "loss_at_sl_u": _round(first_loss), + "profit_at_tp_u": _round(first_profit), + "rr": _money_rr(first_profit, first_loss), + } + ) + + current_qty = qty_f + current_avg = avg + + for i, leg in enumerate(legs_in): + leg_no = i + 1 + preview, err = _roll_leg_preview( + direction=direction, + qty_existing=current_qty, + entry_existing=current_avg, + take_profit=tp, + add_price=leg["add_price"], + new_stop_loss=leg["new_stop_loss"], + risk_budget=risk_budget, + contract_size=cs, + amount_precise=amount_precise, + ) + if err: + return None, f"滚仓第 {leg_no} 次:{err}" + if not preview: + return None, f"滚仓第 {leg_no} 次计算失败" + + current_qty = float(preview["qty_after"]) + current_avg = float(preview["avg_entry_after"]) + loss = preview.get("loss_at_sl_usdt") + reward = preview.get("reward_at_tp_usdt") + rows.append( + { + "label": f"滚仓{leg_no}", + "leg_index": leg_no, + "already_done": leg_no <= done, + "entry_or_add_price": _round(preview.get("add_price"), px_dec), + "stop_loss": _round(preview.get("new_stop_loss"), px_dec), + "add_contracts": _round(preview.get("add_amount_raw"), amt_dec), + "total_contracts": _round(current_qty, amt_dec), + "avg_entry": _round(current_avg, px_dec), + "take_profit": _round(tp, px_dec), + "loss_at_sl_u": _round(loss), + "profit_at_tp_u": _round(reward), + "rr": _money_rr(reward, loss), + } + ) + + last = rows[-1] + return { + "direction": direction, + "capital_usdt": _round(capital), + "risk_percent": _round(rp, 2), + "risk_budget_u": _round(risk_budget), + "entry_price": _round(entry, px_dec), + "stop_loss": _round(initial_sl, px_dec), + "take_profit": _round(tp, px_dec), + "legs_done": done, + "roll_legs_planned": len(legs_in), + "first_contracts": _round(qty_f, amt_dec), + "final_contracts": last.get("total_contracts"), + "final_avg_entry": last.get("avg_entry"), + "final_loss_at_sl_u": last.get("loss_at_sl_u"), + "final_profit_at_tp_u": last.get("profit_at_tp_u"), + "final_rr": last.get("rr"), + "market": market, + "rows": rows, + }, None diff --git a/lib/hub/hub_calculator_market_lib.py b/lib/hub/hub_calculator_market_lib.py new file mode 100644 index 0000000..d1ba5cd --- /dev/null +++ b/lib/hub/hub_calculator_market_lib.py @@ -0,0 +1,257 @@ +"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则.""" + +from __future__ import annotations + +import json +import threading +import time +import urllib.error +import urllib.request +from typing import Any, Callable, Optional, Tuple +from urllib.parse import urlencode + +try: + from settings_store import enabled_exchanges, load_settings +except ImportError: + from manual_trading_hub.settings_store import enabled_exchanges, load_settings + +MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} +MARKET_LOCK = threading.Lock() +MARKET_TTL_SEC = 300.0 +HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20")) + + +def normalize_base_symbol(text: str) -> str: + s = str(text or "").upper().strip() + for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"): + if s.endswith(suf) and len(s) > len(suf): + s = s[: -len(suf)].strip("-/") + break + if "/" in s: + s = s.split("/", 1)[0].strip() + if ":" in s: + s = s.split(":", 1)[0].strip() + return s + + +def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]: + base_u = normalize_base_symbol(base) + if not base_u: + return None, "请输入币种,如 ETH" + candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"] + markets = getattr(exchange, "markets", None) or {} + for sym in candidates: + m = markets.get(sym) + if not m: + continue + if m.get("active") is False: + continue + if m.get("swap") or m.get("linear") or m.get("contract"): + return sym, None + for sym, m in markets.items(): + if m.get("active") is False: + continue + if not (m.get("swap") or m.get("linear")): + continue + if (m.get("quote") or "").upper() != "USDT": + continue + if (m.get("base") or "").upper() == base_u: + return sym, None + return None, f"未找到 {base_u}/USDT 永续合约" + + +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 + + +def price_decimals_from_exchange( + exchange: Any, exchange_symbol: str, price_tick: Optional[float] +) -> int: + from lib.hub.hub_ohlcv_lib import normalize_price_tick + + tick = normalize_price_tick(price_tick) + if tick and tick > 0: + if tick >= 1: + return 0 + s = f"{tick:.12f}".rstrip("0") + if "." in s: + return min(12, len(s.split(".", 1)[1])) + try: + return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234)) + except Exception: + market = exchange.market(exchange_symbol) + prec = (market.get("precision") or {}).get("price") + d = _decimals_from_precision_value(prec) + return d if d is not None else 4 + + +def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]: + dec = max(0, int(market.get("amount_decimals") or 4)) + min_amt = market.get("min_amount") + + def _fn(amount: float) -> Optional[float]: + try: + v = float(amount) + except (TypeError, ValueError): + return None + if v <= 0: + return None + factor = 10**dec + v = int(v * factor + 1e-12) / factor + if min_amt is not None: + try: + if v < float(min_amt): + return None + except (TypeError, ValueError): + pass + if v <= 0: + return None + return v + + return _fn + + +def find_exchange(exchange_id: str) -> dict | None: + needle = str(exchange_id or "").strip() + if not needle: + return None + for ex in load_settings().get("exchanges") or []: + if str(ex.get("id") or "").strip() == needle: + return ex + if str(ex.get("key") or "").strip().lower() == needle.lower(): + return ex + return None + + +def list_calculator_exchanges() -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for ex in enabled_exchanges(): + rows.append( + { + "id": str(ex.get("id") or ""), + "key": str(ex.get("key") or ""), + "name": str(ex.get("name") or ex.get("key") or ""), + "enabled": bool(ex.get("enabled")), + } + ) + return rows + + +def _hub_headers() -> dict[str, str]: + import os + + token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip() + if token: + return {"X-Hub-Token": token} + return {} + + +def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]: + base_url = (ex.get("flask_url") or "").rstrip("/") + if not base_url: + return {"ok": False, "msg": "未配置 flask_url"} + params = urlencode({"base": normalize_base_symbol(base) or base}) + url = f"{base_url}/api/hub/market?{params}" + req = urllib.request.Request(url, headers=_hub_headers(), method="GET") + try: + with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp: + status = int(getattr(resp, "status", 200) or 200) + raw = resp.read().decode("utf-8", errors="replace") + data = json.loads(raw) if raw else {} + if not isinstance(data, dict): + return {"ok": False, "msg": "无效 JSON"} + if status >= 400: + data.setdefault("ok", False) + return data + except urllib.error.HTTPError as exc: + try: + raw = exc.read().decode("utf-8", errors="replace") + body = json.loads(raw) if raw else {} + except Exception: + body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)} + if isinstance(body, dict): + body.setdefault("ok", False) + return body + return {"ok": False, "msg": f"HTTP {exc.code}"} + except Exception as exc: + return {"ok": False, "msg": str(exc)} + + +def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]: + out = dict(payload) + out["exchange_id"] = str(ex.get("id") or "") + out["exchange_key"] = str(ex.get("key") or "") + out["exchange_name"] = str(ex.get("name") or ex.get("key") or "") + out["exchange_label"] = out["exchange_name"] + return out + + +def get_calculator_market( + exchange_id: str, + base: str, + *, + ex: dict | None = None, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """从系统设置中的交易实例拉取合约精度(与实盘一致).""" + row = ex or find_exchange(exchange_id) + if not row: + return None, "未找到该交易所配置" + if not row.get("enabled"): + return None, f"{row.get('name') or exchange_id} 未启用" + + base_u = normalize_base_symbol(base) + if not base_u: + return None, "请输入币种,如 ETH" + + cache_key = f"{row.get('id')}:{base_u}" + now = time.time() + with MARKET_LOCK: + cached = MARKET_CACHE.get(cache_key) + if cached and now - cached[0] < MARKET_TTL_SEC: + return dict(cached[1]), None + + remote = fetch_instance_market_sync(row, base=base_u) + if not remote.get("ok"): + return None, str(remote.get("msg") or "实例返回失败") + + data = _enrich_market_from_settings(row, remote) + with MARKET_LOCK: + MARKET_CACHE[cache_key] = (now, data) + return data, None + + +def clear_market_cache() -> None: + with MARKET_LOCK: + MARKET_CACHE.clear() diff --git a/lib/hub/hub_compare_lib.py b/lib/hub/hub_compare_lib.py new file mode 100644 index 0000000..5224fd1 --- /dev/null +++ b/lib/hub/hub_compare_lib.py @@ -0,0 +1,400 @@ +"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数).""" + +from __future__ import annotations + +import math +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 default_contract_size(base: str) -> float: + """OKX 线性永续常用面值(币/张);与计算器缺省一致.""" + b = (base or "ETH").strip().upper() + return 0.01 + + +def default_ct_mult(base: str) -> float: + return 0.01 + + +def floor_sheets(n: float, step: float = 1.0) -> float: + if n is None or not math.isfinite(n) or n <= 0: + return 0.0 + s = float(step) if step and step > 0 else 1.0 + return math.floor(n / s + 1e-12) * s + + +def option_unit_cost(*, ask: float, ct_mult: float) -> float: + return float(ask) * float(ct_mult or 0.01) + + +def option_intrinsic_value( + *, + opt_type: str, + strike: float, + spot: float, + sheets: float, + ct_mult: float, +) -> float: + o = (opt_type or "").strip().upper() + k = float(strike) + s = float(spot) + if o == "C": + intrinsic = max(0.0, s - k) + elif o == "P": + intrinsic = max(0.0, k - s) + else: + intrinsic = 0.0 + return intrinsic * float(sheets) * float(ct_mult or 0.01) + + +def option_pnl_at_spot( + *, + opt_type: str, + strike: float, + spot: float, + sheets: float, + ct_mult: float, + premium_paid: float, +) -> float: + return option_intrinsic_value( + opt_type=opt_type, + strike=strike, + spot=spot, + sheets=sheets, + ct_mult=ct_mult, + ) - float(premium_paid) + + +def perp_pnl( + *, + direction: str, + entry: float, + exit_px: float, + contracts: float, + contract_size: float, +) -> float: + coins = float(contracts) * float(contract_size or 0.01) + d = (direction or "long").strip().lower() + if d == "short": + return (float(entry) - float(exit_px)) * coins + return (float(exit_px) - float(entry)) * coins + + +def _validate_common(inp: dict[str, Any]) -> Optional[str]: + base = str(inp.get("base") or "ETH").strip().upper() + if base not in ("ETH", "BTC"): + return "标的仅支持 ETH / BTC" + direction = str(inp.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + return "方向须为 long / short" + s0 = _f(inp.get("entry")) + sl = _f(inp.get("sl")) + tp = _f(inp.get("tp")) + risk = _f(inp.get("risk_u")) + if s0 is None or s0 <= 0: + return "请填写有效入场价" + if sl is None or sl <= 0: + return "请填写有效止损价" + if tp is None or tp <= 0: + return "请填写有效止盈价" + if risk is None or risk <= 0: + return "请填写有效风险额 R" + if direction == "long" and not (sl < s0 < tp): + return "做多须满足 止损 < 入场 < 止盈" + if direction == "short" and not (tp < s0 < sl): + return "做空须满足 止盈 < 入场 < 止损" + return None + + +def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]: + direction = str(inp.get("direction") or "long").strip().lower() + s0 = float(inp["entry"]) + sl = float(inp["sl"]) + tp = float(inp["tp"]) + risk = float(inp["risk_u"]) + per_sheet_sl = abs(s0 - sl) * contract_size + sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0 + actual_sl_loss = abs(perp_pnl( + direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size + )) + tp_pnl = perp_pnl( + direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size + ) + # 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利 + path_a = round(tp_pnl, 4) + path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4) + path_c_realized = path_b + path_c_missed = path_a + return { + "kind": "perp", + "sheets": sheets, + "contract_size": contract_size, + "per_sheet_sl_u": round(per_sheet_sl, 6), + "risk_used_u": round(actual_sl_loss, 4), + "path_a_tp": path_a, + "path_b_sl": path_b, + "path_c_realized": path_c_realized, + "path_c_missed": path_c_missed, + "path_c_note": "本单已止损;踏空未拿到原止盈空间", + "worst_u": path_b, + } + + +def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]: + direction = str(inp.get("direction") or "long").strip().lower() + risk = float(inp["risk_u"]) + tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"]) + sl = float(inp["sl"]) + opt = inp.get("option") if isinstance(inp.get("option"), dict) else {} + default_type = "C" if direction == "long" else "P" + opt_type = str(opt.get("opt_type") or default_type).strip().upper() + if opt_type not in ("C", "P"): + opt_type = default_type + strike = _f(opt.get("strike")) + ask = _f(opt.get("ask")) + if strike is None or strike <= 0: + return {"ok": False, "msg": "请填写单期权行权价"} + if ask is None or ask <= 0: + return {"ok": False, "msg": "请填写单期权卖一价"} + unit = option_unit_cost(ask=ask, ct_mult=ct_mult) + sheets = floor_sheets(risk / unit) if unit > 0 else 0.0 + premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0 + # 若张数为 0 + path_a = option_pnl_at_spot( + opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium + ) + path_b_at_sl = option_pnl_at_spot( + opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium + ) + path_b_worst = -premium + # 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈 + path_c = path_a + return { + "ok": True, + "kind": "option", + "opt_type": opt_type, + "strike": strike, + "ask": ask, + "ct_mult": ct_mult, + "sheets": sheets, + "unit_cost_u": round(unit, 6), + "premium_u": round(premium, 4), + "path_a_tp": round(path_a, 4), + "path_b_sl": round(path_b_at_sl, 4), + "path_b_worst": round(path_b_worst, 4), + "path_c_hold_to_tp": round(path_c, 4), + "path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)", + "worst_u": round(path_b_worst, 4), + } + + +def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]: + direction = str(inp.get("direction") or "long").strip().lower() + risk = float(inp["risk_u"]) + tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"]) + sl = float(inp["sl"]) + hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {} + main_default = "C" if direction == "long" else "P" + side_default = "P" if direction == "long" else "C" + main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {} + side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {} + main_type = str(main.get("opt_type") or main_default).strip().upper() + side_type = str(side.get("opt_type") or side_default).strip().upper() + if main_type not in ("C", "P"): + main_type = main_default + if side_type not in ("C", "P"): + side_type = side_default + main_k = _f(main.get("strike")) + main_ask = _f(main.get("ask")) + side_k = _f(side.get("strike")) + side_ask = _f(side.get("ask")) + if None in (main_k, main_ask, side_k, side_ask) or min( + main_k or 0, main_ask or 0, side_k or 0, side_ask or 0 + ) <= 0: + return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"} + main_budget = 0.7 * risk + side_budget = 0.3 * risk + main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult) + side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult) + main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0 + side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0 + main_prem = main_unit * main_sheets + side_prem = side_unit * side_sheets + premium = main_prem + side_prem + + def combo_at(spot: float) -> float: + a = option_pnl_at_spot( + opt_type=main_type, + strike=float(main_k), + spot=spot, + sheets=main_sheets, + ct_mult=ct_mult, + premium_paid=main_prem, + ) + b = option_pnl_at_spot( + opt_type=side_type, + strike=float(side_k), + spot=spot, + sheets=side_sheets, + ct_mult=ct_mult, + premium_paid=side_prem, + ) + return a + b + + path_a = combo_at(tp) + path_b_at_sl = combo_at(sl) + path_b_worst = -premium + path_c = path_a + return { + "ok": True, + "kind": "hedge", + "ratio": "7:3", + "ct_mult": ct_mult, + "main": { + "opt_type": main_type, + "strike": main_k, + "ask": main_ask, + "sheets": main_sheets, + "premium_u": round(main_prem, 4), + "budget_u": round(main_budget, 4), + }, + "side": { + "opt_type": side_type, + "strike": side_k, + "ask": side_ask, + "sheets": side_sheets, + "premium_u": round(side_prem, 4), + "budget_u": round(side_budget, 4), + }, + "premium_u": round(premium, 4), + "path_a_tp": round(path_a, 4), + "path_b_sl": round(path_b_at_sl, 4), + "path_b_worst": round(path_b_worst, 4), + "path_c_hold_to_tp": round(path_c, 4), + "path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)", + "worst_u": round(path_b_worst, 4), + } + + +def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]: + """可解释规则推荐.""" + candidates: list[tuple[str, float, dict[str, Any]]] = [] + if perp and perp.get("sheets", 0) > 0: + candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp)) + if opt and opt.get("ok") and opt.get("sheets", 0) > 0: + candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt)) + if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0: + candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge)) + if not candidates: + return { + "choice": "—", + "reason": "输入不足,无法推荐", + "bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"], + } + + best_name, best_a, _ = max(candidates, key=lambda x: x[1]) + perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0 + opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0 + hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0 + + # 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A + perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0 + opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None + hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None + anti_whipsaw = False + if perp_miss > 0 and ( + (opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0) + ): + anti_whipsaw = True + + # 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约 + others_max = max(opt_a, hedge_a, 0.0) + choice = best_name + if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99: + choice = "合约" + if anti_whipsaw: + reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约" + else: + reason = "同风险下合约干净止盈赔付最高" + elif anti_whipsaw and (opt_a > 0 or hedge_a > 0): + # 抗踏空优先期权类;期期与单腿接近时推期期 + if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85): + choice = "期期对冲" + reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护" + else: + choice = "单期权" + reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约" + else: + reason = f"同风险下「{best_name}」干净止盈赔付最高" + + bullets = [ + f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)", + ( + "止损与踏空:合约打止损即结束并可能踏空;" + "期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标" + if anti_whipsaw + else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈" + ), + f"选用建议:{reason}", + ] + return {"choice": choice, "reason": reason, "bullets": bullets} + + +def run_compare(inp: dict[str, Any]) -> dict[str, Any]: + err = _validate_common(inp) + if err: + return {"ok": False, "msg": err} + base = str(inp.get("base") or "ETH").strip().upper() + risk = float(inp["risk_u"]) + cs = _f(inp.get("contract_size")) or default_contract_size(base) + ct = _f(inp.get("ct_mult")) or default_ct_mult(base) + perp = _calc_perp(inp, contract_size=float(cs)) + opt = _calc_single_option(inp, ct_mult=float(ct)) + hedge = _calc_hedge(inp, ct_mult=float(ct)) + rec = recommend( + perp, + opt if opt.get("ok") else {"ok": False}, + hedge if hedge.get("ok") else {"ok": False}, + risk, + ) + warnings: list[str] = [] + if perp.get("sheets", 0) <= 0: + warnings.append("合约张数为 0:止损距过大或 R 过小") + if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0: + warnings.append("单期权张数为 0:卖一过高或 R 过小") + if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0: + warnings.append("期期对冲未开出张数:卖一过高或 R 过小") + if isinstance(opt, dict) and not opt.get("ok"): + warnings.append(str(opt.get("msg") or "单期权输入不完整")) + if isinstance(hedge, dict) and not hedge.get("ok"): + warnings.append(str(hedge.get("msg") or "期期对冲输入不完整")) + return { + "ok": True, + "base": base, + "direction": str(inp.get("direction") or "long").strip().lower(), + "entry": float(inp["entry"]), + "sl": float(inp["sl"]), + "tp": float(inp["tp"]), + "risk_u": risk, + "contract_size": float(cs), + "ct_mult": float(ct), + "perp": perp, + "option": opt, + "hedge": hedge, + "recommend": rec, + "warnings": warnings, + "notes": [ + "期权止盈按标的到价的内在价值近似,非盘口卖出价", + "到期小盈/小亏未纳入主表与推荐", + "仅本地测算,不下单", + ], + } diff --git a/lib/hub/hub_divergence_scan_lib.py b/lib/hub/hub_divergence_scan_lib.py new file mode 100644 index 0000000..53dc300 --- /dev/null +++ b/lib/hub/hub_divergence_scan_lib.py @@ -0,0 +1,465 @@ +"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振.""" +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from lib.hub.hub_volume_rank_lib import TOP_N_DEFAULT, get_cached_rank, volume_rank_timezone + +SCAN_CACHE_VERSION = 1 +SCAN_TIMEFRAMES: tuple[str, ...] = ("4h", "1d", "1w") +SWING_LOOKBACK = 4 +SWING_ALIGN_BARS = 30 +RECENCY_BARS = 60 +MACD_FAST = 12 +MACD_SLOW = 26 +MACD_SIGNAL = 9 + +TAB_LABELS: dict[str, str] = { + "4h": "4h背离", + "1d": "日线背离", + "1w": "周线背离", +} + +TF_SHORT: dict[str, str] = {"4h": "4h", "1d": "日线", "1w": "周线"} + + +def default_cache_path() -> Path: + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_divergence_scan.json" + + +def ema_array(values: Sequence[float | None], period: int) -> list[float | None]: + out: list[float | None] = [None] * len(values) + if period <= 0 or len(values) < period: + return out + k = 2.0 / (period + 1) + sma = sum(v for v in values[:period] if v is not None) / period + out[period - 1] = sma + prev = sma + for i in range(period, len(values)): + v = values[i] + if v is None: + continue + prev = v * k + prev * (1 - k) + out[i] = prev + return out + + +def find_swings(values: Sequence[float | None], lookback: int) -> tuple[list[dict], list[dict]]: + lows: list[dict] = [] + highs: list[dict] = [] + lb = max(1, int(lookback)) + n = len(values) + for i in range(lb, n - lb): + v = values[i] + if v is None: + continue + is_low = True + is_high = True + for j in range(1, lb + 1): + lv = values[i - j] + rv = values[i + j] + if lv is None or rv is None or v > lv or v > rv: + is_low = False + if lv is None or rv is None or v < lv or v < rv: + is_high = False + if is_low: + lows.append({"i": i, "v": float(v)}) + if is_high: + highs.append({"i": i, "v": float(v)}) + return lows, highs + + +def build_macd_by_index(closes: Sequence[float]) -> list[float | None]: + ema12 = ema_array(closes, MACD_FAST) + ema26 = ema_array(closes, MACD_SLOW) + macd: list[float | None] = [None] * len(closes) + for i in range(len(closes)): + if ema12[i] is not None and ema26[i] is not None: + macd[i] = ema12[i] - ema26[i] + return macd + + +def detect_latest_macd_divergence( + closes: Sequence[float], + *, + swing_lookback: int = SWING_LOOKBACK, + align_bars: int = SWING_ALIGN_BARS, + recency_bars: int = RECENCY_BARS, +) -> dict[str, Any]: + """档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类).""" + if len(closes) < swing_lookback * 2 + 10: + return {"direction": None} + macd = build_macd_by_index(closes) + p_lows, p_highs = find_swings(closes, swing_lookback) + i_lows, i_highs = find_swings(macd, swing_lookback) + + def recent_enough(idx: int) -> bool: + return idx >= max(0, len(closes) - recency_bars) + + if len(p_lows) >= 2 and len(i_lows) >= 2: + p1, p2 = p_lows[-2], p_lows[-1] + i1, i2 = i_lows[-2], i_lows[-1] + if ( + abs(p1["i"] - i1["i"]) < align_bars + and abs(p2["i"] - i2["i"]) < align_bars + and p2["v"] < p1["v"] + and i2["v"] > i1["v"] + and recent_enough(p2["i"]) + ): + return {"direction": "bull", "bar_index": p2["i"]} + + if len(p_highs) >= 2 and len(i_highs) >= 2: + p1, p2 = p_highs[-2], p_highs[-1] + i1, i2 = i_highs[-2], i_highs[-1] + if ( + abs(p1["i"] - i1["i"]) < align_bars + and abs(p2["i"] - i2["i"]) < align_bars + and p2["v"] > p1["v"] + and i2["v"] < i1["v"] + and recent_enough(p2["i"]) + ): + return {"direction": "bear", "bar_index": p2["i"]} + + return {"direction": None} + + +def chart_candles_to_bars(candles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for c in candles: + try: + t = c.get("time") + if t is None: + continue + ms = int(t) * 1000 if int(t) < 10_000_000_000 else int(t) + out.append( + { + "open_time_ms": ms, + "open": float(c["open"]), + "high": float(c["high"]), + "low": float(c["low"]), + "close": float(c["close"]), + "volume": float(c.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +def normalize_ohlcv_rows(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + if not rows: + return [] + first = rows[0] + if first.get("open_time_ms") is not None: + return [dict(r) for r in rows] + return chart_candles_to_bars(rows) + + +def bars_to_closes(bars: Sequence[Mapping[str, Any]], *, exclude_open: bool = True) -> list[float]: + rows = list(bars) + if exclude_open and len(rows) > 1: + rows = rows[:-1] + out: list[float] = [] + for b in rows: + try: + out.append(float(b["close"])) + except (KeyError, TypeError, ValueError): + continue + return out + + +def bar_time_at(bars: Sequence[Mapping[str, Any]], index: int) -> int | None: + if index < 0 or index >= len(bars): + return None + try: + return int(bars[index]["open_time_ms"]) + except (KeyError, TypeError, ValueError): + return None + + +def analyze_ohlcv_bars(bars: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + closed = list(bars) + if len(closed) > 1: + closed = closed[:-1] + closes = bars_to_closes(bars, exclude_open=True) + hit = detect_latest_macd_divergence(closes) + direction = hit.get("direction") + bar_index = hit.get("bar_index") + open_time_ms = None + if direction and bar_index is not None: + open_time_ms = bar_time_at(closed, int(bar_index)) + bars_ago = None + if direction and bar_index is not None: + bars_ago = max(0, len(closed) - 1 - int(bar_index)) + return { + "direction": direction, + "bar_index": bar_index, + "open_time_ms": open_time_ms, + "bars_ago": bars_ago, + } + + +def compute_confluence(tf_hits: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]: + dirs: dict[str, str] = {} + for tf in SCAN_TIMEFRAMES: + d = (tf_hits.get(tf) or {}).get("direction") + if d in ("bull", "bear"): + dirs[tf] = d + + if not dirs: + return { + "confluence": 0, + "confluence_kind": "none", + "confluence_css": "none", + "is_split": False, + "split_detail": "", + "primary_direction": None, + "direction_label": "", + "timeframes_hit": [], + } + + unique = set(dirs.values()) + if len(unique) > 1: + parts = [] + for tf in SCAN_TIMEFRAMES: + if tf in dirs: + label = "底" if dirs[tf] == "bull" else "顶" + parts.append(f"{TF_SHORT.get(tf, tf)}{label}") + return { + "confluence": 0, + "confluence_kind": "分歧", + "confluence_css": "split", + "is_split": True, + "split_detail": " · ".join(parts), + "primary_direction": _latest_direction(tf_hits), + "direction_label": "分歧", + "timeframes_hit": list(dirs.keys()), + } + + direction = next(iter(unique)) + count = len(dirs) + return { + "confluence": count, + "confluence_kind": f"{count}周期", + "confluence_css": f"c{count}", + "is_split": False, + "split_detail": "", + "primary_direction": direction, + "direction_label": "底背离" if direction == "bull" else "顶背离", + "timeframes_hit": list(dirs.keys()), + } + + +def _latest_direction(tf_hits: Mapping[str, Mapping[str, Any]]) -> str | None: + best_tf = None + best_ms = -1 + for tf in SCAN_TIMEFRAMES: + row = tf_hits.get(tf) or {} + d = row.get("direction") + ms = row.get("open_time_ms") + if d not in ("bull", "bear") or ms is None: + continue + if int(ms) > best_ms: + best_ms = int(ms) + best_tf = tf + if best_tf is None: + return None + return (tf_hits.get(best_tf) or {}).get("direction") + + +def freshness_label(timeframe: str, bars_ago: int | None) -> str: + if bars_ago is None: + return "" + n = int(bars_ago) + if timeframe == "1w": + return f"{n}周前" if n else "本周" + if n <= 0: + return "当根" + return f"{n}根K前" + + +def build_symbol_scan_row( + *, + rank: int, + symbol: str, + volume_label: str, + tf_hits: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + conf = compute_confluence(tf_hits) + tf_map = {tf: (tf_hits.get(tf) or {}).get("direction") for tf in SCAN_TIMEFRAMES} + return { + "rank": rank, + "symbol": symbol, + "volume_label": volume_label, + "direction": conf.get("primary_direction"), + "direction_label": conf.get("direction_label") or "", + "confluence": conf.get("confluence") or 0, + "confluence_kind": conf.get("confluence_kind") or "none", + "confluence_css": conf.get("confluence_css") or "none", + "is_split": bool(conf.get("is_split")), + "split_detail": conf.get("split_detail") or "", + "timeframes": tf_map, + "tf_detail": { + tf: { + "direction": (tf_hits.get(tf) or {}).get("direction"), + "open_time_ms": (tf_hits.get(tf) or {}).get("open_time_ms"), + "bars_ago": (tf_hits.get(tf) or {}).get("bars_ago"), + "freshness": freshness_label(tf, (tf_hits.get(tf) or {}).get("bars_ago")), + } + for tf in SCAN_TIMEFRAMES + }, + } + + +def filter_tab_items(items: Sequence[Mapping[str, Any]], tab: str) -> list[dict[str, Any]]: + tab = (tab or "").strip().lower() + if tab not in SCAN_TIMEFRAMES: + return [dict(x) for x in items] + out: list[dict[str, Any]] = [] + for row in items: + tf = (row.get("tf_detail") or {}).get(tab) or {} + if tf.get("direction") not in ("bull", "bear"): + continue + item = dict(row) + item["tab_timeframe"] = tab + item["tab_direction"] = tf.get("direction") + item["tab_direction_label"] = "底背离" if tf.get("direction") == "bull" else "顶背离" + item["tab_freshness"] = tf.get("freshness") or "" + item["tab_open_time_ms"] = tf.get("open_time_ms") + out.append(item) + out.sort( + key=lambda x: ( + -1 if x.get("is_split") else int(x.get("confluence") or 0), + int(x.get("rank") or 999), + ), + reverse=True, + ) + return out + + +def load_scan_cache(path: Path | None = None) -> dict[str, Any]: + p = path or default_cache_path() + if not p.is_file(): + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + try: + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + if int(data.get("version") or 0) < SCAN_CACHE_VERSION: + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + data.setdefault("version", SCAN_CACHE_VERSION) + data.setdefault("exchanges", {}) + return data + except Exception: + return {"version": SCAN_CACHE_VERSION, "exchanges": {}} + + +def save_scan_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"] = SCAN_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_scan( + cache: dict[str, Any], + exchange_key: str, + *, + rank_date: str | None, + items: list[dict[str, Any]], + error: str | None = None, +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + exchanges = dict(cache.get("exchanges") or {}) + exchanges[ex_k] = { + "rank_date": rank_date, + "items": items, + "error": error, + "scanned_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"), + } + out = dict(cache) + out["exchanges"] = exchanges + return out + + +def get_cached_scan( + cache: dict[str, Any], + exchange_key: str, + *, + tab: str = "4h", +) -> dict[str, Any]: + ex_k = str(exchange_key or "").strip().lower() + ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} + all_items = list(ex_data.get("items") or []) + tab_key = (tab or "4h").strip().lower() + items = filter_tab_items(all_items, tab_key) if tab_key in SCAN_TIMEFRAMES else all_items + return { + "ok": True, + "exchange_key": ex_k, + "tab": tab_key, + "rank_date": ex_data.get("rank_date"), + "updated_at": cache.get("updated_at"), + "scanned_at": ex_data.get("scanned_at"), + "items": items, + "item_count": len(items), + "error": ex_data.get("error"), + } + + +def scan_top_symbols( + rank_items: Sequence[Mapping[str, Any]], + fetch_bars: Callable[[str, str], Sequence[Mapping[str, Any]]], +) -> list[dict[str, Any]]: + """对 Top N 币种扫描三周期背离.fetch_bars(symbol, timeframe) -> OHLCV rows.""" + out: list[dict[str, Any]] = [] + for row in rank_items: + symbol = str(row.get("symbol") or "").strip().upper() + if not symbol: + continue + tf_hits: dict[str, dict[str, Any]] = {} + for tf in SCAN_TIMEFRAMES: + try: + bars = fetch_bars(symbol, tf) + tf_hits[tf] = analyze_ohlcv_bars(bars) + except Exception: + tf_hits[tf] = {"direction": None} + out.append( + build_symbol_scan_row( + rank=int(row.get("rank") or 0), + symbol=symbol, + volume_label=str(row.get("volume_label") or row.get("volume_quote") or ""), + tf_hits=tf_hits, + ) + ) + return out + + +def cache_is_stale( + cache: dict[str, Any], + exchange_key: str, + *, + rank_date: str | None, + max_age_sec: float = 3600.0, +) -> bool: + ex_k = str(exchange_key or "").strip().lower() + ex_data = (cache.get("exchanges") or {}).get(ex_k) or {} + if not ex_data.get("items") and not ex_data.get("error"): + return True + if rank_date and ex_data.get("rank_date") != rank_date: + return True + updated = cache.get("updated_at") or ex_data.get("scanned_at") + if not updated: + return True + try: + dt = datetime.fromisoformat(str(updated)) + age = (datetime.now(dt.tzinfo) - dt).total_seconds() + return age > max_age_sec + except Exception: + return True diff --git a/lib/hub/hub_entry_plan_lib.py b/lib/hub/hub_entry_plan_lib.py new file mode 100644 index 0000000..ae635fb --- /dev/null +++ b/lib/hub/hub_entry_plan_lib.py @@ -0,0 +1,453 @@ +"""中控开仓计划:进行中 / 历史归档 / 胜率统计.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +PLAN_TYPES = { + "trend": "趋势单", + "swing": "波段单", + "intraday": "日内短线", +} +TREND_TIMEFRAMES = ("5m", "15m", "30m", "1h", "4h", "1d") +ENTRY_TIMEFRAMES = ("1m", "5m", "15m", "30m", "1h") +DIRECTIONS = {"long": "多", "short": "空"} +ENTRY_SCHEMES = { + "breakout": "突破方案", + "false_breakout": "假突破突破方案", + "box_inflection": "箱体拐点方案", +} +RESULTS = {"win": "盈", "loss": "亏"} +STAT_DIMENSIONS = ("symbol", "trend_tf", "entry_scheme") + +DISPLAY_TZ = ZoneInfo( + (os.getenv("HUB_ENTRY_PLAN_TZ") or os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" +) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_ENTRY_PLAN_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_entry_plans.db" + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS entry_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_date TEXT NOT NULL, + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + plan_type TEXT NOT NULL, + trend_timeframe TEXT NOT NULL, + entry_timeframe TEXT NOT NULL, + direction TEXT NOT NULL, + target_level TEXT NOT NULL DEFAULT '', + current_range TEXT NOT NULL DEFAULT '', + entry_scheme TEXT NOT NULL, + result TEXT, + pnl_amount REAL, + note TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + archived_at INTEGER + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_entry_plans_status_date + ON entry_plans (status, plan_date DESC, id DESC) + """ + ) + finally: + conn.close() + + +def normalize_plan_symbol(raw: str) -> str: + s = str(raw or "").strip().upper() + if not s: + raise ValueError("缺少币种") + if ":" in s: + s = s.split(":", 1)[0] + if "/" in s: + base, quote = s.split("/", 1) + base = base.strip() + quote = (quote or "USDT").strip() or "USDT" + if not base: + raise ValueError("币种无效") + return f"{base}/{quote}" + if s.endswith("USDT") and len(s) > 4: + return f"{s[:-4]}/{s[-4:]}" + return f"{s}/USDT" + + +def _validate_choice(value: str, allowed: dict[str, str] | tuple[str, ...], field: str) -> str: + key = str(value or "").strip().lower() + if isinstance(allowed, dict): + if key not in allowed: + raise ValueError(f"{field} 无效") + return key + if key not in allowed: + raise ValueError(f"{field} 无效") + return key + + +def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + d = dict(row) + d["plan_type_label"] = PLAN_TYPES.get(d.get("plan_type") or "", d.get("plan_type") or "") + d["direction_label"] = DIRECTIONS.get(d.get("direction") or "", d.get("direction") or "") + d["entry_scheme_label"] = ENTRY_SCHEMES.get( + d.get("entry_scheme") or "", d.get("entry_scheme") or "" + ) or "待填写" + res = d.get("result") + d["result_label"] = RESULTS.get(res, "") if res else "" + return d + + +def _parse_optional_pnl(raw: Any) -> float | None: + if raw is None or raw == "": + return None + try: + return round(float(raw), 4) + except (TypeError, ValueError) as e: + raise ValueError("盈亏金额无效") from e + + +def create_entry_plan(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]: + init_db(db_path) + plan_date = str(payload.get("plan_date") or "").strip()[:10] + if not plan_date: + raise ValueError("缺少 plan_date") + exchange_key = str(payload.get("exchange_key") or "").strip().lower() + if not exchange_key: + raise ValueError("缺少 exchange_key") + symbol = normalize_plan_symbol(payload.get("symbol") or "") + plan_type = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型") + trend_tf = _validate_choice(payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期") + entry_tf = _validate_choice(payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期") + direction = _validate_choice(payload.get("direction"), DIRECTIONS, "方向") + entry_scheme = "" + if payload.get("entry_scheme"): + entry_scheme = _validate_choice(payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案") + target_level = str(payload.get("target_level") or "").strip() + current_range = str(payload.get("current_range") or "").strip() + note = str(payload.get("note") or "").strip() + now = _now_ms() + conn = _connect(db_path) + try: + cur = conn.execute( + """ + INSERT INTO entry_plans ( + plan_date, exchange_key, symbol, plan_type, trend_timeframe, entry_timeframe, + direction, target_level, current_range, entry_scheme, note, status, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + """, + ( + plan_date, + exchange_key, + symbol, + plan_type, + trend_tf, + entry_tf, + direction, + target_level, + current_range, + entry_scheme, + note, + now, + now, + ), + ) + row = conn.execute( + "SELECT * FROM entry_plans WHERE id=?", + (int(cur.lastrowid),), + ).fetchone() + return _row_to_dict(row) or {} + finally: + conn.close() + + +def list_entry_plans( + *, + status: str = "active", + db_path: Path | None = None, +) -> list[dict[str, Any]]: + init_db(db_path) + st = (status or "active").strip().lower() + if st not in ("active", "archived"): + raise ValueError("status 无效") + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM entry_plans + WHERE status=? + ORDER BY plan_date DESC, id DESC + """, + (st,), + ).fetchall() + return [_row_to_dict(r) for r in rows if r] + finally: + conn.close() + + +def get_entry_plan(plan_id: int, *, db_path: Path | None = None) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + return _row_to_dict(row) + finally: + conn.close() + + +def update_entry_plan( + plan_id: int, + payload: dict[str, Any], + *, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + if not row: + return None + if row["status"] == "archived": + raise ValueError("已归档计划不可修改") + fields: dict[str, Any] = {} + if "plan_date" in payload: + qd = str(payload.get("plan_date") or "").strip()[:10] + if not qd: + raise ValueError("缺少 plan_date") + fields["plan_date"] = qd + if "exchange_key" in payload: + ex = str(payload.get("exchange_key") or "").strip().lower() + if not ex: + raise ValueError("缺少 exchange_key") + fields["exchange_key"] = ex + if "symbol" in payload: + fields["symbol"] = normalize_plan_symbol(payload.get("symbol") or "") + if "plan_type" in payload: + fields["plan_type"] = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型") + if "trend_timeframe" in payload: + fields["trend_timeframe"] = _validate_choice( + payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期" + ) + if "entry_timeframe" in payload: + fields["entry_timeframe"] = _validate_choice( + payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期" + ) + if "direction" in payload: + fields["direction"] = _validate_choice(payload.get("direction"), DIRECTIONS, "方向") + if "entry_scheme" in payload: + fields["entry_scheme"] = _validate_choice( + payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案" + ) + if "target_level" in payload: + fields["target_level"] = str(payload.get("target_level") or "").strip() + if "current_range" in payload: + fields["current_range"] = str(payload.get("current_range") or "").strip() + if "note" in payload: + fields["note"] = str(payload.get("note") or "").strip() + if "pnl_amount" in payload: + fields["pnl_amount"] = _parse_optional_pnl(payload.get("pnl_amount")) + archive_now = False + if "result" in payload: + res_raw = payload.get("result") + if res_raw is None or str(res_raw).strip() == "": + fields["result"] = None + else: + fields["result"] = _validate_choice(res_raw, RESULTS, "结果") + archive_now = True + if not fields: + return _row_to_dict(row) + now = _now_ms() + fields["updated_at"] = now + if archive_now: + scheme_val = fields.get("entry_scheme", row["entry_scheme"]) + if not str(scheme_val or "").strip(): + raise ValueError("归档前请在进行中计划里选择入场方案") + fields["status"] = "archived" + fields["archived_at"] = now + sets = ", ".join(f"{k}=?" for k in fields) + conn.execute( + f"UPDATE entry_plans SET {sets} WHERE id=?", + (*fields.values(), int(plan_id)), + ) + updated = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + return _row_to_dict(updated) + finally: + conn.close() + + +def delete_entry_plan(plan_id: int, *, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT status FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone() + if not row: + return False + if row["status"] != "active": + raise ValueError("仅进行中的计划可删除") + cur = conn.execute("DELETE FROM entry_plans WHERE id=? AND status='active'", (int(plan_id),)) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def _today_iso() -> str: + return datetime.now(DISPLAY_TZ).strftime("%Y-%m-%d") + + +def resolve_stats_date_bounds( + *, + period: str = "all", + date_from: str = "", + date_to: str = "", +) -> tuple[str | None, str | None, str]: + """返回 (date_from, date_to, label);all 时 bounds 为 None.""" + p = (period or "all").strip().lower() or "all" + today = _today_iso() + if p == "all": + return None, None, "全部历史" + if p == "week": + day_dt = datetime.strptime(today, "%Y-%m-%d") + monday = (day_dt - timedelta(days=day_dt.weekday())).strftime("%Y-%m-%d") + return monday, today, f"本周 {monday}~{today}" + if p == "month": + day_dt = datetime.strptime(today, "%Y-%m-%d") + first = day_dt.replace(day=1).strftime("%Y-%m-%d") + return first, today, f"本月 {first}~{today}" + if p == "range": + df = (date_from or "").strip()[:10] or today + dt = (date_to or "").strip()[:10] or df + if df > dt: + df, dt = dt, df + label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}" + return df, dt, label + return None, None, "全部历史" + + +def compute_entry_plan_stats( + *, + dimension: str = "symbol", + period: str = "all", + date_from: str = "", + date_to: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + dim = (dimension or "symbol").strip().lower() + if dim not in STAT_DIMENSIONS: + raise ValueError("dimension 无效") + df_bound, dt_bound, period_label = resolve_stats_date_bounds( + period=period, date_from=date_from, date_to=date_to + ) + col_map = { + "symbol": "symbol", + "trend_tf": "trend_timeframe", + "entry_scheme": "entry_scheme", + } + col = col_map[dim] + conn = _connect(db_path) + try: + where = "status='archived' AND result IN ('win','loss')" + params: list[Any] = [] + if df_bound: + where += " AND plan_date >= ? AND plan_date <= ?" + params.extend([df_bound, dt_bound]) + rows = conn.execute( + f""" + SELECT {col} AS dim_key, + COUNT(*) AS total, + SUM(CASE WHEN result='win' THEN 1 ELSE 0 END) AS win_count, + SUM(CASE WHEN result='loss' THEN 1 ELSE 0 END) AS loss_count + FROM entry_plans + WHERE {where} + GROUP BY {col} + ORDER BY total DESC, dim_key ASC + """, + params, + ).fetchall() + items = [] + for r in rows: + total = int(r["total"] or 0) + wins = int(r["win_count"] or 0) + losses = int(r["loss_count"] or 0) + key = str(r["dim_key"] or "") + label = key + if dim == "entry_scheme": + label = ENTRY_SCHEMES.get(key, key) + elif dim == "trend_tf": + label = key + win_rate = round(wins / total * 100, 1) if total else None + items.append( + { + "key": key, + "label": label, + "total": total, + "win_count": wins, + "loss_count": losses, + "win_rate": win_rate, + } + ) + return { + "dimension": dim, + "period": period, + "period_label": period_label, + "date_from": df_bound, + "date_to": dt_bound, + "items": items, + } + finally: + conn.close() + + +def meta_payload(exchanges: list[dict[str, Any]] | None = None) -> dict[str, Any]: + return { + "plan_types": [{"value": k, "label": v} for k, v in PLAN_TYPES.items()], + "trend_timeframes": list(TREND_TIMEFRAMES), + "entry_timeframes": list(ENTRY_TIMEFRAMES), + "directions": [{"value": k, "label": v} for k, v in DIRECTIONS.items()], + "entry_schemes": [{"value": k, "label": v} for k, v in ENTRY_SCHEMES.items()], + "results": [{"value": k, "label": v} for k, v in RESULTS.items()], + "stat_dimensions": [ + {"value": "symbol", "label": "币种"}, + {"value": "trend_tf", "label": "趋势周期"}, + {"value": "entry_scheme", "label": "入场方案"}, + ], + "exchanges": exchanges or [], + } diff --git a/lib/hub/hub_fund_history_lib.py b/lib/hub/hub_fund_history_lib.py new file mode 100644 index 0000000..1f5888b --- /dev/null +++ b/lib/hub/hub_fund_history_lib.py @@ -0,0 +1,470 @@ +"""中控资金概况:分户日快照(180 交易日),总资金曲线与回撤.""" +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional + +from lib.hub.hub_trades_lib import current_trading_day +from lib.hub.hub_options_funds_lib import merge_board_row_balances, repair_double_counted_fund_entry + +from lib.paths import manual_trading_hub_dir + +HUB_DIR = manual_trading_hub_dir() +FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json" +LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json" + +try: + FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180")) +except ValueError: + FUND_HISTORY_DAYS = 180 + +FUND_HISTORY_START_DAY = (os.getenv("HUB_FUND_HISTORY_START_DAY") or "2026-06-09").strip()[:10] + + +def fund_history_start_day() -> str: + return FUND_HISTORY_START_DAY or "2026-06-09" + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _safe_float(value: Any) -> Optional[float]: + try: + v = float(value) + return v if v >= 0 else None + except (TypeError, ValueError): + return None + + +def account_total_usdt(funding: Any, trading: Any) -> Optional[float]: + """资金户 + 交易户;任一侧缺失则不计入(返回 None).""" + fu = _safe_float(funding) + tu = _safe_float(trading) + if fu is None or tu is None: + return None + return round(fu + tu, 4) + + +def compute_drawdown(values: list[float]) -> dict[str, Any]: + """基于资金权益序列计算峰值回撤(U 与 %).""" + peak = 0.0 + max_dd_u = 0.0 + peak_at_end = 0.0 + for v in values: + if not isinstance(v, (int, float)): + continue + fv = float(v) + if fv > peak: + peak = fv + dd = peak - fv + if dd > max_dd_u: + max_dd_u = dd + peak_at_end = peak + max_dd_u = round(max_dd_u, 4) + peak_at_end = round(peak_at_end, 4) + max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None + return { + "peak_usdt": peak_at_end, + "max_drawdown_u": max_dd_u, + "max_drawdown_pct": max_dd_pct, + } + + +def compute_period_delta(series: list[dict]) -> dict[str, Any]: + """相对曲线起点的资金变动(U 与 %);含出入金影响,口径同权益曲线.""" + pts = [ + p + for p in (series or []) + if isinstance(p, dict) and isinstance(p.get("total_usdt"), (int, float)) + ] + if not pts: + return { + "start_usdt": None, + "period_delta_usdt": None, + "period_delta_pct": None, + } + start = round(float(pts[0]["total_usdt"]), 4) + end = round(float(pts[-1]["total_usdt"]), 4) + delta = round(end - start, 4) + pct = round((delta / start) * 100, 2) if start > 0 else None + return { + "start_usdt": start, + "period_delta_usdt": delta, + "period_delta_pct": pct, + } + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +def _prune_days( + days: dict, + *, + keep_days: int, + anchor_day: str, + start_day: Optional[str] = None, +) -> dict: + try: + anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d") + except ValueError: + anchor = datetime.now() + rolling_cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d") + start = (start_day or fund_history_start_day()).strip()[:10] + cutoff = max(rolling_cutoff, start) if start else rolling_cutoff + return {k: v for k, v in (days or {}).items() if str(k) >= cutoff} + + +def _migrate_legacy_store(days: dict) -> dict: + if not LEGACY_FUND_HISTORY_PATH.is_file(): + return days + try: + loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8")) + legacy_days = loaded.get("days") if isinstance(loaded, dict) else {} + if not isinstance(legacy_days, dict): + return days + merged = dict(days) + for day, block in legacy_days.items(): + if day in merged: + continue + if isinstance(block, dict) and block.get("accounts"): + merged[day] = block + return merged + except Exception: + return days + + +def _load_store() -> dict: + if not FUND_HISTORY_PATH.is_file(): + store = {"version": 1, "days": _migrate_legacy_store({})} + if store["days"]: + _atomic_write(FUND_HISTORY_PATH, store) + return store + try: + loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + loaded.setdefault("version", 1) + days = dict(loaded.get("days") or {}) + loaded["days"] = _migrate_legacy_store(days) + return loaded + except Exception: + pass + return {"version": 1, "days": {}} + + +def record_fund_snapshot( + trading_day: str, + accounts: list[dict], + *, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, +) -> dict[str, Any]: + """写入当日各户资金账户/交易账户余额,并裁剪历史.""" + day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour) + start = fund_history_start_day() + if start and day < start: + return _load_store().get("days") or {} + store = _load_store() + days = dict(store.get("days") or {}) + row_accounts: dict[str, dict] = {} + for ac in accounts or []: + key = str(ac.get("key") or ac.get("id") or "").strip() + if not key: + continue + if not ac.get("monitored"): + continue + fu = _safe_float(ac.get("funding_usdt")) + tu = _safe_float(ac.get("trading_usdt")) + total = account_total_usdt(fu, tu) + if total is None: + continue + entry: dict[str, Any] = { + "name": ac.get("name"), + "funding_usdt": fu, + "trading_usdt": tu, + "total_usdt": total, + "recorded_at": _now_str(), + } + ofu = _safe_float(ac.get("options_funding_usdt")) + otu = _safe_float(ac.get("options_trading_usdt")) + if ofu is not None: + entry["options_funding_usdt"] = ofu + if otu is not None: + entry["options_trading_usdt"] = otu + row_accounts[key] = entry + if row_accounts: + days[day] = {"accounts": row_accounts, "updated_at": _now_str()} + days = _prune_days( + days, keep_days=keep_days, anchor_day=day, start_day=fund_history_start_day() + ) + _atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days}) + return days + + +def record_fund_snapshot_from_board( + rows: list[dict], + *, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, +) -> dict[str, Any]: + """监控板行写入当日快照(仅 account_ok 且资金/交易户齐全).""" + day = current_trading_day(reset_hour=reset_hour) + accounts = [] + for row in rows or []: + if not isinstance(row, dict): + continue + if not row.get("account_ok") and not ( + "options" in (row.get("capabilities") or []) + and isinstance(row.get("options"), dict) + and row.get("options", {}).get("ok") + ): + continue + merged = merge_board_row_balances(row) + if not merged.get("data_ok"): + continue + accounts.append( + { + "key": row.get("key") or row.get("id"), + "name": row.get("name"), + "funding_usdt": merged.get("funding_usdt"), + "trading_usdt": merged.get("trading_usdt"), + "options_funding_usdt": merged.get("options_funding_usdt"), + "options_trading_usdt": merged.get("options_trading_usdt"), + "monitored": True, + } + ) + return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour) + + +def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]: + store = _load_store() + return _prune_days( + dict(store.get("days") or {}), + keep_days=keep_days, + anchor_day=anchor_day, + start_day=fund_history_start_day(), + ) + + +def _exchange_monitored(ex: dict) -> bool: + return bool(ex.get("enabled")) and not bool(ex.get("env_disabled")) + + +def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]: + key = str(ex.get("key") or "").strip() + if not key: + return None + return rows_by_key.get(key) + + +def _series_from_history( + history: dict[str, dict], + account_keys: list[str], +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for day in sorted(history.keys()): + block = history.get(day) or {} + ac_map = block.get("accounts") or {} + total = 0.0 + n = 0 + for key in account_keys: + ac = repair_double_counted_fund_entry(ac_map.get(key) or {}) + t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) + if t is None: + t = _safe_float(ac.get("total_usdt")) + if t is None: + continue + total += t + n += 1 + if n > 0: + out.append({"day": day, "total_usdt": round(total, 4)}) + return out + + +def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for day in sorted(history.keys()): + raw = (history.get(day) or {}).get("accounts", {}).get(key) or {} + ac = repair_double_counted_fund_entry(raw) + t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) + if t is None: + t = _safe_float(ac.get("total_usdt")) + if t is None: + continue + out.append( + { + "day": day, + "total_usdt": t, + "funding_usdt": _safe_float(ac.get("funding_usdt")), + "trading_usdt": _safe_float(ac.get("trading_usdt")), + } + ) + return out + + +def build_fund_overview( + exchanges: list[dict], + *, + board_rows: Optional[list[dict]] = None, + trading_day: Optional[str] = None, + keep_days: int = FUND_HISTORY_DAYS, + reset_hour: int = 8, + updated_at: Optional[str] = None, +) -> dict[str, Any]: + day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour) + history = get_fund_history(anchor_day=day, keep_days=keep_days) + rows_by_key: dict[str, dict] = {} + for row in board_rows or []: + if isinstance(row, dict): + k = str(row.get("key") or "").strip() + if k: + rows_by_key[k] = row + + monitored_keys: list[str] = [] + accounts_out: list[dict[str, Any]] = [] + live_total = 0.0 + live_known = 0 + + for ex in exchanges or []: + if not _exchange_monitored(ex): + continue + key = str(ex.get("key") or "").strip() + monitored = True + row = _live_row_for_exchange(ex, rows_by_key) + fu = tu = total = None + pf = pt = ofu = otu = None + data_ok = False + caps = ex.get("capabilities") or [] + if row: + merged = merge_board_row_balances({**row, "capabilities": caps}) + if merged.get("data_ok"): + fu = merged.get("funding_usdt") + tu = merged.get("trading_usdt") + total = merged.get("total_usdt") + pf = merged.get("perpetual_funding_usdt") + pt = merged.get("perpetual_trading_usdt") + ofu = merged.get("options_funding_usdt") + otu = merged.get("options_trading_usdt") + data_ok = True + live_total += float(total) + live_known += 1 + + series = _account_series(history, key) if key else [] + dd = compute_drawdown([p["total_usdt"] for p in series]) if series else { + "peak_usdt": None, + "max_drawdown_u": None, + "max_drawdown_pct": None, + } + day_delta = None + if series: + if len(series) >= 2: + day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4) + elif data_ok and total is not None: + day_delta = round(total - series[-1]["total_usdt"], 4) + period = compute_period_delta(series) + + accounts_out.append( + { + "id": ex.get("id"), + "key": key, + "name": ex.get("name") or key, + "monitored": monitored, + "data_ok": data_ok, + "funding_usdt": fu, + "trading_usdt": tu, + "perpetual_funding_usdt": pf, + "perpetual_trading_usdt": pt, + "options_funding_usdt": ofu, + "options_trading_usdt": otu, + "total_usdt": total, + "series": series, + "drawdown": dd, + "day_delta_usdt": day_delta, + "start_usdt": period["start_usdt"], + "period_delta_usdt": period["period_delta_usdt"], + "period_delta_pct": period["period_delta_pct"], + } + ) + if key: + monitored_keys.append(key) + + total_series = _series_from_history(history, monitored_keys) + if live_known > 0: + last_day = total_series[-1]["day"] if total_series else None + live_point = round(live_total, 4) + if last_day == day and total_series: + total_series[-1]["total_usdt"] = live_point + total_series[-1]["live"] = True + else: + total_series.append({"day": day, "total_usdt": live_point, "live": True}) + + total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else { + "peak_usdt": None, + "max_drawdown_u": None, + "max_drawdown_pct": None, + } + total_day_delta = None + if total_series: + if len(total_series) >= 2: + total_day_delta = round( + total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4 + ) + total_period = compute_period_delta(total_series) + + return { + "ok": True, + "trading_day": day, + "reset_hour": reset_hour, + "keep_days": keep_days, + "history_start_day": fund_history_start_day(), + "updated_at": updated_at, + "totals": { + "monitored_count": len(monitored_keys), + "live_known_count": live_known, + "total_usdt": round(live_total, 4) if live_known > 0 else None, + "day_delta_usdt": total_day_delta, + "start_usdt": total_period["start_usdt"], + "period_delta_usdt": total_period["period_delta_usdt"], + "period_delta_pct": total_period["period_delta_pct"], + "series": total_series, + "drawdown": total_dd, + }, + "accounts": accounts_out, + } + + +def format_fund_history_text( + history: dict[str, dict], + *, + account_names: Optional[dict[str, str]] = None, +) -> str: + if not history: + return "(暂无资金历史快照)" + names = account_names or {} + lines = ["【资金快照(资金账户 + 交易账户 USDT,含期权 USDC≈USDT)】"] + for day in sorted(history.keys()): + block = history.get(day) or {} + ac_map = block.get("accounts") or {} + if not ac_map: + continue + parts = [] + for key, ac in ac_map.items(): + label = names.get(key) or ac.get("name") or key + fu = ac.get("funding_usdt") + tu = ac.get("trading_usdt") + tot = ac.get("total_usdt") + if tot is None: + tot = account_total_usdt(fu, tu) + fu_txt = f"{fu}U" if fu is not None else "未知" + tu_txt = f"{tu}U" if tu is not None else "未知" + tot_txt = f"{tot}U" if tot is not None else "未知" + parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt})") + lines.append(f"- {day}: " + ";".join(parts)) + return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)" diff --git a/lib/hub/hub_help_lib.py b/lib/hub/hub_help_lib.py new file mode 100644 index 0000000..9456b61 --- /dev/null +++ b/lib/hub/hub_help_lib.py @@ -0,0 +1,53 @@ +"""中控「使用说明」:读取 manual_trading_hub/docs/help 下的 MD.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from lib.hub.hub_strategy_lib import render_markdown_html +from lib.paths import REPO_ROOT + +HELP_SECTIONS: tuple[dict[str, str], ...] = ( + {"key": "quickstart", "label": "快速开始", "title": "快速开始", "md_file": "01-quickstart.md"}, + {"key": "hub-nav", "label": "中控导航", "title": "中控导航说明", "md_file": "02-hub-nav.md"}, + {"key": "monitor", "label": "监控区", "title": "监控区与实例入口", "md_file": "03-monitor.md"}, + {"key": "instance", "label": "实例页面", "title": "实例页导航说明", "md_file": "04-instance.md"}, + {"key": "settings", "label": "设置说明", "title": "设置与配置说明", "md_file": "05-settings.md"}, +) + + +def _help_dir() -> Path: + return REPO_ROOT / "manual_trading_hub" / "docs" / "help" + + +def _section_meta(key: str) -> dict[str, str]: + k = (key or "").strip().lower() + for item in HELP_SECTIONS: + if item["key"] == k: + return item + raise KeyError(key) + + +def _md_path(section_key: str) -> Path: + return _help_dir() / _section_meta(section_key)["md_file"] + + +def help_meta_payload() -> dict[str, Any]: + sections = [{"key": s["key"], "label": s["label"], "title": s["title"]} for s in HELP_SECTIONS] + return {"ok": True, "sections": sections} + + +def load_help_payload(section_key: str) -> dict[str, Any]: + key = (section_key or "").strip().lower() + meta = _section_meta(key) + md_path = _md_path(key) + md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else "" + return { + "ok": True, + "section_key": key, + "label": meta["label"], + "title": meta["title"], + "md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"), + "content_html": render_markdown_html(md_text), + } diff --git a/lib/hub/hub_host_status_lib.py b/lib/hub/hub_host_status_lib.py new file mode 100644 index 0000000..11dace1 --- /dev/null +++ b/lib/hub/hub_host_status_lib.py @@ -0,0 +1,98 @@ +"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条).""" +from __future__ import annotations + +import os +import socket +import time +from typing import Any + +_state: dict[str, Any] = { + "primed": False, + "net_ts": 0.0, + "net_sent": 0, + "net_recv": 0, +} + + +def _disk_path() -> str: + raw = (os.getenv("HUB_HOST_DISK_PATH") or "").strip() + if raw: + return raw + if os.name == "nt": + drive = (os.environ.get("SystemDrive") or "C:").strip() + return drive if drive.endswith(("\\", "/")) else drive + "\\" + return "/" + + +def _safe_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def get_host_status() -> dict[str, Any]: + try: + import psutil + except ImportError: + return { + "ok": False, + "msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil", + } + + now = time.time() + if not _state["primed"]: + psutil.cpu_percent(interval=None) + _state["primed"] = True + + cpu_pct = float(psutil.cpu_percent(interval=None)) + cpu_count = int(psutil.cpu_count(logical=True) or 0) + + vm = psutil.virtual_memory() + disk_path = _disk_path() + du = psutil.disk_usage(disk_path) + + net = psutil.net_io_counters() + sent_rate = 0.0 + recv_rate = 0.0 + if net is not None and _state["net_ts"] > 0: + dt = max(0.001, now - float(_state["net_ts"])) + sent_rate = max(0.0, (net.bytes_sent - int(_state["net_sent"])) / dt) + recv_rate = max(0.0, (net.bytes_recv - int(_state["net_recv"])) / dt) + if net is not None: + _state["net_ts"] = now + _state["net_sent"] = int(net.bytes_sent) + _state["net_recv"] = int(net.bytes_recv) + + disk_total = _safe_int(du.total) + disk_used = _safe_int(du.used) + disk_pct = round(disk_used / disk_total * 100, 1) if disk_total > 0 else 0.0 + + boot = float(psutil.boot_time()) + return { + "ok": True, + "hostname": socket.gethostname(), + "uptime_sec": max(0, int(now - boot)), + "cpu": { + "percent": round(cpu_pct, 1), + "count": cpu_count, + }, + "memory": { + "total_bytes": _safe_int(vm.total), + "used_bytes": _safe_int(vm.used), + "percent": round(float(vm.percent), 1), + }, + "disk": { + "path": disk_path, + "total_bytes": disk_total, + "used_bytes": disk_used, + "percent": disk_pct, + }, + "network": { + "bytes_sent": _safe_int(net.bytes_sent if net else 0), + "bytes_recv": _safe_int(net.bytes_recv if net else 0), + "sent_rate_bps": round(sent_rate, 1), + "recv_rate_bps": round(recv_rate, 1), + }, + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } diff --git a/lib/hub/hub_kline_store.py b/lib/hub/hub_kline_store.py new file mode 100644 index 0000000..e1a6e6f --- /dev/null +++ b/lib/hub/hub_kline_store.py @@ -0,0 +1,881 @@ +"""中控 K 线 SQLite:分周期保留,交易所直拉,分页读取.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from pathlib import Path +from typing import Any, Callable, Optional + +from lib.hub.hub_ohlcv_lib import ( + HUB_KLINE_1M_MAX_BARS, + HUB_KLINE_5M_1H_RETENTION_DAYS, + TIMEFRAME_MS, + YEAR_ROLLING_STORED, + chart_chunk_limit, + chart_initial_limit, + chart_memory_cap, + history_cutoff_ms_for_storage, + normalize_chart_timeframe, + normalize_price_tick, + format_price_by_tick, + last_closed_bar_open_ms, + retention_policy_meta, + round_ohlcv_bars_to_tick, + seed_bar_target, +) + +HUB_KLINE_MIN_BARS_BEFORE_TAIL = 200 +HUB_KLINE_REMOTE_FETCH_CAP = 1500 + +_DEFAULT_RETENTION_DAYS = 15 + + +def retention_days() -> int: + """兼容旧配置;新策略见 retention_policy_meta.""" + try: + return max(1, int(os.getenv("HUB_KLINE_RETENTION_DAYS", str(_DEFAULT_RETENTION_DAYS)))) + except ValueError: + return _DEFAULT_RETENTION_DAYS + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_KLINE_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_kline.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ohlcv_bars ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + open_time_ms INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol, timeframe, open_time_ms) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_ohlcv_series + ON ohlcv_bars (exchange_key, symbol, timeframe, open_time_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ohlcv_symbol_meta ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + price_tick REAL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol) + ) + """ + ) + finally: + conn.close() + + +def save_symbol_price_tick( + exchange_key: str, + symbol: str, + price_tick: float | None, + db_path: Path | None = None, +) -> None: + tick = price_tick + if tick is None: + return + try: + t = float(tick) + except (TypeError, ValueError): + return + if t <= 0: + return + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + conn.execute( + """ + INSERT INTO ohlcv_symbol_meta (exchange_key, symbol, price_tick, updated_at) + VALUES (?,?,?,?) + ON CONFLICT(exchange_key, symbol) DO UPDATE SET + price_tick=excluded.price_tick, + updated_at=excluded.updated_at + """, + (ex_k, sym, t, int(time.time())), + ) + finally: + conn.close() + + +def load_symbol_price_tick( + exchange_key: str, + symbol: str, + db_path: Path | None = None, +) -> float | None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT price_tick FROM ohlcv_symbol_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if not row or row["price_tick"] is None: + return None + return float(row["price_tick"]) + except (TypeError, ValueError): + return None + finally: + conn.close() + + +def purge_timeframe_by_days( + timeframe: str, + days: int, + db_path: Path | None = None, +) -> int: + cutoff = int(time.time() * 1000) - max(1, int(days)) * 86400000 + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + cur = conn.execute( + "DELETE FROM ohlcv_bars WHERE timeframe=? AND open_time_ms < ?", + (tf, cutoff), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def purge_1m_bar_cap(db_path: Path | None = None, *, max_bars: int | None = None) -> int: + cap = max(100, int(max_bars or HUB_KLINE_1M_MAX_BARS)) + conn = _connect(db_path) + try: + cur = conn.execute( + """ + DELETE FROM ohlcv_bars + WHERE timeframe='1m' AND rowid IN ( + SELECT rowid FROM ( + SELECT rowid, + ROW_NUMBER() OVER ( + PARTITION BY exchange_key, symbol + ORDER BY open_time_ms DESC + ) AS rn + FROM ohlcv_bars + WHERE timeframe='1m' + ) WHERE rn > ? + ) + """, + (cap,), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def clear_series_bars( + exchange_key: str, + symbol: str, + timeframe: str | None = None, + db_path: Path | None = None, +) -> int: + """删除某交易所+币种 K 线(可指定周期);用于清库后全量重拉.""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + return 0 + conn = _connect(db_path) + try: + if timeframe: + tf = normalize_chart_timeframe(timeframe) + cur = conn.execute( + "DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=? AND timeframe=?", + (ex_k, sym, tf), + ) + else: + cur = conn.execute( + "DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def clear_all_bars(db_path: Path | None = None) -> int: + """清空 hub K 线库全部 OHLCV 行.""" + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute("DELETE FROM ohlcv_bars") + return int(cur.rowcount or 0) + finally: + conn.close() + + +def purge_retention(db_path: Path | None = None) -> int: + """按周期策略清理:5m/15m/1h/2h/4h 一年;1m 保留最近 N 根;1d/1w 不删.""" + n = 0 + for tf in sorted(YEAR_ROLLING_STORED): + n += purge_timeframe_by_days(tf, HUB_KLINE_5M_1H_RETENTION_DAYS, db_path) + n += purge_1m_bar_cap(db_path) + return n + + +def upsert_bars( + exchange_key: str, + symbol: str, + timeframe: str, + bars: list[dict[str, Any]], + db_path: Path | None = None, +) -> int: + if not bars: + return 0 + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + now = int(time.time()) + conn = _connect(db_path) + n = 0 + try: + for b in bars: + try: + oms = int(b["open_time_ms"]) + conn.execute( + """ + INSERT INTO ohlcv_bars + (exchange_key, symbol, timeframe, open_time_ms, open, high, low, close, volume, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, symbol, timeframe, open_time_ms) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at + """, + ( + ex_k, + sym, + tf, + oms, + float(b["open"]), + float(b["high"]), + float(b["low"]), + float(b["close"]), + float(b.get("volume") or 0), + now, + ), + ) + n += 1 + except (KeyError, TypeError, ValueError): + continue + finally: + conn.close() + return n + + +def load_bars_range( + exchange_key: str, + symbol: str, + timeframe: str, + start_ms: int, + end_ms: int, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + AND open_time_ms >= ? AND open_time_ms <= ? + ORDER BY open_time_ms ASC + """, + (ex_k, sym, tf, int(start_ms), int(end_ms)), + ).fetchall() + return _rows_to_bars(rows) + finally: + conn.close() + + +def count_series_bars( + exchange_key: str, + symbol: str, + timeframe: str, + db_path: Path | None = None, +) -> int: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + row = conn.execute( + """ + SELECT COUNT(*) AS c FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + """, + (ex_k, sym, tf), + ).fetchone() + return int(row["c"] or 0) if row else 0 + finally: + conn.close() + + +def _remote_fetch_limit( + *, + need: int, + force_refresh: bool, + storage_tf: str, + tail_only: bool, +) -> int: + if tail_only: + return min(need + 20, 300) + cap = HUB_KLINE_REMOTE_FETCH_CAP + if force_refresh: + return min(seed_bar_target(storage_tf), cap) + return min(max(need + 20, 1), cap) + + +def _since_ms_for_span( + *, + now_ms: int, + period_ms: int, + span_bars: int, + cutoff_ms: int, +) -> int: + """拉取窗口起点:跨度必须与 fetch_limit 一致,保证数据能铺到最近.""" + span = max(1, int(span_bars)) + return max(int(cutoff_ms), int(now_ms) - int(period_ms) * span) + + +def load_bars_latest( + exchange_key: str, + symbol: str, + timeframe: str, + limit: int, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + lim = max(1, int(limit)) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + ORDER BY open_time_ms DESC + LIMIT ? + """, + (ex_k, sym, tf, lim), + ).fetchall() + return list(reversed(_rows_to_bars(rows))) + finally: + conn.close() + + +def load_bars_before( + exchange_key: str, + symbol: str, + timeframe: str, + before_ms: int, + limit: int, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + lim = max(1, int(limit)) + bms = int(before_ms) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? + AND open_time_ms < ? + ORDER BY open_time_ms DESC + LIMIT ? + """, + (ex_k, sym, tf, bms, lim), + ).fetchall() + return list(reversed(_rows_to_bars(rows))) + finally: + conn.close() + + +def trim_contiguous_tail( + bars: list[dict[str, Any]], + period_ms: int, + *, + max_gap_factor: float = 3.0, +) -> tuple[list[dict[str, Any]], int]: + """只保留最近一段连续 K 线,丢弃左侧与主段断开的孤立数据.""" + if len(bars) <= 1: + return list(bars), 0 + try: + period = max(1, int(period_ms)) + except (TypeError, ValueError): + period = 60_000 + max_gap = int(period * max_gap_factor) + split = 0 + for i in range(len(bars) - 1, 0, -1): + gap = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"]) + if gap > max_gap: + split = i + break + return bars[split:], split + + +def normalize_contiguous_db_rows( + bars: list[dict[str, Any]], + *, + period_ms: int, + exchange_key: str, + symbol: str, + timeframe: str, + db_path: Path | None = None, + purge_orphans: bool = True, +) -> list[dict[str, Any]]: + """去掉与主段断开的孤立前缀;可选同步清理库内孤立数据.""" + if len(bars) <= 1: + return list(bars) + trimmed, split_at = trim_contiguous_tail(bars, period_ms) + if split_at > 0 and purge_orphans: + purge_bars_open_before( + exchange_key, + symbol, + timeframe, + int(trimmed[0]["open_time_ms"]), + db_path, + ) + return trimmed + + +def purge_bars_open_before( + exchange_key: str, + symbol: str, + timeframe: str, + open_time_ms: int, + db_path: Path | None = None, +) -> int: + """删除某品种周期下早于 open_time_ms 的 K 线(清理与主段断开的孤立历史).""" + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + tf = normalize_chart_timeframe(timeframe) + conn = _connect(db_path) + try: + cur = conn.execute( + """ + DELETE FROM ohlcv_bars + WHERE exchange_key=? AND symbol=? AND timeframe=? AND open_time_ms < ? + """, + (ex_k, sym, tf, int(open_time_ms)), + ) + return int(cur.rowcount or 0) + finally: + conn.close() + + +def _rows_to_bars(rows) -> list[dict[str, Any]]: + return [ + { + "open_time_ms": int(r["open_time_ms"]), + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"] or 0), + } + for r in rows + ] + + +def _to_chart_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for b in bars: + try: + out.append( + { + "time": int(b["open_time_ms"] // 1000), + "open": float(b["open"]), + "high": float(b["high"]), + "low": float(b["low"]), + "close": float(b["close"]), + "volume": float(b.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +def _trim_display_bars( + bars: list[dict[str, Any]], + *, + need: int, + before_ms: int | None, +) -> list[dict[str, Any]]: + if not bars: + return [] + if before_ms is not None and int(before_ms) > 0: + bms = int(before_ms) + bars = [b for b in bars if int(b["open_time_ms"]) < bms] + if len(bars) > need: + bars = bars[-need:] + return bars + if len(bars) > need: + bars = bars[-need:] + return bars + + +def resolve_chart_bars( + exchange_key: str, + symbol: str, + timeframe: str, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, + force_refresh: bool = False, + tail_refresh: bool = False, + clear_db: bool = False, + limit: int | None = None, + before_ms: int | None = None, +) -> dict[str, Any]: + """ + 分页读库:首屏 / 左拖 before_ms / 尾部 tail_refresh. + 各展示周期均直读交易所同步入库的同名 K 线. + """ + init_db(db_path) + purged = purge_retention(db_path) + cleared = 0 + + sym = (symbol or "").strip().upper() + ex_k = (exchange_key or "").strip().lower() + display_tf = normalize_chart_timeframe(timeframe) + if not sym or not ex_k: + return {"ok": False, "msg": "缺少 exchange 或 symbol"} + + storage_tf = display_tf + is_history = before_ms is not None and int(before_ms) > 0 + need = int( + limit + or (chart_chunk_limit(display_tf) if is_history else chart_initial_limit(display_tf)) + ) + need = max(1, min(need, chart_memory_cap(display_tf))) + + now_ms = int(time.time() * 1000) + period_display = TIMEFRAME_MS[display_tf] + period_storage = TIMEFRAME_MS[storage_tf] + series_bar_count = ( + count_series_bars(ex_k, sym, storage_tf, db_path) if not is_history else 0 + ) + if tail_refresh and not is_history: + min_seed = min(chart_initial_limit(display_tf) // 5, HUB_KLINE_MIN_BARS_BEFORE_TAIL) + if series_bar_count < max(1, min_seed): + tail_refresh = False + else: + need = min(need, 30) + cutoff = history_cutoff_ms_for_storage(storage_tf, now_ms) + + if clear_db and not is_history and not tail_refresh: + cleared = clear_series_bars(ex_k, sym, storage_tf, db_path) + + def load_display_rows() -> list[dict[str, Any]]: + if is_history: + rows = load_bars_before(ex_k, sym, storage_tf, int(before_ms), need, db_path) + return _trim_display_bars(rows, need=need, before_ms=int(before_ms)) + return load_bars_latest(ex_k, sym, storage_tf, need, db_path) + + db_rows: list[dict[str, Any]] = [] + if not force_refresh: + db_rows = load_display_rows() + if not is_history and db_rows: + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + + last_closed = last_closed_bar_open_ms(display_tf, now_ms) + newest_db = db_rows[-1]["open_time_ms"] if db_rows else None + if is_history: + newest_ok = True + else: + newest_ok = newest_db is not None and int(newest_db) >= int(last_closed) - period_display + + need_fetch = force_refresh or ( + not is_history and (len(db_rows) < need or not newest_ok) + ) + if is_history and len(db_rows) < need: + need_fetch = True + + tail_only = False + if tail_refresh and not is_history and db_rows and not force_refresh and not need_fetch: + need_fetch = True + tail_only = True + + fetched = 0 + price_tick: Optional[float] = None + remote_err: Optional[str] = None + + if need_fetch: + if is_history: + bms = int(before_ms) + anchor = bms - period_display + since = max(cutoff, anchor - period_storage * need) + fetch_limit = min(need + 20, 1500) + elif tail_only: + anchor_ms = int(newest_db) if newest_db is not None else now_ms + fetch_limit = _remote_fetch_limit( + need=need, force_refresh=False, storage_tf=storage_tf, tail_only=True + ) + since = _since_ms_for_span( + now_ms=anchor_ms, + period_ms=period_storage, + span_bars=5, + cutoff_ms=cutoff, + ) + else: + fetch_limit = _remote_fetch_limit( + need=need, + force_refresh=force_refresh, + storage_tf=storage_tf, + tail_only=False, + ) + since = _since_ms_for_span( + now_ms=now_ms, + period_ms=period_storage, + span_bars=fetch_limit, + cutoff_ms=cutoff, + ) + + remote = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=since, + limit=fetch_limit, + ) + if remote.get("ok") and remote.get("bars"): + fetched = upsert_bars(ex_k, sym, storage_tf, remote["bars"], db_path) + price_tick = remote.get("price_tick") + if price_tick is not None: + save_symbol_price_tick(ex_k, sym, price_tick, db_path) + db_rows = load_display_rows() + if not is_history and db_rows: + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + if not is_history and not tail_only and db_rows: + newest_ms = int(db_rows[-1]["open_time_ms"]) + if newest_ms < int(last_closed) - period_display: + gap_limit = min( + 500, + int((now_ms - newest_ms) // period_storage) + 10, + ) + if gap_limit > 1: + gap_remote = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=newest_ms, + limit=gap_limit, + ) + if gap_remote.get("ok") and gap_remote.get("bars"): + fetched += upsert_bars( + ex_k, sym, storage_tf, gap_remote["bars"], db_path + ) + db_rows = load_display_rows() + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + else: + remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败" + if not db_rows: + if is_history: + exhausted = True + else: + return {"ok": False, "msg": remote_err, "purged": purged} + + exhausted = False + if is_history: + if not db_rows: + exhausted = True + elif len(db_rows) < need: + oldest = int(db_rows[0]["open_time_ms"]) + if cutoff > 0 and oldest <= cutoff + period_storage: + exhausted = True + elif fetched == 0: + exhausted = True + + if price_tick is None: + price_tick = load_symbol_price_tick(ex_k, sym, db_path) + if price_tick is None and not is_history: + try: + tick_probe = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=None, + limit=3, + ) + if tick_probe.get("ok"): + price_tick = tick_probe.get("price_tick") + if price_tick is not None: + save_symbol_price_tick(ex_k, sym, price_tick, db_path) + except Exception: + pass + + if not is_history and db_rows: + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + + if not is_history and len(db_rows) < need: + missing = need - len(db_rows) + backfill_limit = min(missing + 60, HUB_KLINE_REMOTE_FETCH_CAP) + if db_rows: + oldest = int(db_rows[0]["open_time_ms"]) + backfill_since = _since_ms_for_span( + now_ms=oldest, + period_ms=period_storage, + span_bars=backfill_limit, + cutoff_ms=cutoff, + ) + else: + backfill_since = _since_ms_for_span( + now_ms=now_ms, + period_ms=period_storage, + span_bars=backfill_limit, + cutoff_ms=cutoff, + ) + try: + remote_back = remote_fetch( + symbol=sym, + timeframe=storage_tf, + since_ms=backfill_since, + limit=backfill_limit, + ) + if remote_back.get("ok") and remote_back.get("bars"): + fetched += upsert_bars(ex_k, sym, storage_tf, remote_back["bars"], db_path) + if remote_back.get("price_tick") is not None: + price_tick = remote_back.get("price_tick") + save_symbol_price_tick(ex_k, sym, price_tick, db_path) + db_rows = load_display_rows() + db_rows = normalize_contiguous_db_rows( + db_rows, + period_ms=period_display, + exchange_key=ex_k, + symbol=sym, + timeframe=storage_tf, + db_path=db_path, + ) + elif not remote_err: + remote_err = ( + remote_back.get("msg") + or remote_back.get("error") + or "实例补拉 K 线失败" + ) + except Exception as e: + if not remote_err: + remote_err = str(e) + + price_tick = normalize_price_tick(price_tick) + if db_rows and price_tick is not None: + round_ohlcv_bars_to_tick(db_rows, price_tick) + + candles = _to_chart_candles(db_rows) + if not is_history and not candles and not exhausted: + return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged} + + oldest_ms = int(db_rows[0]["open_time_ms"]) if db_rows else None + newest_ms = int(db_rows[-1]["open_time_ms"]) if db_rows else None + + from_cache = max(0, len(candles) - min(fetched, len(candles))) if fetched else len(candles) + + return { + "ok": True, + "symbol": sym, + "exchange_key": ex_k, + "timeframe": display_tf, + "storage_timeframe": storage_tf, + "limit": need, + "before_ms": int(before_ms) if is_history else None, + "oldest_ms": oldest_ms, + "newest_ms": newest_ms, + "exhausted": exhausted, + "source": "remote" if fetched else "db", + "retention_policy": retention_policy_meta(), + "candles": candles, + "from_cache": from_cache, + "fetched": fetched, + "cleared": cleared, + "purged": purged, + "price_tick": price_tick, + "stale": bool(remote_err), + "stale_message": remote_err if remote_err else None, + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + } + + +def format_ohlcv_detail(bar: dict[str, Any] | None, tick: Optional[float]) -> dict[str, str]: + if not bar: + return {"open": "-", "high": "-", "low": "-", "close": "-", "volume": "-"} + return { + "open": format_price_by_tick(bar.get("open"), tick), + "high": format_price_by_tick(bar.get("high"), tick), + "low": format_price_by_tick(bar.get("low"), tick), + "close": format_price_by_tick(bar.get("close"), tick), + "volume": format_price_by_tick(bar.get("volume"), tick), + } diff --git a/lib/hub/hub_macro_calendar_lib.py b/lib/hub/hub_macro_calendar_lib.py new file mode 100644 index 0000000..6e02099 --- /dev/null +++ b/lib/hub/hub_macro_calendar_lib.py @@ -0,0 +1,311 @@ +"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from lib.hub.hub_symbol_archive_lib import parse_wall_clock_ms + +DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai")) + +MACRO_EVENT_TYPES = ("fomc", "cpi", "employment") + +MACRO_EVENT_LABELS: dict[str, str] = { + "fomc": "FOMC 联邦基金利率", + "cpi": "美国 CPI 通胀", + "employment": "就业与劳工数据", +} + +WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000 +WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000 +IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000 +LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60")) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_macro_calendar.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS macro_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + event_at_ms INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)" + ) + finally: + conn.close() + + +def normalize_event_type(raw: str) -> str: + key = (raw or "").strip().lower() + if key not in MACRO_EVENT_TYPES: + raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}") + return key + + +def parse_event_at_ms(raw: Any) -> int: + ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ) + if ms is None: + raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM") + return int(ms) + + +def format_event_at(ms: int) -> str: + dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ) + return dt.strftime("%Y-%m-%d %H:%M") + + +def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]: + ms = int(row["event_at_ms"]) + et = str(row["event_type"]) + return { + "id": int(row["id"]), + "event_type": et, + "event_type_label": MACRO_EVENT_LABELS.get(et, et), + "event_at_ms": ms, + "event_at": format_event_at(ms), + "note": str(row["note"] or ""), + "created_at_ms": int(row["created_at_ms"]), + "updated_at_ms": int(row["updated_at_ms"]), + } + + +def _window_bounds(event_at_ms: int) -> tuple[int, int]: + start = int(event_at_ms) - WINDOW_BEFORE_MS + end = int(event_at_ms) + WINDOW_AFTER_MS + return start, end + + +def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None: + now = int(now_ms if now_ms is not None else time.time() * 1000) + event_at_ms = int(row["event_at_ms"]) + window_start, window_end = _window_bounds(event_at_ms) + if now < window_start or now > window_end: + return None + imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end + mins_to_event = max(0, int((event_at_ms - now) / 60000)) + mins_from_event = max(0, int((now - event_at_ms) / 60000)) + return { + **row, + "window_start_ms": window_start, + "window_end_ms": window_end, + "window_start": format_event_at(window_start), + "window_end": format_event_at(window_end), + "phase": "imminent" if imminent else "window", + "phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口", + "minutes_to_event": mins_to_event if now < event_at_ms else 0, + "minutes_from_event": mins_from_event if now >= event_at_ms else 0, + } + + +def list_events( + *, + now_ms: int | None = None, + include_expired_hours: int = 24, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + init_db(db_path) + now = int(now_ms if now_ms is not None else time.time() * 1000) + horizon = now + LIST_FUTURE_DAYS * 86400 * 1000 + expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM macro_events + WHERE event_at_ms >= ? AND event_at_ms <= ? + ORDER BY event_at_ms ASC, id ASC + """, + (expired_cutoff, horizon), + ).fetchall() + return [_row_to_dict(r) for r in rows] + finally: + conn.close() + + +def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() + + +def _assert_no_duplicate( + conn: sqlite3.Connection, + event_type: str, + event_at_ms: int, + *, + exclude_id: int | None = None, +) -> None: + if exclude_id is None: + row = conn.execute( + "SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1", + (event_type, int(event_at_ms)), + ).fetchone() + else: + row = conn.execute( + """ + SELECT id FROM macro_events + WHERE event_type=? AND event_at_ms=? AND id<>? + LIMIT 1 + """, + (event_type, int(event_at_ms), int(exclude_id)), + ).fetchone() + if row: + raise ValueError("同类型,同发布时间的记录已存在") + + +def create_event( + event_type: str, + event_at: Any, + *, + note: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + et = normalize_event_type(event_type) + event_at_ms = parse_event_at_ms(event_at) + note_s = str(note or "").strip()[:500] + now_ms = int(time.time() * 1000) + conn = _connect(db_path) + try: + _assert_no_duplicate(conn, et, event_at_ms) + cur = conn.execute( + """ + INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?) + """, + (et, event_at_ms, note_s, now_ms, now_ms), + ) + eid = int(cur.lastrowid) + finally: + conn.close() + row = get_event(eid, db_path=db_path) + assert row is not None + return row + + +def update_event( + event_id: int, + *, + event_type: str | None = None, + event_at: Any | None = None, + note: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + existing = get_event(event_id, db_path=db_path) + if not existing: + return None + et = normalize_event_type(event_type if event_type is not None else existing["event_type"]) + event_at_ms = ( + parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"]) + ) + note_s = existing["note"] if note is None else str(note or "").strip()[:500] + now_ms = int(time.time() * 1000) + conn = _connect(db_path) + try: + _assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id)) + conn.execute( + """ + UPDATE macro_events + SET event_type=?, event_at_ms=?, note=?, updated_at_ms=? + WHERE id=? + """, + (et, event_at_ms, note_s, now_ms, int(event_id)), + ) + finally: + conn.close() + return get_event(event_id, db_path=db_path) + + +def delete_event(event_id: int, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),)) + return cur.rowcount > 0 + finally: + conn.close() + + +def list_active_alerts( + now_ms: int | None = None, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + now = int(now_ms if now_ms is not None else time.time() * 1000) + lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS + lookahead = now + WINDOW_AFTER_MS + init_db(db_path) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM macro_events + WHERE event_at_ms >= ? AND event_at_ms <= ? + ORDER BY event_at_ms ASC, id ASC + """, + (lookback, lookahead), + ).fetchall() + finally: + conn.close() + alerts: list[dict[str, Any]] = [] + for row in rows: + item = enrich_alert(_row_to_dict(row), now_ms=now) + if item: + alerts.append(item) + return alerts + + +def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str: + label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据" + phase = alert.get("phase") or "window" + if has_positions: + if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0: + return ( + f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟)," + "注意仓位风险:勿加仓,检查止损/减仓" + ) + return f"「{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓" + if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0: + return ( + f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟)," + "建议等待,避免新开仓" + ) + return f"「{label}」高波动窗口(±1h),建议等待,避免新开仓" diff --git a/lib/hub/hub_market_info_lib.py b/lib/hub/hub_market_info_lib.py new file mode 100644 index 0000000..3dba2b7 --- /dev/null +++ b/lib/hub/hub_market_info_lib.py @@ -0,0 +1,81 @@ +"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致).""" + +from __future__ import annotations + +from typing import Any, Callable, Optional, Tuple + +from lib.hub.hub_calculator_market_lib import ( + amount_decimals_from_exchange, + normalize_base_symbol, + price_decimals_from_exchange, + resolve_usdt_perp_symbol, +) +from lib.hub.hub_ohlcv_lib import normalize_price_tick, price_tick_from_market + + +def fetch_usdt_swap_market_info( + *, + base_or_symbol: str, + normalize_symbol_input: Callable[[str], str], + normalize_exchange_symbol: Callable[[str], str], + ensure_markets_loaded: Callable[[], None], + exchange: Any, + exchange_id: str = "", +) -> dict[str, Any]: + """供各实例 /api/hub/market 调用.""" + raw = str(base_or_symbol or "").strip() + if not raw: + return {"ok": False, "msg": "请输入币种,如 ETH"} + + try: + ensure_markets_loaded() + except Exception as exc: + return {"ok": False, "msg": f"加载市场失败: {exc}"} + + base_u = normalize_base_symbol(raw) + hub_sym = normalize_symbol_input(raw if base_u else raw) + try: + ex_sym = normalize_exchange_symbol(hub_sym) + except Exception: + ex_sym = hub_sym + + sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym) + if err and ex_sym: + markets = getattr(exchange, "markets", None) or {} + if ex_sym in markets: + sym = ex_sym + err = None + if err or not sym: + return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"} + + market = exchange.market(sym) + try: + contract_size = float(market.get("contractSize") or 1.0) + except (TypeError, ValueError): + contract_size = 1.0 + if contract_size <= 0: + contract_size = 1.0 + + price_tick = normalize_price_tick(price_tick_from_market(exchange, sym)) + amt_dec = amount_decimals_from_exchange(exchange, sym) + px_dec = price_decimals_from_exchange(exchange, sym, price_tick) + min_amount = None + try: + min_amount = float((market.get("limits") or {}).get("amount", {}).get("min")) + except (TypeError, ValueError): + min_amount = None + + base_out = (market.get("base") or base_u or "").upper() or base_u + return { + "ok": True, + "exchange": (exchange_id or "").strip().lower(), + "base": base_out, + "exchange_symbol": sym, + "display_symbol": f"{base_out}/USDT" if base_out else sym, + "contract_size": contract_size, + "price_tick": price_tick, + "price_decimals": px_dec, + "amount_decimals": amt_dec, + "min_amount": min_amount, + } + diff --git a/lib/hub/hub_monitor_totals_lib.py b/lib/hub/hub_monitor_totals_lib.py new file mode 100644 index 0000000..7c7f4f3 --- /dev/null +++ b/lib/hub/hub_monitor_totals_lib.py @@ -0,0 +1,136 @@ +"""监控区看板:三所当日统计聚合.""" +from __future__ import annotations + +from typing import Any + +from lib.hub.hub_options_funds_lib import ( + options_float_pnl_usdt, + options_open_position_count as count_options_positions, +) +from lib.hub.hub_position_metrics import is_option_like_position + + +def _coerce_float(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def position_unrealized_pnl(pos: dict[str, Any]) -> float: + for key in ("unrealized_pnl", "unrealizedPnl", "upnl"): + v = _coerce_float(pos.get(key)) + if v is not None: + return v + return 0.0 + + +def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(agent, dict): + return [] + positions = agent.get("positions") + if not isinstance(positions, list): + return [] + out: list[dict[str, Any]] = [] + for p in positions: + if not isinstance(p, dict): + continue + if is_option_like_position(p): + continue + try: + c = abs(float(p.get("contracts") or 0)) + except (TypeError, ValueError): + c = 0.0 + if c > 1e-12: + out.append(p) + return out + + +def _raw_open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(agent, dict): + return [] + positions = agent.get("positions") + if not isinstance(positions, list): + return [] + out: list[dict[str, Any]] = [] + for p in positions: + if not isinstance(p, dict): + continue + try: + c = abs(float(p.get("contracts") or 0)) + except (TypeError, ValueError): + c = 0.0 + if c > 1e-12: + out.append(p) + return out + + +def aggregate_monitor_board_totals( + rows: list[dict[str, Any]], + *, + trading_day: str, + reset_hour: int = 8, +) -> dict[str, Any]: + """汇总监控 board 各行 → 左上统计卡数据.""" + open_count = 0 + closed_count = 0 + win_count = 0 + loss_count = 0 + win_pnl_u = 0.0 + loss_pnl_u = 0.0 + open_position_count = 0 + options_open_position_count = 0 + float_pnl_u = 0.0 + options_float_pnl_u = 0.0 + + for row in rows or []: + if not isinstance(row, dict): + continue + day_stats = row.get("day_stats") if isinstance(row.get("day_stats"), dict) else {} + if day_stats.get("ok"): + open_count += int(day_stats.get("opens_today") or 0) + st = day_stats.get("trade_stats") if isinstance(day_stats.get("trade_stats"), dict) else {} + closed_count += int(st.get("closed_count") or 0) + win_count += int(st.get("win_count") or 0) + loss_count += int(st.get("loss_count") or 0) + win_pnl_u += float(st.get("win_pnl_u") or 0) + loss_pnl_u += float(st.get("loss_pnl_u") or 0) + + ag = row.get("agent") if isinstance(row.get("agent"), dict) else {} + open_pos = _open_positions(ag) + open_position_count += len(open_pos) + raw_pos = _raw_open_positions(ag) + contaminated = any(is_option_like_position(p) for p in raw_pos) + agent_upnl = _coerce_float(ag.get("total_unrealized_pnl")) + # 子代理若把期权混进永续合计,改按过滤后腿求和;期权浮盈由下方 options 段计入 + if agent_upnl is not None and not contaminated: + float_pnl_u += agent_upnl + else: + float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos) + + opt_snap = row.get("options") if "options" in (row.get("capabilities") or []) else None + opt_count = count_options_positions(opt_snap) + options_open_position_count += opt_count + open_position_count += opt_count + opt_upl = options_float_pnl_usdt(opt_snap) + if opt_upl is not None: + options_float_pnl_u += opt_upl + float_pnl_u += opt_upl + + return { + "trading_day": trading_day, + "reset_hour": int(reset_hour), + "open_count": open_count, + "closed_count": closed_count, + "win_count": win_count, + "loss_count": loss_count, + "win_pnl_u": round(win_pnl_u, 4), + "loss_pnl_u": round(loss_pnl_u, 4), + "realized_pnl_u": round(win_pnl_u + loss_pnl_u, 4), + "open_position_count": open_position_count, + "options_open_position_count": options_open_position_count, + "float_pnl_u": round(float_pnl_u, 4), + "options_float_pnl_u": round(options_float_pnl_u, 4), + } diff --git a/lib/hub/hub_ohlcv_lib.py b/lib/hub/hub_ohlcv_lib.py new file mode 100644 index 0000000..9b3473f --- /dev/null +++ b/lib/hub/hub_ohlcv_lib.py @@ -0,0 +1,692 @@ +"""中控行情区:各实例 ccxt OHLCV 拉取(hub_bridge /api/hub/ohlcv 共用).""" + +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,供 hub_bridge /api/hub/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/hub/hub_options_archive_lib.py b/lib/hub/hub_options_archive_lib.py new file mode 100644 index 0000000..a44e1ac --- /dev/null +++ b/lib/hub/hub_options_archive_lib.py @@ -0,0 +1,599 @@ +"""中控期权档案:同步 OKX options_review_trades 到 hub_symbol_archive.db.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +from lib.hub.hub_symbol_archive_lib import ( + TRADING_DAY_RESET_HOUR, + _connect, + default_db_path, + init_db as init_perp_archive_db, + ms_to_trading_day, + parse_wall_clock_ms, + resolve_period_bounds, + trading_day_bounds_ms, +) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def init_options_archive_db(db_path: Path | None = None) -> None: + """确保期权缓存表存在(与永续共用同一 SQLite).""" + init_perp_archive_db(db_path) + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_options_trade_cache ( + exchange_key TEXT NOT NULL, + history_key TEXT NOT NULL, + source_type TEXT, + underlying TEXT, + opened_at TEXT, + closed_at TEXT, + opened_at_ms INTEGER, + closed_at_ms INTEGER, + hold_seconds INTEGER, + realized_pnl_total REAL, + status_raw 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, + strategy_tag TEXT, + result_tag TEXT, + reviewed INTEGER DEFAULT 0, + source_label TEXT, + payload_json TEXT NOT NULL, + synced_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, history_key) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_options_closed + ON archive_options_trade_cache (exchange_key, closed_at_ms) + """ + ) + finally: + conn.close() + + +def purge_stale_options_trades_cache( + exchange_key: str, + active_history_keys: list[str], + *, + db_path: Path | None = None, +) -> int: + init_options_archive_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + active = {str(k).strip() for k in (active_history_keys or []) if str(k).strip()} + conn = _connect(db_path) + try: + rows = conn.execute( + "SELECT history_key FROM archive_options_trade_cache WHERE exchange_key=?", + (ex_k,), + ).fetchall() + stale = [r["history_key"] for r in rows if r["history_key"] not in active] + removed = 0 + for hk in stale: + cur = conn.execute( + "DELETE FROM archive_options_trade_cache WHERE exchange_key=? AND history_key=?", + (ex_k, hk), + ) + removed += int(cur.rowcount or 0) + return removed + finally: + conn.close() + + +def _optional_float(raw: Any) -> float | None: + if raw in (None, ""): + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _optional_int(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def upsert_options_trades_cache( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, + prune_missing: bool = True, +) -> dict[str, int]: + init_options_archive_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return {"upserted": 0, "removed": 0} + now = _now_ms() + n = 0 + active_keys: list[str] = [] + conn = _connect(db_path) + try: + for t in trades or []: + if not isinstance(t, dict): + continue + hk = str(t.get("history_key") or "").strip() + if not hk: + continue + if int(t.get("excluded_as_hedge_leg") or 0): + continue + active_keys.append(hk) + opened_at = t.get("opened_at") + closed_at = t.get("closed_at") + opened_ms = t.get("opened_at_ms") or parse_wall_clock_ms(opened_at) + closed_ms = t.get("closed_at_ms") or parse_wall_clock_ms(closed_at) + entry = t.get("entry") if isinstance(t.get("entry"), dict) else {} + strategy_tag = t.get("strategy_tag") or (entry or {}).get("strategy_tag") + result_tag = t.get("result_tag") or (entry or {}).get("result_tag") + reviewed = 1 if t.get("reviewed") or entry else 0 + row = dict(t) + row["exchange_key"] = ex_k + payload = json.dumps(row, ensure_ascii=False, default=str) + conn.execute( + """ + INSERT INTO archive_options_trade_cache ( + exchange_key, history_key, source_type, underlying, + opened_at, closed_at, opened_at_ms, closed_at_ms, hold_seconds, + realized_pnl_total, status_raw, + pos_id, inst_id, opt_type, strike, exp_time, sheets, + open_avg, close_avg, premium_paid, realized_pnl, + hedge_plan_id, plan_close_reason, realized_pnl_perp, realized_pnl_options, + premium_total, direction, tp, sl, target_price, target_price_up, target_price_down, + legs_json, linked_hedge_plan_id, excluded_as_hedge_leg, + strategy_tag, result_tag, reviewed, source_label, + payload_json, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, history_key) DO UPDATE SET + source_type=excluded.source_type, + underlying=excluded.underlying, + opened_at=excluded.opened_at, + closed_at=excluded.closed_at, + opened_at_ms=excluded.opened_at_ms, + closed_at_ms=excluded.closed_at_ms, + hold_seconds=excluded.hold_seconds, + realized_pnl_total=excluded.realized_pnl_total, + status_raw=excluded.status_raw, + pos_id=excluded.pos_id, + inst_id=excluded.inst_id, + opt_type=excluded.opt_type, + strike=excluded.strike, + exp_time=excluded.exp_time, + sheets=excluded.sheets, + open_avg=excluded.open_avg, + close_avg=excluded.close_avg, + premium_paid=excluded.premium_paid, + realized_pnl=excluded.realized_pnl, + hedge_plan_id=excluded.hedge_plan_id, + plan_close_reason=excluded.plan_close_reason, + realized_pnl_perp=excluded.realized_pnl_perp, + realized_pnl_options=excluded.realized_pnl_options, + premium_total=excluded.premium_total, + direction=excluded.direction, + tp=excluded.tp, + sl=excluded.sl, + target_price=excluded.target_price, + target_price_up=excluded.target_price_up, + target_price_down=excluded.target_price_down, + legs_json=excluded.legs_json, + linked_hedge_plan_id=excluded.linked_hedge_plan_id, + excluded_as_hedge_leg=excluded.excluded_as_hedge_leg, + strategy_tag=excluded.strategy_tag, + result_tag=excluded.result_tag, + reviewed=excluded.reviewed, + source_label=excluded.source_label, + payload_json=excluded.payload_json, + synced_at=excluded.synced_at + """, + ( + ex_k, + hk, + t.get("source_type"), + t.get("underlying"), + opened_at, + closed_at, + int(opened_ms) if opened_ms else None, + int(closed_ms) if closed_ms else None, + _optional_int(t.get("hold_seconds")), + float(t.get("realized_pnl_total") or t.get("realized_pnl") or 0), + t.get("status_raw"), + t.get("pos_id"), + t.get("inst_id"), + t.get("opt_type"), + _optional_float(t.get("strike")), + t.get("exp_time"), + _optional_int(t.get("sheets")), + _optional_float(t.get("open_avg")), + _optional_float(t.get("close_avg")), + _optional_float(t.get("premium_paid")), + _optional_float(t.get("realized_pnl")), + _optional_int(t.get("hedge_plan_id")), + t.get("plan_close_reason"), + _optional_float(t.get("realized_pnl_perp")), + _optional_float(t.get("realized_pnl_options")), + _optional_float(t.get("premium_total")), + t.get("direction"), + _optional_float(t.get("tp")), + _optional_float(t.get("sl")), + _optional_float(t.get("target_price")), + _optional_float(t.get("target_price_up")), + _optional_float(t.get("target_price_down")), + t.get("legs_json") + if isinstance(t.get("legs_json"), str) + else (json.dumps(t.get("legs"), ensure_ascii=False) if t.get("legs") else None), + _optional_int(t.get("linked_hedge_plan_id")), + int(t.get("excluded_as_hedge_leg") or 0), + strategy_tag, + result_tag, + reviewed, + t.get("source_label"), + payload, + now, + ), + ) + n += 1 + finally: + conn.close() + removed = 0 + if prune_missing: + removed = purge_stale_options_trades_cache(ex_k, active_keys, db_path=db_path) + return {"upserted": n, "removed": removed} + + +def _options_row_to_dict(row: Any) -> dict[str, Any]: + out: dict[str, Any] = dict(row) + payload = {} + raw = out.get("payload_json") + if raw: + try: + payload = json.loads(raw) if isinstance(raw, str) else {} + except (TypeError, ValueError, json.JSONDecodeError): + payload = {} + if isinstance(payload, dict): + for k, v in payload.items(): + if k not in out or out.get(k) in (None, ""): + out[k] = v + pnl = float(out.get("realized_pnl_total") or out.get("realized_pnl") or 0) + out["realized_pnl_total"] = pnl + out["pnl_amount"] = pnl # 复用永续统计/日历字段名 + hold_sec = out.get("hold_seconds") + if hold_sec is not None: + try: + out["hold_minutes"] = round(float(hold_sec) / 60.0, 2) + except (TypeError, ValueError): + pass + if not out.get("opened_at_ms") and out.get("opened_at"): + ms = parse_wall_clock_ms(out.get("opened_at")) + if ms: + out["opened_at_ms"] = int(ms) + if not out.get("closed_at_ms") and out.get("closed_at"): + ms = parse_wall_clock_ms(out.get("closed_at")) + if ms: + out["closed_at_ms"] = int(ms) + out["trade_id"] = out.get("history_key") + out["id"] = out.get("history_key") + out["symbol"] = out.get("inst_id") or out.get("underlying") or "" + return out + + +def _empty_options_stats() -> dict[str, Any]: + return { + "open_count": 0, + "sick_count": 0, + "sick_pct": 0.0, + "pnl_total": 0.0, + "pnl_ex_sick": 0.0, + "win_count": 0, + "loss_count": 0, + "avg_win": 0.0, + "avg_loss": 0.0, + "max_win": 0.0, + "max_loss": 0.0, + "win_rate": 0.0, + "profit_loss_ratio": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "premium_total": 0.0, + "by_exchange": {}, + "by_source_type": {}, + } + + +def _compute_options_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: + st = _empty_options_stats() + wins: list[float] = [] + losses: list[float] = [] + by_ex: dict[str, dict[str, Any]] = {} + by_src: dict[str, dict[str, Any]] = {} + + def bucket() -> dict[str, Any]: + return { + "open_count": 0, + "pnl_total": 0.0, + "win_count": 0, + "loss_count": 0, + "premium_total": 0.0, + } + + for td in trade_rows: + pnl = float(td.get("pnl_amount") or td.get("realized_pnl_total") or 0) + ex = str(td.get("exchange_key") or "okx") + src = str(td.get("source_type") or td.get("source_label") or "?") + prem = float(td.get("premium_total") or td.get("premium_paid") or 0) + st["open_count"] += 1 + st["pnl_total"] += pnl + st["premium_total"] += prem + if pnl > 0.0001: + st["win_count"] += 1 + wins.append(pnl) + elif pnl < -0.0001: + st["loss_count"] += 1 + losses.append(pnl) + if ex not in by_ex: + by_ex[ex] = bucket() + by_ex[ex]["open_count"] += 1 + by_ex[ex]["pnl_total"] += pnl + by_ex[ex]["premium_total"] += prem + if pnl > 0.0001: + by_ex[ex]["win_count"] += 1 + elif pnl < -0.0001: + by_ex[ex]["loss_count"] += 1 + if src not in by_src: + by_src[src] = bucket() + by_src[src]["open_count"] += 1 + by_src[src]["pnl_total"] += pnl + + total = int(st["open_count"] or 0) + st["pnl_ex_sick"] = round(float(st["pnl_total"]), 4) + st["pnl_total"] = round(float(st["pnl_total"]), 4) + st["premium_total"] = round(float(st["premium_total"]), 4) + st["avg_win"] = round(sum(wins) / len(wins), 4) if wins else 0.0 + st["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else 0.0 + st["max_win"] = round(max(wins), 4) if wins else 0.0 + st["max_loss"] = round(min(losses), 4) if losses else 0.0 + st["win_rate"] = round(st["win_count"] / total * 100, 1) if total else 0.0 + if wins and losses and abs(st["avg_loss"]) > 1e-9: + st["profit_loss_ratio"] = round(abs(st["avg_win"] / st["avg_loss"]), 2) + for ex, b in by_ex.items(): + b["pnl_total"] = round(float(b["pnl_total"]), 4) + b["premium_total"] = round(float(b["premium_total"]), 4) + b["sick_count"] = 0 + b["sick_pct"] = 0.0 + b["pnl_ex_sick"] = b["pnl_total"] + b["avg_win"] = 0.0 + b["avg_loss"] = 0.0 + b["max_win"] = 0.0 + b["max_loss"] = 0.0 + b["win_rate"] = ( + round(b["win_count"] / b["open_count"] * 100, 1) if b["open_count"] else 0.0 + ) + b["profit_loss_ratio"] = 0.0 + b["turnover_total"] = 0.0 + b["commission_total"] = 0.0 + for src, b in by_src.items(): + b["pnl_total"] = round(float(b["pnl_total"]), 4) + st["by_exchange"] = by_ex + st["by_source_type"] = by_src + return st + + +def list_daily_options_trades( + trading_day: str = "", + *, + period: str = "", + date_from: str = "", + date_to: str = "", + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + search: str = "", + source_type: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + init_options_archive_db(db_path) + p = (period or "today").strip().lower() or "today" + start_ms, end_ms, df, dt, period_label = resolve_period_bounds( + period=p, + trading_day=trading_day, + date_from=date_from, + date_to=date_to, + ) + ex_filter = (exchange_key or "").strip().lower() + src_filter = (source_type or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + where += " AND COALESCE(excluded_as_hedge_leg,0)=0" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + if src_filter: + where += " AND LOWER(COALESCE(source_type,''))=?" + params.append(src_filter) + rows = conn.execute( + f""" + SELECT * FROM archive_options_trade_cache + WHERE {where} + ORDER BY closed_at_ms DESC, history_key DESC + """, + params, + ).fetchall() + trades: list[dict[str, Any]] = [] + q = (search or "").strip().lower() + for r in rows: + td = _options_row_to_dict(r) + pnl = float(td.get("pnl_amount") or 0) + if filter_profit and pnl <= 0.0001: + continue + if filter_loss and pnl >= -0.0001: + continue + if q: + blob = " ".join( + str(td.get(k) or "") + for k in ( + "underlying", + "inst_id", + "exchange_key", + "source_type", + "source_label", + "opt_type", + "strategy_tag", + "result_tag", + "direction", + ) + ).lower() + if q not in blob: + continue + trades.append(td) + return { + "period": p, + "period_label": period_label, + "trading_day": dt, + "date_from": df, + "date_to": dt, + "product": "options", + "trades": trades, + "stats": _compute_options_period_stats(trades), + } + finally: + conn.close() + + +def list_archive_options_calendar( + year: int, + month: int, + *, + exchange_key: str = "", + db_path: Path | None = None, + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> dict[str, Any]: + init_options_archive_db(db_path) + y = int(year) + m = int(month) + if m < 1 or m > 12: + raise ValueError("month 无效") + from datetime import datetime, timedelta + + 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") + start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = ( + "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + " AND COALESCE(excluded_as_hedge_leg,0)=0" + ) + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + rows = conn.execute( + f"SELECT * FROM archive_options_trade_cache WHERE {where}", + params, + ).fetchall() + days: dict[str, dict[str, Any]] = {} + for r in rows: + td = _options_row_to_dict(r) + closed_ms = td.get("closed_at_ms") or parse_wall_clock_ms(td.get("closed_at")) + if not closed_ms: + continue + day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour) + if not day or day < first or day > last: + continue + bucket = days.setdefault( + day, + { + "trading_day": day, + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + }, + ) + bucket["open_count"] += 1 + bucket["pnl_total"] += float(td.get("pnl_amount") or 0) + for d in days.values(): + d["pnl_total"] = round(float(d["pnl_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, + "product": "options", + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + } + finally: + conn.close() + + +def sync_options_exchange_archive( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """仅缓存期权交易,不做 K 线.""" + r = upsert_options_trades_cache( + exchange_key, trades, db_path=db_path, prune_missing=True + ) + return { + "ok": True, + "exchange_key": (exchange_key or "").strip().lower(), + "product": "options", + "trades_upserted": r.get("upserted", 0), + "trades_removed": r.get("removed", 0), + "trade_count": len(trades or []), + } diff --git a/lib/hub/hub_options_funds_lib.py b/lib/hub/hub_options_funds_lib.py new file mode 100644 index 0000000..1ee1126 --- /dev/null +++ b/lib/hub/hub_options_funds_lib.py @@ -0,0 +1,219 @@ +"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计.""" +from __future__ import annotations + +from typing import Any, Optional + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + v = float(value) + return v if v >= 0 else None + except (TypeError, ValueError): + return None + + +def _account_total_usdt(funding: Any, trading: Any) -> Optional[float]: + fu = _safe_float(funding) + tu = _safe_float(trading) + if fu is None or tu is None: + return None + return round(fu + tu, 4) + + +def stablecoin_usdt_equiv(value: Any) -> Optional[float]: + """USDC / USDT 按 1:1 折算为 USDT 统计口径.""" + return _safe_float(value) + + +def _sum_optional(*values: Any) -> Optional[float]: + parts = [_safe_float(v) for v in values] + present = [p for p in parts if p is not None] + if not present: + return None + return round(sum(present), 4) + + +def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]: + """从期权 snapshot 提取资金户/交易户 USDT 等价余额. + + - funding_usdt / trading_usdt: USDT+USDC(+USDG) 全账户(勿与永续 USDT 再加总) + - funding_usdc_equiv / trading_usdc_equiv: 仅非 USDT 稳定币,可安全加到永续 USDT 上 + """ + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False: + return { + "ok": False, + "funding_usdt": None, + "trading_usdt": None, + "funding_usdc_equiv": None, + "trading_usdc_equiv": None, + } + if snap.get("ok") is False: + return { + "ok": False, + "funding_usdt": None, + "trading_usdt": None, + "funding_usdc_equiv": None, + "trading_usdc_equiv": None, + } + bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap + funding_usdc_equiv = _sum_optional(bal.get("funding_usdc"), bal.get("funding_usdg")) + trading_usdc_equiv = _sum_optional(bal.get("trading_usdc"), bal.get("trading_usdg")) + funding = _sum_optional(bal.get("funding_usdt"), funding_usdc_equiv) + trading = _sum_optional(bal.get("trading_usdt"), trading_usdc_equiv) + ok = funding is not None and trading is not None + return { + "ok": ok, + "funding_usdt": funding, + "trading_usdt": trading, + "funding_usdc_equiv": funding_usdc_equiv, + "trading_usdc_equiv": trading_usdc_equiv, + } + + +def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]: + """期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U. + + 优先逐仓 display_pnl_usdt_from_option_row(已含 BTC/ETH × 指数); + 回退 upl_total_usdc 时按「已是 USDT」处理——实例 hub 快照合计已折 U, + 不可再 × 指数,否则币本位会双计. + """ + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False or snap.get("ok") is False: + return None + + try: + from lib.options.options_positions_lib import display_pnl_usdt_from_option_row + + total_u = 0.0 + found = False + for p in snap.get("positions") or []: + if not isinstance(p, dict): + continue + pnl = display_pnl_usdt_from_option_row(p) + if pnl is None: + continue + found = True + total_u += float(pnl) + if found: + return round(total_u, 4) + except Exception: + pass + + upl = snap.get("upl_total_usdc") + if upl is not None: + try: + return round(float(upl), 4) + except (TypeError, ValueError): + return None + return None + + +def options_open_position_count(options_snap: dict[str, Any] | None) -> int: + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False or snap.get("ok") is False: + return 0 + if snap.get("position_count") is not None: + try: + return max(0, int(snap.get("position_count"))) + except (TypeError, ValueError): + pass + pos = snap.get("positions") + return len(pos) if isinstance(pos, list) else 0 + + +def merge_perp_options_balances( + perpetual_funding_usdt: Any, + perpetual_trading_usdt: Any, + options_snap: dict[str, Any] | None, +) -> dict[str, Any]: + """永续 USDT + 期权非 USDT 稳定币合并为中控总资金(避免 OKX 同账户 USDT 双计). + + 与实例顶栏 total_funds_usdt(..., options_usdc, None, None) 口径一致: + 期权 snapshot 里的 USDT 与永续资金/交易户是同一钱包,只把 USDC/USDG 加上. + """ + opt = options_balances_usdt_equiv(options_snap) + # 展示用期权户:优先非 USDT 稳定币;若仅有 USDT 则仍给出全量以便辨识 + opt_fund_disp = opt.get("funding_usdc_equiv") + opt_trade_disp = opt.get("trading_usdc_equiv") + if opt_fund_disp is None and opt_trade_disp is None and opt.get("ok"): + opt_fund_disp = opt.get("funding_usdt") + opt_trade_disp = opt.get("trading_usdt") + + if opt.get("ok"): + if perpetual_funding_usdt is None and perpetual_trading_usdt is None: + # 永续账户未取到时,期权 snapshot 已含同账户 USDT+USDC,直接用全量 + funding = opt.get("funding_usdt") + trading = opt.get("trading_usdt") + else: + funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdc_equiv")) + trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdc_equiv")) + else: + funding = _safe_float(perpetual_funding_usdt) + trading = _safe_float(perpetual_trading_usdt) + + total = _account_total_usdt(funding, trading) + # 任一侧齐全即可展示;永续缺一侧但有期权 USDC 时仍尽量给出合计 + if total is None: + total = _sum_optional(funding, trading) + perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt) + opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt")) + data_ok = total is not None + return { + "perpetual_funding_usdt": _safe_float(perpetual_funding_usdt), + "perpetual_trading_usdt": _safe_float(perpetual_trading_usdt), + "options_funding_usdt": opt_fund_disp, + "options_trading_usdt": opt_trade_disp, + "options_funding_full_usdt": opt.get("funding_usdt"), + "options_trading_full_usdt": opt.get("trading_usdt"), + "options_ok": bool(opt.get("ok")), + "funding_usdt": funding, + "trading_usdt": trading, + "total_usdt": total, + "perpetual_total_usdt": perp_total, + "options_total_usdt": opt_total, + "data_ok": data_ok, + } + + +def repair_double_counted_fund_entry(ac: dict[str, Any]) -> dict[str, Any]: + """识别并修复历史快照中「永续 USDT + 期权(USDT+USDC)」的双计. + + 旧口径 options_* 存的是 USDT+USDC 全量,且 funding≈2×期权资金户 USDT 部分. + 新口径 options_* 多为纯 USDC,不会误伤. + """ + if not isinstance(ac, dict): + return {} + out = dict(ac) + ofu = _safe_float(ac.get("options_funding_usdt")) + otu = _safe_float(ac.get("options_trading_usdt")) + fu = _safe_float(ac.get("funding_usdt")) + tu = _safe_float(ac.get("trading_usdt")) + if ofu is None or otu is None or fu is None or tu is None: + return out + if ofu < 1.0: + return out + ratio = fu / ofu if ofu > 0 else 0.0 + # 经典双计:合并资金户 ≈ 2 × 期权资金户(同钱包 USDT 加了两遍) + if 1.8 <= ratio <= 2.25: + out["funding_usdt"] = ofu + out["trading_usdt"] = otu + out["total_usdt"] = round(ofu + otu, 4) + out["repaired_double_count"] = True + return out + + +def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]: + """监控板行 → 含期权的资金统计.""" + caps = row.get("capabilities") or [] + options_snap = row.get("options") if "options" in caps else None + merged = merge_perp_options_balances( + row.get("funding_usdt") if row.get("account_ok") else None, + row.get("trading_usdt") if row.get("account_ok") else None, + options_snap, + ) + merged["options_float_pnl_u"] = options_float_pnl_usdt(options_snap) + merged["options_open_position_count"] = options_open_position_count(options_snap) + return merged diff --git a/lib/hub/hub_order_sync_lib.py b/lib/hub/hub_order_sync_lib.py new file mode 100644 index 0000000..c37b17e --- /dev/null +++ b/lib/hub/hub_order_sync_lib.py @@ -0,0 +1,114 @@ +"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单).""" +from __future__ import annotations + +from typing import Any, Callable + + +def cond_order_role(row: dict[str, Any]) -> str | None: + lbl = (row.get("label") or "").strip().lower() + if "止损" in lbl and "止盈止损" not in lbl: + return "sl" + if "止盈" in lbl: + return "tp" + return None + + +def dedupe_conditional_orders_by_role(orders: list) -> list: + """同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复).""" + if not orders: + return [] + by_role: dict[str, dict] = {} + others: list[dict] = [] + for row in orders: + if not isinstance(row, dict): + continue + role = cond_order_role(row) + if role: + by_role[role] = row + else: + others.append(row) + out = list(others) + for role in ("tp", "sl"): + if role in by_role: + out.append(by_role[role]) + return out + + +def exchange_tpsl_from_cond_orders(cond: list) -> dict[str, Any] | None: + """从子代理条件单列表还原 exchange_tpsl 槽位.""" + slots: dict[str, Any] = {"sl": None, "tp": None} + for row in cond or []: + if not isinstance(row, dict): + continue + role = cond_order_role(row) + if role not in ("sl", "tp"): + continue + trig = row.get("trigger_price") + if trig is None: + continue + try: + trig_f = float(trig) + except (TypeError, ValueError): + continue + oid = row.get("algo_id") or row.get("id") or "" + slots[role] = { + "order_id": str(oid) if oid not in (None, "") else "", + "trigger_price": trig_f, + "trigger_display": f"{trig_f:g}", + "amount": row.get("amount"), + "type": row.get("type") or "", + } + if not slots["sl"] and not slots["tp"]: + return None + return slots + + +def sync_active_monitor_tpsl_prices( + conn, + symbol: str, + direction: str, + stop_loss: float, + take_profit: float, + *, + symbols_match: Callable[[str, str], bool], +) -> dict[str, Any]: + """按 symbol+方向更新 active 下单监控的 stop_loss / take_profit.""" + sym = (symbol or "").strip() + side = (direction or "").strip().lower() + if not sym: + return {"ok": False, "msg": "symbol 不能为空"} + if side not in ("long", "short"): + return {"ok": False, "msg": "side 须为 long 或 short"} + try: + sl = float(stop_loss) + tp = float(take_profit) + except (TypeError, ValueError): + return {"ok": False, "msg": "stop_loss / take_profit 须为数字"} + if sl <= 0 or tp <= 0: + return {"ok": False, "msg": "止损,止盈须大于 0"} + + rows = conn.execute( + "SELECT id, symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + updated_ids: list[int] = [] + for row in rows: + r_sym = row["exchange_symbol"] if "exchange_symbol" in row.keys() else row["symbol"] + r_sym = r_sym or row["symbol"] + if not symbols_match(sym, r_sym or ""): + continue + r_dir = (row["direction"] or "").strip().lower() + if r_dir and r_dir != side: + continue + oid = int(row["id"]) + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=? AND status='active'", + (sl, tp, oid), + ) + updated_ids.append(oid) + return { + "ok": True, + "updated": len(updated_ids), + "order_monitor_ids": updated_ids, + "stop_loss": sl, + "take_profit": tp, + } diff --git a/lib/hub/hub_perp_options_calc_lib.py b/lib/hub/hub_perp_options_calc_lib.py new file mode 100644 index 0000000..070c9b3 --- /dev/null +++ b/lib/hub/hub_perp_options_calc_lib.py @@ -0,0 +1,387 @@ +"""中控永期对冲计算器:永续 1 币 + 按目标盈利反推期权仓位/波动点数(纯函数).""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt, taker_fee_rate + +DEFAULT_CT_MULT = 0.01 +PERP_COINS = 1.0 + + +def _f(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _parse_base_common( + *, + base: str, + spot: Any, + capital_usdt: Any, + target_profit_u: Any, + perp_leverage: Any, + option_leverage: Any, + ct_mult: Any, +) -> Tuple[Optional[dict[str, float]], Optional[str]]: + b = (base or "ETH").strip().upper() + if b not in ("ETH", "BTC"): + return None, "币种仅支持 BTC / ETH" + s = _f(spot) + capital = _f(capital_usdt) + target = _f(target_profit_u) + p_lev = _f(perp_leverage) + o_lev = _f(option_leverage) + ct = _f(ct_mult) + if s is None or capital is None or target is None or p_lev is None or o_lev is None: + return None, "参数格式错误" + if ct is None or ct <= 0: + ct = DEFAULT_CT_MULT + if s <= 0 or capital <= 0 or p_lev <= 0 or o_lev <= 0: + return None, "现价、资金、杠杆须大于 0" + if target < 0: + return None, "目标盈利不能为负" + prem_per_coin = s / o_lev + if prem_per_coin <= 0: + return None, "单币权利金无效" + margin = (s * PERP_COINS) / p_lev + return { + "base_ok": 1.0, + "spot": s, + "capital": capital, + "target": target, + "p_lev": p_lev, + "o_lev": o_lev, + "ct": ct, + "prem_per_coin": prem_per_coin, + "margin": margin, + "fee_rate": taker_fee_rate(), + }, None + + +def _move_for_perp_correct( + *, + spot: float, + target: float, + premium: float, + fee_rate: float, + perp_coins: float = 1.0, +) -> float: + """净利 = qty*move − premium − fee(move,qty) = target → 解 move. + + fee = (2*spot + move) * qty * fee_rate + qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate + """ + qty = float(perp_coins) + if qty <= 0: + return 0.0 + denom = qty * (1.0 - float(fee_rate)) + if denom <= 0: + return 0.0 + return (float(target) + float(premium) + 2.0 * float(spot) * qty * float(fee_rate)) / denom + + +def _case_sideways( + *, + spot: float, + premium_total: float, + perp_coins: float = 1.0, +) -> dict[str, Any]: + """横盘/到期无方向:永续≈0,期权权利金全亏,另计永续开平同价手续费. + + 最大亏损(正数) = 权利金总额 + 开平手续费(exit=entry) + 组合净利 = −最大亏损 + """ + qty = float(perp_coins) if float(perp_coins) > 0 else PERP_COINS + fee_flat = estimate_roundtrip_fee_usdt(spot, spot, qty=qty, contract_size=1.0) + prem = float(premium_total) + max_loss = prem + float(fee_flat) + return { + "label": "横盘", + "perp_pnl_u": 0.0, + "premium_u": round(prem, 8), + "fee_u": round(float(fee_flat), 8), + "max_loss_u": round(max_loss, 8), + "net_u": round(-max_loss, 8), + } + + +def calc_perp_options_hedge( + *, + base: str = "ETH", + spot: float, + capital_usdt: float, + target_profit_u: float, + move_mode: str = "points", + move_value: float, + perp_leverage: float, + option_leverage: float, + ct_mult: float = DEFAULT_CT_MULT, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """由波动反推期权开仓币数/张数(calc_mode=size).""" + common, err = _parse_base_common( + base=base, + spot=spot, + capital_usdt=capital_usdt, + target_profit_u=target_profit_u, + perp_leverage=perp_leverage, + option_leverage=option_leverage, + ct_mult=ct_mult, + ) + if err or not common: + return None, err + + s = common["spot"] + capital = common["capital"] + target = common["target"] + p_lev = common["p_lev"] + o_lev = common["o_lev"] + ct = common["ct"] + prem_per_coin = common["prem_per_coin"] + margin = common["margin"] + fee_rate = common["fee_rate"] + b = (base or "ETH").strip().upper() + + move = _f(move_value) + mode = (move_mode or "points").strip().lower() + if mode not in ("points", "pct", "percent", "rate"): + return None, "波动模式须为 points 或 pct" + if mode in ("percent", "rate"): + mode = "pct" + if move is None: + return None, "参数格式错误" + if move <= 0: + return None, "现价、资金、波动、杠杆须大于 0" + + if mode == "pct": + move_points = s * (move / 100.0) + else: + move_points = move + if move_points <= 0: + return None, "波动对应价格变动须大于 0" + + exit_px = s + move_points + perp_gross = move_points * PERP_COINS + fee = estimate_roundtrip_fee_usdt(s, exit_px, qty=PERP_COINS, contract_size=1.0) + + premium_budget = perp_gross - target - fee + if premium_budget <= 0: + return None, "波动收益不足以覆盖目标盈利+手续费,无法开期权" + + opt_coins = premium_budget / prem_per_coin + opt_sheets = opt_coins / ct + premium_total = opt_coins * prem_per_coin + + case_a_net = perp_gross - premium_total - fee + opt_intrinsic = opt_coins * move_points + opt_net = opt_intrinsic - premium_total + perp_loss = -perp_gross + portfolio_net = opt_net + perp_loss + + return { + "calc_mode": "size", + "base": b, + "spot": round(s, 8), + "capital_usdt": round(capital, 8), + "target_profit_u": round(target, 8), + "move_mode": mode, + "move_value": round(move, 8), + "move_points": round(move_points, 8), + "exit_price": round(exit_px, 8), + "perp_coins": PERP_COINS, + "perp_leverage": round(p_lev, 8), + "option_leverage": round(o_lev, 8), + "ct_mult": ct, + "prem_per_coin": round(prem_per_coin, 8), + "perp_gross_u": round(perp_gross, 8), + "perp_fee_u": round(fee, 8), + "fee_rate": fee_rate, + "premium_budget_u": round(premium_budget, 8), + "opt_coins": round(opt_coins, 8), + "opt_sheets": round(opt_sheets, 8), + "premium_total_u": round(premium_total, 8), + "perp_margin_u": round(margin, 8), + "capital_ok": bool(capital >= margin), + "case_a": { + "label": "永续方向对", + "perp_pnl_u": round(perp_gross, 8), + "premium_u": round(premium_total, 8), + "fee_u": round(fee, 8), + "net_u": round(case_a_net, 8), + }, + "case_b": { + "label": "期权方向对", + "opt_intrinsic_u": round(opt_intrinsic, 8), + "premium_u": round(premium_total, 8), + "opt_net_u": round(opt_net, 8), + "perp_pnl_u": round(perp_loss, 8), + "portfolio_net_u": round(portfolio_net, 8), + }, + "case_sideways": _case_sideways(spot=s, premium_total=premium_total), + }, None + + +def calc_perp_options_points( + *, + base: str = "ETH", + spot: float, + capital_usdt: float, + target_profit_u: float, + perp_leverage: float, + option_leverage: float, + ratio_perp: float = 1.0, + ratio_opt: float = 2.0, + ct_mult: float = DEFAULT_CT_MULT, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """按永续/期权币数 + 目标盈利,反推两套情景所需波动点数. + + 永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 币). + 例 2:4 → 永续 2 币 + 期权 4 币;1:2 → 永续 1 币 + 期权 2 币. + + A 永续方向对: qty*move − premium − fee(move,qty) = 目标盈利 + B 期权方向对: + - 期权净利达目标: opt_coins*move − premium = 目标 + - 组合净利达目标: move*(opt_coins − perp_coins) − premium = 目标 + """ + common, err = _parse_base_common( + base=base, + spot=spot, + capital_usdt=capital_usdt, + target_profit_u=target_profit_u, + perp_leverage=perp_leverage, + option_leverage=option_leverage, + ct_mult=ct_mult, + ) + if err or not common: + return None, err + + rp = _f(ratio_perp) + ro = _f(ratio_opt) + if rp is None or ro is None or rp <= 0 or ro <= 0: + return None, "永续/期权币数须大于 0" + + s = common["spot"] + capital = common["capital"] + target = common["target"] + p_lev = common["p_lev"] + o_lev = common["o_lev"] + ct = common["ct"] + prem_per_coin = common["prem_per_coin"] + fee_rate = common["fee_rate"] + b = (base or "ETH").strip().upper() + + perp_coins = rp + opt_coins = ro + premium_total = opt_coins * prem_per_coin + opt_sheets = opt_coins / ct + margin = (s * perp_coins) / p_lev + + move_a = _move_for_perp_correct( + spot=s, + target=target, + premium=premium_total, + fee_rate=fee_rate, + perp_coins=perp_coins, + ) + if move_a <= 0: + return None, "无法解出永续方向对所需点数" + + fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=perp_coins, contract_size=1.0) + net_a = move_a * perp_coins - premium_total - fee_a + + # 期权净利 = 目标 + move_b_opt = (target + premium_total) / opt_coins + opt_net_at_b_opt = opt_coins * move_b_opt - premium_total + portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins + + # 组合净利 = 目标 + edge = opt_coins - perp_coins + if edge <= 0: + move_b_port = None + port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利" + else: + move_b_port = (target + premium_total) / edge + port_err = None + if move_b_port is not None: + opt_net_at_b_port = opt_coins * move_b_port - premium_total + portfolio_at_b_port = opt_net_at_b_port - move_b_port * perp_coins + else: + opt_net_at_b_port = None + portfolio_at_b_port = None + + return { + "calc_mode": "points", + "base": b, + "spot": round(s, 8), + "capital_usdt": round(capital, 8), + "target_profit_u": round(target, 8), + "ratio_perp": round(rp, 8), + "ratio_opt": round(ro, 8), + "ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}", + "perp_coins": round(perp_coins, 8), + "opt_coins": round(opt_coins, 8), + "opt_sheets": round(opt_sheets, 8), + "perp_leverage": round(p_lev, 8), + "option_leverage": round(o_lev, 8), + "ct_mult": ct, + "prem_per_coin": round(prem_per_coin, 8), + "premium_total_u": round(premium_total, 8), + "fee_rate": fee_rate, + "perp_margin_u": round(margin, 8), + "capital_ok": bool(capital >= margin), + "case_a": { + "label": "永续方向对", + "move_points": round(move_a, 8), + "move_pct": round(move_a / s * 100.0, 8), + "perp_pnl_u": round(move_a * perp_coins, 8), + "premium_u": round(premium_total, 8), + "fee_u": round(fee_a, 8), + "net_u": round(net_a, 8), + }, + "case_b": { + "label": "期权方向对", + "move_points_opt_net": round(move_b_opt, 8), + "move_pct_opt_net": round(move_b_opt / s * 100.0, 8), + "opt_net_u": round(opt_net_at_b_opt, 8), + "portfolio_net_at_opt_target_u": round(portfolio_at_b_opt, 8), + "move_points_portfolio": None if move_b_port is None else round(move_b_port, 8), + "move_pct_portfolio": None + if move_b_port is None + else round(move_b_port / s * 100.0, 8), + "opt_net_at_portfolio_target_u": None + if opt_net_at_b_port is None + else round(opt_net_at_b_port, 8), + "portfolio_net_u": None if portfolio_at_b_port is None else round(portfolio_at_b_port, 8), + "portfolio_error": port_err, + "premium_u": round(premium_total, 8), + }, + "case_sideways": _case_sideways( + spot=s, premium_total=premium_total, perp_coins=perp_coins + ), + }, None + + +def _fmt_ratio(v: float) -> str: + if abs(v - round(v)) < 1e-9: + return str(int(round(v))) + s = f"{v:.4f}".rstrip("0").rstrip(".") + return s + + +def calc_perp_options( + *, + calc_mode: str = "size", + **kwargs: Any, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """统一入口:size=由波动推仓位;points=由比例推点数.""" + mode = (calc_mode or "size").strip().lower() + if mode in ("points", "ratio", "move"): + return calc_perp_options_points(**kwargs) + # size mode: ignore ratio kwargs if present + kwargs.pop("ratio_perp", None) + kwargs.pop("ratio_opt", None) + return calc_perp_options_hedge(**kwargs) diff --git a/lib/hub/hub_poll_wait_lib.py b/lib/hub/hub_poll_wait_lib.py new file mode 100644 index 0000000..fbd017f --- /dev/null +++ b/lib/hub/hub_poll_wait_lib.py @@ -0,0 +1,45 @@ +"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU.""" +from __future__ import annotations + +import asyncio +import time + + +async def wait_poll_interval( + *, + refresh: asyncio.Event, + stop: asyncio.Event, + interval_sec: float, + started_at: float, + min_early_wake_sec: float | None = None, +) -> None: + """距 started_at 至少间隔 interval_sec 再进入下一轮. + + 期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖). + """ + interval = max(0.05, float(interval_sec)) + min_early = ( + float(min_early_wake_sec) + if min_early_wake_sec is not None + else min(2.0, interval * 0.4) + ) + while not stop.is_set(): + left = interval - (time.monotonic() - started_at) + if left <= 0: + return + refresh.clear() + stop_task = asyncio.create_task(stop.wait()) + refresh_task = asyncio.create_task(refresh.wait()) + done, pending = await asyncio.wait( + {stop_task, refresh_task}, + timeout=left, + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + if stop.is_set(): + return + if not done: + return + if refresh_task in done and (time.monotonic() - started_at) >= min_early: + return diff --git a/lib/hub/hub_position_metrics.py b/lib/hub/hub_position_metrics.py new file mode 100644 index 0000000..fc62640 --- /dev/null +++ b/lib/hub/hub_position_metrics.py @@ -0,0 +1,310 @@ +"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用).""" +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/hub/hub_reconcile_flat_lib.py b/lib/hub/hub_reconcile_flat_lib.py new file mode 100644 index 0000000..42fe455 --- /dev/null +++ b/lib/hub/hub_reconcile_flat_lib.py @@ -0,0 +1,95 @@ +"""Hub 中控市价全平后立即同步 order_monitors(三所共用).""" +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} diff --git a/lib/hub/hub_sso.py b/lib/hub/hub_sso.py new file mode 100644 index 0000000..b7bc6af --- /dev/null +++ b/lib/hub/hub_sso.py @@ -0,0 +1,166 @@ +""" +实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN).无 Flask 依赖,供中控 FastAPI 与各实例共用. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import threading +import time + +HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200")) +HUB_EMBED_BOOTSTRAP_TTL_SEC = int(os.getenv("HUB_EMBED_BOOTSTRAP_TTL_SEC", "120")) + +_used_nonces: dict[str, float] = {} +_nonce_lock = threading.Lock() + + +def hub_bridge_token() -> str: + return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() + + +def safe_next_path(raw: str | None) -> str: + p = (raw or "/").strip() + if not p.startswith("/") or p.startswith("//"): + return "/" + if "://" in p: + return "/" + return p + + +def _sso_secret() -> str: + return hub_bridge_token() + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _b64url_decode(data: str) -> bytes: + pad = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + pad) + + +def _prune_used_nonces() -> None: + now = time.time() + with _nonce_lock: + dead = [k for k, exp in _used_nonces.items() if exp <= now] + for k in dead: + del _used_nonces[k] + + +def mint_hub_sso_token(exchange_key: str, next_path: str = "/") -> str | None: + secret = _sso_secret() + ex = (exchange_key or "").strip().lower() + if not secret or not ex: + return None + payload = { + "ex": ex, + "exp": int(time.time()) + max(60, HUB_SSO_TTL_SEC), + "nonce": secrets.token_urlsafe(16), + "next": safe_next_path(next_path), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def verify_hub_sso_token( + token: str | None, expected_exchange: str +) -> tuple[bool, str, str | None]: + secret = _sso_secret() + expected = (expected_exchange or "").strip().lower() + if not secret or not expected: + return False, "/", "未配置 HUB_BRIDGE_TOKEN" + raw = (token or "").strip() + if "." not in raw: + return False, "/", "token 无效" + body, sig = raw.rsplit(".", 1) + try: + expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expect_sig, sig): + return False, "/", "签名校验失败" + payload = json.loads(_b64url_decode(body).decode()) + except Exception: + return False, "/", "token 解析失败" + if not isinstance(payload, dict): + return False, "/", "payload 无效" + if str(payload.get("ex") or "").lower() != expected: + return False, "/", "实例不匹配" + try: + exp = int(payload.get("exp") or 0) + except (TypeError, ValueError): + return False, "/", "exp 无效" + if exp < int(time.time()): + return False, "/", "链接已过期" + nonce = str(payload.get("nonce") or "") + if not nonce: + return False, "/", "nonce 缺失" + _prune_used_nonces() + with _nonce_lock: + if nonce in _used_nonces: + return False, "/", "链接已使用" + _used_nonces[nonce] = float(exp) + return True, safe_next_path(str(payload.get("next") or "/")), None + + +def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None: + """iframe 内嵌登录引导 token(短效,单次),供 /hub-embed-auth 写入 SameSite=None Cookie.""" + secret = _sso_secret() + ex = (exchange_key or "").strip().lower() + if not secret or not ex: + return None + payload = { + "kind": "embed", + "ex": ex, + "exp": int(time.time()) + max(30, HUB_EMBED_BOOTSTRAP_TTL_SEC), + "nonce": secrets.token_urlsafe(16), + "next": safe_next_path(next_path), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + return f"{body}.{sig}" + + +def verify_hub_embed_bootstrap( + token: str | None, expected_exchange: str +) -> tuple[bool, str, str | None]: + secret = _sso_secret() + expected = (expected_exchange or "").strip().lower() + if not secret or not expected: + return False, "/", "未配置 HUB_BRIDGE_TOKEN" + raw = (token or "").strip() + if "." not in raw: + return False, "/", "token 无效" + body, sig = raw.rsplit(".", 1) + try: + expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expect_sig, sig): + return False, "/", "签名校验失败" + payload = json.loads(_b64url_decode(body).decode()) + except Exception: + return False, "/", "token 解析失败" + if not isinstance(payload, dict) or payload.get("kind") != "embed": + return False, "/", "token 类型无效" + if str(payload.get("ex") or "").lower() != expected: + return False, "/", "实例不匹配" + try: + exp = int(payload.get("exp") or 0) + except (TypeError, ValueError): + return False, "/", "exp 无效" + if exp < int(time.time()): + return False, "/", "链接已过期" + nonce = str(payload.get("nonce") or "") + if not nonce: + return False, "/", "nonce 缺失" + key = f"embed:{nonce}" + _prune_used_nonces() + with _nonce_lock: + if key in _used_nonces: + return False, "/", "链接已使用" + _used_nonces[key] = float(exp) + return True, safe_next_path(str(payload.get("next") or "/")), None diff --git a/lib/hub/hub_strategy_lib.py b/lib/hub/hub_strategy_lib.py new file mode 100644 index 0000000..5751665 --- /dev/null +++ b/lib/hub/hub_strategy_lib.py @@ -0,0 +1,542 @@ +"""中控「策略说明」:读取 docs/strategy MD + checklists JSON.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from lib.paths import REPO_ROOT + +STRATEGY_EXCHANGES: tuple[str, ...] = ( + "playbook_v2", + "playbook", + "behavior", + "binance", + "okx", + "gate", +) + +STRATEGY_META: dict[str, dict[str, str]] = { + "playbook_v2": { + "label": "执行手册v2", + "title": "交易执行手册 v2(期权 / 合约 · 无对冲)", + "md_rel": "docs/交易执行手册-v2-期权与合约.md", + }, + "playbook": { + "label": "执行手册v1", + "title": "交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)", + # 相对仓库根;其余条目用 md_file 相对 docs/strategy + "md_rel": "docs/交易执行手册-期权与Gate.md", + }, + "behavior": { + "label": "行为准则", + "title": "交易行为准则(开单三检)", + "md_rel": "docs/交易行为准则-开单三检.md", + }, + "binance": { + "label": "币安", + "title": "币安·山寨多头趋势", + "md_file": "binance-alt-trend-long.md", + }, + "okx": { + "label": "OKX", + "title": "OKX·多空趋势", + "md_file": "okx-trend-both.md", + }, + "gate": { + "label": "Gate", + "title": "Gate·BTC 日内", + "md_file": "gate-intraday.md", + }, +} + + +def _strategy_dir() -> Path: + return REPO_ROOT / "docs" / "strategy" + + +def _checklist_path(exchange_key: str) -> Path: + return _strategy_dir() / "checklists" / f"{exchange_key.strip().lower()}.json" + + +def _md_path(exchange_key: str) -> Path: + meta = STRATEGY_META.get((exchange_key or "").strip().lower()) + if not meta: + raise KeyError(exchange_key) + md_rel = (meta.get("md_rel") or "").strip() + if md_rel: + return REPO_ROOT / md_rel + return _strategy_dir() / meta["md_file"] + + +def _parse_version(md_text: str) -> str: + m = re.search(r">\s*\*\*状态\*\*[::]\s*(v[\d.]+)", md_text) + if m: + return m.group(1) + m = re.search(r"\|\s*v([\d.]+)\s*\|", md_text) + if m: + return f"v{m.group(1)}" + return "" + + +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"
      3. {_inline_md(item)}
      4. ") + 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 + + +def load_checklist(exchange_key: str) -> dict[str, Any]: + path = _checklist_path(exchange_key) + if not path.is_file(): + return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []} + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []} + return data + + +def load_strategy_payload(exchange_key: str) -> dict[str, Any]: + key = (exchange_key or "").strip().lower() + if key not in STRATEGY_META: + raise KeyError(exchange_key) + meta = STRATEGY_META[key] + md_path = _md_path(key) + md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else "" + checklist = load_checklist(key) + version = _parse_version(md_text) or str(checklist.get("version") or "") + return { + "ok": True, + "exchange_key": key, + "label": meta["label"], + "title": meta["title"], + "version": version, + "md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"), + "strategy_html": render_markdown_html(md_text), + "checklist": checklist, + } + + +_STRATEGY_TAB_DISPLAY_PREF: dict[str, str] = { + "playbook_v2": "show_strategy_playbook_v2", + "playbook": "show_strategy_playbook", + "behavior": "show_strategy_behavior", + "binance": "show_strategy_binance", + "okx": "show_strategy_okx", + "gate": "show_strategy_gate", +} + + +def strategy_meta_payload(display: dict[str, Any] | None = None) -> dict[str, Any]: + prefs = display if isinstance(display, dict) else {} + tabs = [] + for k in STRATEGY_EXCHANGES: + pref_key = _STRATEGY_TAB_DISPLAY_PREF.get(k) + if pref_key and prefs.get(pref_key) is False: + continue + tabs.append( + { + "key": k, + "label": STRATEGY_META[k]["label"], + "title": STRATEGY_META[k]["title"], + } + ) + return {"ok": True, "exchanges": tabs} + + +def _checklist_html(checklist: dict[str, Any], *, include_title: bool = True) -> str: + groups = checklist.get("groups") or [] + parts: list[str] = [] + if include_title: + parts.append(f"

        {escape_html(str(checklist.get('title') or '开仓检查清单'))}

        ") + for grp in groups: + if not isinstance(grp, dict): + continue + gtitle = escape_html(str(grp.get("title") or "")) + parts.append(f"

        {gtitle}

          ") + for item in grp.get("items") or []: + # 用 CSS 方框代替 ☐,避免部分打印机/预览吞掉 Unicode 勾选符 + parts.append( + f"
        • " + f"{escape_html(str(item))}
        • " + ) + parts.append("
        ") + footnotes = checklist.get("footnotes") or [] + if footnotes: + parts.append("
          ") + for note in footnotes: + parts.append(f"
        • {escape_html(str(note))}
        • ") + parts.append("
        ") + return "\n".join(parts) + + +def _print_document_css() -> str: + return """ +body { + margin: 0; + padding: 36px 28px 48px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-size: 14px; + line-height: 1.65; + color: #1a1a1a; + background: #fff; +} +.doc { + max-width: 720px; + margin: 0 auto; +} +.doc-head { + margin-bottom: 28px; + padding-bottom: 14px; + border-bottom: 1px solid #e5e5e5; +} +.doc-head h1 { + margin: 0 0 8px; + font-size: 1.55rem; + font-weight: 600; + line-height: 1.3; +} +.doc-meta { + margin: 0; + color: #666; + font-size: 0.85rem; +} +.doc-body h2 { + font-size: 1.12rem; + margin: 1.6em 0 0.55em; + font-weight: 600; +} +.doc-body h3 { + font-size: 1rem; + margin: 1.2em 0 0.45em; + font-weight: 600; +} +.doc-body h2:first-child, +.doc-body h3:first-child { + margin-top: 0; +} +.doc-body p { + margin: 0.65em 0; +} +.doc-body table { + border-collapse: collapse; + width: 100%; + font-size: 0.92rem; + margin: 10px 0 14px; +} +.doc-body th, +.doc-body td { + border: 1px solid #d8d8d8; + padding: 8px 10px; + text-align: left; + vertical-align: top; +} +.doc-body blockquote { + margin: 12px 0; + padding: 8px 14px; + border-left: 4px solid #c8c8c8; + color: #444; + background: #fafafa; +} +.doc-body pre, +.doc-body code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; +} +.doc-body pre { + padding: 10px 12px; + background: #f6f6f6; + border: 1px solid #e8e8e8; + border-radius: 4px; + overflow-x: auto; +} +.doc-body hr { + border: none; + border-top: 1px solid #e5e5e5; + margin: 1.4em 0; +} +.checklist { + list-style: none; + margin: 0 0 16px; + padding: 0; +} +.checklist li { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 8px 0; + padding: 0; +} +.box { + flex: 0 0 auto; + display: inline-block; + width: 0.95em; + height: 0.95em; + margin-top: 0.2em; + border: 1.5px solid #333; + border-radius: 2px; + background: #fff; + box-sizing: border-box; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} +.item-text { + flex: 1 1 auto; + min-width: 0; +} +.footnotes { + margin: 18px 0 0; + padding-left: 20px; + color: #666; + font-size: 0.85rem; +} +.doc-foot { + margin-top: 28px; + padding-top: 12px; + border-top: 1px dashed #ddd; + color: #888; + font-size: 0.78rem; +} +@media print { + body { padding: 0; } + .doc { max-width: none; } + .doc-head { break-after: avoid; } + .doc-body h2, .doc-body h3 { break-after: avoid; } + .checklist li { break-inside: avoid; } +} +""" + + +def _print_auto_script() -> str: + # 不在 afterprint 里 window.close(): Firefox 会在打开打印框时就触发 afterprint, + # 短页(检查清单)更容易被立刻关掉,表现为「打不开/打不出来」;策略长文相对不易复现. + return """""" + + +def build_print_html(exchange_key: str, part: str = "doc") -> str: + """part: doc | checklist""" + payload = load_strategy_payload(exchange_key) + key = payload["exchange_key"] + label = payload["label"] + title = payload["title"] + version = payload.get("version") or "" + checklist = payload.get("checklist") or {} + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M") + part = (part or "doc").strip().lower() + css = _print_document_css() + + if part == "checklist": + cl_title = str(checklist.get("title") or "开仓检查清单") + page_title = f"{label} · {cl_title}" + body = f"""
        +
        +

        {escape_html(cl_title)}

        +

        {escape_html(label)} · {escape_html(version)} · 打印 {escape_html(now)}

        +
        +
        {_checklist_html(checklist, include_title=False)}
        +
        """ + elif part == "doc": + page_title = f"{label} · 策略说明" + source = payload.get("md_source") or "" + body = f"""
        +
        +

        {escape_html(title)}

        +

        {escape_html(label)} · {escape_html(version)} · 打印 {escape_html(now)}

        +
        +
        {payload.get("strategy_html") or ""}
        +
        文档:{escape_html(str(source))}{(" · " + escape_html(version)) if version else ""}
        +
        """ + else: + raise KeyError(part) + + return f""" + + + + +{escape_html(page_title)} + + + +{body} +{_print_auto_script()} + +""" + + +def build_export_html(exchange_key: str) -> str: + payload = load_strategy_payload(exchange_key) + key = payload["exchange_key"] + label = payload["label"] + title = payload["title"] + version = payload.get("version") or "" + checklist = payload.get("checklist") or {} + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M") + checklist_html = _checklist_html(checklist) + + return f""" + + + +{escape_html(label)} · 策略说明 + + + +

        {escape_html(title)}

        +
        {escape_html(label)} · {escape_html(version)} · 导出 {now}
        +
        +
        {payload.get("strategy_html") or ""}
        +
        {checklist_html}
        +
        + +""" + + +def escape_html(text: str) -> str: + from html import escape + + return escape(text, quote=True) diff --git a/lib/hub/hub_symbol_archive_lib.py b/lib/hub/hub_symbol_archive_lib.py new file mode 100644 index 0000000..97d094c --- /dev/null +++ b/lib/hub/hub_symbol_archive_lib.py @@ -0,0 +1,1718 @@ +"""中控币种档案:永久 5m K 线库(建档种子 + 4h 增量),交易缓存与 overlay.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +CHART_DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai")) + +from lib.hub.hub_ohlcv_lib import ( + TIMEFRAME_MS, + aggregate_ohlcv_bars, + normalize_chart_timeframe, + normalize_perpetual_symbol, +) +from lib.hub.hub_trades_lib import ( + display_entry_type_label, + effective_hold_minutes, + format_hold_minutes, +) + +ARCHIVE_TIMEFRAMES = frozenset({"5m", "15m", "1h", "4h"}) +ARCHIVE_DEFAULT_TIMEFRAME = "15m" +ARCHIVE_SEED_LOOKBACK_DAYS = 30 +ARCHIVE_VISIBLE_BARS_DEFAULT = 200 +ARCHIVE_MAX_CANDLES: dict[str, int] = { + "5m": 9000, + "15m": 15000, + "1h": 4000, + "4h": 2000, +} +ARCHIVE_SYNC_INTERVAL_SEC = int(os.getenv("HUB_ARCHIVE_SYNC_INTERVAL_SEC", str(4 * 3600))) +ARCHIVE_TRADE_DAYS = int(os.getenv("HUB_ARCHIVE_TRADE_DAYS", "365")) +ARCHIVE_TRADE_LIMIT = int(os.getenv("HUB_ARCHIVE_TRADE_LIMIT", "2000")) +ARCHIVE_QUOTES_MAX = int(os.getenv("HUB_ARCHIVE_QUOTES_MAX", "100")) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +ARCHIVE_QUOTE_MAX_LEN = 5000 + +BEHAVIOR_TAGS = frozenset({"", "sick", "emotion"}) + + +def default_db_path() -> Path: + raw = (os.getenv("HUB_ARCHIVE_DB_PATH") or "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_symbol_archive.db" + + +def _connect(db_path: Path | None = None) -> sqlite3.Connection: + path = db_path or default_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path: Path | None = None) -> None: + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_meta ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + first_trade_opened_ms INTEGER, + archive_started_at INTEGER NOT NULL, + last_kline_sync_ms INTEGER, + last_trade_sync_ms INTEGER, + seed_complete INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (exchange_key, symbol) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_bars_5m ( + exchange_key TEXT NOT NULL, + symbol TEXT NOT NULL, + open_time_ms INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, symbol, open_time_ms) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_bars_series + ON archive_bars_5m (exchange_key, symbol, open_time_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_trade_cache ( + exchange_key TEXT NOT NULL, + trade_id INTEGER NOT NULL, + symbol TEXT NOT NULL, + direction TEXT, + result TEXT, + pnl_amount REAL, + opened_at TEXT, + closed_at TEXT, + opened_at_ms INTEGER, + closed_at_ms INTEGER, + monitor_type TEXT, + entry_reason TEXT, + exchange_turnover_usdt REAL, + exchange_commission_usdt REAL, + payload_json TEXT, + synced_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, trade_id) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_trades_sym + ON archive_trade_cache (exchange_key, symbol, closed_at_ms) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS trade_overlay ( + exchange_key TEXT NOT NULL, + trade_id INTEGER NOT NULL, + behavior_tag TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, trade_id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_review_quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_date TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_quotes_date + ON archive_review_quotes (quote_date DESC) + """ + ) + for ddl in ( + "ALTER TABLE archive_trade_cache ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE archive_trade_cache ADD COLUMN exchange_commission_usdt REAL", + ): + try: + conn.execute(ddl) + except Exception: + pass + finally: + conn.close() + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _optional_float(raw: Any) -> float | None: + if raw in (None, ""): + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def parse_wall_clock_ms(raw: Any, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> int | None: + """将 YYYY-MM-DD[ HH:MM[:SS]] 按指定时区墙钟解析为 UTC 毫秒(默认 UTC+8).""" + if raw in (None, ""): + return None + try: + if isinstance(raw, (int, float)): + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + pass + s = str(raw).strip().replace("Z", "").replace("T", " ") + if not s: + return None + if s.isdigit(): + v = int(s) + return v if v > 1_000_000_000_000 else v * 1000 + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + dt = datetime.strptime(s[:ln], fmt) + aware = dt.replace(tzinfo=tz) + return int(aware.timestamp() * 1000) + except ValueError: + continue + return None + + +def ms_to_wall_clock_str(ms: int, *, tz: ZoneInfo = CHART_DISPLAY_TZ) -> str: + dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _parse_dt_ms(raw: Any) -> int | None: + return parse_wall_clock_ms(raw) + + +def _trade_entry_reason_for_cache(t: dict[str, Any]) -> str: + for key in ("entry_type", "entry_reason", "reviewed_entry_reason"): + raw = t.get(key) + if raw is not None and str(raw).strip(): + return str(raw).strip() + return display_entry_type_label(t) if isinstance(t, dict) else "" + + +def purge_stale_trades_cache( + exchange_key: str, + active_trade_ids: list[int] | set[int], + *, + db_path: Path | None = None, +) -> int: + """删除该所缓存中已不在复盘/交易记录里的条目.""" + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + ids: list[int] = [] + for raw in active_trade_ids or []: + try: + ids.append(int(raw)) + except (TypeError, ValueError): + continue + conn = _connect(db_path) + try: + if not ids: + rows = conn.execute( + "SELECT trade_id FROM archive_trade_cache WHERE exchange_key=?", + (ex_k,), + ).fetchall() + stale_ids = [int(r["trade_id"]) for r in rows] + cur = conn.execute( + "DELETE FROM archive_trade_cache WHERE exchange_key=?", + (ex_k,), + ) + else: + placeholders = ",".join("?" * len(ids)) + rows = conn.execute( + f""" + SELECT trade_id FROM archive_trade_cache + WHERE exchange_key=? AND trade_id NOT IN ({placeholders}) + """, + (ex_k, *ids), + ).fetchall() + stale_ids = [int(r["trade_id"]) for r in rows] + cur = conn.execute( + f""" + DELETE FROM archive_trade_cache + WHERE exchange_key=? AND trade_id NOT IN ({placeholders}) + """, + (ex_k, *ids), + ) + removed = int(cur.rowcount or 0) + if stale_ids: + ph2 = ",".join("?" * len(stale_ids)) + conn.execute( + f""" + DELETE FROM trade_overlay + WHERE exchange_key=? AND trade_id IN ({ph2}) + """, + (ex_k, *stale_ids), + ) + return removed + finally: + conn.close() + + +def delete_trade_from_archive( + exchange_key: str, + trade_id: int, + *, + db_path: Path | None = None, +) -> bool: + ex_k = (exchange_key or "").strip().lower() + tid = int(trade_id) + conn = _connect(db_path) + try: + cur = conn.execute( + """ + DELETE FROM archive_trade_cache + WHERE exchange_key=? AND trade_id=? + """, + (ex_k, tid), + ) + conn.execute( + "DELETE FROM trade_overlay WHERE exchange_key=? AND trade_id=?", + (ex_k, tid), + ) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def upsert_trades_cache( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, + prune_missing: bool = True, +) -> dict[str, int]: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return {"upserted": 0, "removed": 0} + now = _now_ms() + n = 0 + active_ids: list[int] = [] + conn = _connect(db_path) + try: + for t in trades or []: + try: + tid = int(t.get("id")) + except (TypeError, ValueError): + continue + sym = (t.get("symbol") or "").strip().upper() + if not sym: + continue + active_ids.append(tid) + row = dict(t) + row["exchange_key"] = ex_k + row.pop("account_exchange_key", None) + payload = {k: row.get(k) for k in row.keys()} + entry_label = _trade_entry_reason_for_cache(t) + conn.execute( + """ + INSERT INTO archive_trade_cache ( + exchange_key, trade_id, symbol, direction, result, pnl_amount, + opened_at, closed_at, opened_at_ms, closed_at_ms, + monitor_type, entry_reason, exchange_turnover_usdt, exchange_commission_usdt, + payload_json, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, trade_id) DO UPDATE SET + symbol=excluded.symbol, + direction=excluded.direction, + result=excluded.result, + pnl_amount=excluded.pnl_amount, + opened_at=excluded.opened_at, + closed_at=excluded.closed_at, + opened_at_ms=excluded.opened_at_ms, + closed_at_ms=excluded.closed_at_ms, + monitor_type=excluded.monitor_type, + entry_reason=excluded.entry_reason, + exchange_turnover_usdt=excluded.exchange_turnover_usdt, + exchange_commission_usdt=excluded.exchange_commission_usdt, + payload_json=excluded.payload_json, + synced_at=excluded.synced_at + """, + ( + ex_k, + tid, + sym, + t.get("direction"), + t.get("result"), + float(t.get("pnl_amount") or 0), + t.get("opened_at"), + t.get("closed_at"), + t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")), + t.get("closed_at_ms") or _parse_dt_ms(t.get("closed_at")), + t.get("monitor_type"), + entry_label, + _optional_float(t.get("exchange_turnover_usdt")), + _optional_float(t.get("exchange_commission_usdt")), + json.dumps(payload, ensure_ascii=False, default=str), + now, + ), + ) + n += 1 + finally: + conn.close() + removed = 0 + if prune_missing: + removed = purge_stale_trades_cache(ex_k, active_ids, db_path=db_path) + return {"upserted": n, "removed": removed} + + +def _enrich_trade_display_fields(out: dict[str, Any]) -> dict[str, Any]: + """缓存行补齐复盘优先的展示字段(兼容旧同步数据).""" + opened_ms = out.get("opened_at_ms") or _parse_dt_ms(out.get("opened_at")) + closed_ms = out.get("closed_at_ms") or _parse_dt_ms(out.get("closed_at")) + if opened_ms: + out["opened_at_ms"] = int(opened_ms) + if closed_ms: + out["closed_at_ms"] = int(closed_ms) + if not out.get("opened_at") and opened_ms: + out["opened_at"] = ms_to_wall_clock_str(int(opened_ms)) + if not out.get("closed_at") and closed_ms: + out["closed_at"] = ms_to_wall_clock_str(int(closed_ms)) + entry_type = display_entry_type_label(out) + if entry_type and entry_type != "—": + out["entry_type"] = entry_type + out["entry_reason"] = entry_type + hold_m = out.get("hold_minutes") + if hold_m in (None, ""): + hold_m = effective_hold_minutes( + out, + opened_ms=out.get("opened_at_ms"), + closed_ms=out.get("closed_at_ms"), + ) + try: + hold_m = max(0, int(hold_m or 0)) + except (TypeError, ValueError): + hold_m = 0 + out["hold_minutes"] = hold_m + out["hold_minutes_text"] = out.get("hold_minutes_text") or format_hold_minutes(hold_m) + if "reviewed" not in out: + out["reviewed"] = bool( + out.get("reviewed_at") + or out.get("reviewed_result") + or out.get("reviewed_opened_at") + or out.get("reviewed_closed_at") + or out.get("reviewed_entry_reason") + or out.get("reviewed_hold_minutes") + ) + return out + + +def _trade_row_to_dict(row: sqlite3.Row, overlay: dict | None = None) -> dict[str, Any]: + d = dict(row) + payload = {} + raw = d.pop("payload_json", None) + if raw: + try: + payload = json.loads(raw) + except (json.JSONDecodeError, TypeError): + payload = {} + out = {**payload, **{k: d[k] for k in d.keys() if k not in payload}} + for key in ( + "exchange_key", + "symbol", + "trade_id", + "direction", + "result", + "pnl_amount", + "opened_at", + "closed_at", + "opened_at_ms", + "closed_at_ms", + "monitor_type", + "entry_reason", + "exchange_turnover_usdt", + "exchange_commission_usdt", + "synced_at", + ): + if key in d and d[key] not in (None, ""): + out[key] = d[key] + ov = overlay or {} + from lib.trade.account_risk_lib import parse_mood_issues + + journal_issues = parse_mood_issues( + out.get("journal_mood_issues") or payload.get("journal_mood_issues") + ) + tag_from_journal = bool( + out.get("journal_mood_sick") + or payload.get("journal_mood_sick") + or journal_issues + ) + tag = (ov.get("behavior_tag") or "").strip().lower() + if tag_from_journal: + tag = "sick" + out["behavior_tag"] = tag + out["behavior_tag_from_journal"] = tag_from_journal + if journal_issues: + out["journal_mood_issues"] = journal_issues + out["note"] = ov.get("note") or "" + out["trade_id"] = out.get("trade_id") or out.get("id") + ex_col = str(d.get("exchange_key") or "").strip().lower() + if ex_col: + out["exchange_key"] = ex_col + out.pop("account_exchange_key", None) + return _enrich_trade_display_fields(out) + + +def load_overlays( + exchange_key: str, + trade_ids: list[int] | None = None, + *, + db_path: Path | None = None, +) -> dict[int, dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + if trade_ids: + placeholders = ",".join("?" * len(trade_ids)) + rows = conn.execute( + f""" + SELECT exchange_key, trade_id, behavior_tag, note, updated_at + FROM trade_overlay + WHERE exchange_key=? AND trade_id IN ({placeholders}) + """, + (ex_k, *trade_ids), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT exchange_key, trade_id, behavior_tag, note, updated_at + FROM trade_overlay WHERE exchange_key=? + """, + (ex_k,), + ).fetchall() + return { + int(r["trade_id"]): { + "behavior_tag": r["behavior_tag"] or "", + "note": r["note"] or "", + "updated_at": r["updated_at"], + } + for r in rows + } + finally: + conn.close() + + +def upsert_trade_overlay( + exchange_key: str, + trade_id: int, + *, + behavior_tag: str | None = None, + note: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + tid = int(trade_id) + tag = (behavior_tag or "").strip().lower() + if tag not in BEHAVIOR_TAGS: + tag = "" + note_text = (note or "").strip()[:2000] + now = _now_ms() + conn = _connect(db_path) + try: + conn.execute( + """ + INSERT INTO trade_overlay (exchange_key, trade_id, behavior_tag, note, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT(exchange_key, trade_id) DO UPDATE SET + behavior_tag=excluded.behavior_tag, + note=excluded.note, + updated_at=excluded.updated_at + """, + (ex_k, tid, tag, note_text, now), + ) + finally: + conn.close() + return {"exchange_key": ex_k, "trade_id": tid, "behavior_tag": tag, "note": note_text} + + +def apply_journal_behavior_overlays( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> int: + """复盘情绪标签 → 写入 trade_overlay.behavior_tag=sick(仅正 trade_id).""" + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + n = 0 + for t in trades or []: + if not isinstance(t, dict) or not t.get("journal_mood_sick"): + continue + try: + tid = int(t.get("id")) + except (TypeError, ValueError): + continue + if tid <= 0: + continue + upsert_trade_overlay(ex_k, tid, behavior_tag="sick", db_path=db_path) + n += 1 + return n + + +def list_symbol_rows( + *, + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + filter_sick: bool = False, + filter_emotion: bool = False, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + """一所一币一行汇总.""" + init_db(db_path) + conn = _connect(db_path) + try: + params: list[Any] = [] + where = "1=1" + ex_filter = (exchange_key or "").strip().lower() + if ex_filter: + where += " AND t.exchange_key=?" + params.append(ex_filter) + + rows = conn.execute( + f""" + SELECT t.exchange_key, t.symbol, + COUNT(*) AS trade_count, + SUM(CASE WHEN t.pnl_amount > 0.0001 THEN 1 ELSE 0 END) AS win_count, + SUM(CASE WHEN t.pnl_amount < -0.0001 THEN 1 ELSE 0 END) AS loss_count, + SUM(COALESCE(t.pnl_amount, 0)) AS total_pnl, + MIN(COALESCE(t.opened_at_ms, 0)) AS first_opened_ms, + MAX(COALESCE(t.closed_at_ms, 0)) AS last_closed_ms + FROM archive_trade_cache t + WHERE {where} + GROUP BY t.exchange_key, t.symbol + ORDER BY last_closed_ms DESC + """, + params, + ).fetchall() + + overlays_by_ex: dict[str, dict[int, dict]] = {} + out: list[dict[str, Any]] = [] + for r in rows: + ex_k = r["exchange_key"] + sym = r["symbol"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + + trade_rows = conn.execute( + """ + SELECT trade_id, pnl_amount FROM archive_trade_cache + WHERE exchange_key=? AND symbol=? + """, + (ex_k, sym), + ).fetchall() + has_profit = any(float(x["pnl_amount"] or 0) > 0.0001 for x in trade_rows) + has_loss = any(float(x["pnl_amount"] or 0) < -0.0001 for x in trade_rows) + has_sick = False + has_emotion = False + ov_map = overlays_by_ex.get(ex_k) or {} + for tr in trade_rows: + ov = ov_map.get(int(tr["trade_id"])) or {} + if ov.get("behavior_tag") == "sick": + has_sick = True + if ov.get("behavior_tag") == "emotion": + has_emotion = True + + if filter_profit and not has_profit: + continue + if filter_loss and not has_loss: + continue + if filter_sick and not has_sick: + continue + if filter_emotion and not has_emotion: + continue + + meta = conn.execute( + "SELECT seed_complete, last_kline_sync_ms FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + + out.append( + { + "exchange_key": ex_k, + "symbol": sym, + "trade_count": int(r["trade_count"] or 0), + "win_count": int(r["win_count"] or 0), + "loss_count": int(r["loss_count"] or 0), + "total_pnl": round(float(r["total_pnl"] or 0), 4), + "first_opened_ms": int(r["first_opened_ms"] or 0) or None, + "last_closed_ms": int(r["last_closed_ms"] or 0) or None, + "seed_complete": bool(meta["seed_complete"]) if meta else False, + "last_kline_sync_ms": int(meta["last_kline_sync_ms"] or 0) if meta else None, + } + ) + return out + finally: + conn.close() + + +def load_symbol_trades( + exchange_key: str, + symbol: str, + *, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT * FROM archive_trade_cache + WHERE exchange_key=? AND symbol=? + ORDER BY COALESCE(closed_at_ms, 0) DESC, trade_id DESC + """, + (ex_k, sym), + ).fetchall() + ids = [int(r["trade_id"]) for r in rows] + ov = load_overlays(ex_k, ids, db_path=db_path) + return [_trade_row_to_dict(r, ov.get(int(r["trade_id"]))) for r in rows] + finally: + conn.close() + + +def upsert_bars_5m( + exchange_key: str, + symbol: str, + bars: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> int: + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + now = _now_ms() + n = 0 + conn = _connect(db_path) + try: + for b in bars or []: + try: + conn.execute( + """ + INSERT INTO archive_bars_5m ( + exchange_key, symbol, open_time_ms, open, high, low, close, volume, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, symbol, open_time_ms) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at + """, + ( + ex_k, + sym, + int(b["open_time_ms"]), + float(b["open"]), + float(b["high"]), + float(b["low"]), + float(b["close"]), + float(b.get("volume") or 0), + now, + ), + ) + n += 1 + except (KeyError, TypeError, ValueError): + continue + finally: + conn.close() + return n + + +def load_bars_5m_range( + exchange_key: str, + symbol: str, + start_ms: int, + end_ms: int, + *, + db_path: Path | None = None, +) -> list[dict[str, Any]]: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT open_time_ms, open, high, low, close, volume + FROM archive_bars_5m + WHERE exchange_key=? AND symbol=? + AND open_time_ms >= ? AND open_time_ms <= ? + ORDER BY open_time_ms ASC + """, + (ex_k, sym, int(start_ms), int(end_ms)), + ).fetchall() + return [ + { + "open_time_ms": int(r["open_time_ms"]), + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"] or 0), + } + for r in rows + ] + finally: + conn.close() + + +def _to_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for b in bars or []: + try: + out.append( + { + "time": int(b["open_time_ms"] // 1000), + "open": float(b["open"]), + "high": float(b["high"]), + "low": float(b["low"]), + "close": float(b["close"]), + "volume": float(b.get("volume") or 0), + } + ) + except (KeyError, TypeError, ValueError): + continue + return out + + +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_bars( + bars: list[dict[str, Any]], + period_ms: int, + start_ms: int, + end_ms: int, +) -> list[dict[str, Any]]: + """5m 缺口用上一根收盘价填平,保证聚合后 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), origin, step_ms) + aligned_end = max(int(end_ms), 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 _archive_earliest_bar_ms( + exchange_key: str, + symbol: str, + *, + db_path: Path | None = None, +) -> int | None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT MIN(open_time_ms) AS mn FROM archive_bars_5m WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if row and row["mn"] is not None: + return int(row["mn"]) + finally: + conn.close() + return None + + +def _trim_bars_for_cap( + bars: list[dict[str, Any]], + *, + end_ms: int, + max_n: int, +) -> list[dict[str, Any]]: + """超长时优先保留到平仓,再从最古老端截断.""" + if len(bars) <= max_n: + return bars + cut_end = len(bars) + for i in range(len(bars) - 1, -1, -1): + if int(bars[i]["open_time_ms"]) <= int(end_ms): + cut_end = i + 1 + break + essential = bars[:cut_end] + if len(essential) <= max_n: + return essential + return essential[len(essential) - max_n :] + + +def resolve_archive_chart( + exchange_key: str, + symbol: str, + timeframe: str = ARCHIVE_DEFAULT_TIMEFRAME, + *, + anchor_ms: int | None = None, + opened_ms: int | None = None, + closed_ms: int | None = None, + mode: str = "hold", + bars: int = ARCHIVE_VISIBLE_BARS_DEFAULT, + range_mode: str = "window", + db_path: Path | None = None, +) -> dict[str, Any]: + """从永久 5m 库聚合出档案 K 线视窗. + + range_mode=history:建档起点 → 平仓(不含「到现在」),供拖动/缩放查看建仓前全局形态. + """ + tf = normalize_chart_timeframe(timeframe, default=ARCHIVE_DEFAULT_TIMEFRAME) + if tf not in ARCHIVE_TIMEFRAMES: + return {"ok": False, "msg": f"档案仅支持 {', '.join(sorted(ARCHIVE_TIMEFRAMES))}"} + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + if not ex_k or not sym: + return {"ok": False, "msg": "缺少 exchange_key 或 symbol"} + + period = TIMEFRAME_MS[tf] + period_5m = TIMEFRAME_MS["5m"] + hold_open = int(opened_ms) if opened_ms else None + hold_close = int(closed_ms) if closed_ms else None + rm = (range_mode or "window").strip().lower() + if hold_open and hold_close and hold_close >= hold_open and rm == "history": + seed_back = max(0, hold_open - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000) + earliest = _archive_earliest_bar_ms(ex_k, sym, db_path=db_path) + if earliest is not None: + start_ms = min(earliest, seed_back) + else: + start_ms = seed_back + end_ms = hold_close + max(period * 16, period_5m * 8) + anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open + elif hold_open and hold_close and hold_close >= hold_open: + hold_len = hold_close - hold_open + pad = max(period * 24, hold_len // 3, period_5m * 12) + start_ms = max(0, hold_open - pad) + end_ms = hold_close + pad + anchor = hold_close if (mode or "hold").strip().lower() != "entry" else hold_open + else: + visible = max(50, min(int(bars or ARCHIVE_VISIBLE_BARS_DEFAULT), 500)) + anchor = int(anchor_ms) if anchor_ms else _now_ms() + half = visible // 2 + start_ms = max(0, anchor - half * period) + end_ms = anchor + half * period + + raw_5m = load_bars_5m_range( + ex_k, + sym, + start_ms - period_5m * 6, + end_ms + period_5m * 6, + db_path=db_path, + ) + if not raw_5m: + return {"ok": False, "msg": "档案库暂无 K 线,请等待同步或手动刷新"} + + filled_5m = _fill_missing_bars(raw_5m, period_5m, start_ms - period_5m * 2, end_ms + period_5m * 2) + + if tf == "5m": + merged = [b for b in filled_5m if start_ms <= int(b["open_time_ms"]) <= end_ms] + else: + agg = aggregate_ohlcv_bars(filled_5m, tf) + merged = [b for b in agg if start_ms <= int(b["open_time_ms"]) <= end_ms] + + max_n = ARCHIVE_MAX_CANDLES.get(tf, 2000) + if rm == "history" and merged and len(merged) > max_n: + merged = merged[:max_n] + + candles = _to_candles(merged) + if not candles: + return {"ok": False, "msg": "视窗内无 K 线"} + + ex_sym = normalize_perpetual_symbol(sym) + return { + "ok": True, + "exchange_key": ex_k, + "symbol": sym, + "exchange_symbol": ex_sym, + "market_type": "swap", + "timeframe": tf, + "mode": (mode or "hold").strip().lower(), + "range_mode": rm, + "anchor_ms": anchor, + "opened_ms": hold_open, + "closed_ms": hold_close, + "window_start_ms": start_ms, + "window_end_ms": end_ms, + "candles": candles, + "bar_count": len(candles), + "gaps_filled": sum(1 for b in filled_5m if b.get("filled")), + } + + +def _ensure_meta( + exchange_key: str, + symbol: str, + first_opened_ms: int | None, + *, + db_path: Path | None = None, +) -> None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + now = _now_ms() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT first_trade_opened_ms FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + if row: + if first_opened_ms and ( + not row["first_trade_opened_ms"] + or int(first_opened_ms) < int(row["first_trade_opened_ms"]) + ): + conn.execute( + """ + UPDATE archive_meta SET first_trade_opened_ms=? + WHERE exchange_key=? AND symbol=? + """, + (int(first_opened_ms), ex_k, sym), + ) + return + conn.execute( + """ + INSERT INTO archive_meta ( + exchange_key, symbol, first_trade_opened_ms, + archive_started_at, last_kline_sync_ms, last_trade_sync_ms, seed_complete + ) VALUES (?,?,?,?,?,?,0) + """, + (ex_k, sym, int(first_opened_ms) if first_opened_ms else None, now, None, None), + ) + finally: + conn.close() + + +def _mark_meta_sync( + exchange_key: str, + symbol: str, + *, + kline_ms: int | None = None, + trade_ms: int | None = None, + seed_complete: bool | None = None, + db_path: Path | None = None, +) -> None: + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + sets = [] + params: list[Any] = [] + if kline_ms is not None: + sets.append("last_kline_sync_ms=?") + params.append(int(kline_ms)) + if trade_ms is not None: + sets.append("last_trade_sync_ms=?") + params.append(int(trade_ms)) + if seed_complete is not None: + sets.append("seed_complete=?") + params.append(1 if seed_complete else 0) + if not sets: + return + params.extend([ex_k, sym]) + conn.execute( + f"UPDATE archive_meta SET {', '.join(sets)} WHERE exchange_key=? AND symbol=?", + params, + ) + finally: + conn.close() + + +def fetch_remote_5m_range( + remote_fetch: Callable[..., dict[str, Any]], + symbol: str, + start_ms: int, + end_ms: int, +) -> list[dict[str, Any]]: + """经实例 /api/hub/ohlcv 分页拉取 5m.""" + period = TIMEFRAME_MS["5m"] + since = max(0, int(start_ms)) + end = int(end_ms) + merged: dict[int, dict[str, Any]] = {} + guard = 0 + while since < end and guard < 120: + guard += 1 + remote = remote_fetch(symbol=symbol, timeframe="5m", since_ms=since, limit=500) + if not remote.get("ok"): + break + batch = remote.get("bars") or [] + if not batch: + break + for b in batch: + try: + ts = int(b["open_time_ms"]) + merged[ts] = b + except (KeyError, TypeError, ValueError): + continue + last_ts = max(int(b["open_time_ms"]) for b in batch) + next_since = last_ts + period + if next_since <= since: + break + since = next_since + if last_ts >= end: + break + return [merged[k] for k in sorted(merged.keys()) if start_ms <= k <= end] + + +def seed_symbol_archive( + exchange_key: str, + symbol: str, + first_opened_ms: int, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """建档:最早开仓向前 30 天 5m 种子.""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + anchor = int(first_opened_ms) + start_ms = max(0, anchor - ARCHIVE_SEED_LOOKBACK_DAYS * 86400000) + end_ms = _now_ms() + _ensure_meta(ex_k, sym, anchor, db_path=db_path) + bars = fetch_remote_5m_range(remote_fetch, sym, start_ms, end_ms) + n = upsert_bars_5m(ex_k, sym, bars, db_path=db_path) + now = _now_ms() + _mark_meta_sync(ex_k, sym, kline_ms=now, seed_complete=True, db_path=db_path) + return {"ok": True, "seed_bars": n, "start_ms": start_ms, "end_ms": end_ms} + + +def sync_symbol_klines_incremental( + exchange_key: str, + symbol: str, + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """增量补 5m 至当前.""" + init_db(db_path) + ex_k = (exchange_key or "").strip().lower() + sym = (symbol or "").strip().upper() + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT MAX(open_time_ms) AS mx FROM archive_bars_5m WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + last_bar = int(row["mx"]) if row and row["mx"] else None + finally: + conn.close() + + period = TIMEFRAME_MS["5m"] + start_ms = max(0, (last_bar + period) if last_bar else 0) + end_ms = _now_ms() + if start_ms >= end_ms - period: + return {"ok": True, "appended": 0, "skipped": True} + bars = fetch_remote_5m_range(remote_fetch, sym, start_ms, end_ms) + n = upsert_bars_5m(ex_k, sym, bars, db_path=db_path) + now = _now_ms() + _mark_meta_sync(ex_k, sym, kline_ms=now, db_path=db_path) + return {"ok": True, "appended": n, "start_ms": start_ms, "end_ms": end_ms} + + +def sync_exchange_symbol_archives( + exchange_key: str, + trades: list[dict[str, Any]], + remote_fetch: Callable[..., dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """同步单所:交易缓存 + 各币种 K 线种子/增量.""" + ex_k = (exchange_key or "").strip().lower() + cache_stats = upsert_trades_cache(ex_k, trades, db_path=db_path, prune_missing=True) + apply_journal_behavior_overlays(ex_k, trades, db_path=db_path) + + by_sym: dict[str, int] = {} + for t in trades or []: + sym = (t.get("symbol") or "").strip().upper() + if not sym: + continue + oms = t.get("opened_at_ms") or _parse_dt_ms(t.get("opened_at")) + if oms: + cur = by_sym.get(sym) + if cur is None or int(oms) < cur: + by_sym[sym] = int(oms) + + seeded = 0 + appended = 0 + for sym, first_ms in by_sym.items(): + _ensure_meta(ex_k, sym, first_ms, db_path=db_path) + conn = _connect(db_path) + try: + meta = conn.execute( + "SELECT seed_complete FROM archive_meta WHERE exchange_key=? AND symbol=?", + (ex_k, sym), + ).fetchone() + finally: + conn.close() + if not meta or not int(meta["seed_complete"] or 0): + r = seed_symbol_archive(ex_k, sym, first_ms, remote_fetch, db_path=db_path) + seeded += int(r.get("seed_bars") or 0) + else: + r = sync_symbol_klines_incremental(ex_k, sym, remote_fetch, db_path=db_path) + appended += int(r.get("appended") or 0) + + return { + "ok": True, + "exchange_key": ex_k, + "symbols": len(by_sym), + "trades_upserted": int(cache_stats.get("upserted") or 0), + "trades_removed": int(cache_stats.get("removed") or 0), + "seed_bars": seeded, + "appended_bars": appended, + "trades": len(trades or []), + } + + +def ms_to_trading_day( + ms: int | None, + *, + reset_hour: int = TRADING_DAY_RESET_HOUR, + tz: ZoneInfo = CHART_DISPLAY_TZ, +) -> str | None: + if ms is None: + return None + try: + dt = datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc).astimezone(tz) + except (TypeError, ValueError, OSError): + return None + if dt.hour < reset_hour: + dt = dt - timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +def today_trading_day(*, reset_hour: int = TRADING_DAY_RESET_HOUR) -> str: + return ms_to_trading_day(_now_ms(), reset_hour=reset_hour) or datetime.now( + CHART_DISPLAY_TZ + ).strftime("%Y-%m-%d") + + +def trading_day_bounds_ms( + trading_day: str, + *, + reset_hour: int = TRADING_DAY_RESET_HOUR, + tz: ZoneInfo = CHART_DISPLAY_TZ, +) -> tuple[int, int]: + day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d") + start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0, tzinfo=tz) + end = start + timedelta(days=1) + return int(start.timestamp() * 1000), int(end.timestamp() * 1000) + + +def resolve_period_bounds( + *, + period: str = "", + trading_day: str = "", + date_from: str = "", + date_to: str = "", + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> tuple[int, int, str, str, str]: + """返回 (start_ms, end_ms, date_from, date_to, period_label).""" + td = today_trading_day(reset_hour=reset_hour) + p = (period or "today").strip().lower() + if p in ("day", "today", ""): + d = (trading_day or "").strip()[:10] or td + start_ms, end_ms = trading_day_bounds_ms(d, reset_hour=reset_hour) + return start_ms, end_ms, d, d, f"本日 {d}" + if p == "week": + day_dt = datetime.strptime(td, "%Y-%m-%d") + monday = day_dt - timedelta(days=day_dt.weekday()) + df = monday.strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(td, reset_hour=reset_hour) + return start_ms, end_ms, df, td, f"本周 {df}~{td}" + if p == "month": + day_dt = datetime.strptime(td, "%Y-%m-%d") + first = day_dt.replace(day=1) + df = first.strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(td, reset_hour=reset_hour) + return start_ms, end_ms, df, td, f"本月 {df}~{td}" + if p == "range": + df = (date_from or "").strip()[:10] or td + dt = (date_to or "").strip()[:10] or df + if df > dt: + df, dt = dt, df + start_ms, _ = trading_day_bounds_ms(df, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(dt, reset_hour=reset_hour) + label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}" + return start_ms, end_ms, df, dt, label + d = (trading_day or "").strip()[:10] or td + start_ms, end_ms = trading_day_bounds_ms(d, reset_hour=reset_hour) + return start_ms, end_ms, d, d, f"本日 {d}" + + +def _pnl_side(pnl: float) -> str: + if pnl > 0.0001: + return "win" + if pnl < -0.0001: + return "loss" + return "flat" + + +def _empty_pnl_bucket() -> dict[str, Any]: + return { + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "pnl_ex_sick": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "win_count": 0, + "loss_count": 0, + "avg_win": None, + "avg_loss": None, + "max_win": None, + "max_loss": None, + } + + +def _finalize_pnl_bucket(bucket: dict[str, Any]) -> None: + wins = bucket.pop("_wins", []) + losses = bucket.pop("_losses", []) + open_count = int(bucket.get("open_count") or 0) + win_count = len(wins) + bucket["win_count"] = win_count + bucket["loss_count"] = len(losses) + bucket["avg_win"] = round(sum(wins) / len(wins), 4) if wins else None + avg_loss = round(sum(losses) / len(losses), 4) if losses else None + bucket["avg_loss"] = avg_loss + bucket["max_win"] = round(max(wins), 4) if wins else None + bucket["max_loss"] = round(min(losses), 4) if losses else None + bucket["pnl_total"] = round(float(bucket.get("pnl_total") or 0), 4) + bucket["pnl_ex_sick"] = round(float(bucket.get("pnl_ex_sick") or 0), 4) + bucket["turnover_total"] = round(float(bucket.get("turnover_total") or 0), 4) + bucket["commission_total"] = round(float(bucket.get("commission_total") or 0), 4) + bucket["win_rate"] = round(win_count / open_count * 100, 1) if open_count else None + avg_win = bucket["avg_win"] + if avg_win is not None and avg_loss is not None and avg_loss != 0: + bucket["profit_loss_ratio"] = round(avg_win / abs(avg_loss), 2) + else: + bucket["profit_loss_ratio"] = None + + +def _accumulate_trade_stat( + bucket: dict[str, Any], + *, + pnl: float, + is_sick: bool, + turnover: float = 0.0, + commission: float = 0.0, +) -> None: + bucket["open_count"] += 1 + bucket["pnl_total"] += pnl + bucket["turnover_total"] += turnover + bucket["commission_total"] += commission + if is_sick: + bucket["sick_count"] += 1 + else: + bucket["pnl_ex_sick"] += pnl + side = _pnl_side(pnl) + if side == "win": + bucket.setdefault("_wins", []).append(pnl) + elif side == "loss": + bucket.setdefault("_losses", []).append(pnl) + + +def _compute_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: + total_bucket = _empty_pnl_bucket() + by_ex: dict[str, dict[str, Any]] = {} + for td_row in trade_rows: + ex = str(td_row.get("exchange_key") or "?") + pnl = float(td_row.get("pnl_amount") or 0) + tag = str(td_row.get("behavior_tag") or "") + is_sick = tag == "sick" + turnover = float(td_row.get("exchange_turnover_usdt") or 0) + commission = float(td_row.get("exchange_commission_usdt") or 0) + _accumulate_trade_stat( + total_bucket, pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission + ) + if ex not in by_ex: + by_ex[ex] = _empty_pnl_bucket() + _accumulate_trade_stat( + by_ex[ex], pnl=pnl, is_sick=is_sick, turnover=turnover, commission=commission + ) + _finalize_pnl_bucket(total_bucket) + for ex in by_ex: + _finalize_pnl_bucket(by_ex[ex]) + total = int(total_bucket["open_count"] or 0) + sick = int(total_bucket["sick_count"] or 0) + sick_pct = round(sick / total * 100, 1) if total else 0.0 + return { + "open_count": total, + "sick_count": sick, + "sick_pct": sick_pct, + "pnl_total": total_bucket["pnl_total"], + "pnl_ex_sick": total_bucket["pnl_ex_sick"], + "win_count": total_bucket["win_count"], + "loss_count": total_bucket["loss_count"], + "avg_win": total_bucket["avg_win"], + "avg_loss": total_bucket["avg_loss"], + "max_win": total_bucket["max_win"], + "max_loss": total_bucket["max_loss"], + "win_rate": total_bucket["win_rate"], + "profit_loss_ratio": total_bucket["profit_loss_ratio"], + "turnover_total": total_bucket["turnover_total"], + "commission_total": total_bucket["commission_total"], + "by_exchange": by_ex, + } + + +def list_review_quotes(*, db_path: Path | None = None) -> list[dict[str, Any]]: + init_db(db_path) + conn = _connect(db_path) + try: + rows = conn.execute( + """ + SELECT id, quote_date, content, created_at, updated_at + FROM archive_review_quotes + ORDER BY quote_date DESC + LIMIT ? + """, + (ARCHIVE_QUOTES_MAX,), + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def create_review_quote( + quote_date: str, + content: str, + *, + db_path: Path | None = None, +) -> dict[str, Any]: + init_db(db_path) + qd = (quote_date or "").strip()[:10] + if not qd: + raise ValueError("缺少 quote_date") + text = (content or "").strip() + if not text: + raise ValueError("语录内容不能为空") + if len(text) > ARCHIVE_QUOTE_MAX_LEN: + raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN} 字") + conn = _connect(db_path) + try: + cnt = conn.execute("SELECT COUNT(*) AS c FROM archive_review_quotes").fetchone() + if int(cnt["c"] or 0) >= ARCHIVE_QUOTES_MAX: + raise ValueError(f"复盘语录最多保存 {ARCHIVE_QUOTES_MAX} 条") + now = _now_ms() + try: + cur = conn.execute( + """ + INSERT INTO archive_review_quotes (quote_date, content, created_at, updated_at) + VALUES (?,?,?,?) + """, + (qd, text, now, now), + ) + except sqlite3.IntegrityError as e: + raise ValueError("该日期已有语录,请展开编辑") from e + rid = int(cur.lastrowid) + row = conn.execute( + "SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?", + (rid,), + ).fetchone() + return dict(row) + finally: + conn.close() + + +def update_review_quote( + quote_id: int, + *, + quote_date: str | None = None, + content: str | None = None, + db_path: Path | None = None, +) -> dict[str, Any] | None: + init_db(db_path) + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT id, quote_date, content FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ).fetchone() + if not row: + return None + qd = (quote_date or row["quote_date"] or "").strip()[:10] + text = (content if content is not None else row["content"] or "").strip() + if not qd or not text: + raise ValueError("日期与内容均不能为空") + if len(text) > ARCHIVE_QUOTE_MAX_LEN: + raise ValueError(f"语录最长 {ARCHIVE_QUOTE_MAX_LEN} 字") + now = _now_ms() + conn.execute( + """ + UPDATE archive_review_quotes + SET quote_date=?, content=?, updated_at=? + WHERE id=? + """, + (qd, text, now, int(quote_id)), + ) + out = conn.execute( + "SELECT id, quote_date, content, created_at, updated_at FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ).fetchone() + return dict(out) if out else None + finally: + conn.close() + + +def delete_review_quote(quote_id: int, *, db_path: Path | None = None) -> bool: + init_db(db_path) + conn = _connect(db_path) + try: + cur = conn.execute( + "DELETE FROM archive_review_quotes WHERE id=?", + (int(quote_id),), + ) + return int(cur.rowcount or 0) > 0 + finally: + conn.close() + + +def list_daily_trades( + trading_day: str = "", + *, + period: str = "", + date_from: str = "", + date_to: str = "", + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + filter_sick: bool = False, + search: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + """按日期区间列出平仓记录(本日/本周/本月/自选,以平仓时间计),含犯病与盈亏统计.""" + init_db(db_path) + p = (period or "today").strip().lower() or "today" + start_ms, end_ms, df, dt, period_label = resolve_period_bounds( + period=p, + trading_day=trading_day, + date_from=date_from, + date_to=date_to, + ) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + rows = conn.execute( + f""" + SELECT * FROM archive_trade_cache + WHERE {where} + ORDER BY closed_at_ms DESC, trade_id DESC + """, + params, + ).fetchall() + overlays_by_ex: dict[str, dict[int, dict]] = {} + trades: list[dict[str, Any]] = [] + q = (search or "").strip().lower() + for r in rows: + ex_k = r["exchange_key"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"]))) + pnl = float(td_row.get("pnl_amount") or 0) + tag = td_row.get("behavior_tag") or "" + if filter_profit and pnl <= 0.0001: + continue + if filter_loss and pnl >= -0.0001: + continue + if filter_sick and tag != "sick": + continue + if q: + blob = " ".join( + str(td_row.get(k) or "") + for k in ( + "symbol", + "exchange_key", + "direction", + "result", + "note", + "monitor_type", + "entry_reason", + ) + ).lower() + if q not in blob: + continue + trades.append(td_row) + return { + "period": p, + "period_label": period_label, + "trading_day": dt, + "date_from": df, + "date_to": dt, + "trades": trades, + "stats": _compute_period_stats(trades), + } + finally: + conn.close() + + +def list_archive_calendar( + year: int, + month: int, + *, + exchange_key: str = "", + db_path: Path | None = None, + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> dict[str, Any]: + """按月返回每个交易日的盈亏,笔数,犯病标记(08:00 切日).""" + init_db(db_path) + 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") + start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + rows = conn.execute( + f"SELECT * FROM archive_trade_cache WHERE {where}", + params, + ).fetchall() + overlays_by_ex: dict[str, dict[int, dict]] = {} + days: dict[str, dict[str, Any]] = {} + for r in rows: + ex_k = r["exchange_key"] + if ex_k not in overlays_by_ex: + overlays_by_ex[ex_k] = load_overlays(ex_k, db_path=db_path) + td_row = _trade_row_to_dict(r, overlays_by_ex[ex_k].get(int(r["trade_id"]))) + closed_ms = td_row.get("closed_at_ms") or _parse_dt_ms(td_row.get("closed_at")) + if not closed_ms: + continue + day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour) + if not day: + continue + if day < first or day > last: + continue + bucket = days.setdefault( + day, + { + "trading_day": day, + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + }, + ) + pnl = float(td_row.get("pnl_amount") or 0) + tag = str(td_row.get("behavior_tag") or "") + is_sick = tag == "sick" + bucket["open_count"] += 1 + bucket["pnl_total"] += pnl + bucket["turnover_total"] += float(td_row.get("exchange_turnover_usdt") or 0) + bucket["commission_total"] += float(td_row.get("exchange_commission_usdt") or 0) + if is_sick: + bucket["sick_count"] += 1 + bucket["has_sick"] = True + 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, + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + } + finally: + conn.close() diff --git a/lib/hub/hub_symbol_lib.py b/lib/hub/hub_symbol_lib.py new file mode 100644 index 0000000..2ec4aa6 --- /dev/null +++ b/lib/hub/hub_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/hub/hub_system_logs_lib.py b/lib/hub/hub_system_logs_lib.py new file mode 100644 index 0000000..43aba88 --- /dev/null +++ b/lib/hub/hub_system_logs_lib.py @@ -0,0 +1,172 @@ +"""中控:读取 PM2 进程 stdout/stderr 日志(系统日志页).""" +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path +from typing import Any + +LOG_TARGETS: dict[str, dict[str, str]] = { + "binance": {"label": "币安", "pm2_name": "crypto_binance"}, + "gate": {"label": "Gate", "pm2_name": "crypto_gate"}, + "okx": {"label": "OKX", "pm2_name": "crypto_okx"}, + "hub": {"label": "中控", "pm2_name": "manual-trading-hub"}, +} + +DEFAULT_LINES = 200 +MAX_LINES = 500 +DEFAULT_TAIL_BYTES = 400_000 +_PATH_CACHE_TTL_SEC = 30 + +_path_cache: dict[str, tuple[Path, Path]] = {} +_path_cache_at = 0.0 + + +def pm2_logs_dir() -> Path: + raw = (os.getenv("PM2_HOME") or "").strip() + base = Path(raw) if raw else Path.home() / ".pm2" + return base / "logs" + + +def _pm2_jlist() -> list[dict[str, Any]]: + try: + proc = subprocess.run( + ["pm2", "jlist"], + capture_output=True, + text=True, + timeout=8, + check=False, + ) + if proc.returncode != 0: + return [] + data = json.loads(proc.stdout or "[]") + return data if isinstance(data, list) else [] + except (OSError, subprocess.SubprocessError, json.JSONDecodeError, ValueError): + return [] + + +def _newest_match(pattern: str, logs_dir: Path) -> Path | None: + matches = [p for p in logs_dir.glob(pattern) if p.is_file()] + if not matches: + return None + return max(matches, key=lambda p: p.stat().st_mtime) + + +def _glob_log_paths(pm2_name: str) -> tuple[Path, Path]: + logs_dir = pm2_logs_dir() + out_path = Path() + err_path = Path() + if not logs_dir.is_dir(): + return out_path, err_path + slugs = {pm2_name, pm2_name.replace("_", "-")} + for slug in slugs: + if not out_path.is_file(): + hit = _newest_match(f"{slug}-out*.log", logs_dir) + if hit is not None: + out_path = hit + if not err_path.is_file(): + hit = _newest_match(f"{slug}-error*.log", logs_dir) + if hit is not None: + err_path = hit + return out_path, err_path + + +def resolve_log_paths(pm2_name: str) -> tuple[Path, Path]: + global _path_cache_at + now = time.time() + if now - _path_cache_at > _PATH_CACHE_TTL_SEC: + _path_cache.clear() + _path_cache_at = now + cached = _path_cache.get(pm2_name) + if cached is not None: + return cached + + out_path = Path() + err_path = Path() + for proc in _pm2_jlist(): + if proc.get("name") != pm2_name: + continue + env = proc.get("pm2_env") or {} + raw_out = (env.get("pm_out_log_path") or "").strip() + raw_err = (env.get("pm_err_log_path") or "").strip() + if raw_out: + out_path = Path(raw_out) + if raw_err: + err_path = Path(raw_err) + break + + glob_out, glob_err = _glob_log_paths(pm2_name) + if not out_path.is_file() and glob_out.is_file(): + out_path = glob_out + if not err_path.is_file() and glob_err.is_file(): + err_path = glob_err + + result = (out_path, err_path) + _path_cache[pm2_name] = result + return result + + +def log_file_paths(pm2_name: str) -> tuple[Path, Path]: + return resolve_log_paths(pm2_name) + + +def tail_lines( + path: Path, + lines: int = DEFAULT_LINES, + *, + max_bytes: int = DEFAULT_TAIL_BYTES, +) -> str: + if not path.is_file(): + return "" + try: + size = path.stat().st_size + with path.open("rb") as handle: + if size <= max_bytes: + data = handle.read() + else: + handle.seek(max(0, size - max_bytes)) + data = handle.read() + text = data.decode("utf-8", errors="replace") + parts = text.splitlines() + if len(parts) > lines: + parts = parts[-lines:] + return "\n".join(parts) + except OSError: + return "" + + +def system_logs_meta() -> dict[str, Any]: + return { + "ok": True, + "targets": [ + {"key": key, "label": cfg["label"], "pm2_name": cfg["pm2_name"]} + for key, cfg in LOG_TARGETS.items() + ], + "default_lines": DEFAULT_LINES, + "max_lines": MAX_LINES, + } + + +def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]: + key = (target or "").strip().lower() + if key not in LOG_TARGETS: + raise KeyError(key) + cfg = LOG_TARGETS[key] + line_count = max(20, min(MAX_LINES, int(lines or DEFAULT_LINES))) + out_path, err_path = resolve_log_paths(cfg["pm2_name"]) + return { + "ok": True, + "key": key, + "label": cfg["label"], + "pm2_name": cfg["pm2_name"], + "lines": line_count, + "out": tail_lines(out_path, line_count), + "err": tail_lines(err_path, line_count), + "out_exists": out_path.is_file(), + "err_exists": err_path.is_file(), + "out_path": str(out_path) if str(out_path) else "", + "err_path": str(err_path) if str(err_path) else "", + "updated_at": int(time.time()), + } diff --git a/lib/hub/hub_trades_lib.py b/lib/hub/hub_trades_lib.py new file mode 100644 index 0000000..ddc4a92 --- /dev/null +++ b/lib/hub/hub_trades_lib.py @@ -0,0 +1,742 @@ +"""各实例当日平仓记录查询(供 hub_bridge /api/hub/trades/today 与中控 AI 聚合).""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Callable, Optional + +from lib.strategy.strategy_trade_labels import ( + MONITOR_TYPE_ROLL, + MONITOR_TYPE_TREND_PULLBACK, + entry_reason_for_monitor_type, +) +from lib.trade.time_close_lib import TIME_CLOSE_RESULT +from lib.trade.entry_model_lib import format_entry_type_display + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + + +def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str: + """与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日.""" + if dt.hour < reset_hour: + dt = dt - timedelta(days=1) + return dt.strftime("%Y-%m-%d") + + +def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> str: + return trading_day_from_dt(now or datetime.now(), reset_hour) + + +def parse_dt_for_trading_day(raw: Any) -> datetime | None: + if raw is None: + return None + s = str(raw).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 trading_day_window_bounds(trading_day: str, reset_hour: int = 8) -> tuple[str, str]: + """交易日 [reset_hour, 次日 reset_hour) 对应的北京时间字符串区间(闭区间).""" + day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d") + start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) - timedelta(seconds=1) + return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S") + + +def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict: + if row is None: + return {} + if row_to_dict: + try: + return dict(row_to_dict(row)) + except Exception: + pass + try: + keys = row.keys() if hasattr(row, "keys") else () + if keys: + return {k: row[k] for k in keys} + except Exception: + pass + try: + return dict(row) + except Exception: + return {} + + +def _effective_field(d: dict, reviewed_key: str, base_key: str, default: Any = None) -> Any: + rv = d.get(reviewed_key) + if rv is not None and str(rv).strip() != "": + return rv + bv = d.get(base_key) + if bv is not None and str(bv).strip() != "": + return bv + return default + + +def format_hold_minutes(minutes: Any) -> str: + try: + total = int(minutes or 0) + except (TypeError, ValueError): + return "0分钟" + if total <= 0: + return "0分钟" + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def _normalize_monitor_type_label(raw: Any) -> str: + mt = str(raw or "").strip() + if mt in ("trend_pullback", "trend"): + return MONITOR_TYPE_TREND_PULLBACK + if mt in ("roll",): + return MONITOR_TYPE_ROLL + return mt + + +def effective_entry_type(d: dict) -> str: + """复盘开仓类型优先,与实例交易记录 effective_entry_reason 一致.""" + er = _effective_field(d, "reviewed_entry_reason", "entry_reason") + if er is not None and str(er).strip(): + return str(er).strip() + mt = _normalize_monitor_type_label(d.get("monitor_type")) + er2 = entry_reason_for_monitor_type(mt) + if er2: + return er2 + kst = str(d.get("key_signal_type") or "").strip() + if kst: + return kst + legacy = str(d.get("entry_type") or "").strip() + if legacy and legacy not in ("trend_pullback", "roll", "trend"): + return _normalize_monitor_type_label(legacy) or legacy + return mt + + +def display_entry_type_label(d: dict) -> str: + """档案/列表展示用开仓类型(不回落为「下单监控」若已有复盘或建档类型).""" + label = effective_entry_type(d).strip() + if not label: + return "—" + formatted = format_entry_type_display( + label, + entry_model=d.get("entry_model"), + trade_style=d.get("trade_style"), + ) + out = _normalize_monitor_type_label(formatted) or formatted + return out or "—" + + +def effective_hold_minutes( + d: dict, + *, + opened_ms: int | None = None, + closed_ms: int | None = None, +) -> int: + hm = _effective_field(d, "reviewed_hold_minutes", "hold_minutes") + if hm is not None and str(hm).strip() != "": + try: + return max(0, int(hm)) + except (TypeError, ValueError): + pass + hs = _effective_field(d, "reviewed_hold_seconds", "hold_seconds") + if hs is not None and str(hs).strip() != "": + try: + return max(0, int(int(hs) // 60)) + except (TypeError, ValueError): + pass + oms = opened_ms if opened_ms is not None else d.get("opened_at_ms") + cms = closed_ms if closed_ms is not None else d.get("closed_at_ms") + try: + oms_i = int(oms) if oms not in (None, "") else None + cms_i = int(cms) if cms not in (None, "") else None + except (TypeError, ValueError): + oms_i = cms_i = None + if oms_i and cms_i and cms_i > oms_i: + return max(0, int((cms_i - oms_i) // 60_000)) + return 0 + + +def _effective_pnl(d: dict) -> float: + reviewed = d.get("reviewed_pnl_amount") + if reviewed is not None and str(reviewed).strip() != "": + try: + return float(reviewed) + except (TypeError, ValueError): + pass + ex = d.get("exchange_realized_pnl") + if ex is not None and str(ex).strip() != "": + try: + return float(ex) + except (TypeError, ValueError): + pass + try: + return float(d.get("pnl_amount") or 0) + except (TypeError, ValueError): + return 0.0 + + +def _trade_close_dt(d: dict) -> datetime | None: + raw = _effective_field(d, "reviewed_closed_at", "closed_at") + if raw is None or str(raw).strip() == "": + raw = d.get("created_at") or d.get("opened_at") + return parse_dt_for_trading_day(raw) + + +def _normalize_trade_row( + d: dict, + *, + trading_day: str, + reset_hour: int, +) -> dict[str, Any] | None: + effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + return None + close_dt = _trade_close_dt(d) + if not close_dt: + return None + if trading_day_from_dt(close_dt, reset_hour) != trading_day: + return None + pnl = _effective_pnl(d) + closed_at = _effective_field(d, "reviewed_closed_at", "closed_at") + opened_at = _effective_field(d, "reviewed_opened_at", "opened_at") + return { + "symbol": d.get("symbol"), + "direction": d.get("direction"), + "result": effective_result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "monitor_type": d.get("monitor_type"), + "actual_rr": d.get("actual_rr"), + "planned_rr": d.get("planned_rr"), + "trade_style": d.get("trade_style"), + "entry_reason": d.get("entry_reason"), + "reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")), + } + + +def fetch_trades_for_trading_day( + conn, + trading_day: str, + *, + row_to_dict_fn: Optional[Callable] = None, + reset_hour: int = 8, + limit: int = 200, +) -> list[dict[str, Any]]: + """返回指定交易日的已平仓记录(与 /records 交易记录一致,复盘字段优先).""" + day = (trading_day or "").strip()[:10] + if not day: + return [] + lim = max(1, min(int(limit or 200), 500)) + start_bj, end_bj = trading_day_window_bounds(day, reset_hour) + ts_expr = "REPLACE(COALESCE(reviewed_closed_at, closed_at, created_at, opened_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT symbol, direction, result, reviewed_result, pnl_amount, reviewed_pnl_amount, + exchange_realized_pnl, closed_at, reviewed_closed_at, opened_at, reviewed_opened_at, + created_at, monitor_type, actual_rr, planned_rr, trade_style, entry_reason, + reviewed_at + FROM trade_records + WHERE {ts_expr} >= ? AND {ts_expr} <= ? + ORDER BY {ts_expr} ASC + LIMIT ? + """, + (start_bj, end_bj, lim * 3), + ).fetchall() + out: list[dict[str, Any]] = [] + for row in rows: + d = _row_dict(row, row_to_dict_fn) + norm = _normalize_trade_row(d, trading_day=day, reset_hour=reset_hour) + if norm: + out.append(norm) + if len(out) >= lim: + break + return out + + +def _normalize_archive_trade_row( + d: dict, + *, + exchange_key: str = "", + reset_hour: int = 8, +) -> dict[str, Any] | None: + """全历史档案用:已平仓记录(不按交易日截断).""" + effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + return None + close_dt = _trade_close_dt(d) + if not close_dt: + return None + pnl = _effective_pnl(d) + closed_at = _effective_field(d, "reviewed_closed_at", "closed_at") + opened_at = _effective_field(d, "reviewed_opened_at", "opened_at") + opened_ms = d.get("opened_at_ms") + closed_ms = d.get("closed_at_ms") + if opened_ms in (None, ""): + odt = parse_dt_for_trading_day(opened_at) + opened_ms = int(odt.timestamp() * 1000) if odt else None + if closed_ms in (None, ""): + cdt = close_dt + closed_ms = int(cdt.timestamp() * 1000) if cdt else None + try: + trade_id = int(d.get("id")) + except (TypeError, ValueError): + return None + opened_ms_i = int(opened_ms) if opened_ms else None + closed_ms_i = int(closed_ms) if closed_ms else None + hold_m = effective_hold_minutes(d, opened_ms=opened_ms_i, closed_ms=closed_ms_i) + entry_type = display_entry_type_label(d) + reviewed = bool( + d.get("reviewed_at") + or d.get("reviewed_result") + or d.get("reviewed_opened_at") + or d.get("reviewed_closed_at") + or d.get("reviewed_entry_reason") + or d.get("reviewed_hold_minutes") + ) + return { + "id": trade_id, + "exchange_key": (exchange_key or "").strip().lower(), + "symbol": (d.get("symbol") or "").strip().upper(), + "direction": d.get("direction"), + "result": effective_result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "opened_at_ms": opened_ms_i, + "closed_at_ms": closed_ms_i, + "monitor_type": _normalize_monitor_type_label(d.get("monitor_type")), + "entry_type": entry_type, + "entry_reason": entry_type, + "hold_minutes": hold_m, + "hold_minutes_text": format_hold_minutes(hold_m), + "actual_rr": d.get("actual_rr"), + "planned_rr": d.get("planned_rr"), + "trade_style": d.get("trade_style"), + "trigger_price": d.get("trigger_price"), + "stop_loss": _effective_field(d, "reviewed_stop_loss", "stop_loss"), + "take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"), + "reviewed": reviewed, + "trading_day": trading_day_from_dt(close_dt, reset_hour), + "exchange_turnover_usdt": d.get("exchange_turnover_usdt"), + "exchange_commission_usdt": d.get("exchange_commission_usdt"), + } + + +_SNAPSHOT_STATUS_TO_RESULT = { + "stopped_sl": "止损", + "stopped_tp": "止盈", + "stopped_manual": "手动平仓", + "stopped_external": "外部平仓", +} + + +def _table_columns(conn, table: str) -> set[str]: + try: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + except Exception: + return set() + out: set[str] = set() + for r in rows: + try: + out.add(str(r[1])) + except (IndexError, TypeError): + try: + out.add(str(r["name"])) + except Exception: + continue + return out + + +def _archive_ts_expr(cols: set[str]) -> str: + parts = [c for c in ("reviewed_closed_at", "closed_at", "created_at", "opened_at") if c in cols] + if not parts: + return "''" + return f"REPLACE(COALESCE({', '.join(parts)}), 'T', ' ')" + + +def _archive_trade_select_sql(cols: set[str]) -> str: + wanted = [ + "id", + "symbol", + "direction", + "result", + "reviewed_result", + "pnl_amount", + "reviewed_pnl_amount", + "exchange_realized_pnl", + "closed_at", + "reviewed_closed_at", + "opened_at", + "reviewed_opened_at", + "opened_at_ms", + "closed_at_ms", + "created_at", + "monitor_type", + "key_signal_type", + "actual_rr", + "planned_rr", + "trade_style", + "entry_reason", + "reviewed_entry_reason", + "hold_minutes", + "reviewed_hold_minutes", + "hold_seconds", + "reviewed_hold_seconds", + "trigger_price", + "stop_loss", + "take_profit", + "reviewed_stop_loss", + "reviewed_take_profit", + "reviewed_at", + "trend_plan_id", + "exchange_turnover_usdt", + "exchange_commission_usdt", + ] + select_cols = [c for c in wanted if c in cols] + if "id" not in select_cols: + select_cols = ["id"] + select_cols + return ", ".join(select_cols) + + +def _existing_trend_plan_ids(conn) -> set[int]: + cols = _table_columns(conn, "trade_records") + if "trend_plan_id" not in cols: + return set() + rows = conn.execute( + "SELECT DISTINCT trend_plan_id FROM trade_records WHERE trend_plan_id IS NOT NULL" + ).fetchall() + out: set[int] = set() + for row in rows: + d = _row_dict(row) + try: + out.add(int(d.get("trend_plan_id"))) + except (TypeError, ValueError): + continue + return out + + +def _normalize_snapshot_archive_row( + snap: dict, + *, + exchange_key: str = "", + reset_hour: int = 8, +) -> dict[str, Any] | None: + result = str(snap.get("result_label") or "").strip() + if not result: + result = _SNAPSHOT_STATUS_TO_RESULT.get( + str(snap.get("status_at_close") or "").strip(), "" + ) + if result not in TRADE_COMPLETED_RESULTS: + return None + closed_at = snap.get("closed_at") + close_dt = parse_dt_for_trading_day(closed_at) + if not close_dt: + return None + opened_at = snap.get("opened_at") + opened_ms = _parse_ms_from_row(snap.get("opened_at")) + closed_ms = _parse_ms_from_row(closed_at) + try: + snap_id = int(snap.get("id")) + except (TypeError, ValueError): + return None + try: + pnl = float(snap.get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + st = str(snap.get("strategy_type") or "").strip() + monitor_type = _normalize_monitor_type_label( + "trend_pullback" if st == "trend_pullback" else ("roll" if st == "roll" else st) + ) + hold_m = effective_hold_minutes( + {}, + opened_ms=opened_ms, + closed_ms=closed_ms, + ) + entry_type = entry_reason_for_monitor_type(monitor_type) or monitor_type + return { + "id": -snap_id, + "exchange_key": (exchange_key or "").strip().lower(), + "symbol": (snap.get("symbol") or "").strip().upper(), + "direction": snap.get("direction"), + "result": result, + "pnl_amount": round(pnl, 4), + "closed_at": closed_at, + "opened_at": opened_at, + "opened_at_ms": opened_ms, + "closed_at_ms": closed_ms, + "monitor_type": monitor_type, + "entry_type": entry_type, + "entry_reason": entry_type, + "hold_minutes": hold_m, + "hold_minutes_text": format_hold_minutes(hold_m), + "from_snapshot": True, + "snapshot_id": snap_id, + "trend_plan_id": snap.get("source_id"), + "reviewed": False, + "trading_day": trading_day_from_dt(close_dt, reset_hour), + } + + +def _parse_ms_from_row(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + if isinstance(raw, (int, float)): + v = int(raw) + return v if v > 1_000_000_000_000 else v * 1000 + except (TypeError, ValueError): + pass + dt = parse_dt_for_trading_day(raw) + return int(dt.timestamp() * 1000) if dt else None + + +def _fetch_strategy_snapshots_for_archive( + conn, + *, + exchange_key: str = "", + days: int = 365, + reset_hour: int = 8, + limit: int = 2000, + skip_plan_ids: set[int] | None = None, +) -> list[dict[str, Any]]: + cols = _table_columns(conn, "strategy_trade_snapshots") + if not cols: + return [] + lim = max(1, min(int(limit or 2000), 5000)) + day_span = max(1, min(int(days or 365), 3650)) + cutoff = datetime.now() - timedelta(days=day_span) + cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S") + ts_expr = "REPLACE(COALESCE(closed_at, opened_at, created_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT * FROM strategy_trade_snapshots + WHERE {ts_expr} >= ? + ORDER BY {ts_expr} DESC + LIMIT ? + """, + (cutoff_s, lim * 2), + ).fetchall() + skip = skip_plan_ids or set() + out: list[dict[str, Any]] = [] + for row in rows: + d = _row_dict(row) + try: + source_id = int(d.get("source_id") or 0) + except (TypeError, ValueError): + source_id = 0 + if source_id > 0 and source_id in skip: + continue + norm = _normalize_snapshot_archive_row( + d, exchange_key=exchange_key, reset_hour=reset_hour + ) + if norm: + out.append(norm) + if len(out) >= lim: + break + return out + + +def fetch_trades_for_archive( + conn, + *, + exchange_key: str = "", + days: int = 365, + row_to_dict_fn: Optional[Callable] = None, + reset_hour: int = 8, + limit: int = 2000, + include_strategy_snapshots: bool = True, +) -> list[dict[str, Any]]: + """返回近 N 天已平仓记录(trade_records + 未落库的 strategy 快照).""" + lim = max(1, min(int(limit or 2000), 5000)) + day_span = max(1, min(int(days or 365), 3650)) + cutoff = datetime.now() - timedelta(days=day_span) + cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S") + cols = _table_columns(conn, "trade_records") + if not cols: + records: list[dict[str, Any]] = [] + else: + ts_expr = _archive_ts_expr(cols) + sql = f""" + SELECT {_archive_trade_select_sql(cols)} + FROM trade_records + WHERE {ts_expr} >= ? + ORDER BY {ts_expr} DESC + LIMIT ? + """ + rows = conn.execute(sql, (cutoff_s, lim * 2)).fetchall() + records = [] + for row in rows: + d = _row_dict(row, row_to_dict_fn) + norm = _normalize_archive_trade_row( + d, exchange_key=exchange_key, reset_hour=reset_hour + ) + if norm: + records.append(norm) + if len(records) >= lim: + break + + if not include_strategy_snapshots: + return records + + skip_ids = _existing_trend_plan_ids(conn) + for rec in records: + try: + pid = int(rec.get("trend_plan_id") or 0) + except (TypeError, ValueError): + pid = 0 + if pid > 0: + skip_ids.add(pid) + + snaps = _fetch_strategy_snapshots_for_archive( + conn, + days=days, + exchange_key=exchange_key, + reset_hour=reset_hour, + limit=max(0, lim - len(records)), + skip_plan_ids=skip_ids, + ) + merged = records + snaps + merged.sort( + key=lambda x: int(x.get("closed_at_ms") or 0), + reverse=True, + ) + merged = merged[:lim] + attach_journal_mood_tags(conn, merged, cutoff_s=cutoff_s) + return merged + + +def _symbol_coin_base(symbol: str) -> str: + s = (symbol or "").strip().upper() + if "/" in s: + return s.split("/")[0] + return s + + +def _datetime_minute_key(raw: Any) -> str: + if raw is None: + return "" + s = str(raw).strip().replace("T", " ").replace("Z", "") + if len(s) >= 16: + return s[:16] + return s[:10] if len(s) >= 10 else s + + +def journal_trade_match_key(symbol: str, opened_at: Any, closed_at: Any) -> tuple[str, str, str]: + return ( + _symbol_coin_base(symbol), + _datetime_minute_key(opened_at), + _datetime_minute_key(closed_at), + ) + + +def load_journal_mood_match_index( + conn, + *, + cutoff_s: str, +) -> dict[tuple[str, str, str], list[str]]: + """复盘 mood_issues → 交易匹配键(币种 + 开/平仓分钟).""" + from lib.trade.account_risk_lib import parse_mood_issues + + cols = _table_columns(conn, "journal_entries") + if not cols or "mood_issues" not in cols: + return {} + close_expr = "REPLACE(COALESCE(close_datetime, open_datetime, created_at), 'T', ' ')" + open_expr = "REPLACE(COALESCE(open_datetime, created_at), 'T', ' ')" + rows = conn.execute( + f""" + SELECT coin, open_datetime, close_datetime, mood_issues + FROM journal_entries + WHERE {close_expr} >= ? OR {open_expr} >= ? + """, + (cutoff_s, cutoff_s), + ).fetchall() + out: dict[tuple[str, str, str], list[str]] = {} + for row in rows: + d = _row_dict(row) + issues = parse_mood_issues(d.get("mood_issues")) + if not issues: + continue + coin = str(d.get("coin") or "").strip().upper() + sym = coin if "/" in coin else (f"{coin}/USDT" if coin else "") + key = journal_trade_match_key(sym, d.get("open_datetime"), d.get("close_datetime")) + if not key[0]: + continue + out[key] = issues + return out + + +def attach_journal_mood_tags( + conn, + trades: list[dict[str, Any]], + *, + cutoff_s: str, +) -> None: + """实例复盘勾选情绪标签 → 档案交易自动标犯病(hub 同步用).""" + if not trades: + return + mood_index = load_journal_mood_match_index(conn, cutoff_s=cutoff_s) + if not mood_index: + return + for t in trades: + if not isinstance(t, dict): + continue + key = journal_trade_match_key( + str(t.get("symbol") or ""), + t.get("opened_at"), + t.get("closed_at"), + ) + issues = mood_index.get(key) + if not issues: + continue + t["journal_mood_issues"] = issues + t["journal_mood_sick"] = True + t["behavior_tag_from_journal"] = True + t["behavior_tag"] = "sick" + + +def summarize_trades(trades: list[dict]) -> dict[str, Any]: + """单笔列表 → 笔数 / 盈亏 / 胜败统计.""" + total_pnl = 0.0 + win_pnl = 0.0 + loss_pnl = 0.0 + win = loss = flat = 0 + for t in trades or []: + try: + pnl = float(t.get("pnl_amount") or 0) + except (TypeError, ValueError): + pnl = 0.0 + total_pnl += pnl + if pnl > 1e-9: + win += 1 + win_pnl += pnl + elif pnl < -1e-9: + loss += 1 + loss_pnl += pnl + else: + flat += 1 + return { + "closed_count": len(trades or []), + "win_count": win, + "loss_count": loss, + "flat_count": flat, + "total_pnl_u": round(total_pnl, 4), + "win_pnl_u": round(win_pnl, 4), + "loss_pnl_u": round(loss_pnl, 4), + } diff --git a/lib/hub/hub_volume_rank_lib.py b/lib/hub/hub_volume_rank_lib.py new file mode 100644 index 0000000..4bde011 --- /dev/null +++ b/lib/hub/hub_volume_rank_lib.py @@ -0,0 +1,595 @@ +"""行情区:各交易所 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 + +from lib.hub.hub_trades_lib import trading_day_from_dt + +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 "").strip() + if raw: + return Path(raw) + from lib.paths import hub_data_dir + + return hub_data_dir() / "hub_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/hub/price_snapshot_lib.py b/lib/hub/price_snapshot_lib.py new file mode 100644 index 0000000..ccc7d50 --- /dev/null +++ b/lib/hub/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.hub.hub_position_metrics 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.hub.hub_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/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..0423e14 --- /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.hub.hub_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..e90397b --- /dev/null +++ b/lib/instance/instance_dashboard_lib.py @@ -0,0 +1,564 @@ +"""实例数据看板:本户活跃监控 / 持仓只读聚合.""" +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.strategy.strategy_trade_labels import apply_order_monitor_source_labels + + od = apply_order_monitor_source_labels(od) + except Exception: + pass + try: + from lib.trade.entry_model_lib import enrich_entry_model_display + + enrich_entry_model_display(od) + except Exception: + pass + sym = od.get("exchange_symbol") or od.get("symbol") or "-" + direction = str(od.get("direction") or "long").lower() + mt = od.get("monitor_type_display") or od.get("monitor_type") or "" + kst = od.get("key_signal_type") or "" + title = f"{sym} {_dir_label(direction)}" + bits = [x for x in (mt, kst) if x] + subtitle = " · ".join(bits) if bits else "" + entry = _safe_float(od.get("trigger_price")) + sl = _safe_float(od.get("stop_loss")) + tp = _safe_float(od.get("take_profit")) + return { + "id": od.get("id"), + "kind": "order", + "tab": "trade", + "title": title, + "subtitle": subtitle, + "symbol": sym, + "price_symbol": od.get("symbol") or sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "mark_price": None, + "contracts": _safe_float(od.get("order_amount")), + "tp_profit": None, + "float_pnl": None, + "stop_loss": sl, + "take_profit": tp, + "status": od.get("status") or "active", + } + + +OPTIONS_SOURCE_LABELS = { + "option": "纯期权", + "perp_options": "永期对冲", + "options_options": "期期对冲", +} + +HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) + + +def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]: + """根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id).""" + default = ("option", OPTIONS_SOURCE_LABELS["option"], None) + if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"): + return default + try: + row = conn.execute( + """ + SELECT p.plan_type, p.id + FROM hedge_plans p + JOIN hedge_plan_legs l ON l.plan_id = p.id + WHERE p.status IN ('opening', 'active', 'partial') + AND l.status = 'open' + AND l.inst_id = ? + ORDER BY p.id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + except Exception: + return default + if not row: + return default + d = _row_dict(row) + pt = str(d.get("plan_type") or "").strip() + try: + plan_id = int(d["id"]) if d.get("id") is not None else None + except (TypeError, ValueError): + plan_id = None + if pt in OPTIONS_SOURCE_LABELS and pt != "option": + return pt, OPTIONS_SOURCE_LABELS[pt], plan_id + return default + + +def _format_profit_exit_mult(mult: Any) -> str: + try: + n = float(mult) + except (TypeError, ValueError): + return "1倍" + if n <= 0: + return "1倍" + if abs(n - round(n)) < 1e-9: + return f"{int(round(n))}倍" + return f"{n:g}倍" + + +def _format_options_target(p: dict[str, Any]) -> str: + hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + if hedge: + rr = _safe_float(hedge.get("profit_rr")) + pid = hedge.get("plan_id") + if rr is not None and rr > 0: + return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}" + ot = str(hedge.get("opt_type") or opt_type).upper() + side = "Put ≤" if ot == "P" else "Call ≥" + tgt = _safe_float(hedge.get("target_index")) + if tgt is not None: + return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" + parts: list[str] = [] + tgt = _safe_float(p.get("target_index")) + if tgt is not None and tgt > 0: + side = "Put ≤" if opt_type == "P" else "Call ≥" + parts.append(f"{side} {tgt:g}") + if p.get("profit_exit_enabled"): + parts.append(_format_profit_exit_mult(p.get("profit_exit_mult"))) + if parts: + return " · ".join(parts) + return "—" + + +def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: + inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") + # 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数) + pnl = None + try: + from lib.options.options_positions_lib import display_pnl_usdt_from_option_row + + pnl = display_pnl_usdt_from_option_row(p) + except Exception: + pnl = None + pos = _safe_float(p.get("pos")) + exp_ms = p.get("exp_time_ms") + if exp_ms is None: + exp_ms = p.get("exp_time") + try: + exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None + except (TypeError, ValueError): + exp_ms = None + if conn is not None: + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + else: + source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None + return { + "id": inst, + "kind": "options", + "tab": "options", + "title": f"{inst} {label}", + "subtitle": f"张数 {pos if pos is not None else '-'}", + "inst_id": inst, + "opt_type": opt_type, + "opt_type_label": label, + "source": source_key, + "source_label": source_label, + "source_plan_id": source_plan_id, + "pos": pos, + "exp_time_ms": exp_ms, + "target_monitor": _format_options_target(p), + "pnl": round(pnl, 4) if pnl is not None else None, + } + + +def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]: + pid = plan.get("id") + underlying = plan.get("underlying") or "-" + plan_type = plan.get("plan_type") or "" + status = str(plan.get("status") or "") + summary = plan.get("contracts_summary") or "" + plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type) + active = status in HEDGE_ACTIVE_STATUSES + status_label = "进行中" if active else (status or "—") + return { + "id": pid, + "kind": "hedge_plan", + "tab": "hedge_plan", + "title": f"对冲 #{pid} {underlying}", + "subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x), + "underlying": underlying, + "plan_type": plan_type, + "plan_type_label": plan_type_label, + "status": status, + "status_label": status_label, + "status_active": active, + "contracts_summary": summary, + } + + +def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]: + sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" + direction = str(kd.get("direction") or "long").lower() + signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or "" + upper = _safe_float(kd.get("upper")) + lower = _safe_float(kd.get("lower")) + subtitle_parts = [] + if signal: + subtitle_parts.append(str(signal)) + if upper is not None or lower is not None: + subtitle_parts.append( + f"上{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}" + ) + return { + "id": kd.get("id"), + "kind": "key", + "tab": "key_monitor", + "title": f"{sym} {_dir_label(direction)}", + "subtitle": " · ".join(subtitle_parts), + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "upper": upper, + "lower": lower, + "status": kd.get("status") or "active", + } + + +def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]: + sym = td.get("exchange_symbol") or td.get("symbol") or "-" + direction = str(td.get("direction") or "long").lower() + status = td.get("status") or "active" + entry = _safe_float(td.get("entry_price") or td.get("trigger_price")) + return { + "id": td.get("id"), + "kind": "trend", + "tab": "strategy", + "title": f"趋势回调 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "entry": entry, + "status": status, + } + + +def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]: + sym = rd.get("exchange_symbol") or rd.get("symbol") or "-" + direction = str(rd.get("direction") or "long").lower() + status = rd.get("status") or "active" + return { + "id": rd.get("id"), + "kind": "roll", + "tab": "strategy", + "title": f"顺势加仓 {sym} {_dir_label(direction)}", + "subtitle": f"状态 {status}", + "symbol": sym, + "direction": direction, + "direction_label": _dir_label(direction), + "status": status, + } + + +def _table_exists(conn, name: str) -> bool: + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (name,), + ).fetchone() + return bool(row) + except Exception: + return False + + +def collect_orders(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "order_monitors"): + return [] + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall() + return [_format_order_item(_row_dict(r)) for r in rows] + + +def collect_keys(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "key_monitors"): + return [] + rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + return [_format_key_item(_row_dict(r)) for r in rows] + + +def collect_trends(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "trend_pullback_plans"): + return [] + try: + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + except Exception: + return [] + return [_format_trend_item(_row_dict(r)) for r in rows] + + +def collect_rolls(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"): + return [] + try: + rows = conn.execute( + """SELECT g.* FROM roll_groups g + INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active' + WHERE g.status='active' ORDER BY g.id DESC""" + ).fetchall() + except Exception: + return [] + return [_format_roll_item(_row_dict(r)) for r in rows] + + +def collect_hedge_plans(conn) -> list[dict[str, Any]]: + if not _table_exists(conn, "hedge_plans"): + return [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + rows: list[dict[str, Any]] = [] + for status in ("opening", "active", "partial"): + rows.extend(list_plans(conn, status=status, limit=80)) + rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + plans = attach_legs_to_plans(conn, rows) + return [_format_hedge_item(p) for p in plans] + except Exception: + return [] + + +def collect_options_items( + fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, + *, + conn=None, +) -> list[dict[str, Any]]: + if not callable(fetch_options_positions): + return [] + try: + raw = fetch_options_positions() or [] + except Exception: + return [] + pe_map: dict[str, dict[str, Any]] = {} + tgt_map: dict[str, dict[str, Any]] = {} + hedge_map: dict[str, dict[str, Any]] = {} + if conn is not None: + try: + from lib.options.options_profit_exit_lib import profit_exit_by_inst + from lib.options.options_target_lib import targets_by_inst + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + + pe_map = profit_exit_by_inst(conn) + tgt_map = targets_by_inst(conn) + hedge_map = active_options_targets_by_inst(conn) + except Exception: + pe_map, tgt_map, hedge_map = {}, {}, {} + out: list[dict[str, Any]] = [] + for p in raw: + if not isinstance(p, dict): + continue + row = dict(p) + inst = str(row.get("inst_id") or row.get("instId") or "").strip() + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + pe = pe_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + hedge = hedge_map.get(inst) + if hedge: + row["hedge_plan_target"] = hedge + if not mon: + row["target_index"] = hedge.get("target_index") + out.append(_format_options_item(row, conn=conn)) + return out + + +def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]: + """优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1.""" + raw: list[str] = [] + for key in ("symbol", "exchange_symbol", "price_symbol"): + s = str(row.get(key) or "").strip() + if s and s not in raw: + raw.append(s) + swapish: list[str] = [] + others: list[str] = [] + for s in raw: + if ":" in s: + swapish.append(s) + continue + others.append(s) + if "/" in s: + base, quote = s.split("/", 1) + q = quote.split(":")[0].strip() + if base and q: + swapish.append(f"{base}/{q}:{q}") + out: list[str] = [] + for s in swapish + others: + if s and s not in out: + out.append(s) + return out + + +def _resolve_contract_size( + row_or_sym: Any, + *, + get_contract_size: Optional[Callable[[str], Any]] = None, +) -> float: + if not callable(get_contract_size): + return 1.0 + if isinstance(row_or_sym, dict): + candidates = _swap_symbol_candidates(row_or_sym) + else: + sym = str(row_or_sym or "").strip() + candidates = _swap_symbol_candidates({"symbol": sym}) if sym else [] + for sym in candidates: + try: + cs = float(get_contract_size(sym) or 0) + if cs > 0: + return cs + except Exception: + continue + return 1.0 + + +def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None: + """按线性 U 本位补看板「盈利金额 / 浮盈」.""" + direction = str(row.get("direction") or "long").lower() + entry = _safe_float(row.get("entry")) + contracts = _safe_float(row.get("contracts")) + tp = _safe_float(row.get("take_profit")) + if entry is None or contracts is None or contracts <= 0: + return + cs = float(contract_size) if contract_size and contract_size > 0 else 1.0 + if mark is not None: + try: + from lib.hub.hub_position_metrics 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: + from lib.strategy.strategy_trend_lib import calc_tp_profit_usdt + + profit = calc_tp_profit_usdt(direction, entry, tp, contracts, cs) + 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) + trends = collect_trends(conn) + rolls = collect_rolls(conn) + strategy_items = trends + rolls + 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": len(orders), "items": orders, "tab": "trade"}, + "keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"}, + "strategy": { + "title": "策略交易", + "count": len(strategy_items), + "items": strategy_items, + "trends": trends, + "rolls": rolls, + "tab": "strategy", + }, + "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..c6b1833 --- /dev/null +++ b/lib/instance/instance_display_prefs_lib.py @@ -0,0 +1,146 @@ +"""实例顶栏 / 系统设置区块显示开关(存 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": False, + "show_nav_trade": False, + "show_nav_strategy": False, + "show_nav_strategy_records": False, + "show_nav_records": True, + "show_nav_stats": True, + "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 {}) + key = NAV_TAB_ALLOWED.get((tab or "").strip()) + 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_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", + "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..17bd25a --- /dev/null +++ b/lib/instance/instance_embed_context_lib.py @@ -0,0 +1,251 @@ +"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"}) + +_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=page == "records", + # 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏 + records_summary=is_shell and page != "records", + key_history=page == "key_monitor", + key_list=page in ("key_monitor", "trade") or is_strategy, + orders=page == "trade" or is_strategy, + stats_bundle=page == "stats", + strategy=is_strategy, + orphan_live=page == "trade" and is_shell, + ) + + +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, + funding_eth: float | None = None, + margin_mode: str | None = None, + underly: str = "ETH", +) -> str: + """期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列).""" + if funding_usdc is None: + return "—" + try: + return f"{float(funding_usdc):.2f} USDC" + except (TypeError, ValueError): + return "—" + + +def _fmt_coin_amount(v: float | None, *, min_amt: float = 1e-6) -> str | None: + if v is None: + return None + try: + n = float(v) + except Exception: + # Jinja Undefined 等也吞掉,避免顶栏 float(Undefined) → HTTP 500 + return None + if n < min_amt: + return None + txt = f"{n:.6f}".rstrip("0").rstrip(".") + return txt or None + + +def trading_account_label( + usdt: float | None, + eth: float | None = None, + btc: float | None = None, + *, + margin_mode: str | None = None, +) -> str: + """交易账户顶栏文案. + + 币本位:USDT / ETH / BTC 多行(有余额才带上,不显示其它币种). + 其它模式:xx.xxU. + """ + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + mode = normalize_options_margin_mode(margin_mode) + except Exception: + mode = str(margin_mode or "coin").strip().lower() or "coin" + if mode != "coin": + if usdt is None: + return "—" + try: + return f"{float(usdt):.2f}U" + except (TypeError, ValueError): + return "—" + parts: list[str] = [] + if usdt is not None: + try: + parts.append(f"{float(usdt):.2f} USDT") + except (TypeError, ValueError): + pass + eth_txt = _fmt_coin_amount(eth, min_amt=1e-6) + if eth_txt is not None: + parts.append(f"{eth_txt} ETH") + btc_txt = _fmt_coin_amount(btc, min_amt=1e-7) + if btc_txt is not None: + parts.append(f"{btc_txt} BTC") + # 顶栏多行:USDT / ETH 各占一行 + return "\n".join(parts) if parts else "—" + + +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..29e1f4c --- /dev/null +++ b/lib/instance/instance_embed_lib.py @@ -0,0 +1,238 @@ +"""中控 iframe:壳常驻 + tab 内容 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", + "trade", + "strategy", + "strategy_records", + "options", + "options_review", + "hedge_plan", + "records", + "stats", + "risk_policy", + "system_guide", + "env_config", + "settings", +) + +PATH_TO_EMBED_TAB: dict[str, str] = { + "/": "trade", + "/trade": "trade", + "/dashboard": "dashboard", + "/account_ledger": "account_ledger", + "/key_monitor": "key_monitor", + "/strategy": "strategy", + "/strategy/trend": "strategy", + "/strategy/roll": "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", +} + +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: + return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() 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 "trade").strip() + if tab not in EMBED_TABS: + tab = "trade" + 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: + # 顶栏共享模板调用 trading_account_label / options_funding_label; + # 须注入三所,否则 Gate/Binance 渲染会 UndefinedError → HTTP 500. + from lib.instance.instance_embed_context_lib import ( + options_funding_label, + trading_account_label, + ) + + 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), + "options_funding_label": options_funding_label, + "trading_account_label": trading_account_label, + } diff --git a/lib/instance/instance_live_pnl_lib.py b/lib/instance/instance_live_pnl_lib.py new file mode 100644 index 0000000..17fa9c4 --- /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.hub.hub_position_metrics 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..844cf98 --- /dev/null +++ b/lib/instance/instance_nav_lib.py @@ -0,0 +1,19 @@ +"""中控 iframe 内软导航:服务端跳过重型同步,避免切 tab 等待数秒.""" + +from __future__ import annotations + +from flask import Request + + +def request_is_hub_soft_nav(req: Request | None = None) -> bool: + """embed=1 且带 X-Instance-Soft-Nav 头:实例页内 fetch 换页,非整页刷新.""" + try: + from flask import request as flask_request + + r = req or flask_request + if str(r.args.get("embed") or "").strip() != "1": + return False + flag = (r.headers.get("X-Instance-Soft-Nav") or "").strip().lower() + return flag in ("1", "true", "yes") + except Exception: + 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..5a3e12b --- /dev/null +++ b/lib/instance/instance_settings_lib.py @@ -0,0 +1,242 @@ +"""实例「系统设置」页:从 .env 汇总风控说明(三所共用).""" +from __future__ import annotations + +import os +from typing import Any, Optional + +from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled +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() + key_auto = load_key_auto_order_enabled() + 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()), + "复盘勾选心态标签可触发当日冻结", + ), + ], + } + ) + + key_rows = [ + _row("关键位自动单", _on_off(key_auto)), + _row("关键位最低盈亏比", f"> {_env_float('KEY_AUTO_MIN_PLANNED_RR', 1.5):g}:1"), + ] + if is_full_margin_mode(sizing_mode): + key_rows.append( + _row( + "全仓模式", + "仅触价类自动单", + "箱体/斐波等自动开仓在全仓下禁用", + ) + ) + sections.append({"title": "关键位与自动单", "rows": key_rows}) + + 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": "导航显示"}] + 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..8369887 --- /dev/null +++ b/lib/instance/instance_settings_register.py @@ -0,0 +1,194 @@ +"""实例系统设置 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(hub_token_write_allowed: bool = False): + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + from lib.hub.hub_auth import request_allowed as hub_request_allowed + + logged_in = bool(session.get("logged_in")) + auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + hub_hdr = (request.headers.get("X-Hub-Token") or "").strip() + bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() + if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed: + return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403 + if hub_request_allowed(logged_in, auth_disabled): + 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}) + if "OKX_OPTIONS_MARGIN_MODE" in clean: + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + from lib.options.options_spot_bridge_lib import mode_switch_block_msg + + lines = read_env_lines(env_path) + old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin") + new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE")) + if old_mode != new_mode: + conn_m = get_db() + try: + block = mode_switch_block_msg(conn_m, None) + if block: + return jsonify({"ok": False, "msg": block}), 400 + finally: + conn_m.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400 + 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..6a59790 --- /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.hub.hub_strategy_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..1aed0a6 --- /dev/null +++ b/lib/instance/journal_form_lib.py @@ -0,0 +1,54 @@ +"""复盘表单:下单类型与开仓类型校验(三所共用).""" +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +from lib.strategy.strategy_trade_labels 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" + # 兼容旧隐藏字段 direction_hint + 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..f1d651e --- /dev/null +++ b/lib/instance/templates/embed_boot_scripts.html @@ -0,0 +1,1630 @@ + diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html new file mode 100644 index 0000000..e64fd96 --- /dev/null +++ b/lib/instance/templates/embed_page_fragment.html @@ -0,0 +1,352 @@ +{# 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 == 'trade' %} +
        +
        +
        +

        实盘下单监控

        + {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
        + {% include order_rule_tips_tpl %} + {% include 'order_monitor_open_form.html' %} +
        +
        +

        实时持仓

        + {% if ui_orphan_recovery_enabled %} + {% if not order and orphan_live_positions %} + {% set o = orphan_live_positions[0] %} +
        + 检测到交易所仍有 {{ o.symbol }} {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录). + {% if o.recoverable_monitor_id %} + + {% else %} + 未找到可恢复的监控记录,需在服务器数据库处理. + {% endif %} +
        + {% else %} + + {% endif %} + {% endif %} +
        + {% for o in order %} +
        +
        +
        + {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {% include 'force_close_order_badge.html' %} + {{ '做多' if o.direction == 'long' else '做空' }} +
        +
        + + 平仓 +
        +
        +
        + 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + + {% if intraday_discipline %} + {% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + +
        +
        +
        + 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
        +
        + 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
        +
        + 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
        +
        + 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
        +
        + 张数 + {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} +
        +
        + 盈利金额 + — +
        +
        + 标记价 + - +
        +
        + 浮盈亏 + - +
        +
        + +
        +
        交易所止盈止损
        +
        + 止损:加载中… + +
        +
        + 止盈:加载中… + +
        +
        +
        + {% else %} +
        暂无持仓
        + {% endfor %} +
        +
        + +
        +
        +

        挂止盈止损

        +

        将先撤销该合约已有 TP/SL,再按下列价格重挂.

        +
        + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + +
        + {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.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 == 'records' %} + {% include 'records_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 %} + + {% if page == 'stats' %} +
        +
        +

        数据统计

        + +
        +
        +
        + 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
        +
        + +
        + {% for seg in stats_bundle.segments %} + + {% endfor %} +
        +
        + {% endif %} diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html new file mode 100644 index 0000000..f596b3e --- /dev/null +++ b/lib/instance/templates/embed_shell.html @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + {{ pwa_app_name }} + + +
        +
        +

        加密货币|交易监控 + AI复盘一体化

        +
        + + + + {% 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/index.html b/lib/instance/templates/index.html new file mode 100644 index 0000000..3af3ba9 --- /dev/null +++ b/lib/instance/templates/index.html @@ -0,0 +1,2159 @@ +{# 三所共用 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 %} +
        +
        +

        CoinOX|币本位期权 + U 本位对冲

        +
        +
        + + + + + {% if not intraday_discipline and display.show_nav_strategy %} + 策略交易 + {% endif %} + {% if not intraday_discipline and display.show_nav_strategy_records %} + 策略交易记录 + {% endif %} + {% if display.show_nav_records %} + 交易记录与复盘 + {% endif %} + {% if display.show_nav_stats %} + 统计分析 + {% endif %} + {% 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 == 'trade' %} +
        +
        +
        +

        实盘下单监控

        + {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
        + {% include order_rule_tips_tpl %} + {% include 'order_monitor_open_form.html' %} +
        +
        +

        实时持仓

        + {% if ui_orphan_recovery_enabled %} + {% if not order and orphan_live_positions %} + {% set o = orphan_live_positions[0] %} +
        + 检测到交易所仍有 {{ o.symbol }} {{ '空' if o.direction == 'short' else '多' }}仓,但本地监控已中断(误同步时可能无交易记录). + {% if o.recoverable_monitor_id %} + + {% else %} + 未找到可恢复的监控记录,需在服务器数据库处理. + {% endif %} +
        + {% else %} + + {% endif %} + {% endif %} +
        + {% for o in order %} +
        +
        +
        + {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {% include 'force_close_order_badge.html' %} + {{ '做多' if o.direction == 'long' else '做空' }} +
        +
        + + 平仓 +
        +
        +
        + 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + {% if o.entry_model_label %}开仓: {{ o.entry_model_label }}{% elif intraday_discipline %}开仓: —{% else %}风格: {{ '波段单' if o.trade_style == 'swing' else '趋势单' }}{% endif %} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + + {% if intraday_discipline %} + {% elif o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + +
        +
        +
        + 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
        +
        + 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
        +
        + 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
        +
        + 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
        +
        + 张数 + {% if o.order_amount is not none %}{{ '%.2f'|format(o.order_amount) }}{% else %}—{% endif %} +
        +
        + 盈利金额 + — +
        +
        + 标记价 + - +
        +
        + 浮盈亏 + - +
        +
        + +
        +
        交易所止盈止损
        +
        + 止损:加载中… + +
        +
        + 止盈:加载中… + +
        +
        +
        + {% else %} +
        暂无持仓
        + {% endfor %} +
        +
        + +
        +
        +

        挂止盈止损

        +

        将先撤销该合约已有 TP/SL,再按下列价格重挂.

        +
        + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        +
        + +
        + {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.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 == 'records' %} + {% include 'records_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 %} + + {% if page == 'stats' %} +
        +
        +

        数据统计

        + +
        +
        +
        + 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
        +
        + +
        + {% for seg in stats_bundle.segments %} + + {% endfor %} +
        +
        + {% 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..3bb9979 --- /dev/null +++ b/lib/instance/templates/instance_header_panel.html @@ -0,0 +1,57 @@ +{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #} +
        +
        +
        + 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' %} +
        +
        + {% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %} + {% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %} + {% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %} + + + + 总资 + {% 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..e120d23 --- /dev/null +++ b/lib/instance/templates/instance_header_stats.html @@ -0,0 +1,53 @@ +{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} +{# 币本位顶栏仅 OKX 期权开启时生效;Gate/Binance 保持原 xx.xxU #} +{% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %} +{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %} +{% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %} +
        +
        +
        交易所
        +
        {{ exchange_display }}
        +
        +
        +
        交易日
        +
        {{ 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 and not _coin_margin %} +
        +
        期权资金账户
        +
        {{ options_funding_label(options_funding_usdc|default(none), options_funding_usdt|default(none), options_funding_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}
        +
        +
        +
        期权交易账户
        +
        {{ options_funding_label(options_trading_usdc|default(none), options_trading_usdt|default(none), options_trading_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}
        +
        + {% 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/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_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/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..dfb9195 --- /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 == '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/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/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/false_breakout_key_monitor_lib.py b/lib/key_monitor/false_breakout_key_monitor_lib.py new file mode 100644 index 0000000..f6c2152 --- /dev/null +++ b/lib/key_monitor/false_breakout_key_monitor_lib.py @@ -0,0 +1,145 @@ +"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验).""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Optional + +FALSE_BREAKOUT_MONITOR_TYPE = "假突破" +FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"}) +FALSE_BREAKOUT_OFFSET_PCT = 0.1 +FALSE_BREAKOUT_SL_PCT = 0.5 +FALSE_BREAKOUT_RR = 1.5 +FALSE_BREAKOUT_VALIDITY_HOURS = 24 + + +def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool: + return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE + + +def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool: + from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type + + return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type) + + +def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]: + s = (symbol or "").strip().upper() + if not s: + return None + if "/" not in s: + s = f"{s}/USDT" + return s if s in FALSE_BREAKOUT_SYMBOLS else None + + +def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]: + k = float(key_price) + if k <= 0: + raise ValueError("关键价位须为正数") + d = (direction or "long").strip().lower() + if d == "short": + return k, k * 0.9999 + if d == "long": + return k * 1.0001, k + raise ValueError("方向须为 long 或 short") + + +def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]: + d = (direction or "long").strip().lower() + try: + if d == "short": + v = float(upper) + else: + v = float(lower) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]: + try: + k = float(key_price) + except (TypeError, ValueError): + return None + if k <= 0: + return None + d = (direction or "long").strip().lower() + off = FALSE_BREAKOUT_OFFSET_PCT / 100.0 + sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0 + rr = float(FALSE_BREAKOUT_RR) + if d == "short": + entry = k * (1 + off) + sl = entry * (1 + sl_pct) + risk = sl - entry + if risk <= 0: + return None + tp = entry - risk * rr + return entry, sl, tp + if d == "long": + entry = k * (1 - off) + sl = entry * (1 - sl_pct) + risk = entry - sl + if risk <= 0: + return None + tp = entry + risk * rr + return entry, sl, tp + return None + + +def _parse_created_at(raw: Any) -> Optional[datetime]: + s = str(raw or "").strip() + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(s[:26], fmt) + except ValueError: + continue + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")[:32]) + except ValueError: + return None + + +def is_false_breakout_expired( + created_at: Any, + now: datetime, + *, + hours: int = FALSE_BREAKOUT_VALIDITY_HOURS, +) -> bool: + dt = _parse_created_at(created_at) + if dt is None: + return False + return now >= dt + timedelta(hours=hours) + + +def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str: + dt = _parse_created_at(created_at) + if dt is None: + return "—" + return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + + +def false_breakout_gate_preview( + *, + entry_display: str, + limit_order_id: Any = None, + created_at: Any = None, + now: Optional[datetime] = None, + hours: int = FALSE_BREAKOUT_VALIDITY_HOURS, +) -> dict[str, Any]: + """假突破门控预览:限价挂单状态,不使用箱体/收敛的量破幅二确门控.""" + now_dt = now or datetime.now() + expired = is_false_breakout_expired(created_at, now_dt, hours=hours) + exp_txt = expires_at_text(created_at, hours=hours) + status = "已过期" if expired else "等待成交" + metrics_parts: list[str] = [] + oid = str(limit_order_id or "").strip() + if oid: + metrics_parts.append(f"限价单:{oid}") + if exp_txt != "—": + metrics_parts.append(f"截至:{exp_txt}") + return { + "summary": f"假突破 挂E={entry_display} {status}", + "metrics": " ".join(metrics_parts), + "gate_ok": not expired, + } diff --git a/lib/key_monitor/fib_key_monitor_lib.py b/lib/key_monitor/fib_key_monitor_lib.py new file mode 100644 index 0000000..2c0d1ff --- /dev/null +++ b/lib/key_monitor/fib_key_monitor_lib.py @@ -0,0 +1,140 @@ +"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用).""" + +from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES + +FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"}) +KEY_MONITOR_TRADE_TYPE = "关键位监控" + +FIB_RATIO_BY_TYPE = { + "斐波回调0.618": 0.618, + "斐波回调0.786": 0.786, +} + + +def is_fib_key_monitor_type(monitor_type): + return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES + + +def fib_ratio_from_type(monitor_type): + return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip()) + + +def calc_fib_plan(direction, upper, lower, ratio): + """ + 上沿 H,下沿 L(H > L). + 做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H. + 做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=L. + 返回 (entry, stop_loss, take_profit) 或 None. + """ + try: + h = float(upper) + l = float(lower) + r = float(ratio) + except (TypeError, ValueError): + return None + if h <= l or r <= 0 or r >= 1: + return None + span = h - l + direction = (direction or "long").strip().lower() + if direction == "short": + entry = l + r * span + return entry, h, l + entry = h - r * span + return entry, l, h + + +def stored_key_signal_type(monitor_type): + """写入 order_monitors / trade_records 的 key_signal_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 + + +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): + """平仓写入 trade_records 时保留箱体/收敛/斐波/假突破来源.""" + kst = (key_signal_type or "").strip() + 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 + return None + + +def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int: + """补全历史 trade_records / order_monitors 中缺失的箱体/收敛 key_signal_type.""" + 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 entry_reason: + 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) + rows = conn.execute( + """SELECT id, symbol, opened_at FROM trade_records + WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')""", + (mt,), + ).fetchall() + for row in rows: + # init_db 连接未设 row_factory,结果为 tuple + rid, sym, opened_at = row[0], row[1], row[2] + opened = (opened_at or "").strip() + for signal in KEY_MONITOR_AUTO_TYPES: + hist = conn.execute( + """SELECT monitor_type FROM key_monitor_history + WHERE symbol=? AND monitor_type=? AND close_reason='auto_opened' + AND (?='' OR closed_at <= ?) + ORDER BY closed_at DESC LIMIT 1""", + (sym, signal, opened, opened), + ).fetchone() + if not hist: + continue + conn.execute( + "UPDATE trade_records SET key_signal_type=? WHERE id=?", + (signal, rid), + ) + updated += 1 + break + return updated + + +def fib_invalidate_by_mark(direction, mark_price, upper, lower): + """先触达止盈侧(标记价)则失效.多:mark>=H;空:mark<=L.""" + try: + m = float(mark_price) + h = float(upper) + l = float(lower) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "short": + return m <= l + return m >= h diff --git a/lib/key_monitor/key_auto_order_lib.py b/lib/key_monitor/key_auto_order_lib.py new file mode 100644 index 0000000..9d46cb5 --- /dev/null +++ b/lib/key_monitor/key_auto_order_lib.py @@ -0,0 +1,164 @@ +"""关键位程序自动下单开关(三所共用,与 POSITION_SIZING_MODE 联动).""" +from __future__ import annotations + +import os +from typing import Any, Optional, Sequence, Tuple + +from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type +from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type +from lib.key_monitor.key_monitor_full_margin_lib import monitor_type_disallowed_in_full_margin +from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES, KEY_MONITOR_RS_TYPES +from lib.key_monitor.trigger_entry_key_monitor_lib import is_trigger_entry_key_monitor_type +from lib.trade.position_sizing_lib import is_full_margin_mode + +KEY_ENTRY_REASON_OPTIONS: Tuple[str, ...] = ( + "关键位箱体突破", + "关键位收敛突破", + "关键位斐波0.618", + "关键位斐波0.786", + "关键位假突破", + "关键位回调触价开仓", + "关键位突破触价开仓", +) + +KEY_ENTRY_REASON_TRIGGER_OPTIONS: frozenset[str] = frozenset( + { + "关键位回调触价开仓", + "关键位突破触价开仓", + } +) + +KEY_STATS_SEGMENT_KEYS: frozenset[str] = frozenset( + { + "key_box", + "key_conv", + "key_fib618", + "key_fib786", + "key_false_breakout", + "key_trigger", + } +) + +KEY_STATS_TRIGGER_ONLY: frozenset[str] = frozenset({"key_trigger"}) + +TREND_MANUAL_ENTRY_REASON_COUNT = 5 + + +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 load_key_auto_order_enabled(env: Optional[dict] = None) -> bool: + e = env if env is not None else os.environ + return _env_bool(e.get("KEY_AUTO_ORDER_ENABLED"), default=False) + + +def is_key_level_entry_reason(reason: str) -> bool: + return (reason or "").strip() in KEY_ENTRY_REASON_OPTIONS + + +def visible_key_entry_reasons(sizing_mode: str, key_auto_enabled: bool) -> Tuple[str, ...]: + if not key_auto_enabled: + return () + if is_full_margin_mode(sizing_mode): + return tuple(x for x in KEY_ENTRY_REASON_OPTIONS if x in KEY_ENTRY_REASON_TRIGGER_OPTIONS) + return KEY_ENTRY_REASON_OPTIONS + + +def effective_entry_reason_options( + all_options: Sequence[str], + sizing_mode: str, + key_auto_enabled: bool, + *, + trend_manual_count: int = TREND_MANUAL_ENTRY_REASON_COUNT, +) -> Tuple[str, ...]: + """复盘/表单下拉:按开关与计仓模式裁剪关键位开仓类型.""" + opts = list(all_options) + if len(opts) <= trend_manual_count: + return tuple(opts) + key_visible = set(visible_key_entry_reasons(sizing_mode, key_auto_enabled)) + out: list[str] = [] + for i, item in enumerate(opts): + if i < trend_manual_count: + out.append(item) + elif is_key_level_entry_reason(item): + if item in key_visible: + out.append(item) + else: + out.append(item) + return tuple(out) + + +def effective_stats_segment_defs( + segment_defs: Sequence[Tuple[str, str, Any]], + sizing_mode: str, + key_auto_enabled: bool, +) -> Tuple[Tuple[str, str, Any], ...]: + if not key_auto_enabled: + hidden = KEY_STATS_SEGMENT_KEYS + elif is_full_margin_mode(sizing_mode): + hidden = KEY_STATS_SEGMENT_KEYS - KEY_STATS_TRIGGER_ONLY + else: + hidden = frozenset() + return tuple(x for x in segment_defs if x[0] not in hidden) + + +def is_key_auto_monitor_type(monitor_type: str) -> bool: + mt = (monitor_type or "").strip() + if mt in KEY_MONITOR_AUTO_TYPES: + return True + if is_fib_key_monitor_type(mt): + return True + if is_false_breakout_key_monitor_type(mt): + return True + if is_trigger_entry_key_monitor_type(mt): + return True + return False + + +def is_rs_key_monitor_type(monitor_type: str) -> bool: + return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES + + +def check_monitor_type_add_allowed( + monitor_type: str, + sizing_mode: str, + key_auto_enabled: bool, +) -> Tuple[bool, str]: + mt = (monitor_type or "").strip() + if is_rs_key_monitor_type(mt): + return True, "" + if not key_auto_enabled: + return False, ( + "已关闭关键位程序自动单(KEY_AUTO_ORDER_ENABLED=false);" + "仅可添加「关键支撑阻力」(微信提醒,不下单)." + ) + if is_full_margin_mode(sizing_mode): + if monitor_type_disallowed_in_full_margin(mt): + return False, ( + "全仓杠杆模式下不可添加箱体/收敛突破,斐波或假突破监控;" + "可使用「回调/突破触价开仓」或关键支撑阻力(仅提醒)." + ) + if not is_key_auto_monitor_type(mt) and not is_rs_key_monitor_type(mt): + return False, "监控类型无效" + return True, "" + + +def key_auto_order_env_comment_lines() -> Tuple[str, ...]: + return ( + "# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)", + "# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价)", + "#", + "# POSITION_SIZING_MODE=risk(以损定仓)", + "# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响", + "# true → 允许关键位全套自动(含触价)", + "#", + "# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换)", + "# false → 不执行触价自动单", + "# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止", + "#", + "# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止.", + "KEY_AUTO_ORDER_ENABLED=false", + ) diff --git a/lib/key_monitor/key_monitor_full_margin_lib.py b/lib/key_monitor/key_monitor_full_margin_lib.py new file mode 100644 index 0000000..724ea78 --- /dev/null +++ b/lib/key_monitor/key_monitor_full_margin_lib.py @@ -0,0 +1,61 @@ +""" +全仓杠杆模式下:撤销已添加的箱体/收敛/斐波关键位监控并微信说明. +""" +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional + +from lib.key_monitor.fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type +from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type +from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES +from lib.trade.position_sizing_lib import is_full_margin_mode, mode_label_zh + + +def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool: + mt = (monitor_type or "").strip() + if mt in KEY_MONITOR_AUTO_TYPES: + return True + if is_fib_key_monitor_type(mt): + return True + return is_false_breakout_key_monitor_type(mt) + + +def purge_disallowed_key_monitors( + conn: Any, + *, + sizing_mode: str, + select_rows: Callable[[Any], Iterable[Any]], + cancel_fib_limit: Callable[[Any], None], + delete_monitor: Callable[[Any, int], None], + send_wechat: Callable[[str], None], + row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""), + row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""), + row_id: Callable[[Any], int] = lambda r: int(r["id"]), +) -> int: + if not is_full_margin_mode(sizing_mode): + return 0 + removed = [] + for row in select_rows(conn): + mt = row_monitor_type(row) + if not monitor_type_disallowed_in_full_margin(mt): + continue + sym = row_symbol(row) + kid = row_id(row) + if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt): + try: + cancel_fib_limit(row) + except Exception: + pass + delete_monitor(conn, kid) + removed.append((sym, mt, kid)) + if removed: + lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]] + if len(removed) > 12: + lines.append(f"… 共 {len(removed)} 条") + send_wechat( + "# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n" + f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n" + "已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n" + + "\n".join(lines) + ) + return len(removed) diff --git a/lib/key_monitor/key_monitor_lib.py b/lib/key_monitor/key_monitor_lib.py new file mode 100644 index 0000000..17ab5f2 --- /dev/null +++ b/lib/key_monitor/key_monitor_lib.py @@ -0,0 +1,390 @@ +""" +关键位监控:阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑. +""" +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, + trigger_entry_validity_hours: int | None = None, +) -> dict[str, Any]: + """关键位监控页规则说明表格(Jinja key_rule_ctx).""" + from lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_OFFSET_PCT, + FALSE_BREAKOUT_RR, + FALSE_BREAKOUT_SL_PCT, + ) + from lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS + + te_hours = ( + int(trigger_entry_validity_hours) + if trigger_entry_validity_hours is not None + else 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_min": key_alert_interval_minutes, + "stop_outside_pct": key_stop_outside_breakout_pct, + "trend_stop_outside_pct": key_trend_stop_outside_pct, + "fb_offset_pct": FALSE_BREAKOUT_OFFSET_PCT, + "fb_sl_pct": FALSE_BREAKOUT_SL_PCT, + "fb_rr": FALSE_BREAKOUT_RR, + "fb_valid_hours": false_breakout_validity_hours, + "trigger_entry_validity_hours": te_hours, + } 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/key_monitor/trigger_entry_key_monitor_lib.py b/lib/key_monitor/trigger_entry_key_monitor_lib.py new file mode 100644 index 0000000..7c67aa7 --- /dev/null +++ b/lib/key_monitor/trigger_entry_key_monitor_lib.py @@ -0,0 +1,324 @@ +"""回调/突破触价开仓关键位监控:程序盯价,触达计划入场后市价成交(三所共用逻辑).""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, Optional + +from lib.key_monitor.false_breakout_key_monitor_lib import ( + _parse_created_at, + expires_at_text, + is_false_breakout_expired, +) +from lib.strategy.strategy_trend_lib import trend_dca_level_reached + +# 回调触价(原「触价开仓」) +CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓" +LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓" + +# 突破触价:标记价穿越 E 后立即市价开仓 +BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓" + +TRIGGER_ENTRY_MONITOR_TYPES = frozenset( + { + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + LEGACY_TRIGGER_ENTRY_MONITOR_TYPE, + } +) + +TRIGGER_ENTRY_VALIDITY_HOURS = 24 +TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled" +TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate" +TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate" +TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired" +TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed" + +KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓" +KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓" +KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓" + +TRIGGER_ENTRY_IN_FLIGHT_OID = "__trigger_entry_in_flight__" + + +def is_trigger_entry_in_flight_row(row: Any) -> bool: + if row is None: + return False + try: + v = row["fib_limit_order_id"] + except (KeyError, IndexError, TypeError): + v = getattr(row, "fib_limit_order_id", None) + return (v or "").strip() == TRIGGER_ENTRY_IN_FLIGHT_OID + + +def acquire_trigger_entry_exec_lock(conn: Any, monitor_id: int) -> bool: + cur = conn.execute( + "UPDATE key_monitors SET fib_limit_order_id=? WHERE id=? " + "AND (fib_limit_order_id IS NULL OR fib_limit_order_id='')", + (TRIGGER_ENTRY_IN_FLIGHT_OID, int(monitor_id)), + ) + return int(cur.rowcount or 0) == 1 + + +def release_trigger_entry_exec_lock(conn: Any, monitor_id: int) -> None: + conn.execute( + "UPDATE key_monitors SET fib_limit_order_id=NULL WHERE id=? AND fib_limit_order_id=?", + (int(monitor_id), TRIGGER_ENTRY_IN_FLIGHT_OID), + ) + + +def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str: + mt = (monitor_type or "").strip() + if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE: + return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + return mt + + +def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: + return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES + + +def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: + mt = normalize_trigger_entry_monitor_type(monitor_type) + return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + + +def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool: + return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE + + +def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str: + if is_breakout_trigger_entry_key_monitor_type(monitor_type): + return KEY_ENTRY_REASON_BREAKOUT + if is_trigger_entry_key_monitor_type(monitor_type): + return KEY_ENTRY_REASON_CALLBACK + return KEY_ENTRY_REASON_TRIGGER_LEGACY + + +def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool: + """回调触价:多=价跌至 E;空=价涨至 E.""" + return trend_dca_level_reached(direction, mark_price, entry) + + +def breakout_trigger_entry_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + entry: float, +) -> bool: + """突破触价:多=向上穿越 E;空=向下穿越 E.""" + try: + m = float(mark) + e = float(entry) + pm = float(prev_mark) if prev_mark is not None else None + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + if pm is None: + return m > e + return pm <= e and m > e + if pm is None: + return m < e + return pm >= e and m < e + + +def trigger_should_fire( + monitor_type: Optional[str], + direction: str, + mark: float, + entry: float, + prev_mark: Optional[float] = None, +) -> bool: + if is_breakout_trigger_entry_key_monitor_type(monitor_type): + return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry) + return trigger_entry_reached(direction, mark, entry) + + +def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool: + """未开仓前标记价先触达止盈侧则失效.""" + try: + m = float(mark_price) + tp = float(take_profit) + except (TypeError, ValueError): + return False + d = (direction or "long").strip().lower() + if d == "short": + return m <= tp + return m >= tp + + +def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool: + """突破触价:未到 E 先触达止损侧则失效.""" + try: + m = float(mark_price) + sl = float(stop_loss) + except (TypeError, ValueError): + return False + d = (direction or "long").strip().lower() + if d == "long": + return m <= sl + return m >= sl + + +def trigger_entry_invalidate( + monitor_type: Optional[str], + direction: str, + mark: float, + stop_loss: float, + take_profit: float, +) -> Optional[str]: + if trigger_entry_invalidate_by_tp(direction, mark, take_profit): + return "tp" + if is_breakout_trigger_entry_key_monitor_type(monitor_type): + if trigger_entry_invalidate_by_sl(direction, mark, stop_loss): + return "sl" + return None + + +def validate_trigger_entry_geometry( + direction: str, + entry: float, + stop_loss: float, + take_profit: float, + mark_at_add: Optional[float] = None, + *, + monitor_type: Optional[str] = None, +) -> Optional[str]: + """返回错误文案;合法则 None.""" + try: + e = float(entry) + sl = float(stop_loss) + tp = float(take_profit) + except (TypeError, ValueError): + return "入场价,止损,止盈须为有效数字" + if e <= 0 or sl <= 0 or tp <= 0: + return "入场价,止损,止盈须大于 0" + d = (direction or "long").strip().lower() + mt = normalize_trigger_entry_monitor_type(monitor_type) + label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓" + if d == "long": + if not (sl < e < tp): + return "做多:须满足 止损 < 入场价 < 止盈" + if mark_at_add is not None: + m = float(mark_at_add) + if m >= tp: + return f"做多:当前价已不低于止盈,无法添加{label}" + if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e: + return "做多:当前价须低于入场价(等待向上突破)" + elif d == "short": + if not (tp < e < sl): + return "做空:须满足 止盈 < 入场价 < 止损" + if mark_at_add is not None: + m = float(mark_at_add) + if m <= tp: + return f"做空:当前价已不高于止盈,无法添加{label}" + if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e: + return "做空:当前价须高于入场价(等待向下跌破)" + else: + return "方向须为 long 或 short" + return None + + +def validate_trigger_entry_rr( + direction: str, + entry: float, + stop_loss: float, + take_profit: float, + min_rr: float, + calc_rr_ratio: Callable[..., Optional[float]], +) -> Optional[str]: + rr = calc_rr_ratio(direction, entry, stop_loss, take_profit) + if rr is None or rr <= float(min_rr): + fmt = f"{rr:.4f}" if rr is not None else "无法计算" + return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1)" + return None + + +def is_trigger_entry_expired( + created_at: Any, + now: datetime, + *, + hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, +) -> bool: + return is_false_breakout_expired(created_at, now, hours=hours) + + +def trigger_entry_expires_at_text( + created_at: Any, + *, + hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, +) -> str: + return expires_at_text(created_at, hours=hours) + + +def count_pending_trigger_entries(conn: Any, trading_day: str) -> int: + td = (trading_day or "").strip() + if not td: + return 0 + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?", + (*TRIGGER_ENTRY_MONITOR_TYPES, td), + ).fetchone() + return int(row[0] if row else 0) + + +def check_trigger_entry_intent_limit( + conn: Any, + trading_day: str, + opens_today: int, + hard_limit: int, +) -> tuple[bool, str]: + """当日开仓意图:已成交次数 + 待触发触价条数.""" + if int(hard_limit) <= 0: + return True, "" + pending = count_pending_trigger_entries(conn, trading_day) + total = int(opens_today) + pending + if total >= int(hard_limit): + return ( + False, + f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)})", + ) + return True, "" + + +def trigger_entry_gate_preview( + *, + monitor_type: Optional[str] = None, + entry_display: str, + take_profit_display: str, + created_at: Any = None, + now: Optional[datetime] = None, + expired: bool = False, + tp_invalidated: bool = False, + sl_invalidated: bool = False, + hours: int = TRIGGER_ENTRY_VALIDITY_HOURS, +) -> dict[str, Any]: + now_dt = now or datetime.now() + is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours) + exp_txt = trigger_entry_expires_at_text(created_at, hours=hours) + mt = normalize_trigger_entry_monitor_type(monitor_type) + if tp_invalidated: + status = "止盈侧失效" + elif sl_invalidated: + status = "止损侧失效" + elif is_exp: + status = "已过期" + elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE: + status = "突破待触发" + else: + status = "回调待触发" + mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调" + metrics_parts: list[str] = [f"TP:{take_profit_display}"] + if exp_txt != "—": + metrics_parts.append(f"截至:{exp_txt}") + return { + "summary": f"{mode}触价 E={entry_display} {status}", + "metrics": " ".join(metrics_parts), + "gate_ok": not is_exp and not tp_invalidated and not sl_invalidated, + } + + +# 兼容旧 import +TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE +KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK 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..489b411 --- /dev/null +++ b/lib/options/options_close_exec_lib.py @@ -0,0 +1,442 @@ +"""期权平仓执行:只锁买一限价卖出;永不市价.""" +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 ( + PREMIUM_DECIMALS_COIN, + estimate_close_by_bids, + fetch_option_mark_px, + is_stub_bid_px, + round_premium_amount, + 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, + inst_id=inst_id, + ) + + +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 时:首次还需目标门控(权利金×倍数或净盈亏,U 口径)并持续 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")) + premium_ccy = str(q.get("premium_ccy") or pos.get("premium_ccy") or "USDC").strip().upper() or "USDC" + index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px")) + + # 已有未成交卖平单:等成交,不撤不重挂 + 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, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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_premium_amount( + total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN + ) or 0.0 + 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_premium_amount( + prem_recv - allocated, decimals=PREMIUM_DECIMALS_COIN + ) or 0.0 + elif total_paid > 0: + recv = round_premium_amount( + prem_recv * (paid / total_paid), decimals=PREMIUM_DECIMALS_COIN + ) or 0.0 + allocated += recv + else: + recv = round_premium_amount( + prem_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN + ) or 0.0 + allocated += recv + pnl = round_premium_amount(recv - paid, decimals=PREMIUM_DECIMALS_COIN) or 0.0 + note_sql = "" + from lib.options.options_time_lib import options_app_now_str + + # 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id + params: list[Any] = [px, recv, pnl, oid or None, options_app_now_str()] + 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 = ? + {note_sql} + WHERE id = ? + """, + tuple(params), + ) + conn.commit() + finally: + conn.close() + elif require_recycle_gate: + # 自动平已挂过单:同仓续批只验流动性 + mark_close_gate_passed(inst_id) + + out = { + "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} 张待下次平仓") + ), + } + if fully_closed: + try: + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out) + if spot_sell is not None: + out["spot_sell"] = spot_sell + if spot_sell.get("bridge_status") == "pending_sell_spot": + out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回" + except Exception as e: + out["spot_sell"] = {"ok": False, "msg": str(e)} + return out + + +# 兼容旧名 +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..46c44a6 --- /dev/null +++ b/lib/options/options_close_gate_lib.py @@ -0,0 +1,334 @@ +"""期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓.""" +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 + + +def _env_optional_float(key: str) -> float | None: + raw = os.getenv(key) + if raw is None or str(raw).strip() == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) + + +def close_net_pnl_min_u() -> float: + return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", 0.0) + + +def close_gate_mode() -> str: + m = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower() + return m if m in ("premium", "net_pnl") else "premium" + + +def resolve_close_recycle_mult(premium_ccy: str | None, min_mult: float | None = None) -> float: + if min_mult is not None: + m = float(min_mult) + return m if m > 0 else 1.05 + global_mult = _env_optional_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT") + if global_mult is not None and global_mult > 0: + return global_mult + ccy = (premium_ccy or "USDC").strip().upper() or "USDC" + if ccy in ("ETH", "BTC"): + return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05) + return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", 2.0) + + +# 兼容旧引用 +CLOSE_RECYCLE_MIN_MULT = resolve_close_recycle_mult("USDC") + +_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 _normalize_premium_ccy(premium_ccy: str | None) -> str: + ccy = (premium_ccy or "USDC").strip().upper() or "USDC" + if ccy not in ("ETH", "BTC", "USDC"): + ccy = "USDC" + return ccy + + +def _to_usdt(amount: float | None, ccy: str, index_px: float | None) -> float | None: + if amount is None: + return None + unit = _normalize_premium_ccy(ccy) + if unit in ("ETH", "BTC"): + idx = _safe_float(index_px) + if idx is None or idx <= 0: + return None + return float(amount) * float(idx) + return float(amount) + + +def _fmt_gate_amt(v: float, *, ccy: str) -> str: + unit = _normalize_premium_ccy(ccy) + if unit in ("ETH", "BTC"): + txt = f"{float(v):.8f}".rstrip("0").rstrip(".") + return txt or "0" + return f"{float(v):.4f}" + + +def _fmt_usdt(v: float | None) -> str: + if v is None: + return "—" + return f"{float(v):.2f}" + + +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: + """标记同仓已通过门控,续批平仓只验流动性.""" + 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, + premium_ccy: str | None = None, + index_px: float | None = None, +) -> dict[str, Any]: + """ + 根据当前买盘可回收金额刷新门控(比较口径均为 USDT 估值). + premium 模式:可回收(U) ≥ 权利金(U) × 倍数 + net_pnl 模式:净盈亏(U) > OKX_OPTIONS_CLOSE_NET_PNL_MIN_U + """ + 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()) + mode = close_gate_mode() + hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) + if hold < 0: + hold = 0.0 + + with _lock: + prev_ccy = (_gates.get(inst) or {}).get("premium_ccy") + ccy = _normalize_premium_ccy(premium_ccy or prev_ccy) + mult = resolve_close_recycle_mult(ccy, min_mult) + min_pnl_u = close_net_pnl_min_u() + + prem = _safe_float(premium_paid) + recv = _safe_float(recycle_usdc) + recv_u = _to_usdt(recv, ccy, index_px) + prem_u = _to_usdt(prem, ccy, index_px) + net_u = round(recv_u - prem_u, 4) if recv_u is not None and prem_u is not None else None + need_u = round(prem_u * mult, 4) if prem_u is not None and prem_u > 0 and mode == "premium" else None + + missing_index = ccy in ("ETH", "BTC") and (index_px is None or _safe_float(index_px) is None or _safe_float(index_px) <= 0) + + if missing_index and prem is not None and prem > 0 and recv is not None: + recycle_ok = False + elif mode == "net_pnl": + recycle_ok = bool(net_u is not None and net_u > min_pnl_u + 1e-9) + else: + recycle_ok = bool( + prem_u is not None + and prem_u > 0 + and recv_u is not None + and need_u is not None + and recv_u + 1e-9 >= need_u + ) + + 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_u, + "updated": ts, + "min_mult": mult, + "hold_seconds": hold, + "passed": passed, + "premium_ccy": ccy, + "gate_mode": mode, + "index_px": _safe_float(index_px), + "recycle_usdt": recv_u, + "premium_usdt": prem_u, + "need_recycle_usdt": need_u, + "net_pnl_usdt": net_u, + "net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None, + } + _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 missing_index: + msg = "缺少指数价,无法按 USDT 校验目标平仓门控" + elif mode == "net_pnl": + if not recycle_ok: + msg = ( + f"净盈亏 {_fmt_usdt(net_u)}U(估) ≤ {_fmt_usdt(min_pnl_u)}U," + f"目标平仓门控未过" + ) + elif not ready: + msg = ( + f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" + ) + else: + msg = ( + f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U" + f"且持续≥{hold:.0f}s,目标触达后可按买一平仓" + ) + elif not recycle_ok: + msg = ( + f"可回收 {_fmt_usdt(recv_u)}U(估) < 权利金×{mult:g}" + f"({_fmt_usdt(need_u)}U),目标平仓门控未过" + ) + elif not ready: + msg = ( + f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}" + f"({_fmt_usdt(recv_u)}/{_fmt_usdt(need_u)}U)," + f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" + ) + else: + msg = ( + f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s," + f"目标触达后可按买一平仓" + ) + + 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_u, + "premium_ccy": ccy, + "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, + "gate_mode": mode, + "index_px": _safe_float(index_px), + "recycle_usdt": recv_u, + "premium_usdt": prem_u, + "need_recycle_usdt": need_u, + "net_pnl_usdt": net_u, + "net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None, + "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, + premium_ccy: str | None = None, + index_px: 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 or premium_ccy is None or index_px 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") + if premium_ccy is None: + premium_ccy = prev.get("premium_ccy") + if index_px is None: + index_px = prev.get("index_px") + return update_close_gate( + inst, + recycle_usdc=recycle_usdc, + premium_paid=premium_paid, + premium_ccy=premium_ccy, + index_px=index_px, + ) + with _lock: + prev = _gates.get(inst) + if not prev: + return update_close_gate( + inst, + recycle_usdc=recycle_usdc, + premium_paid=premium_paid, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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"), + premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"), + index_px=index_px if index_px is not None else prev.get("index_px"), + ) diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py new file mode 100644 index 0000000..6237b41 --- /dev/null +++ b/lib/options/options_coin_open_lib.py @@ -0,0 +1,484 @@ +"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回.""" +from __future__ import annotations + +import os +import time +from typing import Any + +from lib.exchange.okx_options_lib import ( + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, + wait_option_order_full_fill, +) +from lib.options.options_margin_mode_lib import ( + calc_sheets_from_coin_balance, + compute_coin_budget_usdt, + is_coin_margin_mode, + margin_mode_from_inst_id, + normalize_options_margin_mode, + plan_coin_open_by_budget, + premium_ccy_for_mode, +) +from lib.options.options_spot_bridge_lib import ( + BRIDGE_BOUGHT, + BRIDGE_HOLDING, + bridge_blocks_new_open_msg, + fetch_trading_coin_available, + insert_bridge, + rollback_bought_coin_to_usdt, + sell_residual_after_option_flat, + spot_market_buy_coin_with_usdt, + update_bridge, +) + + +def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import fetch_options_balances + + bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True) + trading = bal.get("trading_usdt_avail") + if trading is None: + trading = bal.get("trading_usdt") + try: + trading_f = float(trading or 0) + except (TypeError, ValueError): + trading_f = 0.0 + buf = float(cfg.get("budget_buffer") or 0.95) + return compute_coin_budget_usdt(trading_f, buffer=buf) + + +def open_coin_option_buy_full( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + signal_note: str = "", + target_index: float | None = None, + profit_exit_enabled: bool = False, + profit_exit_mult: float = 1.0, + target_sheets: int | None = None, +) -> dict[str, Any]: + """先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算).""" + from lib.options.options_db import init_options_tables + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if not is_coin_margin_mode(): + return {"ok": False, "msg": "当前非币本位模式"} + if margin_mode_from_inst_id(inst_id) != "coin": + return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"} + + # 解析标的 + parts = inst_id.split("-") + underlying = (parts[0] if parts else "ETH").upper() + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + block = bridge_blocks_new_open_msg(conn) + if block: + return {"ok": False, "msg": block, "can_open": False} + + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return {"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 {"ok": False, "msg": pos_limit_msg, "can_open": False} + + budget_info = coin_budget_preview(cfg, ex) + if not budget_info.get("ok"): + return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info} + budget_usdt = float(budget_info["budget_usdt"]) + + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return 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 "暂无卖一深度,无法买入", + "can_open": False, + } + + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + idx = None + try: + idx = float(q.get("index_px") or q.get("idxPx") or 0) + except (TypeError, ValueError): + idx = 0.0 + if idx <= 0: + try: + from lib.exchange.okx_options_lib import fetch_index_price + + idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0) + except Exception: + idx = 0.0 + + plan = plan_coin_open_by_budget( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + budget_usdt=budget_usdt, + index_px=float(idx), + ask_sz=ask_sz, + target_sheets=target_sheets, + ) + if not plan.get("ok"): + return { + "ok": False, + "msg": plan.get("msg") or "无法规划买币张数", + "plan": plan, + "budget": budget_info, + "can_open": False, + } + buy_usdt = float(plan["buy_usdt"]) + sheets = int(plan["sheets"]) + + # 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算 + coin_before = fetch_trading_coin_available(ex, underlying) or 0.0 + buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt) + if not buy.get("ok"): + return { + "ok": False, + "msg": f"现货买入 {underlying} 失败: {buy.get('msg')}", + "budget": budget_info, + "plan": plan, + } + bridge_id = insert_bridge( + conn, + underlying=underlying, + status=BRIDGE_BOUGHT, + budget_usdt=buy_usdt, + buy_ord_id=str(buy.get("ord_id") or ""), + inst_id=inst_id, + message="已买币,待开期权", + ) + # 等余额落账 + time.sleep(1.5) + try: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + + invalidate_options_balance_cache() + except Exception: + pass + coin_after = fetch_trading_coin_available(ex, underlying) + if coin_after is None: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额" + ) + return { + "ok": False, + "msg": "买币后读不到可用余额,已尝试卖回 USDT", + "rollback": rb, + "budget": budget_info, + "plan": plan, + } + coin_bought = max(0.0, float(coin_after) - float(coin_before or 0)) + if coin_bought <= 0: + # 落账延迟时退化为用当前可用,但仍写入上限提示 + coin_bought = float(coin_after) + if coin_bought <= 0: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效" + ) + return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info} + update_bridge(conn, bridge_id, coin_bought=float(coin_bought)) + + sizing = calc_sheets_from_coin_balance( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + coin_available=float(coin_bought), + ) + if not sizing.get("ok"): + rb = rollback_bought_coin_to_usdt( + conn, + ex, + bridge_id=bridge_id, + underlying=underlying, + reason=sizing.get("msg") or "张数不足", + coin_amount=float(coin_bought), + ) + return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan} + + # 实盘以买到的币为准,但不超过规划张数 + sheets = min(int(sizing["sheets"]), int(plan["sheets"])) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足" + ) + return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb} + if capped < sheets: + sheets = int(capped) + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + "ask_depth_capped": True, + } + else: + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + } + + 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.get("td_mode")), + tick_sz=tick_sz, + ord_type="ioc", + ) + # 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚) + if (not order.get("ok")) and sheets > 1: + msg_l = str(order.get("msg") or "").lower() + if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""): + sheets2 = max(1, sheets // 2) + if sheets2 < sheets: + order2 = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets2, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg.get("td_mode")), + tick_sz=tick_sz, + ord_type="ioc", + ) + if order2.get("ok"): + order = order2 + sheets = sheets2 + sizing = { + "ok": True, + "sheets": sheets, + "eth_amount": round(sheets * ct_mult, 8), + "coin_premium": round(sheets * float(ask) * ct_mult, 8), + "retried_half": True, + } + if not order.get("ok"): + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败" + ) + return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb} + + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + rb = rollback_bought_coin_to_usdt( + conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号" + ) + return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb} + + 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) + if filled_n <= 0: + rb = rollback_bought_coin_to_usdt( + conn, + ex, + bridge_id=bridge_id, + underlying=underlying, + reason=fill.get("msg") or "未成交", + ) + return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb} + sheets = filled_n + + eth_amount = round(float(sheets) * ct_mult, 8) + premium_paid = round(float(ask) * eth_amount, 8) + premium_ccy = premium_ccy_for_mode("coin", underlying) + + update_bridge( + conn, + bridge_id, + status=BRIDGE_HOLDING, + inst_id=inst_id, + message="期权持仓中", + ) + + trade_id = _insert_coin_trade( + conn, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + strike=q.get("strike"), + exp_time=q.get("exp_time"), + sheets=int(sheets), + eth_amount=eth_amount, + open_quote=float(ask), + premium_paid=premium_paid, + signal_note=signal_note, + exchange_ord_id=ord_id, + bridge_id=bridge_id, + budget_usdt=buy_usdt, + premium_ccy=premium_ccy, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + ) + + # 目标位 / 翻倍离场 — 复用现有逻辑若存在 + try: + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + upsert_target_monitor( + conn, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + target_index=float(target_index), + trade_id=trade_id, + sheets=int(sheets), + ) + except Exception: + pass + try: + from lib.options.options_notify_lib import notify_options_open + + notify_options_open( + cfg, + conn, + trade_id=trade_id, + inst_id=inst_id, + underlying=underlying, + opt_type=str(q.get("opt_type") or ""), + sheets=int(sheets), + premium_paid=premium_paid, + open_quote=float(ask), + target_index=target_index, + signal_note=signal_note, + premium_ccy=premium_ccy, + margin_mode="coin", + ) + except Exception: + pass + + return { + "ok": True, + "msg": f"币本位开仓成功 {sheets} 张", + "margin_mode": "coin", + "budget": budget_info, + "sizing": sizing, + "sheets": sheets, + "eth_amount": eth_amount, + "premium_paid": premium_paid, + "premium_ccy": premium_ccy, + "bridge_id": bridge_id, + "trade_id": trade_id, + "order": order, + "fill": fill, + } + finally: + try: + conn.close() + except Exception: + pass + + +def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: + from lib.options.options_time_lib import options_app_now_str + + pe = 1 if kwargs.get("profit_exit_enabled") else 0 + pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) + pe_state = "active" if pe else "idle" + 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, + margin_mode, premium_ccy, bridge_id, budget_usdt, + profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?) + """, + ( + kwargs["inst_id"], + kwargs["underlying"], + kwargs["opt_type"], + kwargs.get("strike"), + str(kwargs.get("exp_time") or ""), + kwargs["sheets"], + kwargs["eth_amount"], + kwargs.get("open_quote"), + kwargs.get("premium_paid"), + kwargs.get("signal_note") or "", + kwargs.get("exchange_ord_id"), + kwargs.get("premium_ccy") or "ETH", + kwargs.get("bridge_id"), + kwargs.get("budget_usdt"), + pe, + pe_mult, + pe_state, + options_app_now_str(), + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def maybe_sell_spot_after_close( + cfg: dict[str, Any], + ex: Any, + *, + inst_id: str, + close_result: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.""" + if margin_mode_from_inst_id(inst_id) != "coin": + return None + # 仍有仓则不卖 + try: + rows = cfg["fetch_option_positions"](ex) or [] + for p in rows: + if str(p.get("instId") or p.get("inst_id") or "") != inst_id: + continue + try: + if abs(float(p.get("pos") or 0)) > 1e-12: + return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} + except (TypeError, ValueError): + pass + except Exception: + pass + parts = inst_id.split("-") + underlying = (parts[0] if parts else "ETH").upper() + conn = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + finally: + try: + conn.close() + except Exception: + pass diff --git a/lib/options/options_dashboard_lib.py b/lib/options/options_dashboard_lib.py new file mode 100644 index 0000000..7c1f5c8 --- /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]]: + """ + 拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合. + 不走 build_options_hub_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..d7fef4f --- /dev/null +++ b/lib/options/options_db.py @@ -0,0 +1,156 @@ +"""期权模块 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'", + "ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'", + "ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'", + "ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER", + "ALTER TABLE options_trades ADD COLUMN budget_usdt REAL", + ): + try: + conn.execute(ddl) + except Exception: + pass + try: + from lib.options.options_spot_bridge_lib import ensure_bridge_table + + ensure_bridge_table(conn) + 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 + # 币本位权利金常 <1e-4,保留 8 位避免被裁成 0 + return round(float(row["total"] or 0), 8) + + +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..ca9283c --- /dev/null +++ b/lib/options/options_history_lib.py @@ -0,0 +1,149 @@ +"""期权历史列表(交易所 positions-history + 当前持仓).""" +from __future__ import annotations + +from typing import Any + +from lib.exchange.okx_options_lib import format_premium_amount +from lib.options.options_db import init_options_tables, sum_open_premium_paid +from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + +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, 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) + row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc" + underly = str(row.get("underlying") or (inst_id.split("-")[0] if inst_id else "ETH") or "ETH") + premium_ccy = premium_ccy_for_mode(row_mode, underly) + row["margin_mode"] = row_mode + row["premium_ccy"] = premium_ccy + row["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC" + if premium_override is not None: + row["premium_paid"] = premium_override + row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy) + return row + + +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 _load_local_closed_by_inst(conn: Any) -> dict[str, dict[str, Any]]: + """同合约取最新已平本地单,用于补交易所历史权利金/盈亏.""" + out: dict[str, dict[str, Any]] = {} + try: + rows = conn.execute( + """ + SELECT inst_id, premium_paid, realized_pnl, premium_ccy, margin_mode, + open_quote, close_quote, sheets, closed_at + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + """ + ).fetchall() + except Exception: + return out + for r in rows: + inst = str(r["inst_id"] or "").strip() + if not inst or inst in out: + continue + out[inst] = dict(r) + return out + + +def _overlay_local_closed(row: dict[str, Any], local: dict[str, Any] | None) -> dict[str, Any]: + if not local: + return row + prem = _safe_float(row.get("premium_paid")) + pnl = _safe_float(row.get("realized_pnl")) + local_prem = _safe_float(local.get("premium_paid")) + local_pnl = _safe_float(local.get("realized_pnl")) + # 交易所缺数或被两位小数抹成 0 时,用本地币本位落库值 + if (prem is None or abs(prem) < 1e-10) and local_prem is not None and abs(local_prem) > 0: + row["premium_paid"] = local_prem + if (pnl is None or abs(pnl) < 1e-10) and local_pnl is not None and abs(local_pnl) > 0: + row["realized_pnl"] = local_pnl + if not row.get("premium_ccy") and local.get("premium_ccy"): + row["premium_ccy"] = local.get("premium_ccy") + if not row.get("margin_mode") and local.get("margin_mode"): + row["margin_mode"] = local.get("margin_mode") + ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" + row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=ccy) + 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() + } + local_closed = _load_local_closed_by_inst(conn) + 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)) + + 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) + row = format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult) + items.append(_overlay_local_closed(row, local_closed.get(inst_id))) + finally: + conn.close() + + 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_hub_lib.py b/lib/options/options_hub_lib.py new file mode 100644 index 0000000..a09294b --- /dev/null +++ b/lib/options/options_hub_lib.py @@ -0,0 +1,185 @@ +"""中控只读聚合:OKX 期权持仓 / 资金(轻量,不含历史统计).""" + +from __future__ import annotations + +import os +from typing import Any + + +def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: + if not cfg.get("enabled"): + return {"ok": True, "enabled": False} + ex = cfg.get("exchange_options") + ready_fn = cfg.get("options_api_ready") + if not callable(ready_fn): + return {"ok": False, "enabled": True, "msg": "期权模块未就绪"} + ok, reason = ready_fn(ex) + if not ok: + return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"} + try: + from lib.options.options_position_limit_lib import options_max_active_positions + from lib.options.options_positions_lib import build_display_option_positions + + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} + positions = build_display_option_positions(cfg, ex, raw) + target_monitors: list[dict[str, Any]] = [] + try: + conn = cfg["get_db"]() + try: + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + from lib.options.options_profit_exit_lib import profit_exit_by_inst + from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst + + target_monitors = list_active_targets(conn) + list_closing_targets(conn) + tgt_map = targets_by_inst(conn) + hedge_target_map = active_options_targets_by_inst(conn) + profit_exit_map = profit_exit_by_inst(conn) + target_monitors.extend(hedge_target_map.values()) + for pe in profit_exit_map.values(): + if pe.get("profit_exit_enabled"): + target_monitors.append( + { + "inst_id": pe.get("inst_id"), + "exit_mode": "profit_exit", + "profit_exit_mult": pe.get("profit_exit_mult"), + "profit_exit_enabled": True, + } + ) + for p in positions: + mon = tgt_map.get(str(p.get("inst_id") or "")) + if mon: + p["target_index"] = mon.get("target_index") + p["target_monitor_id"] = mon.get("id") + p["target_monitor"] = mon + pe = profit_exit_map.get(str(p.get("inst_id") or "")) + if pe: + p["profit_exit_enabled"] = pe.get("profit_exit_enabled") + p["profit_exit_mult"] = pe.get("profit_exit_mult") + p["profit_exit_state"] = pe.get("profit_exit_state") + p["profit_exit_required_recycle"] = pe.get("required_recycle") + hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) + if hedge_target: + p["hedge_plan_target"] = hedge_target + if not mon: + # 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。 + p["target_index"] = hedge_target.get("target_index") + try: + from lib.instance.instance_dashboard_lib import ( + _format_options_target, + _resolve_options_source, + ) + + inst = str(p.get("inst_id") or "") + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + p["source"] = source_key + p["source_label"] = source_label + p["source_plan_id"] = source_plan_id + p["target_monitor_text"] = _format_options_target(p) + except Exception: + p.setdefault("source_label", "—") + p.setdefault("source_plan_id", None) + p.setdefault("target_monitor_text", "—") + finally: + conn.close() + except Exception: + target_monitors = [] + from lib.options.options_positions_lib import display_pnl_usdt_from_option_row + + upl_total = 0.0 + has_upl = False + for p in positions: + # 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加 + pnl = display_pnl_usdt_from_option_row(p) + if pnl is None: + continue + has_upl = True + upl_total += float(pnl) + bal = cfg["fetch_options_balances"](ex) + from lib.options.options_margin_mode_lib import ( + is_coin_margin_mode, + normalize_options_margin_mode, + premium_ccy_for_mode, + ) + + margin_mode = normalize_options_margin_mode() + for p in positions: + mid = str(p.get("inst_id") or "") + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id + + row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode + p["margin_mode"] = row_mode + p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode( + row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH") + ) + p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC" + + underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + options_index_px = None + try: + from lib.exchange.okx_options_lib import fetch_index_price + + options_index_px = fetch_index_price(ex, underly) + except Exception: + options_index_px = None + if options_index_px is None: + for p in positions: + try: + px = float(p.get("idx_px") or p.get("idxPx") or 0) + except (TypeError, ValueError): + px = 0 + if px > 0: + options_index_px = px + break + + coin_budget = None + bridge_status = None + open_bridges = [] + if is_coin_margin_mode(): + try: + from lib.options.options_coin_open_lib import coin_budget_preview + + coin_budget = coin_budget_preview(cfg, ex) + except Exception: + coin_budget = None + try: + conn_b = cfg["get_db"]() + try: + from lib.options.options_spot_bridge_lib import list_open_bridges + + open_bridges = list_open_bridges(conn_b) + if open_bridges: + bridge_status = str(open_bridges[0].get("status") or "") + finally: + conn_b.close() + except Exception: + open_bridges = [] + + return { + "ok": True, + "enabled": True, + "positions": positions, + "position_count": len(positions), + "target_monitors": target_monitors, + "upl_total_usdc": round(upl_total, 4) if has_upl else None, + "balances": bal, + "funding_usdc": bal.get("funding_usdc"), + "funding_usdt": bal.get("funding_usdt"), + "trading_usdc": bal.get("trading_usdc"), + "trading_usdt": bal.get("trading_usdt"), + # 监控区不用历史统计;保留空对象兼容旧调用方 + "stats": {}, + "trade_budget": cfg.get("trade_budget"), + "account_label": cfg.get("account_label") or "OKX期权", + "max_active_positions": options_max_active_positions(), + "options_margin_mode": margin_mode, + "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", + "options_underly": underly, + "options_index_px": options_index_px, + "coin_budget": coin_budget, + "bridge_status": bridge_status, + "open_bridges": open_bridges, + } + except Exception as e: + return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_margin_mode_lib.py b/lib/options/options_margin_mode_lib.py new file mode 100644 index 0000000..b120115 --- /dev/null +++ b/lib/options/options_margin_mode_lib.py @@ -0,0 +1,315 @@ +"""OKX 单笔期权本位模式与币本位 USDT 预算.""" +from __future__ import annotations + +import os +from typing import Any + + +MODE_USDC = "usdc" +MODE_COIN = "coin" + + +def _env_bool(name: str, default: bool = False) -> bool: + v = (os.getenv(name) or "").strip().lower() + if not v: + return default + return v in ("1", "true", "yes", "on", "y") + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name) or default) + except (TypeError, ValueError): + return float(default) + + +def normalize_options_margin_mode(raw: Any = None) -> str: + """CoinOX 固定币本位期权;忽略 USDC 配置.""" + return MODE_COIN + + +def is_coin_margin_mode(raw: Any = None) -> bool: + return normalize_options_margin_mode(raw) == MODE_COIN + + +def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str: + u = (underlying or "ETH").strip().upper() or "ETH" + mode = normalize_options_margin_mode(margin_mode) + if mode == MODE_COIN: + return f"{u}-USD" + return f"{u}-USD_UM" + + +def margin_mode_from_inst_id(inst_id: str) -> str: + inst = (inst_id or "").strip().upper() + if not inst: + return normalize_options_margin_mode() + if "_UM" in inst: + return MODE_USDC + # ETH-USD-260701-2500-C / BTC-USD-... + if "-USD-" in inst and "_UM" not in inst: + return MODE_COIN + return normalize_options_margin_mode() + + +def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str: + if normalize_options_margin_mode(margin_mode) == MODE_COIN: + return (underlying or "ETH").strip().upper() or "ETH" + return "USDC" + + +def spot_quote_inst_id(underlying: str) -> str: + """现货市价买卖: ETH-USDT / BTC-USDT.""" + u = (underlying or "ETH").strip().upper() or "ETH" + return f"{u}-USDT" + + +def compute_coin_budget_usdt( + trading_usdt: float, + *, + compound: bool | None = None, + buffer: float | None = None, + fixed_budget_usdt: float | None = None, + max_enabled: bool | None = None, + max_usdt: float | None = None, +) -> dict[str, Any]: + """ + 币本位单笔 USDT 预算. + 复利开: trading_usdt × buffer; 复利关: fixed × buffer. + 上限开: min(..., max_usdt). + """ + bal = max(0.0, float(trading_usdt or 0)) + use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound) + buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) + if buf <= 0: + buf = 0.95 + fixed = ( + float(fixed_budget_usdt) + if fixed_budget_usdt is not None + else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0) + ) + if use_compound: + raw = bal * buf + source = "compound" + else: + raw = max(0.0, fixed) * buf + source = "fixed" + capped = False + max_on = ( + _env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False) + if max_enabled is None + else bool(max_enabled) + ) + max_n = ( + float(max_usdt) + if max_usdt is not None + else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0) + ) + budget = raw + if max_on and max_n > 0 and budget > max_n: + budget = max_n + capped = True + return { + "ok": budget > 0, + "budget_usdt": round(budget, 8), + "raw_usdt": round(raw, 8), + "trading_usdt": round(bal, 8), + "buffer": buf, + "compound": use_compound, + "source": source, + "max_enabled": max_on, + "max_usdt": max_n if max_on else None, + "capped_by_max": capped, + "msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算", + } + + +def normalize_coin_spot_buy_buffer(raw: Any = None) -> float: + """ + 现货买入相对权利金的倍数缓冲. + env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%). + 也可写 0.10 表示 +10%. + """ + if raw is None: + v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10) + else: + try: + v = float(raw) + except (TypeError, ValueError): + v = 1.10 + if v <= 0: + return 1.10 + if v < 1.0: + return 1.0 + v + return v + + +def plan_coin_open_by_budget( + *, + quote_per_unit: float, + ct_mult: float, + min_sz: int, + budget_usdt: float, + index_px: float, + ask_sz: float | None = None, + spot_buy_buffer: float | None = None, + target_sheets: int | None = None, +) -> dict[str, Any]: + """ + 先按预算/卖一估最大可开张数,再按「权利金 × 现货缓冲」算应买现货 USDT. + 不全额把预算换成币. + """ + import math + + from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth + + ask = float(quote_per_unit or 0) + mult = float(ct_mult or 0.01) + need = max(1, int(min_sz or 1)) + budget = max(0.0, float(budget_usdt or 0)) + idx = float(index_px or 0) + buf = normalize_coin_spot_buy_buffer(spot_buy_buffer) + if ask <= 0 or mult <= 0: + return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0} + if idx <= 0: + return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0} + if budget <= 0: + return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0} + + per_sheet_coin = ask * mult + # 每张开仓需买的币(含缓冲)及其约合 USDT + per_sheet_buy_coin = per_sheet_coin * buf + per_sheet_usdt = per_sheet_buy_coin * idx + if per_sheet_usdt <= 0: + return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0} + + max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12)) + if target_sheets is not None: + try: + want = int(target_sheets) + except (TypeError, ValueError): + want = 0 + if want < need: + return { + "ok": False, + "msg": f"指定张数无效(需≥{need})", + "sheets": 0, + "buy_usdt": 0.0, + "max_by_budget": max_by_budget, + } + sheets = min(want, max_by_budget) + if sheets < want: + return { + "ok": False, + "msg": ( + f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g})," + f"不足指定 {want} 张" + ), + "sheets": 0, + "buy_usdt": 0.0, + "max_by_budget": max_by_budget, + "spot_buy_buffer": buf, + } + else: + sheets = max_by_budget + + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need) + ask_depth_capped = False + if capped is None: + return { + "ok": False, + "msg": cap_msg or "卖一深度不足", + "sheets": 0, + "buy_usdt": 0.0, + "spot_buy_buffer": buf, + } + if int(capped) < sheets: + sheets = int(capped) + ask_depth_capped = True + + if sheets < need: + return { + "ok": False, + "msg": ( + f"预算不足,无法买入 {need} 张" + f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})" + ), + "sheets": sheets, + "buy_usdt": 0.0, + "per_sheet_usdt": round(per_sheet_usdt, 8), + "spot_buy_buffer": buf, + "max_by_budget": max_by_budget, + } + + premium_coin = sheets * per_sheet_coin + buy_coin = premium_coin * buf + buy_usdt = min(budget, buy_coin * idx) + # 再保险:向下对齐,避免浮点导致略超预算 + buy_usdt = min(budget, round(buy_usdt, 8)) + out = { + "ok": True, + "msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets} 张"), + "sheets": sheets, + "eth_amount": round(sheets * mult, 8), + "coin_premium": round(premium_coin, 8), + "total_premium": round(premium_coin, 8), + "per_sheet_coin": per_sheet_coin, + "buy_coin": round(buy_coin, 8), + "buy_usdt": round(buy_usdt, 8), + "budget_usdt": round(budget, 8), + "spot_buy_buffer": buf, + "index_px": idx, + "max_by_budget": max_by_budget, + "ask_depth_capped": ask_depth_capped, + "est_note": ( + f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;" + f"不全额兑换预算" + ), + } + if target_sheets is not None: + out["est_note"] = ( + f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换" + ) + out["target_sheets"] = int(target_sheets) + return out + + +def calc_sheets_from_coin_balance( + *, + quote_per_unit: float, + ct_mult: float, + min_sz: int, + coin_available: float, +) -> dict[str, Any]: + """用可用标的币尽量开满(权利金以币计).""" + import math + + ask = float(quote_per_unit or 0) + mult = float(ct_mult or 0.01) + need = max(1, int(min_sz or 1)) + coin = max(0.0, float(coin_available or 0)) + # 留一点手续费/精度缓冲,避免算满张后下单 51008 + coin_eff = coin * 0.97 + if ask <= 0 or mult <= 0: + return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0} + per_sheet = ask * mult + if per_sheet <= 0: + return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0} + sheets = int(math.floor((coin_eff / per_sheet) + 1e-12)) + if sheets < need: + return { + "ok": False, + "msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})", + "sheets": sheets, + "coin_premium": round(sheets * per_sheet, 8), + "per_sheet_coin": per_sheet, + } + prem = sheets * per_sheet + return { + "ok": True, + "msg": "", + "sheets": sheets, + "coin_premium": round(prem, 8), + "per_sheet_coin": per_sheet, + "eth_amount": round(sheets * mult, 8), + } diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py new file mode 100644 index 0000000..64bbdf5 --- /dev/null +++ b/lib/options/options_monitor_lib.py @@ -0,0 +1,483 @@ +"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any, Callable + +from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history +from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms + + +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, + premium_ccy: str | None = None, +) -> str: + from lib.options.options_notify_lib import resolve_options_premium_ccy + + ccy = resolve_options_premium_ccy(inst_id=inst_id, premium_ccy=premium_ccy) + d = 6 if ccy in ("ETH", "BTC") else 4 + pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—" + bid_txt = f"{bid:.{d}f}" if bid is not None else "—" + mode = "币本位" if ccy != "USDC" else "USDC" + return "\n".join( + [ + "【OKX期权·翻倍提醒】", + f"账户:{account_label}", + f"本位:{mode}", + f"合约:{inst_id}", + f"已付权利金:{premium_paid:.{d}f} {ccy}", + f"未实现盈亏:{upl:+.{d}f} {ccy}({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.""" + return options_wall_clock_to_ms(created_at) + + +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 = ms_to_options_app_str(close_ms) + 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 = ms_to_options_app_str(exp_ms) + else: + continue + + if not closed_at: + closed_at = options_app_now_str() + + 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), + 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..66329a0 --- /dev/null +++ b/lib/options/options_notify_lib.py @@ -0,0 +1,442 @@ +"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).支持 USDC / 币本位(ETH/BTC).""" +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 resolve_options_premium_ccy( + *, + inst_id: str = "", + underlying: str = "", + premium_ccy: Any = None, + margin_mode: Any = None, + row: dict[str, Any] | None = None, +) -> str: + """权利金计价币种:USDC 或 ETH/BTC.""" + raw = premium_ccy + if (raw is None or str(raw).strip() == "") and row: + raw = row.get("premium_ccy") + ccy = str(raw or "").strip().upper() + if ccy: + return ccy + try: + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + mid = str(inst_id or (row or {}).get("inst_id") or "").strip() + mode = margin_mode if margin_mode is not None else (row or {}).get("margin_mode") + if mode is None and mid: + mode = margin_mode_from_inst_id(mid) + u = str( + underlying + or (row or {}).get("underlying") + or (mid.split("-")[0] if mid else "ETH") + or "ETH" + ).strip().upper() or "ETH" + return premium_ccy_for_mode(str(mode or "usdc"), u) + except Exception: + return "USDC" + + +def _amount_decimals(ccy: str) -> int: + c = (ccy or "USDC").strip().upper() + if c in ("ETH", "BTC"): + return 6 + return 4 + + +def _mode_tag(*, inst_id: str = "", premium_ccy: str = "", margin_mode: Any = None) -> str: + ccy = (premium_ccy or "").strip().upper() + if ccy and ccy != "USDC": + return "币本位" + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode, margin_mode_from_inst_id + + mode = margin_mode + if mode is None and inst_id: + mode = margin_mode_from_inst_id(inst_id) + if is_coin_margin_mode(mode): + return "币本位" + except Exception: + pass + return "USDC" + + +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, + premium_ccy: Any = None, + margin_mode: Any = None, +) -> str: + ccy = resolve_options_premium_ccy( + inst_id=inst_id, + underlying=underlying, + premium_ccy=premium_ccy, + margin_mode=margin_mode, + ) + d = _amount_decimals(ccy) + mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode) + lines = [ + "【OKX期权·开仓】", + f"账户:{account_label or 'OKX期权'}", + f"本位:{mode}", + ] + 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, d)} {ccy}", + f"权利金:{_fmt(premium_paid, d)} {ccy}", + ] + ) + 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, + premium_ccy: Any = None, + margin_mode: Any = None, +) -> str: + ccy = resolve_options_premium_ccy( + inst_id=inst_id, + underlying=underlying, + premium_ccy=premium_ccy, + margin_mode=margin_mode, + ) + d = _amount_decimals(ccy) + mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode) + lines = [ + "【OKX期权·平仓】", + f"账户:{account_label or 'OKX期权'}", + f"本位:{mode}", + ] + 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, d)} {ccy}", + f"已付/收回:{_fmt(premium_paid, d)} / {_fmt(premium_received, d)} {ccy}", + f"实现盈亏:{_fmt(realized_pnl, d)} {ccy}", + ] + ) + 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 = "", + premium_ccy: Any = None, + margin_mode: Any = None, +) -> bool: + ensure_options_notify_columns(conn) if conn is not None else None + row_ccy = premium_ccy + row_mode = margin_mode + if conn is not None and trade_id is not None: + row = conn.execute( + "SELECT wechat_open_sent, premium_ccy, margin_mode, underlying FROM options_trades WHERE id=?", + (int(trade_id),), + ).fetchone() + if row and int(row["wechat_open_sent"] or 0): + return False + if row: + if row_ccy is None: + row_ccy = row["premium_ccy"] if "premium_ccy" in row.keys() else None + if row_mode is None: + row_mode = row["margin_mode"] if "margin_mode" in row.keys() else None + if not underlying: + underlying = str(row["underlying"] or "") if "underlying" in row.keys() else underlying + 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, + premium_ccy=row_ccy, + margin_mode=row_mode, + ) + 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, + premium_ccy: Any = None, + margin_mode: 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 not rows: + # 已有平仓记录且均已推送:幂等跳过,避免再走「无库行」重复推 + exists = conn.execute( + """ + SELECT 1 FROM options_trades + WHERE inst_id=? AND status='closed' + LIMIT 1 + """, + (inst_id,), + ).fetchone() + if exists: + return False + + 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, + premium_ccy=premium_ccy or head.get("premium_ccy"), + margin_mode=margin_mode or head.get("margin_mode"), + ) + 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, + premium_ccy=premium_ccy, + margin_mode=margin_mode, + ) + 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"), + premium_ccy=first.get("premium_ccy"), + margin_mode=first.get("margin_mode"), + ) 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..7a7b434 --- /dev/null +++ b/lib/options/options_positions_lib.py @@ -0,0 +1,213 @@ +"""期权持仓展示(实例页 / 中控快照共用).""" +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")), + inst_id=inst_id, + margin_mode=row.get("margin_mode"), + ) + # 与实盘一致:只按买一估算本轮可平 + 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, + ) + premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" + index_px = _safe_float(row.get("idx_px") or row.get("idxPx")) + # 残档时不累计门控;有效买一时刷新计时(仅自动平仓需要) + if preview.get("bid_invalid") or preview.get("auto_close_blocked"): + gate = update_close_gate( + inst_id, + recycle_usdc=None, + premium_paid=paid, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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, + premium_ccy=premium_ccy, + index_px=index_px, + ) + 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: + """与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值.""" + from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount + + 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: + ccy = str(row.get("premium_ccy") or "").strip().upper() + decimals = PREMIUM_DECIMALS_COIN if ccy in ("ETH", "BTC") else None + return round_premium_amount(recv - paid, ccy=ccy or None, decimals=decimals) + 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 display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None: + """顶栏/看板/中控「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样. + + BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱). + """ + pnl = display_pnl_from_option_row(row) + if pnl is None: + return None + ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" + if not ccy or ccy == "USDC": + # 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U + inst = str(row.get("inst_id") or row.get("instId") or "") + mode = str(row.get("margin_mode") or "").strip().lower() + if mode == "coin" or ( + "-USD-" in inst.upper() and "_UM" not in inst.upper() + ): + underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "") or "").upper() + if underly in ("ETH", "BTC"): + ccy = underly + if ccy in ("ETH", "BTC"): + idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px")) + if idx is None or idx <= 0: + # 无指数时无法折算,跳过该仓以免把「币数量」当成 U + return None + return float(pnl) * float(idx) + return float(pnl) + + +def sum_options_net_pnl_usdc( + cfg: dict[str, Any], + ex: Any, + raw_positions: list[dict[str, Any]] | None = None, +) -> float | None: + """ + 期权浮盈合计(USDT/USDC 口径),与顶栏实时盈亏/中控对齐: + 各仓买一可回收 − 权利金;币本位再 × 指数折成 U;残档回退该仓 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_usdt_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..1793950 --- /dev/null +++ b/lib/options/options_pricing_lib.py @@ -0,0 +1,719 @@ +"""OKX USDⓈ 期权:张数与权利金计算.""" +from __future__ import annotations + +import math +from typing import Any + +# 币本位权利金常为 1e-4~1e-5 量级(尤其 BTC);round(...,4) 会把净盈亏抹成 0 +PREMIUM_DECIMALS_COIN = 8 +PREMIUM_DECIMALS_FIAT = 4 + + +def round_premium_amount(v: float | None, *, ccy: str | None = None, decimals: int | None = None) -> float | None: + """权利金/回收/币盈亏入账精度:ETH/BTC 用 8 位,USDC 等用法币 4 位.""" + if v is None: + return None + try: + n = float(v) + except (TypeError, ValueError): + return None + if decimals is None: + unit = (ccy or "").strip().upper() + decimals = PREMIUM_DECIMALS_COIN if unit in ("ETH", "BTC") else PREMIUM_DECIMALS_FIAT + return round(n, int(decimals)) + + +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 _quote_in_coin_from_context( + *, + quote_in_coin: bool | None = None, + inst_id: str | None = None, + margin_mode: str | None = None, +) -> bool: + """币本位(ETH-USD/BTC-USD)权利金按币报价;USDC(USD_UM)按美元点差.""" + if quote_in_coin is not None: + return bool(quote_in_coin) + if inst_id: + try: + from lib.options.options_margin_mode_lib import MODE_COIN, margin_mode_from_inst_id + + return margin_mode_from_inst_id(inst_id) == MODE_COIN + except Exception: + pass + if margin_mode is not None: + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + return is_coin_margin_mode(margin_mode) + except Exception: + return str(margin_mode).strip().lower() in ("coin", "coin_margin", "crypto") + return False + + +def intrinsic_px_per_unit( + opt_type: str | None, + strike: float | None, + index_px: float | None, + *, + quote_in_coin: bool | None = None, + inst_id: str | None = None, + margin_mode: str | None = None, +) -> float | None: + """ + 与盘口同单位的内在价值(每 1 标的). + - USDC / USD_UM: 美元点差 max(0, S−K) / max(0, K−S) + - 币本位 ETH-USD / BTC-USD: 币报价 max(0, S−K)/S / max(0, K−S)/S + """ + 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: + points = idx - k + elif o == "P" and idx < k: + points = k - idx + else: + return None + if _quote_in_coin_from_context( + quote_in_coin=quote_in_coin, inst_id=inst_id, margin_mode=margin_mode + ): + if idx <= 0: + return None + return points / idx + return points + + +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, + quote_in_coin: bool | None = None, + inst_id: str | None = None, + margin_mode: str | None = None, +) -> tuple[float | None, float | None]: + """返回 (mark_px, intrinsic_px) 供残档判断;intrinsic 与盘口同单位.""" + return _safe_px(mark_px), intrinsic_px_per_unit( + opt_type, + strike, + index_px, + quote_in_coin=quote_in_coin, + inst_id=inst_id, + margin_mode=margin_mode, + ) + + +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_premium_amount(received, decimals=PREMIUM_DECIMALS_COIN), + } + ) + 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) + # 必须用 8 位:BTC 权利金常约 1e-4,round(...,4) 会把盈亏抹成 0 + estimated_pnl = round_premium_amount(total_received - paid, decimals=PREMIUM_DECIMALS_COIN) + if paid > 0 and estimated_pnl is not None: + estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2) + return { + "levels": levels, + "covered_sheets": covered, + "uncovered_sheets": remaining, + "total_received": round_premium_amount(total_received, decimals=PREMIUM_DECIMALS_COIN), + "avg_px": round(avg_px, 8) 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, + quote_in_coin: bool | None = None, + inst_id: str | None = None, + margin_mode: str | 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, + quote_in_coin=quote_in_coin, + inst_id=inst_id, + margin_mode=margin_mode, + ) + + +def expiry_breakeven_px( + *, + opt_type: str, + strike: float | None, + avg_px: float | None, + be_px_api: float | None = None, + quote_in_coin: bool | None = None, + inst_id: str | None = None, + margin_mode: str | 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 + try: + k = float(strike) + p = float(avg_px) + except (TypeError, ValueError): + return None + if p <= 0: + return None + o = (opt_type or "").upper() + coin = _quote_in_coin_from_context( + quote_in_coin=quote_in_coin, inst_id=inst_id, margin_mode=margin_mode + ) + if coin: + # 币本位:权利金为币报价;到期结算 payoff 亦为币 → K/(1±p) + if o == "C": + if p >= 1: + return None + return round(k / (1 - p), 2) + if o == "P": + return round(k / (1 + p), 2) + return None + if o == "C": + return round(k + p, 2) + if o == "P": + return round(k - p, 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 strike_distance_to_be( + strike: float | None, + be_px: float | None, + *, + opt_type: str | None = None, +) -> float | None: + """行权价到到期平衡价的价差(Call:BE−K, Put:K−BE).""" + if strike is None or be_px is None: + return None + k = float(strike) + be = float(be_px) + o = (opt_type or "").upper() + if o == "C": + return round(be - k, 2) + if o == "P": + return round(k - be, 2) + return round(abs(be - k), 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, + margin_mode: str | None = None, +) -> float | None: + """名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用). + + USDC: 权利金为美元 → index×eth/premium. + 币本位: 权利金为币 → eth/premium(=1/ask 当 premium=ask×eth). + """ + 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 + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + mode = normalize_options_margin_mode(margin_mode) + except Exception: + mode = (str(margin_mode or "usdc").strip().lower() or "usdc") + if mode == "coin": + return round(float(eth_amount) / float(total_premium), 1) + 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 = None, + *, + call_ask: float | None = None, + put_ask: float | None = None, + quote_in_coin: bool = False, +) -> tuple[float | None, float | None]: + """跨式到期平衡带:下平衡 ~ 上平衡.""" + if strike is None: + return None, None + k = float(strike) + if quote_in_coin: + pc = _safe_px(call_ask) + pp = _safe_px(put_ask) + if pc is None or pp is None or pc <= 0 or pp <= 0 or pc >= 1: + return None, None + return round(k / (1 + pp), 2), round(k / (1 - pc), 2) + if combined_ask_per_unit is None: + return None, None + 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..0d5fce6 --- /dev/null +++ b/lib/options/options_profit_exit_lib.py @@ -0,0 +1,435 @@ +"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓. + +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: + from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount + + prem = float(premium_paid or 0) + m = float(mult or 0) + if prem <= 0 or m <= 0: + return None + return round_premium_amount(prem * (1.0 + m), decimals=PREMIUM_DECIMALS_COIN) + + +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: + """翻倍出场用可回收估算. + + 用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓; + 残档/无效买一则返回 None(禁止按假买盘自动平). + """ + from lib.options.options_positions_lib import attach_close_preview + from lib.options.options_pricing_lib import total_premium + + 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 + levels = preview.get("levels") or [] + ct_mult = float(row.get("ct_mult") or 0.01) + sheets = int( + abs( + _safe_float(row.get("avail_pos") if row.get("avail_pos") is not None else row.get("pos")) + or 0 + ) + ) + if levels and sheets > 0 and ct_mult > 0: + try: + px = float(levels[0].get("px") or 0) + except (TypeError, ValueError, AttributeError): + px = 0.0 + if px > 0: + return round(total_premium(px, sheets * ct_mult), 8) + 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: + from lib.options.options_notify_lib import resolve_options_premium_ccy + + ccy = resolve_options_premium_ccy(inst_id=inst_id) + d = 6 if ccy in ("ETH", "BTC") else 4 + mode = "币本位" if ccy != "USDC" else "USDC" + reason = f"翻倍出场({mult:g}倍)" + 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=reason, + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + premium_ccy=ccy, + ) + # 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推 + return + except Exception: + pass + if not send_wechat: + return + try: + prem_txt = f"{float(premium_paid):.{d}f}" if premium_paid is not None else "—" + recv_txt = f"{float(recycle):.{d}f}" if recycle is not None else "—" + send_wechat( + "\n".join( + [ + "【OKX期权·翻倍出场】", + f"账户:{account_label}", + f"本位:{mode}", + f"合约:{inst_id}", + f"倍数:{mult:g}(1倍=盈利=权利金)", + f"权利金:{prem_txt} {ccy}", + f"可回收:{recv_txt} {ccy}", + 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: + # 对冲表异常时不得整段跳过翻倍出场 + hedge_managed = set() + + rules = profit_exit_by_inst(conn) + triggered = 0 + + for inst_id, info in list(rules.items()): + if not inst_id: + continue + if inst_id in hedge_managed: + _mark_state(conn, inst_id, "idle") + conn.execute( + """ + UPDATE options_trades + SET profit_exit_enabled = 0, profit_exit_state = 'idle' + WHERE inst_id = ? AND status = 'open' + """, + (inst_id,), + ) + _commit(conn) + continue + pos = pos_by_inst.get(inst_id) + if not pos: + # 持仓已平:收尾 + _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) + _notify_profit_exit_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + mult=mult, + premium_paid=prem, + recycle=None, + result={**result, "fully_closed": True}, + conn=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) + 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, + ) + 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..2823143 --- /dev/null +++ b/lib/options/options_register.py @@ -0,0 +1,2088 @@ +"""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) + _register_options_hub_bridge(app, cfg) + if enabled: + _start_monitor_thread(app, cfg) + + +def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: + from lib.options.options_hub_lib import build_options_hub_snapshot + + def snapshot_fn(): + return build_options_hub_snapshot(cfg) + + hub_ctx = dict(app.config.get("HUB_CTX") or {}) + hub_ctx["options_snapshot_fn"] = snapshot_fn + app.config["HUB_CTX"] = hub_ctx + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + estimate_usdt_to_usdc, + execute_convert, + fetch_option_book_depth, + fetch_option_positions, + fetch_options_balances, + format_position_row, + options_api_ready, + cancel_option_order, + fetch_option_pending_orders, + place_option_limit_order, + place_option_market_order, + quote_option_contract, + spot_market_swap_usdt_usdc, + transfer_ccy, + ) + + return { + "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "send_wechat": app_module.send_wechat_msg, + "render_main_page": app_module.render_main_page, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), + "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), + "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + "margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), + "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), + "chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True), + "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), + "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), + # 市价平仓已硬关闭(忽略 env),仅买一限价 + "allow_market_close": False, + # 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60 + "pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0), + "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), + "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), + "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), + "build_option_chain": build_option_chain, + "quote_option_contract": quote_option_contract, + "fetch_option_book_depth": fetch_option_book_depth, + "place_option_limit_order": place_option_limit_order, + "place_option_market_order": place_option_market_order, + "fetch_option_pending_orders": fetch_option_pending_orders, + "cancel_option_order": cancel_option_order, + "fetch_option_positions": fetch_option_positions, + "fetch_options_balances": fetch_options_balances, + "format_position_row": format_position_row, + "estimate_usdt_to_usdc": estimate_usdt_to_usdc, + "execute_convert": execute_convert, + "transfer_ccy": transfer_ccy, + "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, + "options_api_ready": options_api_ready, + "app_module": app_module, + } + + +def _mark_balances_stale(cfg: dict[str, Any]) -> None: + from lib.exchange.okx_options_lib import invalidate_options_balance_cache + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + invalidate_options_balance_cache() + app_mod = cfg.get("app_module") + if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): + app_mod.invalidate_account_balance_cache() + try: + notify_instance_balance_changed() + except Exception: + pass + + +def _require_options_ex(cfg: dict[str, Any]): + if not cfg.get("enabled"): + return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" + ex = cfg.get("exchange_options") + ok, reason = cfg["options_api_ready"](ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer.""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_budget_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0)) + if cap <= 0: + return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)" + return resolve_budget_full_usdc(trading, float(cap)), "" + + +def _compound_full_enabled() -> bool: + return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True) + + +def _budget_full_blocked_by_compound_msg() -> str | None: + if _compound_full_enabled(): + return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式" + return None + + +def _size_mode_budget_cap( + cfg: dict[str, Any], mode: str, budget_cap: float | None +) -> float | None: + """全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制).""" + if mode in ("budget_full", "compound_full"): + return budget_cap + if mode in ("sheets", "eth_amount"): + if _compound_full_enabled(): + return None + return budget_cap + return None + + +def _normalize_size_mode(mode: str) -> tuple[str, str | None]: + """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" + m = (mode or "sheets").strip() or "sheets" + if m == "compound_full" and not _compound_full_enabled(): + return "sheets", "全仓复利已关闭,已改用指定张数" + if m == "budget_full" and _compound_full_enabled(): + return "compound_full", None + return m, None + + +def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" + if not _compound_full_enabled(): + return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_compound_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + # 额度热更读 env(与模板启动值无关) + cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) + cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) + if cap_on and cap_v <= 0: + return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" + return ( + resolve_compound_full_usdc( + trading, + cap_enabled=cap_on, + cap_usdc=cap_v, + ), + "", + ) + + +def _is_budget_mode(mode: str) -> bool: + return mode in ("budget_full", "compound_full") + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + + +def _position_avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: + return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) + + +def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + pos = _find_position(raw, inst_id) + if not pos: + return 0 + return _position_avail_sheets(pos) + + +def _enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.options.options_history_lib import enrich_position_row_display + + return enrich_position_row_display( + cfg, + ex, + raw_pos, + meta_cache=meta_cache, + premium_override=premium_override, + ) + + +def _attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + from lib.options.options_positions_lib import attach_close_preview + + return attach_close_preview( + cfg, + ex, + row, + sheets=sheets, + premium_paid=premium_paid, + ) + + +_OPTIONS_SYNC_LOCK = threading.Lock() +_OPTIONS_SYNC_LAST_AT = 0.0 +_OPTIONS_SYNC_INTERVAL_SEC = 15.0 + + +def _sync_options_trades( + cfg: dict[str, Any], + *, + raw_positions: list[dict[str, Any]] | None = None, + force: bool = False, +) -> None: + global _OPTIONS_SYNC_LAST_AT + ex = cfg.get("exchange_options") + if ex is None: + return + now = time.time() + with _OPTIONS_SYNC_LOCK: + if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: + return + _OPTIONS_SYNC_LAST_AT = now + from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history + from lib.options.options_monitor_lib import ( + backfill_closed_options_realized_pnl_from_history, + reconcile_live_open_trades, + sync_open_options_trades, + ) + + if raw_positions is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return + else: + raw = raw_positions + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + + def _hist(inst_id: str): + return fetch_option_position_history(ex, inst_id) + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) + try: + hist_all = fetch_all_option_positions_history(ex, limit=200) + backfill_closed_options_realized_pnl_from_history(conn, hist_all) + except Exception: + pass + conn.commit() + finally: + conn.close() + + +def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/options/guide") + @lr + def options_trade_guide(): + """期权开平仓与监控说明(独立页).""" + from pathlib import Path + + from flask import render_template_string + + from lib.hub.hub_strategy_lib import render_markdown_html + + md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md" + try: + md_text = md_path.read_text(encoding="utf-8") + except OSError: + md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`." + body = render_markdown_html(md_text) + return render_template_string( + """ + + + + + + 期权开平仓与监控说明 + + + +

        ← 返回期权 · 对冲计划

        + {{ body|safe }} + + + """, + body=body, + ) + + @app.route("/api/options/balances") + @lr + def api_options_balances(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + bal = cfg["fetch_options_balances"](ex, force=force, scope="main") + from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() + payload = { + "ok": True, + **bal, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), + "compound_full_enabled": _compound_full_enabled(), + "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), + "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), + "options_margin_mode": margin_mode, + "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", + } + if is_coin_margin_mode(): + try: + from lib.options.options_coin_open_lib import coin_budget_preview + + payload["coin_budget"] = coin_budget_preview(cfg, ex) + except Exception as e: + payload["coin_budget"] = {"ok": False, "msg": str(e)} + conn = cfg["get_db"]() + try: + init_options_tables(conn) + from lib.options.options_spot_bridge_lib import list_open_bridges + + open_bridges = list_open_bridges(conn) + if open_bridges: + payload["bridge_status"] = str(open_bridges[0].get("status") or "") + payload["bridge_underlying"] = str(open_bridges[0].get("underlying") or "") + except Exception: + pass + finally: + conn.close() + return jsonify(payload) + + @app.route("/api/options/chain") + @lr + def api_options_chain(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + u = (request.args.get("underlying") or cfg["default_underly"]).upper() + # 热更新:链展示天数每次读 env,保存后刷新链即可 + chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) + try: + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + margin_mode = normalize_options_margin_mode() + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=chain_max_dte, + itm_only=False, + itm_max_dist_usd=cfg["itm_max_dist"], + margin_mode=margin_mode, + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) + expiries = chain.get("expiries") or [] + chain_err = chain.get("chain_error") + # 热更新:每次读 env,保存配置后刷新链即可生效 + ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) + budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) + coin_budget = None + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import coin_budget_preview + + if is_coin_margin_mode(): + coin_budget = coin_budget_preview(cfg, ex) + except Exception: + coin_budget = None + if not expiries: + return jsonify( + { + "ok": False, + "msg": chain_err or "暂无到期日,请稍后点「刷新链」", + **chain, + "chain_max_dte_days": chain_max_dte, + "ask_liq_filter_enabled": ask_liq_filter, + "budget_buffer": budget_buffer, + "trade_budget": cfg["trade_budget"], + "options_margin_mode": chain.get("margin_mode") or margin_mode, + "coin_budget": coin_budget, + } + ) + return jsonify( + { + "ok": True, + **chain, + "chain_max_dte_days": chain_max_dte, + "ask_liq_filter_enabled": ask_liq_filter, + "budget_buffer": budget_buffer, + "trade_budget": cfg["trade_budget"], + "options_margin_mode": chain.get("margin_mode") or margin_mode, + "coin_budget": coin_budget, + } + ) + + @app.route("/api/options/quote") + @lr + def api_options_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ct_mult = q.get("ct_mult") or 0.01 + min_sz = q.get("min_sz") or 1 + mode = (request.args.get("mode") or "sheets").strip() + sheet_count = None + try: + if request.args.get("sheets"): + sheet_count = int(request.args.get("sheets")) + except (TypeError, ValueError): + pass + if mode == "close_preview": + paid = _open_premium_paid(cfg, inst_id) + target = sheet_count if sheet_count is not None else 0 + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + row_mode = margin_mode_from_inst_id(inst_id) + prem_ccy = premium_ccy_for_mode(row_mode, (inst_id.split("-")[0] if inst_id else "ETH")) + preview_row = { + **q, + "pos": target, + "premium_paid": paid, + "margin_mode": row_mode, + "premium_ccy": prem_ccy, + } + out = _attach_close_preview(cfg, ex, preview_row, sheets=target, premium_paid=paid) + out["options_margin_mode"] = row_mode + out["premium_ccy"] = prem_ccy + return jsonify(out) + mode, mode_note = _normalize_size_mode(mode) + + # 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC + try: + from lib.options.options_margin_mode_lib import ( + is_coin_margin_mode, + margin_mode_from_inst_id, + ) + from lib.options.options_coin_open_lib import coin_budget_preview + from lib.exchange.okx_options_lib import option_buy_liquidity_ok + + if is_coin_margin_mode(): + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + try: + from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + except Exception as e: + return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"}) + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "options_margin_mode": "coin", + "sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}, + } + ) + if margin_mode_from_inst_id(inst_id) != "coin": + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)", + "options_margin_mode": "coin", + "sizing": { + "ok": False, + "msg": "合约非币本位", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + budget_info = coin_budget_preview(cfg, ex) + if not can_open: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "options_margin_mode": "coin", + "coin_budget": budget_info, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + if not budget_info.get("ok"): + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": budget_info.get("msg") or "交易账户 USDT 不足", + "options_margin_mode": "coin", + "coin_budget": budget_info, + "sizing": { + "ok": False, + "msg": budget_info.get("msg") or "交易账户 USDT 不足", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx")) + budget_usdt = float(budget_info["budget_usdt"]) + target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None + if mode == "eth" and request.args.get("eth"): + # 指定币量:按币量反推张数后再走统一规划 + try: + eth_want = float(request.args.get("eth")) + except (TypeError, ValueError): + eth_want = 0.0 + if eth_want > 0 and float(ct_mult) > 0: + import math + + target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12))) + from lib.options.options_margin_mode_lib import plan_coin_open_by_budget + + sizing = plan_coin_open_by_budget( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdt=budget_usdt, + index_px=float(idx or 0), + ask_sz=ask_sz, + target_sheets=target_sheets, + ) + if sizing.get("ok"): + sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper() + sizing["est_coin"] = sizing.get("buy_coin") + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": bool(sizing.get("ok")), + "quote_per_unit": ask, + "premium_per_sheet": round(float(ask) * float(ct_mult), 8), + "sizing": sizing, + "mode": mode, + "mode_note": mode_note, + "options_margin_mode": "coin", + "coin_budget": budget_info, + "compound_full_enabled": _compound_full_enabled(), + } + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"}) + + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + available_usdc = None + if mode == "budget_full": + blocked = _budget_full_blocked_by_compound_msg() + if blocked: + return jsonify( + { + "ok": False, + "msg": blocked, + "compound_full_enabled": _compound_full_enabled(), + } + ) + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + elif mode == "compound_full": + if not _compound_full_enabled(): + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)", + "compound_full_enabled": False, + } + ) + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True}) + budget_cap = budget + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + + available_usdc = fetch_options_trading_usdc(ex) + elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): + budget_cap = None + eth_amount = None + try: + if request.args.get("eth_amount"): + eth_amount = float(request.args.get("eth_amount")) + except (TypeError, ValueError): + pass + ask = q.get("ask") + ask_sz = q.get("ask_sz") + try: + from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": mode_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + except Exception as e: + return jsonify( + { + "ok": False, + "can_open": False, + "msg": f"交易模式校验失败: {e}", + } + ) + try: + from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg + + conn_q = cfg["get_db"]() + try: + excl = block_standalone_option_open_msg(conn_q) + finally: + conn_q.close() + if excl: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": excl, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": excl, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + except Exception as e: + return jsonify({"ok": False, "can_open": False, "msg": f"互斥校验失败: {e}"}) + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + # 合约可报价,但不可开仓:返回参考标记价供展示 + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "quote_per_unit": None, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": compound_block, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": compound_block, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": None, + "compound_full_usdc": budget, + } + ) + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": pos_limit_msg, + "quote_per_unit": ask, + "premium_per_sheet": None, + "sizing": { + "ok": False, + "msg": pos_limit_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + } + ) + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdc=budget if _is_budget_mode(mode) else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount if mode == "eth_amount" else None, + sheets=sheet_count if mode == "sheets" else None, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if sizing.get("ok"): + capped, cap_msg = cap_option_buy_sheets_to_ask_depth( + int(sizing.get("sheets") or 0), + ask_sz, + min_sz=int(min_sz), + ) + if capped is None: + sizing = { + "ok": False, + "msg": cap_msg, + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + } + elif capped < int(sizing.get("sheets") or 0): + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + sheets=capped, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if sizing.get("ok"): + sizing["ask_depth_capped"] = True + sizing["ask_sz"] = ask_sz + sizing["msg"] = f"已按卖一深度限制为 {capped} 张" + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or sheet_count or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": True, + "quote_per_unit": ask, + "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), + "sizing": sizing, + "available_usdc": available_usdc, + "budget_full_usdc": budget if mode == "budget_full" else None, + "compound_full_usdc": budget if mode == "compound_full" else None, + "mode": mode, + "mode_note": mode_note, + "compound_full_enabled": _compound_full_enabled(), + } + ) + + @app.route("/api/options/open", methods=["POST"]) + @lr + def api_options_open(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + try: + from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify({"ok": False, "msg": mode_block, "can_open": False}) + except Exception as e: + return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) + try: + from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg + + conn_gate = cfg["get_db"]() + try: + block_msg = block_standalone_option_open_msg(conn_gate) + finally: + conn_gate.close() + if block_msg: + return jsonify({"ok": False, "msg": block_msg}) + except Exception as e: + return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + mode = (data.get("mode") or "sheets").strip() + mode, mode_note = _normalize_size_mode(mode) + signal_note = (data.get("signal_note") or "").strip() + if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": + # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 + pass + target_index = None + raw_target = data.get("target_index") + if raw_target is not None and str(raw_target).strip() != "": + try: + target_index = float(raw_target) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + profit_exit_enabled = bool(data.get("profit_exit_enabled")) + profit_exit_mult = 1.0 + if profit_exit_enabled: + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import open_coin_option_buy_full + + if is_coin_margin_mode(): + want_sheets = None + if mode == "sheets": + try: + want_sheets = int(data.get("sheets") or 0) or None + except (TypeError, ValueError): + want_sheets = None + elif mode == "eth": + try: + eth_want = float(data.get("eth") or 0) + except (TypeError, ValueError): + eth_want = 0.0 + if eth_want > 0: + q0 = cfg["quote_option_contract"](ex, inst_id) + ct0 = float((q0 or {}).get("ct_mult") or 0.01) + min0 = int((q0 or {}).get("min_sz") or 1) + if ct0 > 0: + import math + + want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12))) + result = open_coin_option_buy_full( + cfg, + ex, + inst_id=inst_id, + signal_note=signal_note, + target_index=target_index, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + target_sheets=want_sheets, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _mark_balances_stale(cfg) + return jsonify(result) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"}) + q = cfg["quote_option_contract"](ex, inst_id) + if not q.get("ok"): + return jsonify(q) + ask = q.get("ask") + ask_sz = q.get("ask_sz") + can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) + if not can_open: + return jsonify( + { + "ok": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "can_open": False, + "mark": q.get("mark"), + "ref_ask": q.get("ref_ask"), + } + ) + from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + option_position_limit_block_msg, + ) + + if mode == "compound_full": + compound_block = compound_full_single_position_block_msg( + ex, fetch_positions=cfg.get("fetch_option_positions") + ) + if compound_block: + return jsonify({"ok": False, "msg": compound_block, "can_open": False}) + + pos_limit_msg = option_position_limit_block_msg( + ex, + opening_inst_id=inst_id, + fetch_positions=cfg.get("fetch_option_positions"), + ) + if pos_limit_msg: + return jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False}) + ct_mult = float(q.get("ct_mult") or 0.01) + min_sz = int(q.get("min_sz") or 1) + eth_amount = None + sheet_count = None + if mode == "eth_amount": + try: + eth_amount = float(data.get("eth_amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "ETH 数量无效"}) + elif mode == "sheets": + try: + sheet_count = int(data.get("sheets")) + except (TypeError, ValueError): + sheet_count = None + if sheet_count is None or int(sheet_count) < 1: + # 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1 + if (data.get("mode") or "").strip() == "compound_full": + sheet_count = 1 + else: + return jsonify({"ok": False, "msg": "张数无效"}) + budget = cfg["trade_budget"] + budget_cap = cfg["trade_budget"] + if mode == "budget_full": + blocked = _budget_full_blocked_by_compound_msg() + if blocked: + return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()}) + budget, budget_err = _budget_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + elif mode == "compound_full": + if not _compound_full_enabled(): + return jsonify( + { + "ok": False, + "msg": "全仓复利未开启,请改用指定张数或先开启全仓复利", + "compound_full_enabled": False, + } + ) + budget, budget_err = _compound_full_usdc(cfg, ex) + if budget is None: + return jsonify({"ok": False, "msg": budget_err}) + budget_cap = budget + elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): + budget_cap = None + sizing = calc_order_size( + quote_per_unit=float(ask), + ct_mult=ct_mult, + min_sz=min_sz, + budget_usdc=budget if _is_budget_mode(mode) else None, + budget_buffer=cfg["budget_buffer"], + eth_amount=eth_amount, + sheets=sheet_count, + budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) + if mode in ("budget_full", "compound_full", "sheets", "eth_amount") + else None, + ) + if not sizing.get("ok"): + return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) + sheets = int(sizing["sheets"]) + capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) + if capped is None: + return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) + if capped < sheets: + return jsonify( + { + "ok": False, + "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交", + "requested_sheets": int(sheets), + "ask_sz": ask_sz, + } + ) + tick_sz = q.get("tick_sz") + order = cfg["place_option_limit_order"]( + ex, + inst_id=inst_id, + side="buy", + sheets=sheets, + price=float(ask), + td_mode=td_mode_for_option_buy(cfg["td_mode"]), + tick_sz=tick_sz, + ord_type="ioc", + ) + if not order.get("ok"): + return jsonify(order) + ord_id = str((order.get("data") or {}).get("ordId") or "").strip() + if not ord_id: + return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order}) + from lib.exchange.okx_options_lib import wait_option_order_full_fill + + try: + fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) + except (TypeError, ValueError): + fill_timeout = 12.0 + fill = wait_option_order_full_fill( + ex, + inst_id=inst_id, + ord_id=ord_id, + need_sheets=int(sheets), + timeout_sec=fill_timeout, + cancel_on_timeout=True, + ) + if not fill.get("ok"): + filled_n = int(fill.get("filled_sheets") or 0) + orphan_close = None + if filled_n > 0: + try: + from lib.options.options_close_exec_lib import close_option_by_bid1 + + orphan_close = close_option_by_bid1( + cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False + ) + except Exception as e: + orphan_close = {"ok": False, "msg": str(e)} + return jsonify( + { + "ok": False, + "msg": fill.get("msg") or "未完全成交,开仓失败", + "filled_sheets": filled_n, + "orphan_close": orphan_close, + "fill": fill, + "order": order, + } + ) + fill_px = float(fill.get("avg_px") or ask) + filled_n = int(fill.get("filled_sheets") or sheets) + sheets = filled_n + sizing = dict(sizing) + sizing["sheets"] = sheets + sizing["eth_amount"] = round(sheets * ct_mult, 8) + from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount + + sizing["total_premium"] = round_premium_amount( + fill_px * sheets * ct_mult, decimals=PREMIUM_DECIMALS_COIN + ) + 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 + from lib.options.options_time_lib import options_app_now_str + + 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, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) + """, + ( + inst_id, + u, + opt_type, + q.get("strike"), + str(q.get("exp_time") or ""), + sheets, + sizing["eth_amount"], + fill_px, + sizing["total_premium"], + signal_note, + ord_id, + 1 if profit_exit_enabled else 0, + profit_exit_mult if profit_exit_enabled else 1.0, + "active" if profit_exit_enabled else "idle", + options_app_now_str(), + ), + ) + trade_id = int(cur.lastrowid) + if target_index is not None: + from lib.options.options_target_lib import upsert_target_monitor + + target_mon = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=u, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + if profit_exit_enabled: + pass # 列已由 init_options_tables / ensure 迁移 + conn.commit() + finally: + conn.close() + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_notify_lib import notify_options_open + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + try: + conn_n = cfg["get_db"]() + try: + notify_options_open( + cfg, + conn_n, + trade_id=trade_id, + inst_id=inst_id, + underlying=open_underlying, + opt_type=open_opt_type, + sheets=sheets, + premium_paid=sizing.get("total_premium"), + open_quote=fill_px, + target_index=target_index, + signal_note=signal_note, + ) + 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) + from lib.options.options_time_lib import options_app_now_str + + conn.execute( + """ + UPDATE options_trades + SET status = 'cancelled', + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' + ELSE signal_note + END, + closed_at = ? + WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' + """, + (options_app_now_str(), inst_id, ord_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + _sync_options_trades(cfg, force=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/options/positions") + @lr + def api_options_positions(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + _sync_options_trades(cfg, raw_positions=raw) + meta_cache: dict[str, dict[str, Any] | None] = {} + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import targets_by_inst + from lib.options.options_profit_exit_lib import profit_exit_by_inst + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + + tgt_map = targets_by_inst(conn) + profit_exit_map = profit_exit_by_inst(conn) + hedge_target_map = active_options_targets_by_inst(conn) + rows = [] + for p in raw: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + pe = profit_exit_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + row["profit_exit_state"] = pe.get("profit_exit_state") + row["profit_exit_required_recycle"] = pe.get("required_recycle") + hedge_target = hedge_target_map.get(inst) + if hedge_target: + row["hedge_plan_target"] = hedge_target + try: + from lib.instance.instance_dashboard_lib import _resolve_options_source + + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + row["source"] = source_key + row["source_label"] = source_label + row["source_plan_id"] = source_plan_id + except Exception: + row.setdefault("source", "option") + row.setdefault("source_label", "纯期权") + row.setdefault("source_plan_id", None) + rows.append(row) + finally: + conn.close() + 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 + try: + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result) + if spot_sell is not None: + result = dict(result) + result["spot_sell"] = spot_sell + if spot_sell.get("bridge_status") == "pending_sell_spot": + result["msg"] = ( + str(result.get("msg") or "平仓成功") + + ";但卖回 USDT 失败,请点「重试卖回」" + ) + except Exception as e: + result = dict(result) + result["spot_sell"] = {"ok": False, "msg": str(e)} + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) + @lr + def api_options_spot_bridge_retry_sell(): + """币本位:重试把残留标的币市价卖回 USDT.""" + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() + inst_id = (data.get("inst_id") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat + + out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + if out.get("ok"): + _mark_balances_stale(cfg) + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/convert/quote", methods=["POST"]) + @lr + def api_options_convert_quote(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) + + @app.route("/api/options/convert/execute", methods=["POST"]) + @lr + def api_options_convert_execute(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + quote_id = (data.get("quote_id") or "").strip() + result = cfg["execute_convert"](ex, quote_id) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) + VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') + """, + ( + data.get("rfq_sz"), + (result.get("data") or {}).get("baseSz"), + quote_id, + ), + ) + conn.commit() + finally: + conn.close() + return jsonify(result) + + @app.route("/api/options/transfer", methods=["POST"]) + @lr + def api_options_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDC").upper() + from_acct = (data.get("from") or "funding").strip() + to_acct = (data.get("to") or "trading").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + 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) + index_px = None + try: + from lib.exchange.okx_options_lib import fetch_index_price + from lib.options.options_margin_mode_lib import ( + is_coin_margin_mode, + normalize_options_margin_mode, + ) + + underly = (cfg.get("default_underly") or "ETH").strip().upper() or "ETH" + if is_coin_margin_mode(normalize_options_margin_mode(cfg.get("margin_mode"))): + index_px = fetch_index_price(ex, underly) + except Exception: + index_px = None + stats = compute_options_stats_from_history(history, index_px=index_px) + # sum_options_net_pnl_usdc 已按指数折 U;勿再 × 指数(否则顶栏会变成上万假亏) + 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) + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + pnl_unit = "U" if is_coin_margin_mode() else str(stats.get("pnl_unit") or "USDC") + return jsonify( + { + "ok": True, + **stats, + "open_float_pnl": open_float, + "total_pnl": total_pnl, + "pnl_unit": pnl_unit, + } + ) + + @app.route("/api/options/history/", methods=["DELETE"]) + @lr + def api_options_history_delete(history_key: str): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + key = (history_key or "").strip() + if not key: + return jsonify({"ok": False, "msg": "缺少 history_key"}) + data = request.get_json(silent=True) or {} + inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None + closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", + (key,), + ) + # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 + try: + from lib.options.options_review_lib import hide_review_keys + + hide_review_keys( + conn, + history_key=key, + inst_id=inst_id, + closed_at=closed_at, + ) + if inst_id: + # 去掉已导入的复盘快照(按合约+平仓时间) + if closed_at: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) + """, + (inst_id, closed_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + """, + (inst_id,), + ).fetchall() + for r in rows: + conn.execute( + "DELETE FROM options_review_entries WHERE trade_id=?", + (int(r["id"]),), + ) + conn.execute( + "DELETE FROM options_review_trades WHERE id=?", + (int(r["id"]),), + ) + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), + ) + fps = [] + if closed_at: + fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") + fps.append(f"inst:{inst_id}") + for fp in fps: + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (fp, inst_id, (closed_at or "")[:19] or None), + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + return jsonify({"ok": True}) + + +def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: + if app.extensions.get("options_monitor_started"): + return + app.extensions["options_monitor_started"] = True + + def _bid(inst_id: str) -> float | None: + ex = cfg.get("exchange_options") + if ex is None: + return None + try: + q = cfg["quote_option_contract"](ex, inst_id) + return q.get("bid") + except Exception: + return None + + def _positions(): + ex = cfg.get("exchange_options") + if ex is None: + return [] + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return [] + return [cfg["format_position_row"](p) for p in raw] + + def _sync(conn): + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades + + ex = cfg.get("exchange_options") + if ex is None: + return 0 + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return 0 + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + return sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + notify_cfg=cfg, + ) + + def _target_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_target_lib import close_option_by_bid_depth + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_depth(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _profit_exit_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_profit_exit(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _stale_pending() -> dict[str, Any]: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_pending_lib import cancel_stale_close_pending_orders + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + out = cancel_stale_close_pending_orders( + fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), + cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( + _ex, inst_id=inst_id, ord_id=ord_id + ), + ttl_seconds=ttl, + ex=ex, + ) + if out.get("cancelled"): + try: + invalidate_option_positions_cache() + except Exception: + pass + try: + send = cfg.get("send_wechat") + if callable(send): + parts = [ + "【OKX期权·挂单超时撤销】", + f"账户:{cfg.get('account_label') or 'OKX期权'}", + f"超时:{ttl:g}s", + f"撤销:{out.get('cancelled')} 笔", + ] + for o in out.get("orders") or []: + parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") + send("\n".join(parts)) + except Exception: + pass + return out + + t = threading.Thread( + target=options_monitor_loop, + kwargs={ + "enabled": True, + "poll_seconds": cfg["poll_seconds"], + "get_db": cfg["get_db"], + "fetch_positions": _positions, + "ticker_bid_fn": _bid, + "send_wechat": cfg["send_wechat"], + "account_label": cfg["account_label"], + "profit_ratio": cfg["profit_ratio"], + "sync_trades_fn": _sync, + "target_close_fn": _target_close, + "profit_exit_close_fn": _profit_exit_close, + "profit_exit_cfg": cfg, + "stale_pending_fn": _stale_pending, + }, + daemon=True, + name="options-monitor", + ) + t.start() diff --git a/lib/options/options_review_chart_async_lib.py b/lib/options/options_review_chart_async_lib.py new file mode 100644 index 0000000..3b18e0a --- /dev/null +++ b/lib/options/options_review_chart_async_lib.py @@ -0,0 +1,137 @@ +"""期权复盘自动 K 线:后台线程生成标的永续双周期拼图,写回 images_json.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, Optional + +from lib.options.options_review_images_lib import ( + images_json_dumps, + parse_options_review_images_json, +) + +logger = logging.getLogger(__name__) + +OPTIONS_REVIEW_AUTO_CHART_TF = "auto" + + +def underlying_to_okx_perp_symbol( + underlying: str, + *, + normalize_symbol_input_fn: Callable[[str], str], + normalize_okx_symbol_fn: Callable[[str], str], +) -> str: + """ETH / ETH-USD / ETH-USDT-SWAP → ETH/USDT:USDT.""" + raw = str(underlying or "").strip().upper() + if not raw: + return "" + base = raw.split("-", 1)[0].split("/", 1)[0].strip() + if not base: + return "" + guess = normalize_symbol_input_fn(base) or f"{base}/USDT" + return str(normalize_okx_symbol_fn(guess) or "").strip() + + +def merge_auto_chart_into_images( + existing: Optional[list], + filename: str, +) -> list[dict[str, str]]: + """保留手动槽位,用 tf=auto 放置拼图(方案甲一张总图).""" + fname = str(filename or "").strip() + out: list[dict[str, str]] = [] + for item in existing or []: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF: + continue + out.append({"tf": tf, "file": file}) + if fname: + out.insert(0, {"tf": OPTIONS_REVIEW_AUTO_CHART_TF, "file": fname}) + return out + + +def build_options_review_chart_filename(trade_id: int) -> str: + return f"options_journal_chart_{int(trade_id)}.png" + + +def schedule_options_review_exchange_chart( + *, + trade_id: int, + 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], + now_str_fn: Callable[[], str], +) -> None: + """保存后立即返回;线程内画图并合并进 options_review_entries.images_json.""" + tid = int(trade_id or 0) + if tid <= 0 or not callable(generate_chart_fn) or not callable(get_db_fn): + return + symbol = str(exchange_symbol or "").strip() + if not symbol: + 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 = build_options_review_chart_filename(tid) + saved = generate_chart_fn( + symbol, + title_prefix, + timeframes=tfs, + limit=journal_limit, + out_dir=upload_folder, + filename=chart_fname, + filename_prefix="options_journal_chart", + marker_payload=marker_payload, + marker_timeframes={x.lower() for x in tfs}, + layout="vertical", + ) + if not saved: + logger.warning("options review chart async empty trade_id=%s", tid) + return + conn = get_db_fn() + try: + row = conn.execute( + "SELECT images_json FROM options_review_entries WHERE trade_id=?", + (tid,), + ).fetchone() + if not row: + logger.warning( + "options review chart async missing entry trade_id=%s", tid + ) + return + images = parse_options_review_images_json(row["images_json"]) + merged = merge_auto_chart_into_images(images, saved) + primary = merged[0]["file"] if merged else saved + conn.execute( + """UPDATE options_review_entries + SET images_json=?, image=?, updated_at=? + WHERE trade_id=?""", + ( + images_json_dumps(merged), + primary, + now_str_fn(), + tid, + ), + ) + conn.commit() + finally: + conn.close() + except Exception: + logger.exception("options review chart async failed trade_id=%s", tid) + + threading.Thread( + target=_run, + name=f"options-review-chart-{tid}", + daemon=True, + ).start() diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py new file mode 100644 index 0000000..6fa6309 --- /dev/null +++ b/lib/options/options_review_db.py @@ -0,0 +1,147 @@ +"""期权复盘(含对冲) 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") + _ensure_column(conn, "options_review_trades", "premium_ccy", "TEXT") + _ensure_column(conn, "options_review_trades", "pnl_quote_ccy", "TEXT") + _ensure_column(conn, "options_review_trades", "idx_px", "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..ffc2ea6 --- /dev/null +++ b/lib/options/options_review_lib.py @@ -0,0 +1,1199 @@ +"""期权复盘业务: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 _is_coin_option_row(row: dict[str, Any]) -> bool: + ccy = str(row.get("premium_ccy") or "").strip().upper() + if ccy in ("ETH", "BTC"): + return True + if str(row.get("margin_mode") or "").strip().lower() == "coin": + return True + inst = str(row.get("inst_id") or "").strip().upper() + return bool(inst) and "-USD-" in inst and "_UM" not in inst + + +def _resolve_review_index_px( + row: dict[str, Any], + *, + ex: Any = None, + cache: dict[str, float | None] | None = None, +) -> Optional[float]: + px = _safe_float(row.get("idx_px") or row.get("index_px") or row.get("options_index_px")) + if px is not None and px > 0: + return px + underly = str(row.get("underlying") or "").strip().upper() + if not underly: + inst = str(row.get("inst_id") or "") + underly = (inst.split("-")[0] if inst else "ETH").upper() or "ETH" + if cache is not None and underly in cache: + return cache[underly] + if ex is None: + return None + try: + from lib.exchange.okx_options_lib import fetch_index_price + + got = fetch_index_price(ex, underly) + px = _safe_float(got) + if cache is not None: + cache[underly] = px if px is not None and px > 0 else None + return px if px is not None and px > 0 else None + except Exception: + if cache is not None: + cache[underly] = None + return None + + +def convert_option_amounts_to_usdt( + row: dict[str, Any], + *, + index_px: float | None = None, + ex: Any = None, + cache: dict[str, float | None] | None = None, +) -> dict[str, Any]: + """币本位权利金/盈亏换算为 USDT;已标记 pnl_quote_ccy=USDT 则跳过.""" + out = dict(row) + quote = str(out.get("pnl_quote_ccy") or "").strip().upper() + if quote in ("USDT", "USDC", "U"): + return out + if not _is_coin_option_row(out): + out["pnl_quote_ccy"] = "USDT" + return out + px = index_px if index_px is not None and index_px > 0 else _resolve_review_index_px( + out, ex=ex, cache=cache + ) + if px is None or px <= 0: + return out + for key in ("realized_pnl", "premium_paid", "realized_pnl_total"): + v = _safe_float(out.get(key)) + if v is not None: + out[key] = round(float(v) * float(px), 4) + out["idx_px"] = float(px) + out["pnl_quote_ccy"] = "USDT" + out["premium_ccy"] = "USDC" + return out + + +def repair_coin_review_rows_to_usdt( + conn: sqlite3.Connection, + *, + ex: Any = None, +) -> int: + """把仍按币计价落库的复盘纯期权行换算成 U(幂等).""" + init_options_review_tables(conn) + rows = conn.execute( + """ + SELECT * FROM options_review_trades + WHERE source_type = ? + AND (pnl_quote_ccy IS NULL OR TRIM(pnl_quote_ccy) = '' OR UPPER(pnl_quote_ccy) NOT IN ('USDT','USDC','U')) + ORDER BY id DESC + LIMIT 500 + """, + (SOURCE_OPTION,), + ).fetchall() + cache: dict[str, float | None] = {} + fixed = 0 + for raw in rows: + row = dict(raw) + if not _is_coin_option_row(row): + conn.execute( + "UPDATE options_review_trades SET pnl_quote_ccy='USDT' WHERE id=?", + (int(row["id"]),), + ) + continue + converted = convert_option_amounts_to_usdt(row, ex=ex, cache=cache) + if str(converted.get("pnl_quote_ccy") or "").upper() not in ("USDT", "USDC", "U"): + continue + conn.execute( + """ + UPDATE options_review_trades + SET premium_paid=?, realized_pnl=?, realized_pnl_total=?, + premium_ccy=?, pnl_quote_ccy=?, idx_px=? + WHERE id=? + """, + ( + converted.get("premium_paid"), + converted.get("realized_pnl"), + converted.get("realized_pnl") + if converted.get("realized_pnl") is not None + else converted.get("realized_pnl_total"), + converted.get("premium_ccy") or "USDC", + "USDT", + converted.get("idx_px"), + int(row["id"]), + ), + ) + fixed += 1 + return fixed + + +def upsert_option_history_row( + conn: sqlite3.Connection, + row: dict[str, Any], + *, + ex: Any = None, + index_cache: dict[str, float | None] | None = None, +) -> str: + """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入. + 币本位金额在写入前换算为 USDT. + """ + 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" + row = convert_option_amounts_to_usdt(row, ex=ex, cache=index_cache) + 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, + "premium_ccy": str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC", + "pnl_quote_ccy": str(row.get("pnl_quote_ccy") or "USDT").strip().upper() or "USDT", + "idx_px": _safe_float(row.get("idx_px")), + } + 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, + *, + ex: Any = None, +) -> dict[str, Any]: + """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所). + 币本位金额按指数换算为 USDT 后入库. + """ + 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, + margin_mode, premium_ccy + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT 500 + """ + ).fetchall() + inserted = updated = skipped = 0 + index_cache: dict[str, float | None] = {} + 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": "已平", + "margin_mode": r["margin_mode"] if "margin_mode" in r.keys() else None, + "premium_ccy": r["premium_ccy"] if "premium_ccy" in r.keys() else None, + }, + ex=ex, + index_cache=index_cache, + ) + 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] = {} + index_cache: dict[str, float | 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, ex=ex, index_cache=index_cache + ) + 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, ex=ex) + 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]: + """列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘.""" + from lib.options.options_time_lib import ( + migrate_options_utc_wall_clock_to_app, + repair_swapped_closed_at_signal_note, + ) + + ts_migrate = migrate_options_utc_wall_clock_to_app(conn) + note_swap = repair_swapped_closed_at_signal_note(conn) + if backfill_exchange_pnl and ex is not None: + try: + from lib.exchange.okx_options_lib import fetch_all_option_positions_history + from lib.hedge_plan.hedge_plan_settle_lib import ( + backfill_hedge_option_legs_realized_pnl, + ) + from lib.options.options_monitor_lib import ( + backfill_closed_options_realized_pnl_from_history, + ) + + hist = fetch_all_option_positions_history(ex, limit=200) + backfill_closed_options_realized_pnl_from_history(conn, hist) + backfill_hedge_option_legs_realized_pnl(conn, hist) + except Exception: + pass + out = sync_all_review_sources(conn, ex=ex, from_exchange=False) + try: + out["repaired_usdt"] = repair_coin_review_rows_to_usdt(conn, ex=ex) + except Exception: + out["repaired_usdt"] = 0 + out["ts_migrate"] = ts_migrate + out["note_swap"] = note_swap + return out + + +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..1e6b673 --- /dev/null +++ b/lib/options/options_review_register.py @@ -0,0 +1,452 @@ +"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘).""" +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request, send_file +from jinja2 import ChoiceLoader, FileSystemLoader +from werkzeug.utils import secure_filename + +from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables +from lib.options.options_review_images_lib import ( + OPTIONS_REVIEW_UPLOAD_TFS, + normalize_options_review_draft_id, + options_review_image_paths, + options_review_upload_dir, + save_options_review_slot_file, +) +from lib.options.options_review_lib import ( + SOURCE_LABELS, + compute_review_stats, + count_review_trades, + delete_review_entry, + ensure_local_review_synced, + get_review_trade, + hide_review_trade, + list_review_trades, + save_review_entry, +) + + +def _review_source_for_mode(requested: str | None) -> str | None: + """按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" + try: + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + mode = get_okx_trade_mode() + except Exception: + mode = "options" + allowed = { + "options": "option_spot", + "perp_options": "perp_options", + "options_options": "options_options", + }.get(mode, "option_spot") + req = (requested or "").strip() + if not req: + return allowed + if req == allowed: + return allowed + # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制 + return allowed + + +def attach_options_review_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None: + attach_options_review_templates(app, repo_root) + cfg = { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "render_main_page": app_module.render_main_page, + "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None) + or os.path.join(os.path.dirname(getattr(app_module, "BASE_DIR", repo_root)), "static", "images"), + "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)), + "app_module": app_module, + } + app.extensions["options_review_cfg"] = cfg + register_options_review_routes(app, cfg, repo_root) + + +def _require_ex(cfg: dict[str, Any]): + from lib.exchange.okx_options_lib import options_api_ready + + if not cfg.get("options_enabled"): + return None, "期权模块未启用" + ex = cfg.get("exchange_options") + ok, reason = options_api_ready(ex) + if not ok: + return None, reason or "期权 API 未配置" + return ex, "" + + +def _truthy(raw: Any) -> bool: + return str(raw or "").strip().lower() in ("1", "true", "on", "yes") + + +def _prepare_review_entry_images( + conn: Any, + trade_id: int, + data: dict[str, Any], +) -> dict[str, Any]: + """客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图.""" + from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF + from lib.options.options_review_images_lib import parse_options_review_images_json + + images = data.get("images") if isinstance(data.get("images"), list) else [] + cleaned: list[dict[str, str]] = [] + for item in images: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF: + continue + cleaned.append({"tf": tf, "file": file}) + + want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned + if not want_chart: + row = conn.execute( + "SELECT images_json FROM options_review_entries WHERE trade_id=?", + (int(trade_id),), + ).fetchone() + if row: + for item in parse_options_review_images_json(row["images_json"]): + if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF: + cleaned.insert(0, dict(item)) + break + + out = dict(data) + out["images"] = cleaned + return out + + +def _maybe_schedule_options_review_chart( + cfg: dict[str, Any], + trade: dict[str, Any], + data: dict[str, Any], +) -> bool: + """纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场).""" + if str(trade.get("source_type") or "").strip() != "option_spot": + return False + images = data.get("images") if isinstance(data.get("images"), list) else [] + has_manual = any(str((x or {}).get("file") or "").strip() for x in images) + if has_manual or not _truthy(data.get("journal_exchange_chart")): + return False + + app_module = cfg.get("app_module") + if app_module is None: + return False + if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)): + return False + generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None) + if not callable(generate_fn): + return False + + from lib.instance.journal_chart_lib import ( + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + ) + from lib.options.options_review_chart_async_lib import ( + schedule_options_review_exchange_chart, + underlying_to_okx_perp_symbol, + ) + from lib.options.options_review_images_lib import options_review_upload_dir + from lib.options.options_review_lib import _now_str + + fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2] + journal_tfs = parse_journal_chart_timeframes( + data.get("journal_chart_tf1"), + data.get("journal_chart_tf2"), + fallback_tfs or None, + ) + journal_limit = parse_journal_chart_limit( + data.get("journal_chart_limit"), + int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100), + ) + chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor")) + + underlying = str(trade.get("underlying") or data.get("underlying") or "").strip() + exchange_symbol = underlying_to_okx_perp_symbol( + underlying, + normalize_symbol_input_fn=app_module.normalize_symbol_input, + normalize_okx_symbol_fn=app_module.normalize_okx_symbol, + ) + if not exchange_symbol: + return False + + to_ms = getattr(app_module, "_local_input_datetime_to_ms", None) + if not callable(to_ms): + return False + entry_ts = to_ms(trade.get("opened_at")) + exit_ts = to_ms(trade.get("closed_at")) + if entry_ts is None and exit_ts is None: + return False + + now_ms = None + app_now = getattr(app_module, "app_now", None) + if callable(app_now): + try: + now_ms = int(app_now().timestamp() * 1000) + except Exception: + now_ms = None + + inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip() + upload_folder = options_review_upload_dir(cfg["upload_folder"]) + schedule_options_review_exchange_chart( + trade_id=int(trade["id"]), + exchange_symbol=exchange_symbol, + title_prefix=f"{underlying or 'OPT'} {inst} review", + journal_tfs=journal_tfs, + journal_limit=journal_limit, + marker_payload={ + "entry_ts_ms": entry_ts, + "exit_ts_ms": exit_ts, + # 只按时间钉进/出场,不传价格与止损 + "chart_anchor": chart_anchor, + "now_ts_ms": now_ms, + }, + upload_folder=upload_folder, + generate_chart_fn=generate_fn, + get_db_fn=cfg["get_db"], + now_str_fn=_now_str, + ) + return True + + +def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: + lr = cfg["login_required"] + + @app.route("/options/review") + @lr + def options_review_page(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("options_review") + if redir is not None: + return redir + return cfg["render_main_page"]("options_review") + + @app.route("/static/options_review.js") + @lr + def static_options_review_js(): + path = os.path.join(repo_root, "lib", "common", "static", "options_review.js") + if not os.path.isfile(path): + return ("not found", 404) + return send_file(path, mimetype="application/javascript; charset=utf-8") + + @app.route("/static/images/options_journal/") + def static_options_review_image(filename: str): + """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401).""" + folder = options_review_upload_dir(cfg["upload_folder"]) + safe = os.path.basename(filename or "") + path = os.path.join(folder, safe) + if not os.path.isfile(path): + # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件 + root = os.path.abspath(cfg["upload_folder"] or "") + alt = os.path.join(root, safe) + if os.path.isfile(alt): + path = alt + else: + return ("not found", 404) + return send_file(path) + + @app.route("/api/options/review/sync", methods=["POST"]) + @lr + def api_options_review_sync(): + """刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算.""" + conn = cfg["get_db"]() + try: + init_options_review_tables(conn) + ex, _err = _require_ex(cfg) + result = ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + return jsonify(result) + finally: + conn.close() + + @app.route("/api/options/review/trades") + @lr + def api_options_review_trades(): + conn = cfg["get_db"]() + try: + # 翻页可跳过同步,仅刷新当前卡片列表 + do_sync = (request.args.get("sync") or "1").strip().lower() not in ( + "0", + "false", + "no", + ) + if do_sync: + ex, _err = _require_ex(cfg) + ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + filt = dict( + source_type=_review_source_for_mode(request.args.get("source_type")), + underlying=(request.args.get("underlying") or "").strip() or None, + opt_type=(request.args.get("opt_type") or "").strip() or None, + strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, + q=(request.args.get("q") or "").strip() or None, + reviewed=(request.args.get("reviewed") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "") + .strip() + .lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + ) + limit = min(500, max(1, int(request.args.get("limit") or 200))) + offset = max(0, int(request.args.get("offset") or 0)) + total = count_review_trades(conn, **filt) + items = list_review_trades(conn, **filt, limit=limit, offset=offset) + pages = max(1, (total + limit - 1) // limit) if total else 1 + page = (offset // limit) + 1 if limit else 1 + return jsonify( + { + "ok": True, + "trades": items, + "source_labels": SOURCE_LABELS, + "total": total, + "limit": limit, + "offset": offset, + "page": page, + "pages": pages, + } + ) + finally: + conn.close() + + @app.route("/api/options/review/trades/") + @lr + def api_options_review_trade_detail(trade_id: int): + conn = cfg["get_db"]() + try: + item = get_review_trade(conn, trade_id) + if not item: + return jsonify({"ok": False, "msg": "未找到"}), 404 + return jsonify({"ok": True, "trade": item}) + finally: + conn.close() + + @app.route("/api/options/review/entry", methods=["POST"]) + @lr + def api_options_review_entry_save(): + data = request.get_json(silent=True) or {} + try: + trade_id = int(data.get("trade_id")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 + conn = cfg["get_db"]() + try: + data = _prepare_review_entry_images(conn, trade_id, data) + out = save_review_entry(conn, trade_id, data) + if out.get("ok"): + conn.commit() + chart_queued = _maybe_schedule_options_review_chart( + cfg, out.get("trade") or {}, data + ) + if chart_queued: + out = dict(out) + out["chart_queued"] = True + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/trades/", methods=["DELETE"]) + @lr + def api_options_review_trade_hide(trade_id: int): + """从复盘列表删除并持久隐藏(刷新本地源也不会再导入).""" + conn = cfg["get_db"]() + try: + out = hide_review_trade(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/entry/", methods=["DELETE"]) + @lr + def api_options_review_entry_delete(trade_id: int): + conn = cfg["get_db"]() + try: + out = delete_review_entry(conn, trade_id) + if out.get("ok"): + entry = out.get("entry") or {} + folder = options_review_upload_dir(cfg["upload_folder"]) + for path in options_review_image_paths(entry, folder): + try: + os.remove(path) + except OSError: + pass + conn.commit() + return jsonify(out), (200 if out.get("ok") else 400) + finally: + conn.close() + + @app.route("/api/options/review/upload_slot", methods=["POST"]) + @lr + def api_options_review_upload_slot(): + draft_id = normalize_options_review_draft_id( + request.form.get("draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return jsonify({"ok": False, "error": "invalid draft_id"}), 400 + if tf not in OPTIONS_REVIEW_UPLOAD_TFS: + return jsonify({"ok": False, "error": "invalid tf"}), 400 + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return jsonify({"ok": False, "error": "no file"}), 400 + folder = options_review_upload_dir(cfg["upload_folder"]) + item = save_options_review_slot_file( + f, draft_id, tf, folder, secure_filename_fn=secure_filename + ) + if not item: + return jsonify({"ok": False, "error": "save failed"}), 500 + return jsonify({"ok": True, "tf": tf, "file": item["file"]}) + + @app.route("/api/options/review/stats") + @lr + def api_options_review_stats(): + conn = cfg["get_db"]() + try: + ex, _err = _require_ex(cfg) + ensure_local_review_synced(conn, ex=ex if ex is not None else None) + conn.commit() + stats = compute_review_stats( + conn, + source_type=_review_source_for_mode(request.args.get("source_type")), + underlying=(request.args.get("underlying") or "").strip() or None, + include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() + in ("1", "true", "yes"), + closed_from=(request.args.get("closed_from") or "").strip() or None, + closed_to=(request.args.get("closed_to") or "").strip() or None, + require_strategy=(request.args.get("require_strategy") or "").strip().lower() + in ("1", "true", "yes"), + ) + stats["source_types"] = list(SOURCE_TYPES) + stats["source_labels"] = SOURCE_LABELS + return jsonify(stats) + finally: + conn.close() diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py new file mode 100644 index 0000000..9f1e675 --- /dev/null +++ b/lib/options/options_spot_bridge_lib.py @@ -0,0 +1,416 @@ +"""币本位期权:USDT↔标的币现货桥与本地状态.""" +from __future__ import annotations + +import sqlite3 +import time +from typing import Any + +from lib.options.options_margin_mode_lib import spot_quote_inst_id + + +BRIDGE_BOUGHT = "bought_pending_open" +BRIDGE_HOLDING = "holding" +BRIDGE_PENDING_SELL = "pending_sell_spot" +BRIDGE_CLOSED = "closed" + + +def ensure_bridge_table(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_spot_bridge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + underlying TEXT NOT NULL, + status TEXT NOT NULL, + budget_usdt REAL, + buy_ord_id TEXT, + coin_bought REAL, + sell_ord_id TEXT, + coin_sold REAL, + usdt_recovered REAL, + inst_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_spot_bridge_status + ON options_spot_bridge(status) + """ + ) + + +def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_bridge_table(conn) + cur = conn.execute( + """ + SELECT id, underlying, status, budget_usdt, buy_ord_id, coin_bought, + sell_ord_id, coin_sold, usdt_recovered, inst_id, message, + created_at, updated_at, closed_at + FROM options_spot_bridge + WHERE status IN (?, ?, ?) + ORDER BY id DESC + """, + (BRIDGE_BOUGHT, BRIDGE_HOLDING, BRIDGE_PENDING_SELL), + ) + cols = [d[0] for d in cur.description] + return [dict(zip(cols, row)) for row in cur.fetchall()] + + +def has_unfinished_bridge(conn: sqlite3.Connection) -> bool: + return bool(list_open_bridges(conn)) + + +def insert_bridge( + conn: sqlite3.Connection, + *, + underlying: str, + status: str, + budget_usdt: float | None = None, + buy_ord_id: str | None = None, + coin_bought: float | None = None, + inst_id: str | None = None, + message: str | None = None, +) -> int: + ensure_bridge_table(conn) + cur = conn.execute( + """ + INSERT INTO options_spot_bridge( + underlying, status, budget_usdt, buy_ord_id, coin_bought, inst_id, message, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + ( + (underlying or "ETH").upper(), + status, + budget_usdt, + buy_ord_id, + coin_bought, + inst_id, + message, + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def update_bridge( + conn: sqlite3.Connection, + bridge_id: int, + *, + status: str | None = None, + buy_ord_id: str | None = None, + coin_bought: float | None = None, + sell_ord_id: str | None = None, + coin_sold: float | None = None, + usdt_recovered: float | None = None, + inst_id: str | None = None, + message: str | None = None, + close: bool = False, +) -> None: + ensure_bridge_table(conn) + fields: list[str] = ["updated_at=CURRENT_TIMESTAMP"] + vals: list[Any] = [] + if status is not None: + fields.append("status=?") + vals.append(status) + if buy_ord_id is not None: + fields.append("buy_ord_id=?") + vals.append(buy_ord_id) + if coin_bought is not None: + fields.append("coin_bought=?") + vals.append(coin_bought) + if sell_ord_id is not None: + fields.append("sell_ord_id=?") + vals.append(sell_ord_id) + if coin_sold is not None: + fields.append("coin_sold=?") + vals.append(coin_sold) + if usdt_recovered is not None: + fields.append("usdt_recovered=?") + vals.append(usdt_recovered) + if inst_id is not None: + fields.append("inst_id=?") + vals.append(inst_id) + if message is not None: + fields.append("message=?") + vals.append(message) + if close or status == BRIDGE_CLOSED: + fields.append("closed_at=CURRENT_TIMESTAMP") + vals.append(int(bridge_id)) + conn.execute( + f"UPDATE options_spot_bridge SET {', '.join(fields)} WHERE id=?", + vals, + ) + conn.commit() + + +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 fetch_trading_coin_available(ex: Any, ccy: str) -> float | None: + """交易账户标的币可用.""" + from lib.exchange.okx_options_lib import _extract_ccy_free, _safe_float as _sf + + ccy_u = (ccy or "").upper() + if not ccy_u: + return None + try: + bal = ex.fetch_balance(params={"type": "trading"}) + free = _extract_ccy_free(bal, ccy_u) + if free is not None: + return float(free) + # 部分账户结构只有 total + from lib.exchange.okx_options_lib import _extract_ccy_balance + + tot = _extract_ccy_balance(bal, ccy_u) + return float(tot) if tot is not None else None + except Exception: + return None + + +def spot_market_buy_coin_with_usdt( + ex: Any, + *, + underlying: str, + usdt_amount: float, +) -> dict[str, Any]: + """交易账户:用 USDT 市价买入标的币.""" + if usdt_amount <= 0: + return {"ok": False, "msg": "USDT 数量须大于 0"} + inst_id = spot_quote_inst_id(underlying) + try: + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "buy", + "ordType": "market", + "sz": str(usdt_amount), + "tgtCcy": "quote_ccy", + } + 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, + "inst_id": inst_id, + "ord_id": str(data[0].get("ordId") or ""), + "data": data[0], + "raw": resp, + } + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def spot_market_sell_coin_to_usdt( + ex: Any, + *, + underlying: str, + coin_amount: float | None = None, +) -> dict[str, Any]: + """交易账户:市价卖出标的币换 USDT. + + 默认/推荐:coin_amount 为空 → 卖光交易账户全部可用币(全部卖出). + 传入 coin_amount 时仍不超过可用余额,且尽量按可用全额卖出(不故意留粉尘). + """ + ccy = (underlying or "ETH").upper() + avail = fetch_trading_coin_available(ex, ccy) + if avail is None or float(avail) <= 0: + return {"ok": False, "msg": f"交易账户无可用 {ccy}"} + avail_f = float(avail) + # 全部卖出:以可用余额为准;若传入数量则不超过可用(开仓失败回滚用) + if coin_amount is None or float(coin_amount) <= 0: + sell_sz = avail_f + else: + sell_sz = min(float(coin_amount), avail_f) + if sell_sz <= 0: + return {"ok": False, "msg": f"{ccy} 数量须大于 0"} + inst_id = spot_quote_inst_id(ccy) + try: + # 现货卖出:向下截到 8 位,避免超过可用被拒;不再 *0.999 故意留残 + sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".") + if not sz or float(sz) <= 0: + return {"ok": False, "msg": f"{ccy} 可卖数量过小"} + # 二次钳制:格式化后仍不得超过可用 + if float(sz) > avail_f: + sz = f"{avail_f:.8f}".rstrip("0").rstrip(".") + body = { + "instId": inst_id, + "tdMode": "cash", + "side": "sell", + "ordType": "market", + "sz": sz, + "tgtCcy": "base_ccy", + } + 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, + "inst_id": inst_id, + "ord_id": str(data[0].get("ordId") or ""), + "coin_sold": float(sz), + "data": data[0], + "raw": resp, + } + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp} + except Exception as e: + from lib.exchange.okx_options_lib import _okx_trade_error_message + + return {"ok": False, "msg": _okx_trade_error_message(e)} + + +def rollback_bought_coin_to_usdt( + conn: sqlite3.Connection, + ex: Any, + *, + bridge_id: int, + underlying: str, + reason: str = "", + coin_amount: float | None = None, +) -> dict[str, Any]: + """买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量.""" + amt = coin_amount + if amt is None or float(amt) <= 0: + ensure_bridge_table(conn) + row = conn.execute( + "SELECT coin_bought FROM options_spot_bridge WHERE id=?", + (int(bridge_id),), + ).fetchone() + if row: + try: + amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0) + except (TypeError, ValueError, KeyError, IndexError): + amt = None + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt) + if not sell.get("ok"): + update_bridge( + conn, + bridge_id, + status=BRIDGE_PENDING_SELL, + message=(reason or "") + " | 回滚卖币失败: " + str(sell.get("msg") or ""), + ) + return {"ok": False, "msg": sell.get("msg") or "回滚卖币失败", "bridge_status": BRIDGE_PENDING_SELL} + update_bridge( + conn, + bridge_id, + status=BRIDGE_CLOSED, + sell_ord_id=str(sell.get("ord_id") or ""), + coin_sold=_safe_float(sell.get("coin_sold")), + message=reason or "开仓失败已卖回 USDT", + close=True, + ) + return {"ok": True, "sell": sell, "bridge_status": BRIDGE_CLOSED} + + +def sell_residual_after_option_flat( + conn: sqlite3.Connection, + ex: Any, + *, + underlying: str, + inst_id: str | None = None, +) -> dict[str, Any]: + """期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥.""" + ensure_bridge_table(conn) + bridges = list_open_bridges(conn) + target = None + for b in bridges: + if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT): + if not underlying or str(b.get("underlying") or "").upper() == underlying.upper(): + target = b + break + # 平仓后全部卖出交易账户可用标的币(含权利金盈亏留下的币),不按 bridge 记账量限卖 + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=None) + if target is None: + if not sell.get("ok"): + msg = str(sell.get("msg") or "") + if "无可用" in msg or "过小" in msg: + return {"ok": True, "msg": "无残留币需卖回", "skipped": True} + return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL} + return {"ok": True, "sell": sell, "bridge_status": None} + bid = int(target["id"]) + if not sell.get("ok"): + update_bridge( + conn, + bid, + status=BRIDGE_PENDING_SELL, + inst_id=inst_id, + message=str(sell.get("msg") or "卖回 USDT 失败"), + ) + return { + "ok": False, + "msg": sell.get("msg") or "卖回 USDT 失败", + "bridge_id": bid, + "bridge_status": BRIDGE_PENDING_SELL, + } + update_bridge( + conn, + bid, + status=BRIDGE_CLOSED, + sell_ord_id=str(sell.get("ord_id") or ""), + coin_sold=_safe_float(sell.get("coin_sold")), + inst_id=inst_id, + message="期权已平,币已卖回 USDT", + close=True, + ) + return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED} + + +def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None: + bridges = list_open_bridges(conn) + if not bridges: + return None + st = str(bridges[0].get("status") or "") + if st == BRIDGE_PENDING_SELL: + return "存在待卖回 USDT 的币本位桥残留,请先到期权页重试卖回后再开仓" + if st == BRIDGE_BOUGHT: + return "存在已买币未完成开仓的桥流程,请等待回滚或联系处理后重试" + if st == BRIDGE_HOLDING: + return "币本位桥仍在持仓中(一次仅一笔),请先平仓并卖回 USDT" + return "存在未完成的币本位资金桥,暂不可开仓" + + +def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None: + """有单笔期权仓或未完成桥时禁止切换本位.""" + if has_unfinished_bridge(conn): + return "存在未完成的币本位资金桥,禁止切换期权本位模式" + if ex is not None: + try: + from lib.exchange.okx_options_lib import fetch_option_positions + + rows = fetch_option_positions(ex) or [] + for p in rows: + try: + pos = float(p.get("pos") or 0) + except (TypeError, ValueError): + pos = 0.0 + if abs(pos) > 1e-12: + return "存在未平期权持仓,禁止切换期权本位模式" + except Exception: + pass + # 本地 open 交易记录 + try: + row = conn.execute( + "SELECT COUNT(*) FROM options_trades WHERE status='open'" + ).fetchone() + n = int(row[0] if not isinstance(row, dict) else row.get("COUNT(*)") or list(row.values())[0]) + if n > 0: + return "本地仍有未平期权记录,禁止切换期权本位模式" + except Exception: + pass + return None diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py new file mode 100644 index 0000000..916bc70 --- /dev/null +++ b/lib/options/options_stats_lib.py @@ -0,0 +1,192 @@ +"""期权本地交易统计(胜率 / 盈亏 / 持仓时长).""" +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 +from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + +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 _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _row_premium_ccy(row: dict[str, Any]) -> str: + ccy = str(row.get("premium_ccy") or "").strip().upper() + if ccy: + return ccy + inst = str(row.get("inst_id") or "").strip() + mode = str(row.get("margin_mode") or "").strip().lower() + underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "ETH") or "ETH") + if mode: + return premium_ccy_for_mode(mode, underly) + if not inst: + # 旧统计行无合约信息时按 USDC 口径,避免默认币本位把盈亏跳过 + return "USDC" + return premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) + + +def _pnl_as_usdt(row: dict[str, Any], *, fallback_index: float | None = None) -> float | None: + """已平/浮盈统一折算为 USDT(币本位×指数;USDC 原样).""" + pnl = _safe_float(row.get("realized_pnl")) + if pnl is None: + pnl = _safe_float(row.get("upl")) + if pnl is None: + return None + ccy = _row_premium_ccy(row) + if ccy in ("ETH", "BTC"): + px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px")) + if px is None or px <= 0: + px = fallback_index + if px is None or px <= 0: + return None + return float(pnl) * float(px) + return float(pnl) + + +def _history_index_px(history: list[dict[str, Any]]) -> float | None: + for row in history: + px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px")) + if px is not None and px > 0: + return px + return None + + +def compute_options_stats_from_history( + history: list[dict[str, Any]], + *, + index_px: float | None = None, +) -> dict[str, Any]: + """基于期权历史列表计算统计;币本位盈亏按指数折算为 U.""" + 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() + fallback_idx = index_px if index_px is not None and index_px > 0 else _history_index_px(history) + coinish = False + + for row in history: + ccy = _row_premium_ccy(row) + if ccy in ("ETH", "BTC"): + coinish = True + 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 = _pnl_as_usdt(row, fallback_index=fallback_idx) + if pnl is None: + 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), + "pnl_unit": "U" if coinish else "USDC", + "index_px": fallback_idx, + } + + +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, inst_id, premium_ccy, margin_mode + 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() + + hist = [] + for row in closed_rows: + hist.append( + { + "status": "closed", + "realized_pnl": row["realized_pnl"], + "created_at": row["created_at"], + "closed_at": row["closed_at"], + "inst_id": row["inst_id"] if "inst_id" in row.keys() else None, + "premium_ccy": row["premium_ccy"] if "premium_ccy" in row.keys() else None, + "margin_mode": row["margin_mode"] if "margin_mode" in row.keys() else None, + } + ) + for row in open_rows: + hist.append({"status": "open", "created_at": row["created_at"]}) + return compute_options_stats_from_history(hist) diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py new file mode 100644 index 0000000..2b84017 --- /dev/null +++ b/lib/options/options_target_lib.py @@ -0,0 +1,546 @@ +"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" +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, + inst_id=inst_id, + ) + + +def ensure_target_tables(conn: sqlite3.Connection) -> None: + init_options_tables(conn) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS options_target_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id TEXT NOT NULL, + underlying TEXT, + opt_type TEXT, + target_index REAL NOT NULL, + trade_id INTEGER, + sheets INTEGER, + status TEXT DEFAULT 'active', + trigger_idx REAL, + close_ord_id TEXT, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + triggered_at TIMESTAMP + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status + ON options_target_monitors(status) + """ + ) + + +def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: + """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" + ot = (opt_type or "").strip().upper() + if ot == "P": + return index_px <= target_index + return index_px >= target_index + + +def upsert_target_monitor( + conn: sqlite3.Connection, + *, + inst_id: str, + target_index: float, + underlying: str | None = None, + opt_type: str | None = None, + trade_id: int | None = None, + sheets: int | None = None, +) -> dict[str, Any]: + ensure_target_tables(conn) + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + if target_index is None or float(target_index) <= 0: + return {"ok": False, "msg": "目标位无效"} + target_index = float(target_index) + row = conn.execute( + """ + SELECT id FROM options_target_monitors + WHERE inst_id = ? AND status IN ('active', 'closing') + ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC + LIMIT 1 + """, + (inst_id,), + ).fetchone() + if row: + conn.execute( + """ + UPDATE options_target_monitors + SET target_index = ?, + underlying = COALESCE(?, underlying), + opt_type = COALESCE(?, opt_type), + trade_id = COALESCE(?, trade_id), + sheets = COALESCE(?, sheets), + status = 'active', + trigger_idx = NULL, + close_ord_id = NULL, + message = NULL, + triggered_at = NULL + WHERE id = ? + """, + (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), + ) + mon_id = int(row["id"]) + # 同一合约其他进行中的委托取消,避免双轨触发重复推送 + conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '被新目标位覆盖' + WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') + """, + (inst_id, mon_id), + ) + else: + cur = conn.execute( + """ + INSERT INTO options_target_monitors + (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + """, + (inst_id, underlying, opt_type, target_index, trade_id, sheets), + ) + mon_id = int(cur.lastrowid) + return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} + + +def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: + ensure_target_tables(conn) + if monitor_id is not None: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE id = ? AND status IN ('active', 'closing') + """, + (int(monitor_id),), + ) + return int(cur.rowcount or 0) + if inst_id: + cur = conn.execute( + """ + UPDATE options_target_monitors + SET status = 'cancelled', message = '手动取消' + WHERE inst_id = ? AND status IN ('active', 'closing') + """, + (inst_id.strip(),), + ) + return int(cur.rowcount or 0) + return 0 + + +def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: + return { + "id": int(r["id"]), + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "target_index": _safe_float(r["target_index"]), + "trade_id": r["trade_id"], + "sheets": r["sheets"], + "status": r["status"], + "message": r["message"], + "created_at": r["created_at"], + } + + +def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'active' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: + """已挂出平仓单、等待成交的目标(不再重复推送微信).""" + ensure_target_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'closing' + ORDER BY id DESC + """ + ).fetchall() + return [_row_to_target(r) for r in rows] + + +def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: + """UI/持仓挂载:active 与 closing 都算进行中.""" + out: dict[str, dict[str, Any]] = {} + for t in list_closing_targets(conn) + list_active_targets(conn): + inst = str(t.get("inst_id") or "") + if inst and inst not in out: + out[inst] = t + return out + + +def mark_monitor( + conn: sqlite3.Connection, + monitor_id: int, + *, + status: str, + trigger_idx: float | None = None, + close_ord_id: str | None = None, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE options_target_monitors + SET status = ?, + trigger_idx = COALESCE(?, trigger_idx), + close_ord_id = COALESCE(?, close_ord_id), + message = COALESCE(?, message), + triggered_at = CASE + WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP) + ELSE triggered_at + END + WHERE id = ? + """, + (status, trigger_idx, close_ord_id, message, status, int(monitor_id)), + ) + + +def cancel_orphans_without_position( + conn: sqlite3.Connection, + *, + live_inst_ids: set[str], +) -> int: + """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" + ensure_target_tables(conn) + rows = list_active_targets(conn) + list_closing_targets(conn) + n = 0 + for t in rows: + inst = str(t.get("inst_id") or "") + if inst and inst not in live_inst_ids: + mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") + n += 1 + return n + + +def _commit_monitor(conn: sqlite3.Connection) -> None: + """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送.""" + try: + conn.commit() + except Exception: + pass + + +def close_option_by_bid_depth( + cfg: dict[str, Any], + ex: Any, + inst_id: str, + *, + sheets: int | None = None, +) -> dict[str, Any]: + """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性).""" + from lib.options.options_close_exec_lib import close_option_by_bid1 + + return close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=True, + signal_note="目标位平仓", + ) + + + +def _notify_target_close( + cfg: dict[str, Any] | None, + send_wechat: Callable[[str], None] | None, + *, + account_label: str, + inst_id: str, + target: float, + idx: float, + result: dict[str, Any], + conn: Any = None, +) -> None: + """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + from lib.options.options_notify_lib import resolve_options_premium_ccy + + ccy = resolve_options_premium_ccy(inst_id=inst_id) + d = 6 if ccy in ("ETH", "BTC") else 4 + mode = "币本位" if ccy != "USDC" else "USDC" + if result.get("fully_closed") or result.get("already_flat"): + if cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + notify_options_close( + cfg, + conn, + inst_id=inst_id, + reason="目标位平仓", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + target_index=target, + trigger_idx=idx, + premium_ccy=ccy, + ) + # 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推 + return + except Exception: + pass + if not send_wechat: + return + try: + recv = result.get("premium_received") + recv_txt = f"{float(recv):.{d}f} {ccy}" if recv is not None else f"— {ccy}" + send_wechat( + "\n".join( + [ + "【OKX期权·目标位平仓】", + f"账户:{account_label}", + f"本位:{mode}", + f"合约:{inst_id}", + f"目标指数:{target:g}", + f"触发指数:{idx:g}", + f"提交张数:{result.get('submitted_sheets') or '—'}", + f"预估收回:{recv_txt}", + 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) + target = _safe_float(mon.get("target_index")) + idx = _safe_float(mon.get("trigger_idx")) + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=float(target) if target is not None else 0.0, + idx=float(idx) if idx is not None else 0.0, + result={"already_flat": True, "fully_closed": True, "ok": True}, + conn=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) + target = _safe_float(mon.get("target_index")) + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=float(target) if target is not None else 0.0, + idx=float(idx) if idx is not None else 0.0, + result={**result, "fully_closed": True}, + conn=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) + triggered += 1 + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=target, + idx=idx, + result=result, + conn=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/options_time_lib.py b/lib/options/options_time_lib.py new file mode 100644 index 0000000..1cf649e --- /dev/null +++ b/lib/options/options_time_lib.py @@ -0,0 +1,181 @@ +"""期权业务时区墙钟:库内统一按 APP_TIMEZONE(默认 Asia/Shanghai)存储,不可当 UTC.""" + +from __future__ import annotations + +import os +import sqlite3 +from datetime import datetime, timezone +from typing import Any, Optional +from zoneinfo import ZoneInfo + +_APP_TZ = ZoneInfo( + (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" +) +_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done" +_SWAP_REPAIR_FLAG = "options_closed_at_note_swap_done" + + +def options_app_tz() -> ZoneInfo: + return _APP_TZ + + +def options_app_now_str() -> str: + return datetime.now(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def ms_to_options_app_str(ms: Any) -> Optional[str]: + """交易所 ms(UTC 绝对时间) → 业务时区墙钟字符串.""" + try: + val = float(ms) + except (TypeError, ValueError): + return None + if val <= 0: + return None + try: + dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone(_APP_TZ) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError, OverflowError): + return None + + +def options_wall_clock_to_ms(raw: Any) -> Optional[int]: + """业务时区墙钟 → UTC ms(与合约复盘 _local_input_datetime_to_ms 同口径).""" + if raw is None or raw == "": + return None + s = str(raw).strip().replace("T", " ") + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%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 utc_wall_clock_to_app_str(raw: Any) -> Optional[str]: + """把误存成 UTC 墙钟的字符串改写为业务时区墙钟(+8 等).""" + if raw is None or raw == "": + return None + s = str(raw).strip().replace("T", " ") + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)): + try: + dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=timezone.utc) + return dt.astimezone(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + continue + return None + + +def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str, int]: + """一次性:历史 UTC 墙钟(SQLite CURRENT_TIMESTAMP / 旧 monitor)改为业务时区. + + 幂等:同步状态 options_ts_utc_wall_to_app_done=1 后跳过. + """ + from lib.options.options_review_db import init_options_review_tables + from lib.options.options_review_lib import get_sync_state, set_sync_state + + init_options_review_tables(conn) + if get_sync_state(conn, _MIGRATE_FLAG) == "1": + return {"skipped": 1, "trades": 0, "review": 0, "hidden": 0} + + def _shift_table(table: str, cols: tuple[str, ...]) -> int: + try: + rows = conn.execute( + f"SELECT rowid AS _rid, {', '.join(cols)} FROM {table}" + ).fetchall() + except sqlite3.Error: + return 0 + n = 0 + for row in rows: + sets: list[str] = [] + vals: list[Any] = [] + for col in cols: + old = row[col] + new = utc_wall_clock_to_app_str(old) + if new and str(old or "").strip() != new: + sets.append(f"{col}=?") + vals.append(new) + if not sets: + continue + vals.append(int(row["_rid"])) + conn.execute( + f"UPDATE {table} SET {', '.join(sets)} WHERE rowid=?", + vals, + ) + n += 1 + return n + + trades = 0 + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + trades = _shift_table("options_trades", ("created_at", "closed_at")) + except Exception: + trades = 0 + + review = _shift_table("options_review_trades", ("opened_at", "closed_at")) + hidden = _shift_table("options_review_hidden", ("closed_at",)) + + set_sync_state(conn, _MIGRATE_FLAG, "1") + return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden} + + +def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str, int]: + """修复 close_option_by_bid1 曾把 signal_note 写入 closed_at 的坏行. + + 典型表现: closed_at='翻倍出场'/'目标位平仓', signal_note 反而是时间戳. + 幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修). + """ + from lib.options.options_review_db import init_options_review_tables + from lib.options.options_review_lib import set_sync_state + + init_options_review_tables(conn) + trades_fixed = 0 + review_fixed = 0 + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + rows = conn.execute( + """ + SELECT id, closed_at, signal_note + FROM options_trades + WHERE status = 'closed' + AND closed_at IS NOT NULL + AND TRIM(closed_at) != '' + """ + ).fetchall() + for row in rows: + closed = str(row["closed_at"] or "").strip() + note = str(row["signal_note"] or "").strip() + if options_wall_clock_to_ms(closed) is not None: + continue + if options_wall_clock_to_ms(note) is None: + continue + conn.execute( + """ + UPDATE options_trades + SET closed_at = ?, signal_note = ? + WHERE id = ? + """, + (note, closed, int(row["id"])), + ) + trades_fixed += 1 + hk = f"local_opt:{int(row['id'])}" + cur = conn.execute( + """ + UPDATE options_review_trades + SET closed_at = ? + WHERE history_key = ? + AND (closed_at IS NULL OR TRIM(closed_at) = '' OR closed_at = ?) + """, + (note, hk, closed), + ) + review_fixed += int(cur.rowcount or 0) + except Exception: + pass + + set_sync_state(conn, _SWAP_REPAIR_FLAG, "1") + return {"trades": trades_fixed, "review": review_fixed} diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html new file mode 100644 index 0000000..f45ef29 --- /dev/null +++ b/lib/options/templates/options_panel.html @@ -0,0 +1,383 @@ +
        + {% 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=true 及 OKX_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 笔持仓。
        • +
        • 币本位(env OKX_OPTIONS_MARGIN_MODE=coin):按交易户 USDT×缓冲买满 ETH/BTC 再开满期权;平仓后自动卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。
        • +
        • 翻倍出场:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。
        • +
        +

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

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

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

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

        持仓

        +
        + + + +
        +
        +
        + + + + +
        +
        +
        + +
        +
        暂无持仓
        +
        +
        +
        + + + +
        +
        + + 划转 + 收起 + 展开 + +
        +
        账户内划转
        +
        + + + + + + + +
        +
        +
        +
        +
        +
        +
        + + diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html new file mode 100644 index 0000000..54aeeaf --- /dev/null +++ b/lib/options/templates/options_review_panel.html @@ -0,0 +1,450 @@ +{# 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..b9f8ad9 --- /dev/null +++ b/lib/paths.py @@ -0,0 +1,33 @@ +"""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") + + +def manual_trading_hub_dir(repo_root: str | Path | None = None) -> Path: + root = Path(repo_root) if repo_root is not None else REPO_ROOT + return root / "manual_trading_hub" + + +def hub_data_dir(repo_root: str | Path | None = None) -> Path: + path = manual_trading_hub_dir(repo_root) / "data" + path.mkdir(parents=True, exist_ok=True) + return path 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..76611a1 --- /dev/null +++ b/lib/sim/broker_lib.py @@ -0,0 +1,1349 @@ +"""模拟撮合: 用公开行情 bid/ask 成交, 结算到本地钱包.""" + +from __future__ import annotations + +import os +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: + """业务时区墙钟(默认 Asia/Shanghai);勿用 utcnow,否则复盘K线标记会偏 8 小时.""" + try: + from lib.options.options_time_lib import options_app_now_str + + return options_app_now_str() + except Exception: + from zoneinfo import ZoneInfo + + tz = ZoneInfo( + (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" + ) + return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S") + + +def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]: + """取买卖一.优先 public ticker,避免私钥无效时 ccxt 带坏 Key 导致 50111.""" + inst = symbol.replace("/", "-") if symbol and "/" in symbol else (symbol or "") + if inst and exchange is not None and hasattr(exchange, "public_get_market_ticker"): + try: + rows = (exchange.public_get_market_ticker({"instId": inst}) or {}).get("data") or [] + if rows: + t = rows[0] + bid = t.get("bidPx") or t.get("last") or t.get("lastPx") + ask = t.get("askPx") or t.get("last") or t.get("lastPx") + if bid is not None and ask is not None and float(bid) > 0 and float(ask) > 0: + return float(bid), float(ask) + except Exception: + pass + 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 + + +def _premium_ccy_for_inst(inst_id: str) -> str: + try: + from lib.options.options_margin_mode_lib import ( + margin_mode_from_inst_id, + premium_ccy_for_mode, + ) + + underly = (str(inst_id or "").split("-")[0] or "ETH").upper() + return premium_ccy_for_mode(margin_mode_from_inst_id(inst_id), underly) + except Exception: + return "USDC" + + +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") + # 行情先拉,再开库,避免握锁等网络 + bid, ask = _ticker_bid_ask(exchange, symbol) + 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) + 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, + fee: float = 0.0, + fee_ccy: str | None = None, + ) -> None: + conn = self.get_db() + try: + ccy = str(fee_ccy or _premium_ccy_for_inst(inst_id) or "").strip().upper() + conn.execute( + """ + INSERT OR REPLACE INTO sim_option_orders( + ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, + fee, fee_ccy, created_at + ) VALUES (?,?,?,?,?,'filled',?,?,?,?) + """, + ( + ord_id, + inst_id, + side, + float(sheets), + float(avg_px), + float(sheets), + float(fee or 0), + ccy, + _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 + keys = set(row.keys()) if hasattr(row, "keys") else set() + fee = float(row["fee"]) if "fee" in keys and row["fee"] is not None else 0.0 + fee_ccy = str(row["fee_ccy"] or "").strip().upper() if "fee_ccy" in keys else "" + if not fee_ccy: + fee_ccy = _premium_ccy_for_inst(row["inst_id"]) + 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"]), + "fee": -abs(fee) if fee else 0.0, + "fee_ccy": fee_ccy, + "sim": True, + "raw": {"fee": -abs(fee) if fee else 0.0, "feeCcy": fee_ccy, "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 + prem_ccy = _premium_ccy_for_inst(inst_id) + try: + self.wallets.debit_trading( + prem_ccy, + 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, + fee=float(pr.fee or 0), + fee_ccy=prem_ccy, + ) + return { + "ok": True, + "data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"}, + "raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy}, + "px": pr.fill_px, + "ord_type": "ioc", + "fee": float(pr.fee or 0), + "fee_ccy": prem_ccy, + "info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "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"} + # 行情先拉,再开库,避免握锁等网络/嵌套写钱包 + 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) + 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) + 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: + prem_ccy = _premium_ccy_for_inst(inst_id) + self.wallets.credit_trading( + prem_ccy, + 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]}" + prem_ccy = _premium_ccy_for_inst(inst_id) + self._store_option_order( + ord_id=ord_id, + inst_id=inst_id, + side="sell", + sheets=close_n, + avg_px=pr.fill_px, + fee=float(pr.fee or 0), + fee_ccy=prem_ccy, + ) + return { + "ok": True, + "data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"}, + "raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy}, + "px": pr.fill_px, + "ord_type": "ioc", + "fee": float(pr.fee or 0), + "fee_ccy": prem_ccy, + "info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()}, + } + + def convert_usdt_usdc( + self, + exchange: Any, + *, + direction: str, + amount: float, + fee_rate: float | None = None, + account: str = "trading", + symbol: str = "USDC/USDT", + ) -> dict[str, Any]: + """模拟 USDC-USDT 现货市价兑换, 默认扣交易账户.""" + from lib.sim.pricing_lib import spot_usdc_usdt_fill + + fr = sim_fee_rate(fee_rate) + bid, ask = _ticker_bid_ask(exchange, symbol) + fill = spot_usdc_usdt_fill( + direction=direction, amount=float(amount), bid=bid, ask=ask, fee_rate=fr + ) + result = SimWallets(self.get_db).convert( + from_ccy=fill.from_ccy, + to_ccy=fill.to_ccy, + amount=fill.from_amount, + account=account or "trading", + to_amount=fill.to_amount, + rate=fill.fill_px, + fee=fill.fee, + note=f"USDC/USDT mkt {fill.fill_px:.6f} (bid {bid:.6f}/ask {ask:.6f})", + ) + if not result.get("ok"): + return result + result.update( + { + "direction": fill.direction, + "bid": bid, + "ask": ask, + "base_px": fill.base_px, + "fill_px": fill.fill_px, + "fee_rate": fr, + "symbol": symbol, + } + ) + return result + + def convert_usdt_coin( + self, + exchange: Any, + *, + underlying: str, + direction: str, + amount: float, + fee_rate: float | None = None, + account: str = "trading", + ) -> dict[str, Any]: + """模拟 ETH/BTC-USDT 现货市价兑换(交易账户).""" + from lib.options.options_margin_mode_lib import spot_quote_inst_id + from lib.sim.pricing_lib import spot_coin_usdt_fill + + coin = (underlying or "ETH").strip().upper() or "ETH" + if coin not in ("ETH", "BTC"): + return {"ok": False, "msg": f"不支持标的 {coin}"} + # spot_quote_inst_id → ETH-USDT;ccxt 常用 ETH/USDT + inst = spot_quote_inst_id(coin) + symbol = inst.replace("-", "/") if inst else f"{coin}/USDT" + fr = sim_fee_rate(fee_rate) + bid, ask = _ticker_bid_ask(exchange, symbol) + fill = spot_coin_usdt_fill( + direction=direction, + amount=float(amount), + bid=bid, + ask=ask, + fee_rate=fr, + coin=coin, + ) + result = SimWallets(self.get_db).convert( + from_ccy=fill.from_ccy, + to_ccy=fill.to_ccy, + amount=fill.from_amount, + account=account or "trading", + to_amount=fill.to_amount, + rate=fill.fill_px, + fee=fill.fee, + note=f"{symbol} mkt {fill.fill_px:.4f} (bid {bid:.4f}/ask {ask:.4f})", + ) + if not result.get("ok"): + return result + result.update( + { + "direction": fill.direction, + "underlying": coin, + "bid": bid, + "ask": ask, + "base_px": fill.base_px, + "fill_px": fill.fill_px, + "fee_rate": fr, + "symbol": symbol, + "inst_id": inst, + "coin_bought": fill.to_amount if fill.direction == "usdt_to_coin" else None, + "coin_sold": fill.from_amount if fill.direction == "coin_to_usdt" else None, + "usdt_spent": fill.from_amount if fill.direction == "usdt_to_coin" else None, + "usdt_recovered": fill.to_amount if fill.direction == "coin_to_usdt" else None, + } + ) + return result + + def _index_px_for_option( + self, + exchange: Any, + inst_id: str, + *, + idx_cache: dict[str, float | None] | None = None, + ) -> float | None: + """到期结算/持仓展示用指数价:优先合约行情,否则 family 指数.""" + from lib.exchange.okx_options_lib import ( + fetch_index_price, + inst_family_from_inst_id, + quote_option_contract, + ) + + cache = idx_cache if idx_cache is not None else {} + if exchange is None or not inst_id: + return None + try: + q = quote_option_contract(exchange, inst_id) + if q.get("ok") and q.get("index_px") is not None: + return float(q["index_px"]) + except Exception: + pass + family = inst_family_from_inst_id(inst_id) or "" + uly = family.replace("_UM", "") if family else "" + if not uly: + return None + if uly not in cache: + try: + cache[uly] = fetch_index_price(exchange, uly) + except Exception: + cache[uly] = None + return cache.get(uly) + + def settle_expired_option_positions( + self, + exchange: Any = None, + *, + now_ms: int | None = None, + ) -> list[dict[str, Any]]: + """模拟盘到期结算:按指数内在价值兑付后删除本地仓(无实盘交割). + + 虚值兑付 0;实值 credit 交易账户 USDC.同时回写 options_trades 为 closed. + """ + import time + + from lib.exchange.okx_options_lib import ( + expiry_ms_from_inst_id, + option_fields_from_inst_id, + ) + from lib.options.options_pricing_lib import option_expiry_pnl + + now = int(now_ms if now_ms is not None else time.time() * 1000) + settled: list[dict[str, Any]] = [] + idx_cache: dict[str, float | None] = {} + + for p in self.list_option_positions(): + inst_id = str(p.get("inst_id") or "").strip() + if not inst_id: + continue + exp_ms = expiry_ms_from_inst_id(inst_id) + if exp_ms is None or now < int(exp_ms): + continue + opt_type, strike = option_fields_from_inst_id(inst_id) + if strike is None: + continue + spot = self._index_px_for_option(exchange, inst_id, idx_cache=idx_cache) + if spot is None: + # 无指数则本轮跳过,避免实值误按 0 结算 + continue + + sheets = float(p.get("sheets") or 0) + ct_mult = float(p.get("ct_mult") or 0.01) + prem = float(p.get("premium_paid_usdc") or 0) + pnl = float( + option_expiry_pnl( + opt_type=str(opt_type or "P"), + strike=float(strike), + spot=float(spot), + sheets=sheets, + ct_mult=ct_mult, + premium_paid=prem, + ) + ) + settle_recv = round(max(0.0, prem + pnl), 4) + o = (opt_type or "").strip().upper() + if o in ("C", "CALL"): + intrinsic_u = max(0.0, float(spot) - float(strike)) + elif o in ("P", "PUT"): + intrinsic_u = max(0.0, float(strike) - float(spot)) + else: + intrinsic_u = 0.0 + + prem_ccy = _premium_ccy_for_inst(inst_id) + # 币本位到期兑付用币数量: 实值/指数 × 张数 × 乘数 + # 注意: option_expiry_pnl 的 intrinsic 是 U,减权利金时币本位不能直接用 + if prem_ccy in ("ETH", "BTC") and float(spot) > 0: + settle_recv = round( + max(0.0, (intrinsic_u / float(spot)) * sheets * ct_mult), + 8, + ) + # 币盈亏 = 兑付币 − 仓位权利金币(与 options_trades.premium_paid 同口径) + pnl = round(float(settle_recv) - float(prem), 8) + else: + settle_recv = round(max(0.0, prem + pnl), 4) + pnl = round(float(pnl), 4) + + conn = self.get_db() + try: + conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,)) + try: + closed_at = _now() + open_rows = conn.execute( + """ + SELECT id, premium_paid, open_fee, close_fee + FROM options_trades + WHERE inst_id=? AND status='open' + """, + (inst_id,), + ).fetchall() + open_ids = [int(r["id"]) for r in open_rows] + total_paid = 0.0 + for r in open_rows: + try: + total_paid += float(r["premium_paid"] or 0) + except (TypeError, ValueError): + pass + n_open = len(open_rows) or 1 + for r in open_rows: + paid_i = 0.0 + try: + paid_i = float(r["premium_paid"] or 0) + except (TypeError, ValueError): + paid_i = 0.0 + ofee_i = 0.0 + try: + ofee_i = abs(float(r["open_fee"] or 0)) + except (TypeError, ValueError): + ofee_i = 0.0 + # 多笔开仓按权利金占比拆兑付;单笔拿全部 + if n_open == 1: + recv_i = float(settle_recv) + elif total_paid > 1e-12: + recv_i = float(settle_recv) * (paid_i / total_paid) + else: + recv_i = float(settle_recv) / float(n_open) + if prem_ccy in ("ETH", "BTC"): + recv_i = round(recv_i, 8) + pnl_i = round(recv_i - paid_i - ofee_i, 8) + else: + recv_i = round(recv_i, 4) + pnl_i = round(recv_i - paid_i - ofee_i, 4) + conn.execute( + """ + UPDATE options_trades + SET status = 'closed', + close_quote = ?, + premium_received = ?, + realized_pnl = ?, + close_index_px = COALESCE(close_index_px, ?), + closed_at = COALESCE(closed_at, ?), + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' + THEN '到期结算' + ELSE signal_note + END + WHERE id = ? AND status = 'open' + """, + ( + round(intrinsic_u, 6), + recv_i, + pnl_i, + float(spot), + closed_at, + int(r["id"]), + ), + ) + if open_ids: + from lib.options.options_notify_lib import ( + active_options_notify_cfg, + notify_options_close_trade_ids, + ) + + notify_options_close_trade_ids( + active_options_notify_cfg(), + conn, + open_ids, + reason="到期结算", + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + + if settle_recv > 1e-12: + self.wallets.credit_trading( + prem_ccy, + settle_recv, + kind="option_expiry", + note=f"expiry settle {inst_id} @{spot:g} recv={settle_recv}", + ) + ord_id = f"sim-opt-exp-{uuid.uuid4().hex[:16]}" + self._store_option_order( + ord_id=ord_id, + inst_id=inst_id, + side="settle", + sheets=sheets, + avg_px=intrinsic_u, + ) + try: + from lib.options.options_positions_lib import forget_close_gate_for_inst + + forget_close_gate_for_inst(inst_id) + except Exception: + pass + try: + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id + from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat + + if margin_mode_from_inst_id(inst_id) == "coin": + uly = inst_id.split("-")[0].upper() if "-" in inst_id else "ETH" + sell_residual_after_option_flat( + self.get_db, + exchange, + underlying=uly, + inst_id=inst_id, + ) + except Exception as e: + print(f"[sim_expiry_sell] {inst_id}: {e}", flush=True) + settled.append( + { + "inst_id": inst_id, + "spot": spot, + "intrinsic": intrinsic_u, + "premium_received": settle_recv, + "realized_pnl": round(pnl, 4), + "ord_id": ord_id, + } + ) + return settled + + def option_positions_okx_rows(self, exchange: Any = None) -> list[dict[str, Any]]: + """对齐 OKX positions 行字段, 供 format_position_row 使用. + + 模拟盘补充公开行情的 idxPx / markPx, 否则指数价与平掉回本均为空. + 拉取前先结算已到期仓,避免虚值到期后一直挂在当前持仓. + """ + from lib.exchange.okx_options_lib import ( + expiry_ms_from_inst_id, + option_fields_from_inst_id, + quote_option_contract, + ) + + try: + self.settle_expired_option_positions(exchange) + except Exception: + pass + + rows: list[dict[str, Any]] = [] + idx_cache: dict[str, float | None] = {} + for p in self.list_option_positions(): + inst_id = str(p["inst_id"] or "") + sheets = float(p["sheets"]) + entry = float(p["entry_px"]) + ct_mult = float(p.get("ct_mult") or 0.01) + opt_type, strike = option_fields_from_inst_id(inst_id) + mark = entry + idx = None + exp_ms = expiry_ms_from_inst_id(inst_id) + + if exchange is not None and inst_id: + try: + q = quote_option_contract(exchange, inst_id) + if q.get("ok"): + raw_mark = q.get("mark") + if raw_mark is None: + raw_mark = q.get("bid") + if raw_mark is not None and float(raw_mark) > 0: + mark = float(raw_mark) + if q.get("index_px") is not None: + idx = float(q["index_px"]) + if q.get("opt_type"): + opt_type = str(q["opt_type"]) + if q.get("strike") is not None: + strike = float(q["strike"]) + if q.get("exp_time") is not None: + try: + exp_ms = int(float(q["exp_time"])) + except (TypeError, ValueError): + pass + except Exception: + pass + if idx is None: + idx = self._index_px_for_option(exchange, inst_id, idx_cache=idx_cache) + + eth = abs(sheets) * ct_mult + upl = (mark - entry) * eth + prem = float(p.get("premium_paid_usdc") or 0) or (entry * eth) + upl_ratio = (upl / prem) if prem > 1e-12 else 0.0 + row: dict[str, Any] = { + "instId": inst_id, + "pos": str(sheets), + "availPos": str(sheets), + "avgPx": str(entry), + "markPx": str(mark), + "upl": str(round(upl, 4)), + "uplRatio": str(round(upl_ratio, 6)), + "posSide": "long", + "mgnMode": "isolated", + } + if idx is not None: + row["idxPx"] = str(idx) + if opt_type: + row["optType"] = opt_type + if strike is not None: + row["stk"] = str(strike) + if exp_ms: + row["expTime"] = str(exp_ms) + rows.append(row) + return rows + + @staticmethod + def _sells_from_ledger( + conn: Any, + *, + inst: str, + opened: str, + closed: str, + ) -> list[Any]: + """从 sim_ledger_entries 的 option_close 备注解析卖平成交.""" + import re + + try: + params: list[Any] = [f"%sell {inst} %"] + sql = """ + SELECT note, ts AS created_at + FROM sim_ledger_entries + WHERE kind = 'option_close' AND note LIKE ? + """ + if opened: + sql += " AND ts >= ?" + params.append(opened[:19]) + if closed: + sql += " AND ts <= ?" + params.append((closed[:16] + ":59") if len(closed) >= 16 else closed) + sql += " ORDER BY ts ASC, id ASC" + rows = conn.execute(sql, params).fetchall() + except Exception: + return [] + out: list[dict[str, Any]] = [] + pat = re.compile( + rf"sell\s+{re.escape(inst)}\s+x(?P[0-9.]+)@(?P[0-9.]+)", + re.I, + ) + for i, r in enumerate(rows): + note = str(r["note"] or "") + m = pat.search(note) + if not m: + continue + try: + sh = float(m.group("sheets")) + px = float(m.group("px")) + except (TypeError, ValueError): + continue + if sh <= 0 or px <= 0: + continue + out.append( + { + "ord_id": f"ledger-{i}", + "sheets": sh, + "avg_px": px, + "created_at": r["created_at"], + } + ) + return out + + def repair_closed_options_pnl_from_sim_orders(self, *, limit: int = 300) -> int: + """用 sim_option_orders 卖平成交重算已平仓收回(修复分批平仓只记最后一笔). + + 返回更新条数.历史页/复盘合成前调用,使旧数据也能自愈. + """ + from lib.options.options_db import init_options_tables + from lib.options.options_pricing_lib import total_premium + from lib.sim.db_lib import init_sim_tables + + conn = self.get_db() + updated = 0 + try: + init_options_tables(conn) + init_sim_tables(conn) + # 表不存在时跳过(极旧库) + try: + conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone() + except Exception: + return 0 + + trades = conn.execute( + """ + SELECT id, inst_id, sheets, eth_amount, premium_paid, premium_received, + realized_pnl, close_quote, created_at, closed_at, close_ord_id + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT ? + """, + (int(max(1, min(int(limit), 500))),), + ).fetchall() + for tr in trades: + inst = str(tr["inst_id"] or "").strip() + if not inst: + continue + want_sheets = float(tr["sheets"] or 0) + if want_sheets <= 0: + continue + eth_amt = float(tr["eth_amount"] or 0) + ct_mult = (eth_amt / want_sheets) if eth_amt > 0 else 0.01 + if ct_mult <= 0: + ct_mult = 0.01 + opened = str(tr["created_at"] or "").strip() + closed = str(tr["closed_at"] or "").strip() + # 时间窗:开仓后、平仓后 2 分钟内(含下单延迟) + params: list[Any] = [inst] + sql = """ + SELECT ord_id, sheets, avg_px, created_at + FROM sim_option_orders + WHERE inst_id = ? AND lower(side) = 'sell' + """ + if opened: + sql += " AND created_at >= ?" + params.append(opened[:19]) + if closed: + # 字符串时间比较;闭区间放宽到同分钟及之后的短尾 + sql += " AND created_at <= ?" + params.append((closed[:16] + ":59") if len(closed) >= 16 else closed) + sql += " ORDER BY created_at ASC, ord_id ASC" + sells = conn.execute(sql, params).fetchall() + if not sells: + # 回退:从钱包流水 note 解析 sell INST x张数@价 + sells = self._sells_from_ledger( + conn, inst=inst, opened=opened, closed=closed + ) + if not sells: + continue + selected: list[Any] = [] + sheets_sum = 0.0 + for o in sells: + selected.append(o) + sheets_sum += float(o["sheets"] or 0) + if sheets_sum + 1e-9 >= want_sheets: + break + if sheets_sum + 1e-6 < want_sheets * 0.5: + # 成交张数明显不足,不瞎改 + continue + recv = 0.0 + notional = 0.0 + oids: list[str] = [] + for o in selected: + sh = float(o["sheets"] or 0) + px = float(o["avg_px"] or 0) + if sh <= 0 or px <= 0: + continue + recv += total_premium(px, sh * ct_mult) + notional += px * sh + oid = str(o["ord_id"] or "").strip() + if oid and oid not in oids: + oids.append(oid) + if not oids: + continue + total_recv = round(recv, 8) + paid = float(tr["premium_paid"] or 0) if tr["premium_paid"] is not None else None + if paid is None: + continue + new_pnl = round(total_recv - paid, 8) + close_px = round(notional / sheets_sum, 8) if sheets_sum > 0 else None + old_recv = ( + float(tr["premium_received"]) + if tr["premium_received"] is not None + else None + ) + old_pnl = float(tr["realized_pnl"]) if tr["realized_pnl"] is not None else None + # 已与合计一致则跳过 + if ( + old_recv is not None + and old_pnl is not None + and abs(old_recv - total_recv) < 1e-8 + and abs(old_pnl - new_pnl) < 1e-8 + ): + continue + # 仅当卖笔数≥2 或合计收回明显高于本地(漏记)时覆盖 + if len(selected) < 2 and old_recv is not None and total_recv <= old_recv + 1e-10: + continue + conn.execute( + """ + UPDATE options_trades + SET premium_received = ?, + realized_pnl = ?, + close_quote = COALESCE(?, close_quote), + close_ord_id = ? + WHERE id = ? + """, + ( + total_recv, + new_pnl, + close_px, + ",".join(oids) if oids else tr["close_ord_id"], + int(tr["id"]), + ), + ) + updated += 1 + if updated: + conn.commit() + finally: + conn.close() + return updated + + def option_positions_history_okx_rows( + self, + exchange: Any = None, + *, + inst_id: str | None = None, + limit: int = 200, + ) -> list[dict[str, Any]]: + """模拟盘历史仓位:从本地已平 options_trades 合成 OKX positions-history 字段. + + 实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据. + """ + # 先按卖平成交修复分批漏计,再合成展示 + try: + self.repair_closed_options_pnl_from_sim_orders(limit=max(int(limit), 200)) + except Exception: + pass + + from datetime import datetime + from zoneinfo import ZoneInfo + + from lib.exchange.okx_options_lib import option_fields_from_inst_id + from lib.options.options_db import init_options_tables + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + tz = ZoneInfo( + (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() + or "Asia/Shanghai" + ) + + def _to_ms(ts: Any) -> int | None: + if ts is None: + return None + raw = str(ts).strip() + if not raw: + return None + for fmt, ln in ( + ("%Y-%m-%d %H:%M:%S", 19), + ("%Y-%m-%d %H:%M:%S.%f", 26), + ("%Y-%m-%d %H:%M", 16), + ): + try: + dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=tz) + return int(dt.timestamp() * 1000) + except ValueError: + continue + return None + + conn = self.get_db() + try: + init_options_tables(conn) + try: + from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt + + sync_sim_cash_realized_pnl_usdt(conn, exchange) + conn.commit() + except Exception: + pass + sql = """ + SELECT id, inst_id, underlying, sheets, open_quote, close_quote, + premium_paid, premium_received, realized_pnl, realized_pnl_usdt, + premium_ccy, created_at, closed_at + FROM options_trades + WHERE status = 'closed' + """ + params: list[Any] = [] + if inst_id: + sql += " AND inst_id = ?" + params.append(str(inst_id).strip()) + sql += " ORDER BY id DESC LIMIT ?" + params.append(int(max(1, min(int(limit), 500)))) + rows = conn.execute(sql, params).fetchall() + finally: + conn.close() + + out: list[dict[str, Any]] = [] + for r in rows: + iid = str(r["inst_id"] or "").strip() + if not iid: + continue + sheets = float(r["sheets"] or 0) + if sheets <= 0: + continue + open_px = float(r["open_quote"] or 0) or None + close_px = float(r["close_quote"] or 0) or None + paid = float(r["premium_paid"] or 0) if r["premium_paid"] is not None else None + recv = float(r["premium_received"] or 0) if r["premium_received"] is not None else None + pnl = float(r["realized_pnl"]) if r["realized_pnl"] is not None else None + pnl_usdt = float(r["realized_pnl_usdt"]) if r["realized_pnl_usdt"] is not None else None + if pnl is None and paid is not None and recv is not None: + pnl = round(recv - paid, 8) + if pnl is None: + pnl = 0.0 + if paid is None and open_px is not None: + paid = round(open_px * sheets * 0.01, 8) + ccy = str(r["premium_ccy"] or "").strip().upper() + if not ccy: + u = str(r["underlying"] or iid.split("-")[0] or "ETH") + ccy = premium_ccy_for_mode(margin_mode_from_inst_id(iid), u) + if close_px is None and exchange is not None: + try: + bid, _ask, _ = _option_bid_ask(exchange, iid) + if bid and float(bid) > 0: + close_px = float(bid) + except Exception: + pass + open_ms = _to_ms(r["created_at"]) + close_ms = _to_ms(r["closed_at"]) or open_ms + opt_type, strike = option_fields_from_inst_id(iid) + uly = str(r["underlying"] or iid.split("-")[0] or "").upper() + pnl_ratio = None + if paid is not None and abs(float(paid)) > 1e-12: + pnl_ratio = float(pnl) / float(paid) + out.append( + { + "instId": iid, + "uly": f"{uly}-USD" if margin_mode_from_inst_id(iid) == "coin" else f"{uly}-USD_UM", + "posId": f"sim-{int(r['id'])}", + "openAvgPx": str(open_px) if open_px is not None else "", + "closeAvgPx": str(close_px) if close_px is not None else "", + "closeTotalPos": str(sheets), + "openMaxPos": str(sheets), + # 期权历史展示币数量盈亏;U 口径只写 _sim_realized_pnl_usdt 供统计 + "realizedPnl": str(round(pnl, 8)), + "pnl": str(round(pnl, 8)), + "pnlRatio": str(round(pnl_ratio, 8)) if pnl_ratio is not None else "", + "cTime": str(open_ms or ""), + "uTime": str(close_ms or ""), + "type": "2", + "optType": opt_type or "", + "stk": str(strike) if strike is not None else "", + "_sim_premium_paid": paid, + "_sim_premium_ccy": ccy, + "_sim_realized_pnl_usdt": pnl_usdt, + } + ) + return out diff --git a/lib/sim/db_lib.py b/lib/sim/db_lib.py new file mode 100644 index 0000000..f3c14c6 --- /dev/null +++ b/lib/sim/db_lib.py @@ -0,0 +1,142 @@ +"""模拟资金 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 _ensure_sim_wallet_coin_columns(conn: sqlite3.Connection) -> None: + """旧库补齐币本位 ETH/BTC 列.""" + cols = { + str(r[1]) + for r in conn.execute("PRAGMA table_info(sim_wallets)").fetchall() + } + for col in ("funding_eth", "trading_eth", "funding_btc", "trading_btc"): + if col not in cols: + conn.execute( + f"ALTER TABLE sim_wallets ADD COLUMN {col} REAL NOT NULL DEFAULT 0" + ) + + +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, + funding_eth REAL NOT NULL DEFAULT 0, + trading_eth REAL NOT NULL DEFAULT 0, + funding_btc REAL NOT NULL DEFAULT 0, + trading_btc REAL NOT NULL DEFAULT 0, + updated_at TEXT + ) + """ + ) + _ensure_sim_wallet_coin_columns(conn) + 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 + ) + """ + ) + try: + conn.execute("ALTER TABLE sim_option_orders ADD COLUMN fee REAL DEFAULT 0") + except Exception: + pass + try: + conn.execute("ALTER TABLE sim_option_orders ADD COLUMN fee_ccy TEXT") + except Exception: + pass + 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..6e019fa --- /dev/null +++ b/lib/sim/hooks.py @@ -0,0 +1,566 @@ +"""运行时挂钩: 将 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_live_contracts = getattr(app_module, "get_live_position_contracts", None) + + 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 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) + if callable(_orig_live_contracts): + return _orig_live_contracts(exchange_symbol, direction) + return 0.0 + + app_module.ensure_okx_live_ready = ensure_okx_live_ready + app_module.get_exchange_capitals = get_exchange_capitals + app_module.get_available_trading_usdt = get_available_trading_usdt + app_module.get_live_position_contracts = get_live_position_contracts + + _orig_xfer = getattr(app_module, "execute_transfer_usdt", None) + if callable(_orig_xfer): + + def execute_transfer_usdt(amount, from_account, to_account): + if is_sim_mode(app_module.get_db): + from lib.sim.wallets_lib import SimWallets, normalize_sim_account + + try: + result = SimWallets(app_module.get_db).transfer( + ccy="USDT", + amount=float(amount), + from_account=normalize_sim_account(from_account), + to_account=normalize_sim_account(to_account), + ) + except Exception as e: + return False, str(e), None + if not result.get("ok"): + return False, result.get("detail") or result.get("msg") or "划转失败", None + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + return True, "sim 划转成功", result + return _orig_xfer(amount, from_account, to_account) + + app_module.execute_transfer_usdt = execute_transfer_usdt + + 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 + _orig_transfer = opt_lib.transfer_ccy + _orig_swap = opt_lib.spot_market_swap_usdt_usdc + + 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.get("trading_usdc") or 0), 2), + round(float(w.get("funding_usdc") or 0), 2), + round(float(w.get("funding_usdt") or 0), 2), + round(float(w.get("trading_usdt") or 0), 2), + ) + except Exception: + pass + return _orig_header(ex, force=force) + + def fetch_options_balances( + ex, *, force: bool = False, scope: str = "main", sub_acct: str = "" + ): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + w = broker().balances_header() + fu = float(w.get("funding_usdt") or 0) + fc = float(w.get("funding_usdc") or 0) + tu = float(w.get("trading_usdt") or 0) + tc = float(w.get("trading_usdc") or 0) + fe = float(w.get("funding_eth") or 0) + te = float(w.get("trading_eth") or 0) + fb = float(w.get("funding_btc") or 0) + tb = float(w.get("trading_btc") or 0) + return { + "scope": "main", + "funding_usdt": fu, + "funding_usdc": fc, + "trading_usdt": tu, + "trading_usdc": tc, + "funding_eth": fe, + "trading_eth": te, + "funding_btc": fb, + "trading_btc": tb, + "funding_usdt_avail": fu, + "funding_usdc_avail": fc, + "trading_usdt_avail": tu, + "trading_usdc_avail": tc, + "funding_eth_avail": fe, + "trading_eth_avail": te, + "funding_btc_avail": fb, + "trading_btc_avail": tb, + } + except Exception: + pass + return _orig_fetch_bal(ex, force=force, scope=scope, sub_acct=sub_acct) + + 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 transfer_ccy(ex, ccy, amount, from_acct, to_acct): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + from lib.sim.wallets_lib import SimWallets, normalize_sim_account + + result = SimWallets(_GET_DB).transfer( + ccy=str(ccy or "USDC"), + amount=float(amount), + from_account=normalize_sim_account(from_acct), + to_account=normalize_sim_account(to_acct), + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("detail") or result.get("msg") or "划转失败", + } + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + return {"ok": True, "msg": "sim 划转成功", "sim": True, **result} + except Exception as e: + return {"ok": False, "msg": str(e)} + return _orig_transfer(ex, ccy, amount, from_acct, to_acct) + + def spot_market_swap_usdt_usdc(ex, *, direction: str = "usdt_to_usdc", amount: float = 0): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + # 对齐实盘: 交易账户 + USDC/USDT 公开买卖一(含手续费滑点) + pub = _sim_public_exchange(ex) + if pub is None and _APP_MODULE is not None: + pub = getattr(_APP_MODULE, "exchange_options", None) + if pub is None: + return {"ok": False, "msg": "sim: 无公开行情 exchange"} + result = broker().convert_usdt_usdc( + pub, + direction=direction, + amount=float(amount), + account="trading", + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("detail") or result.get("msg") or "兑换失败", + } + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + px = result.get("fill_px") + msg = f"sim 兑换成功 @ {px:.6f}" if px else "sim 兑换成功" + return {"ok": True, "msg": msg, "sim": True, **result} + except Exception as e: + return {"ok": False, "msg": str(e)} + return _orig_swap(ex, direction=direction, amount=amount) + + 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(ex) + except Exception: + pass + return _orig_fetch_pos(ex) + + _orig_fetch_hist = getattr(opt_lib, "fetch_option_position_history", None) + _orig_fetch_all_hist = getattr(opt_lib, "fetch_all_option_positions_history", None) + + def fetch_option_position_history(ex, inst_id, limit=50): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + return broker().option_positions_history_okx_rows( + ex, inst_id=inst_id, limit=limit + ) + except Exception: + pass + if callable(_orig_fetch_hist): + return _orig_fetch_hist(ex, inst_id, limit=limit) + return [] + + def fetch_all_option_positions_history(ex, *, limit=200): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + return broker().option_positions_history_okx_rows(ex, limit=limit) + except Exception: + pass + if callable(_orig_fetch_all_hist): + return _orig_fetch_all_hist(ex, limit=limit) + return [] + + 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.transfer_ccy = transfer_ccy + opt_lib.spot_market_swap_usdt_usdc = spot_market_swap_usdt_usdc + 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.fetch_option_position_history = fetch_option_position_history + opt_lib.fetch_all_option_positions_history = fetch_all_option_positions_history + opt_lib._sim_hooks_applied = True + + _patch_spot_bridge_lib() + + +def _sim_public_exchange(ex: Any = None) -> Any: + """模拟盘行情优先用无密钥/公开实例,避免 options 私钥 50111 污染 public 请求.""" + if _APP_MODULE is not None: + pub = getattr(_APP_MODULE, "exchange", None) + if pub is not None: + return pub + return ex + + +def _patch_spot_bridge_lib() -> None: + """币本位现货桥:模拟盘走本地 USDT↔ETH/BTC,勿打实盘 private_post_trade_order.""" + import lib.options.options_spot_bridge_lib as bridge_lib + + if getattr(bridge_lib, "_sim_hooks_applied", False): + return + + _orig_buy = bridge_lib.spot_market_buy_coin_with_usdt + _orig_sell = bridge_lib.spot_market_sell_coin_to_usdt + _orig_avail = bridge_lib.fetch_trading_coin_available + + def fetch_trading_coin_available(ex, ccy: str): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + ccy_u = (ccy or "").strip().upper() + if ccy_u not in ("ETH", "BTC"): + return None + w = broker().balances_header() + return float(w.get(f"trading_{ccy_u.lower()}") or 0) + except Exception: + pass + return _orig_avail(ex, ccy) + + def spot_market_buy_coin_with_usdt(ex, *, underlying: str, usdt_amount: float): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + pub = _sim_public_exchange(ex) + if pub is None: + return {"ok": False, "msg": "sim: 无公开行情 exchange"} + result = broker().convert_usdt_coin( + pub, + underlying=underlying, + direction="usdt_to_coin", + amount=float(usdt_amount), + account="trading", + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("detail") or result.get("msg") or "买币失败", + } + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + return { + "ok": True, + "inst_id": result.get("inst_id") or "", + "ord_id": f"sim-spot-buy-{result.get('fill_px') or 0}", + "sim": True, + "coin_bought": result.get("coin_bought"), + "data": {"sCode": "0", "sMsg": "sim filled"}, + **{k: result[k] for k in ("fill_px", "usdt_spent", "underlying") if k in result}, + } + except Exception as e: + return {"ok": False, "msg": str(e)} + return _orig_buy(ex, underlying=underlying, usdt_amount=usdt_amount) + + def spot_market_sell_coin_to_usdt(ex, *, underlying: str, coin_amount: float | None = None): + try: + if _GET_DB is not None and is_sim_mode(_GET_DB): + coin = (underlying or "ETH").strip().upper() or "ETH" + avail = fetch_trading_coin_available(ex, coin) + if avail is None or float(avail) <= 0: + return {"ok": False, "msg": f"交易账户无可用 {coin}"} + from lib.options.options_spot_bridge_lib import is_sellable_spot_coin, spot_min_sell_sz + + amt = float(avail) + if coin_amount is not None: + try: + lim = float(coin_amount) + except (TypeError, ValueError): + lim = 0.0 + if lim > 0: + amt = min(amt, lim) + if not is_sellable_spot_coin(ex, coin, amt): + min_sz = spot_min_sell_sz(ex, coin) + return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"} + # 平仓卖回(coin_amount=None)一次卖光;限量卖才留粉尘余量 + if coin_amount is None: + sell_sz = float(amt) + else: + sell_sz = max(0.0, float(amt) * 0.999) + if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz): + return {"ok": False, "msg": f"{coin} 可卖数量过小"} + pub = _sim_public_exchange(ex) + if pub is None: + return {"ok": False, "msg": "sim: 无公开行情 exchange"} + result = broker().convert_usdt_coin( + pub, + underlying=coin, + direction="coin_to_usdt", + amount=float(sell_sz), + account="trading", + ) + if not result.get("ok"): + return { + "ok": False, + "msg": result.get("detail") or result.get("msg") or "卖币失败", + } + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + recovered = result.get("usdt_recovered") + if recovered is None and result.get("fill_px") is not None: + try: + recovered = round(float(sell_sz) * float(result.get("fill_px")), 8) + except (TypeError, ValueError): + recovered = None + return { + "ok": True, + "inst_id": result.get("inst_id") or "", + "ord_id": f"sim-spot-sell-{result.get('fill_px') or 0}", + "coin_sold": float(sell_sz), + "fill_px": result.get("fill_px"), + "sim": True, + "usdt_recovered": recovered, + "data": {"sCode": "0", "sMsg": "sim filled"}, + } + except Exception as e: + return {"ok": False, "msg": str(e)} + return _orig_sell(ex, underlying=underlying, coin_amount=coin_amount) + + bridge_lib.fetch_trading_coin_available = fetch_trading_coin_available + bridge_lib.spot_market_buy_coin_with_usdt = spot_market_buy_coin_with_usdt + bridge_lib.spot_market_sell_coin_to_usdt = spot_market_sell_coin_to_usdt + bridge_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) + + # 与 apply_sim_hooks 对齐: cfg 里可能仍是 import 时的旧引用 + import lib.exchange.okx_options_lib as opt_lib + + for key in ( + "transfer_ccy", + "spot_market_swap_usdt_usdc", + "options_api_ready", + "fetch_options_balances", + "fetch_option_positions", + "fetch_option_order", + "wait_option_order_full_fill", + "fetch_option_position_history", + "fetch_all_option_positions_history", + ): + if key in cfg and hasattr(opt_lib, key): + cfg[key] = getattr(opt_lib, key) + + 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, inst_id=None, **kwargs): + # 调用方多为 (ex, inst_id) 位置参数, 不可只收 **kwargs + if is_sim_mode(get_db): + return [] + if callable(live_pending): + return live_pending(ex, inst_id) + 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..73c64bd --- /dev/null +++ b/lib/sim/mode_lib.py @@ -0,0 +1,88 @@ +"""交易模式: 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 peek_persisted_trading_mode(db_path: str | None = None) -> str | None: + """只读 SQLite 中的 trading.mode(供 env 配置页展示当前生效值).""" + import sqlite3 + + path = (db_path or os.getenv("DB_PATH") or "crypto.db").strip() + if not path: + return None + if not os.path.isabs(path): + path = os.path.abspath(path) + if not os.path.isfile(path): + return None + try: + conn = sqlite3.connect(path, timeout=2) + try: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (TRADING_MODE_KEY,), + ).fetchone() + finally: + conn.close() + except Exception: + return None + if not row: + return None + m = str(row[0] or "").strip().lower() + return m if m in VALID_MODES else None + + +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 + + +def exchange_mode_label(*, is_sim: bool | None = None, get_db: Callable | None = None, exchange_key: str = "okx") -> str: + """顶栏交易所展示: okx_sim / okx_live.""" + ex = (exchange_key or "okx").strip().lower() or "okx" + if is_sim is None and callable(get_db): + is_sim = is_sim_mode(get_db) + return f"{ex}_sim" if bool(is_sim) else f"{ex}_live" diff --git a/lib/sim/pricing_lib.py b/lib/sim/pricing_lib.py new file mode 100644 index 0000000..495ead9 --- /dev/null +++ b/lib/sim/pricing_lib.py @@ -0,0 +1,240 @@ +"""成交价与手续费. + +永续:滑点=1×fee_rate,手续费=名义×fee_rate. +期权(币本位):滑点仍按权利金价×fee_rate;手续费对齐 OKX= + Min(fee_rate×标的币数量, 权利金×7%). +""" + +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_fee_coin( + *, + underlying_coin: float, + premium_coin: float, + fee_rate: float | None = None, + premium_fee_cap: float = 0.07, +) -> float: + """OKX 币本位期权手续费(权利金币种). + + Min(费率 × 张数对应标的币数量, 权利金×封顶比例). + 注意:费率作用在标的币面值上,不是权利金名义上. + """ + f = sim_fee_rate(fee_rate) + qty = abs(float(underlying_coin or 0.0)) + prem = abs(float(premium_coin or 0.0)) + if qty <= 0 and prem <= 0: + return 0.0 + by_rate = qty * f + cap_ratio = abs(float(premium_fee_cap or 0.07)) + if cap_ratio <= 0 or prem <= 0: + return round(by_rate, 8) + return round(min(by_rate, prem * cap_ratio), 8) + + +def option_fill( + *, + action: str, + bid: float, + ask: float, + qty: float, + fee_rate: float, +) -> PriceResult: + """开仓买入吃卖一; 平仓卖出吃买一. + + qty = sheets × ct_mult(标的币数量). + 滑点仍按权利金价×费率;手续费按 OKX 币本位公式(标的币×费率,权利金7%封顶). + """ + f = float(fee_rate) + if action == "open": + base = float(ask) + fill = base * (1.0 + f) + else: + base = float(bid) + fill = base * (1.0 - f) + qty_f = abs(float(qty)) + notional = abs(fill * qty_f) + fee = option_fee_coin( + underlying_coin=qty_f, + premium_coin=notional, + fee_rate=f, + ) + slip = abs(fill - base) * qty_f + return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional) + + +@dataclass(slots=True) +class SpotConvertResult: + """USDC/USDT 现货兑换: price = USDT per USDC.""" + + direction: str + from_ccy: str + to_ccy: str + from_amount: float + to_amount: float + base_px: float + fill_px: float + fee: float + + +def spot_usdc_usdt_fill( + *, + direction: str, + amount: float, + bid: float, + ask: float, + fee_rate: float, +) -> SpotConvertResult: + """ + 对齐实盘 USDC-USDT 现货市价: + - usdt_to_usdc: 用 USDT 买 USDC, 吃卖一 ×(1+f) + - usdc_to_usdt: 卖 USDC 换 USDT, 吃买一 ×(1-f) + amount 为付出币种数量. + """ + f = float(fee_rate) + amt = float(amount) + d = (direction or "").strip().lower() + if d == "usdt_to_usdc": + base = float(ask) + fill = base * (1.0 + f) + if fill <= 0: + raise ValueError("无效卖一价") + to_amt = amt / fill + fee = amt * f + return SpotConvertResult( + direction=d, + from_ccy="USDT", + to_ccy="USDC", + from_amount=amt, + to_amount=to_amt, + base_px=base, + fill_px=fill, + fee=fee, + ) + if d == "usdc_to_usdt": + base = float(bid) + fill = base * (1.0 - f) + if fill <= 0: + raise ValueError("无效买一价") + to_amt = amt * fill + fee = to_amt * f + return SpotConvertResult( + direction=d, + from_ccy="USDC", + to_ccy="USDT", + from_amount=amt, + to_amount=to_amt, + base_px=base, + fill_px=fill, + fee=fee, + ) + raise ValueError("direction 须为 usdt_to_usdc 或 usdc_to_usdt") + + +def spot_coin_usdt_fill( + *, + direction: str, + amount: float, + bid: float, + ask: float, + fee_rate: float, + coin: str = "ETH", +) -> SpotConvertResult: + """ + 对齐实盘 ETH-USDT / BTC-USDT 现货市价: + - usdt_to_coin: 用 USDT 买币, 吃卖一 ×(1+f); amount=USDT + - coin_to_usdt: 卖币换 USDT, 吃买一 ×(1-f); amount=币数量 + """ + f = float(fee_rate) + amt = float(amount) + ccy = (coin or "ETH").strip().upper() or "ETH" + d = (direction or "").strip().lower() + if d in ("usdt_to_coin", "usdt_to_eth", "usdt_to_btc"): + base = float(ask) + fill = base * (1.0 + f) + if fill <= 0: + raise ValueError("无效卖一价") + to_amt = amt / fill + fee = amt * f + return SpotConvertResult( + direction="usdt_to_coin", + from_ccy="USDT", + to_ccy=ccy, + from_amount=amt, + to_amount=to_amt, + base_px=base, + fill_px=fill, + fee=fee, + ) + if d in ("coin_to_usdt", "eth_to_usdt", "btc_to_usdt"): + base = float(bid) + fill = base * (1.0 - f) + if fill <= 0: + raise ValueError("无效买一价") + to_amt = amt * fill + fee = to_amt * f + return SpotConvertResult( + direction="coin_to_usdt", + from_ccy=ccy, + to_ccy="USDT", + from_amount=amt, + to_amount=to_amt, + base_px=base, + fill_px=fill, + fee=fee, + ) + raise ValueError("direction 须为 usdt_to_coin 或 coin_to_usdt") diff --git a/lib/sim/register.py b/lib/sim/register.py new file mode 100644 index 0000000..9d875d4 --- /dev/null +++ b/lib/sim/register.py @@ -0,0 +1,179 @@ +"""安装模拟资金路由与模板.""" + +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",): + cfg = app.extensions.get(key) + if isinstance(cfg, dict): + patch_options_cfg(cfg) + for fn_name in ( + "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 + try: + from app import invalidate_account_balance_cache + + invalidate_account_balance_cache() + except Exception: + pass + 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 + from_ccy = str(body.get("from_ccy") or "USDT").strip().upper() + to_ccy = str(body.get("to_ccy") or "USDC").strip().upper() + if {from_ccy, to_ccy} != {"USDT", "USDC"}: + return jsonify({"ok": False, "msg": "仅支持 USDT↔USDC"}), 400 + direction = "usdt_to_usdc" if from_ccy == "USDT" else "usdc_to_usdt" + account = str(body.get("account") or "trading") + ex = getattr(app_module, "exchange", None) or getattr(app_module, "exchange_options", None) + if ex is None: + return jsonify({"ok": False, "msg": "无公开行情 exchange"}), 500 + try: + result = SimBroker(get_db).convert_usdt_usdc( + ex, direction=direction, amount=amount, account=account + ) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 400 + 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..02d39f7 --- /dev/null +++ b/lib/sim/templates/sim_funds_panel.html @@ -0,0 +1,168 @@ +{# 系统设置 · 模拟资金 #} +
        +

        模拟资金

        +

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

        +

        + 当前模式: + … + +

        + +
        + + +
        + + + +

        +
        + diff --git a/lib/sim/wallets_lib.py b/lib/sim/wallets_lib.py new file mode 100644 index 0000000..b5800ac --- /dev/null +++ b/lib/sim/wallets_lib.py @@ -0,0 +1,397 @@ +"""模拟资金钱包: funding/trading × USDT/USDC/ETH/BTC.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable + + +WALLET_KEYS = ( + "funding_usdt", + "trading_usdt", + "funding_usdc", + "trading_usdc", + "funding_eth", + "trading_eth", + "funding_btc", + "trading_btc", +) + +_ACCT_MAP = { + ("funding", "usdt"): "funding_usdt", + ("trading", "usdt"): "trading_usdt", + ("funding", "usdc"): "funding_usdc", + ("trading", "usdc"): "trading_usdc", + ("funding", "eth"): "funding_eth", + ("trading", "eth"): "trading_eth", + ("funding", "btc"): "funding_btc", + ("trading", "btc"): "trading_btc", +} + + +def normalize_sim_account(account: str | None) -> str: + """统一账户别名: swap/unified → trading; spot → funding.""" + a = (account or "").strip().lower() + if a in ("trading", "swap", "unified", "18"): + return "trading" + if a in ("funding", "spot", "6"): + return "funding" + return a + + +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 _row_to_snap(self, row: Any) -> dict[str, float]: + if row is None: + return {k: 0.0 for k in WALLET_KEYS} + keys = set(row.keys()) if hasattr(row, "keys") else set() + out: dict[str, float] = {} + for k in WALLET_KEYS: + if keys and k not in keys: + out[k] = 0.0 + else: + try: + out[k] = float(row[k] or 0) + except (KeyError, IndexError, TypeError, ValueError): + out[k] = 0.0 + return out + + def snapshot(self) -> dict[str, float]: + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + return self._row_to_snap(row) + finally: + conn.close() + + def view(self) -> dict[str, float]: + return self.snapshot() + + def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float: + """稳定币合计(不含 ETH/BTC 折算).""" + 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() + full = {k: float(snap.get(k) or 0) for k in WALLET_KEYS} + conn.execute( + """ + UPDATE sim_wallets SET + funding_usdt=?, trading_usdt=?, funding_usdc=?, trading_usdc=?, + funding_eth=?, trading_eth=?, funding_btc=?, trading_btc=?, + updated_at=? + WHERE id=1 + """, + ( + full["funding_usdt"], + full["trading_usdt"], + full["funding_usdc"], + full["trading_usdc"], + full["funding_eth"], + full["trading_eth"], + full["funding_btc"], + full["trading_btc"], + now, + ), + ) + if owns: + conn.commit() + return full + 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/ETH/BTC") + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = self._row_to_snap(row) + bal = float(snap[key]) + if amt > bal + 1e-9: + raise InsufficientFunds(f"交易账户 {ccy.upper()} 不足(可用 {bal:.8f})") + 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/ETH/BTC") + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = self._row_to_snap(row) + 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 = normalize_sim_account(from_account) + ta = normalize_sim_account(to_account) + 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/ETH/BTC"} + conn = self.get_db() + try: + row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone() + snap = self._row_to_snap(row) + src_bal = float(snap[src_key]) + if amt > src_bal + 1e-9: + return {"ok": False, "detail": f"余额不足(可用 {src_bal:.8f})"} + 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 = "trading", + to_amount: float | None = None, + rate: float | None = None, + fee: float | None = None, + note: str | None = None, + ) -> dict[str, Any]: + """USDT↔USDC / USDT↔ETH / USDT↔BTC 兑换. to_amount 未给时按 rate(USDT per coin) 换算.""" + amt = float(amount) + if amt <= 0: + return {"ok": False, "detail": "数量须大于 0"} + fa = (from_ccy or "").strip().lower() + ta = (to_ccy or "").strip().lower() + acct = normalize_sim_account(account) or "trading" + if acct not in ("funding", "trading"): + return {"ok": False, "detail": "account 须为 funding / trading"} + pair = {fa, ta} + if pair not in ({"usdt", "usdc"}, {"usdt", "eth"}, {"usdt", "btc"}): + return {"ok": False, "detail": "仅支持 USDT↔USDC/ETH/BTC"} + if to_amount is not None: + got = float(to_amount) + elif rate is not None and float(rate) > 0: + r = float(rate) + # rate = USDT per 1 coin(USDC/ETH/BTC) + got = (amt / r) if fa == "usdt" else (amt * r) + else: + got = amt + if got <= 0: + return {"ok": False, "detail": "兑换所得须大于 0"} + 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 = self._row_to_snap(row) + src = float(snap[src_key]) + if amt > src + 1e-9: + return {"ok": False, "detail": f"{acct} {fa.upper()} 不足(可用 {src:.8f})"} + snap[src_key] = src - amt + snap[dst_key] = float(snap[dst_key]) + got + self._write(snap, conn=conn) + note_s = note or f"to {ta} @{rate if rate is not None else '1:1'}" + self._ledger( + conn, + kind="convert", + amount=-amt, + ccy=fa, + account=acct, + balance_after=snap[src_key], + note=note_s, + ) + self._ledger( + conn, + kind="convert", + amount=got, + ccy=ta, + account=acct, + balance_after=snap[dst_key], + note=f"from {fa}", + ) + conn.commit() + eff_rate = (amt / got) if fa == "usdt" and got > 0 else ((got / amt) if amt > 0 else None) + return { + "ok": True, + "detail": "converted", + "from_ccy": fa.upper(), + "to_ccy": ta.upper(), + "amount": amt, + "to_amount": got, + "rate": float(rate) if rate is not None else eff_rate, + "fee": float(fee or 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") + snap = { + "funding_usdt": amt, + "trading_usdt": 0.0, + "funding_usdc": 0.0, + "trading_usdc": 0.0, + "funding_eth": 0.0, + "trading_eth": 0.0, + "funding_btc": 0.0, + "trading_btc": 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() \ No newline at end of file diff --git a/lib/strategy/__init__.py b/lib/strategy/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/strategy/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/strategy/strategy_config.py b/lib/strategy/strategy_config.py new file mode 100644 index 0000000..4be45c8 --- /dev/null +++ b/lib/strategy/strategy_config.py @@ -0,0 +1,232 @@ +"""各交易所 app 模块 → strategy_register 配置(统一工厂).""" +from __future__ import annotations + +import sys +from typing import Any + + +def resolve_trading_app_module(app_module: Any = None) -> Any: + """ + 须在 login_required 定义之后调用. + PM2 / python app.py 时 __name__ 为 __main__,请传入 sys.modules[__name__]. + """ + if app_module is None: + main = sys.modules.get("__main__") + if main is not None and hasattr(main, "login_required"): + m = main + else: + import inspect + + m = None + for fr in inspect.stack(): + g = fr.frame.f_globals + if callable(g.get("login_required")) and callable(g.get("get_db")): + m = g + break + if m is None: + raise RuntimeError( + "策略交易注册失败:请使用 install_strategy_trading(app, repo_root, app_module=sys.modules[__name__])" + ) + else: + m = app_module + if not hasattr(m, "login_required"): + raise RuntimeError( + "策略交易注册须在 login_required 定义之后执行(将 install_strategy_trading 放在 app.py 末尾)" + ) + return m + + +def build_strategy_config( + app_module: Any = None, *, trend_enabled: bool = False, trend_disabled_note: str = "" +) -> dict: + m = resolve_trading_app_module(app_module) + + def get_trading_capital_usdt(conn): + if hasattr(m, "get_exchange_capitals"): + _, tc = m.get_exchange_capitals(force=True) + if tc is not None: + return float(tc) + if hasattr(m, "get_available_trading_usdt"): + snap = m.get_available_trading_usdt() + if snap is not None: + return float(snap) + day = m.get_trading_day(m.app_now()) + row = m.ensure_session(conn, day) + return float(row["current_capital"]) + + def get_position(ex_sym, direction): + from lib.hub.hub_position_metrics import normalize_contracts_qty + + qty = m.get_live_position_contracts(ex_sym, direction) + entry = None + try: + rows = m.exchange.fetch_positions([ex_sym]) + for p in rows or []: + matcher = getattr(m, "_row_matches_monitor_direction", None) + if matcher and not matcher(direction, p): + continue + contracts = getattr(m, "_position_row_effective_contracts", lambda x: abs(float(x.get("contracts") or 0)))(p) + if contracts <= 0: + continue + coerce = getattr(m, "_coerce_float", None) + if coerce: + entry = coerce( + p.get("entryPrice"), + p.get("average"), + (p.get("info") or {}).get("entryPrice"), + ) + if entry: + break + except Exception: + pass + return {"contracts": normalize_contracts_qty(qty or 0), "entry_price": entry} + + def amount_to_precision(ex_sym, amount): + try: + return float(m.exchange.amount_to_precision(ex_sym, float(amount))) + except Exception: + return None + + def price_to_precision(ex_sym, price): + try: + return float(m.exchange.price_to_precision(ex_sym, float(price))) + except Exception: + return None + + def market_add(ex_sym, direction, amount, leverage): + return m.place_exchange_order(ex_sym, direction, amount, leverage, stop_loss=None, take_profit=None) + + def limit_add(ex_sym, direction, amount, price, leverage): + m.exchange.set_leverage(int(leverage), ex_sym) + side = "buy" if direction == "long" else "sell" + if hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=False) + elif hasattr(m, "build_binance_order_params"): + params = m.build_binance_order_params(direction, reduce_only=False) + elif hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=False) + else: + params = {} + return m.exchange.create_order( + ex_sym, "limit", side, float(amount), float(price), params if params is not None else {} + ) + + def replace_tpsl(ex_sym, direction, sl, tp, order_row): + row = order_row or {"symbol": ex_sym, "exchange_symbol": ex_sym, "direction": direction} + m.replace_active_monitor_tpsl_on_exchange(row, sl, tp) + + def count_trends(conn): + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + def friendly_error(err): + fn = getattr(m, "friendly_exchange_error", None) or getattr( + m, "friendly_okx_error", None + ) + if not callable(fn): + return str(err) + try: + snap = m.get_available_trading_usdt() + except Exception: + snap = None + try: + return fn(err, available_usdt=snap) + except TypeError: + return fn(err) + + def limit_order_status(ex_sym, order_id): + fn = getattr(m, "fib_limit_order_status", None) + if callable(fn): + return fn(ex_sym, order_id) + return "unknown" + + def cancel_limit_order(ex_sym, order_id): + fn = getattr(m, "cancel_fib_limit_order", None) + if callable(fn): + try: + return fn(ex_sym, order_id) + except Exception: + pass + if not order_id: + return False + try: + m.exchange.cancel_order(str(order_id), ex_sym) + return True + except Exception: + return False + + def get_mark_price(symbol): + fn = getattr(m, "get_symbol_mark_price", None) or getattr(m, "get_price", None) + if not callable(fn): + return None + try: + return fn(symbol) + except Exception: + return None + + def wechat_account_label(): + fn = getattr(m, "_wechat_account_label", None) + if callable(fn): + try: + return fn() + except Exception: + pass + return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or "" + + def wechat_direction_text(direction): + fn = getattr(m, "_wechat_direction_text", None) + if callable(fn): + try: + return fn(direction) + except Exception: + pass + d = (direction or "long").strip().lower() + return "做多" if d == "long" else "做空" + + def send_wechat(content): + fn = getattr(m, "send_wechat_msg", None) + if callable(fn): + fn(content) + + note = trend_disabled_note or ( + "趋势回调(自动补仓)请在 Gate机器人实例使用:/strategy/trend" + ) + return { + "app_module": m, + "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), + "trend_enabled": trend_enabled, + "trend_disabled_note": note, + "login_required": m.login_required, + "get_db": m.get_db, + "normalize_symbol_input": m.normalize_symbol_input, + "normalize_exchange_symbol": m.normalize_exchange_symbol, + "get_price": m.get_price, + "get_trading_capital_usdt": get_trading_capital_usdt, + "get_position": get_position, + "amount_to_precision": amount_to_precision, + "price_to_precision": price_to_precision, + "market_add": market_add, + "limit_add": limit_add, + "replace_tpsl": replace_tpsl, + "ensure_live_ready": m.ensure_exchange_live_ready, + "default_risk_percent": float(getattr(m, "RISK_PERCENT", 2)), + "default_leverage": m.infer_leverage, + "friendly_error": friendly_error, + "app_now_str": m.app_now_str, + "resolve_fill_price": m.resolve_order_entry_price, + "price_fmt": m.format_price_for_symbol, + "count_active_trend_plans": count_trends if trend_enabled else count_trends, + "limit_order_status": limit_order_status, + "cancel_limit_order": cancel_limit_order, + "get_mark_price": get_mark_price, + "send_wechat": send_wechat, + "format_price": getattr(m, "format_price_for_symbol", None), + "wechat_account_label": wechat_account_label, + "wechat_direction_text": wechat_direction_text, + } diff --git a/lib/strategy/strategy_db.py b/lib/strategy/strategy_db.py new file mode 100644 index 0000000..8ab3b49 --- /dev/null +++ b/lib/strategy/strategy_db.py @@ -0,0 +1,164 @@ +"""策略交易相关表结构(各所 crypto.db 共用 schema).""" + +ROLL_GROUPS_SQL = """ +CREATE TABLE IF NOT EXISTS roll_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_monitor_id INTEGER, + symbol TEXT NOT NULL, + exchange_symbol TEXT, + direction TEXT NOT NULL, + initial_take_profit REAL, + initial_stop_loss REAL, + current_stop_loss REAL, + risk_percent REAL DEFAULT 2, + leg_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'active', + created_at TEXT, + updated_at TEXT +) +""" + +ROLL_LEGS_SQL = """ +CREATE TABLE IF NOT EXISTS roll_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + roll_group_id INTEGER NOT NULL, + leg_index INTEGER NOT NULL, + add_mode TEXT NOT NULL, + fib_upper REAL, + fib_lower REAL, + limit_price REAL, + fill_price REAL, + amount REAL, + new_stop_loss REAL, + exchange_order_id TEXT, + status TEXT DEFAULT 'filled', + created_at TEXT, + FOREIGN KEY (roll_group_id) REFERENCES roll_groups(id) +) +""" + +TREND_PLANS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT DEFAULT 'active', + symbol TEXT NOT NULL, + exchange_symbol TEXT, + direction TEXT NOT NULL DEFAULT 'long', + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL DEFAULT 5, + snapshot_available_usdt REAL, + snapshot_at TEXT, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER DEFAULT 5, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + legs_done INTEGER DEFAULT 0, + first_order_done INTEGER DEFAULT 0, + last_mark_price REAL, + avg_entry_price REAL, + order_amount_open REAL, + opened_at TEXT, + opened_at_ms INTEGER, + session_date TEXT, + message TEXT, + initial_stop_loss REAL, + breakeven_applied INTEGER DEFAULT 0, + breakeven_applied_at TEXT +) +""" + +TREND_PREVIEWS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_previews ( + id TEXT PRIMARY KEY, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + created_at TEXT +) +""" + +TREND_PREVIEW_SNAPSHOTS_SQL = """ +CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + preview_id TEXT NOT NULL UNIQUE, + symbol TEXT NOT NULL, + exchange_symbol TEXT NOT NULL, + direction TEXT NOT NULL, + leverage INTEGER NOT NULL, + stop_loss REAL NOT NULL, + add_upper REAL NOT NULL, + take_profit REAL NOT NULL, + risk_percent REAL NOT NULL, + snapshot_available_usdt REAL NOT NULL, + snapshot_at TEXT, + live_price_ref REAL, + plan_margin_capital REAL, + target_order_amount REAL, + first_order_amount REAL, + remainder_total REAL, + dca_legs INTEGER, + per_leg_amount REAL, + grid_prices_json TEXT, + leg_amounts_json TEXT, + expires_at_ms INTEGER NOT NULL, + preview_created_at TEXT, + outcome TEXT DEFAULT 'open', + executed_plan_id INTEGER +) +""" + + +def init_strategy_tables(conn) -> None: + from lib.strategy.strategy_snapshot_lib import init_strategy_snapshot_table + + conn.execute(ROLL_GROUPS_SQL) + conn.execute(ROLL_LEGS_SQL) + conn.execute(TREND_PLANS_SQL) + conn.execute(TREND_PREVIEWS_SQL) + conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL) + init_strategy_snapshot_table(conn) + for ddl in ( + "ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT", + "ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'", + "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER", + "ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER", + "ALTER TABLE order_monitors ADD COLUMN trend_plan_id INTEGER", + "ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT", + "ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT", + "ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT", + "ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL", + "ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL", + "ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL", + ): + try: + conn.execute(ddl) + except Exception: + pass diff --git a/lib/strategy/strategy_exchange_base.py b/lib/strategy/strategy_exchange_base.py new file mode 100644 index 0000000..d2c4754 --- /dev/null +++ b/lib/strategy/strategy_exchange_base.py @@ -0,0 +1,48 @@ +"""交易所策略适配器接口(各所 app 注入 ccxt 实现).""" +from __future__ import annotations + +from typing import Any, Optional, Protocol + + +class StrategyExchangeAdapter(Protocol): + exchange_key: str + + def normalize_symbol(self, raw: str) -> str: ... + + def normalize_exchange_symbol(self, symbol: str) -> str: ... + + def get_mark_price(self, symbol: str) -> Optional[float]: ... + + def get_position(self, exchange_symbol: str, direction: str) -> dict[str, Any]: + """返回 {contracts, entry_price, leverage?}.""" + ... + + def amount_to_precision(self, exchange_symbol: str, amount: float) -> Optional[float]: ... + + def price_to_precision(self, exchange_symbol: str, price: float) -> Optional[float]: ... + + def market_add( + self, exchange_symbol: str, direction: str, amount: float, leverage: int + ) -> dict[str, Any]: ... + + def limit_add( + self, + exchange_symbol: str, + direction: str, + amount: float, + price: float, + leverage: int, + ) -> dict[str, Any]: ... + + def cancel_order(self, exchange_symbol: str, order_id: str) -> None: ... + + def replace_position_tpsl( + self, + exchange_symbol: str, + direction: str, + stop_loss: float, + take_profit: float, + order_monitor_row: Any = None, + ) -> None: ... + + def ensure_live_ready(self) -> tuple[bool, str]: ... diff --git a/lib/strategy/strategy_exchange_binance.py b/lib/strategy/strategy_exchange_binance.py new file mode 100644 index 0000000..4951aca --- /dev/null +++ b/lib/strategy/strategy_exchange_binance.py @@ -0,0 +1,4 @@ +"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config).""" +from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter + +__all__ = ["StrategyExchangeAdapter"] diff --git a/lib/strategy/strategy_exchange_gate.py b/lib/strategy/strategy_exchange_gate.py new file mode 100644 index 0000000..67ea792 --- /dev/null +++ b/lib/strategy/strategy_exchange_gate.py @@ -0,0 +1,9 @@ +""" +Gate.io USDT 永续 — 策略交易交易所侧能力. + +实现方式:各 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入 +ccxt 下单,精度,换 TP/SL;本文件为文档与类型锚点,避免在各 app 重复实现滚仓公式. +""" +from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter + +__all__ = ["StrategyExchangeAdapter"] diff --git a/lib/strategy/strategy_exchange_okx.py b/lib/strategy/strategy_exchange_okx.py new file mode 100644 index 0000000..5ea0963 --- /dev/null +++ b/lib/strategy/strategy_exchange_okx.py @@ -0,0 +1,4 @@ +"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config).""" +from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter + +__all__ = ["StrategyExchangeAdapter"] diff --git a/lib/strategy/strategy_records_register.py b/lib/strategy/strategy_records_register.py new file mode 100644 index 0000000..ed2b45a --- /dev/null +++ b/lib/strategy/strategy_records_register.py @@ -0,0 +1,72 @@ +"""策略交易记录页:已结束趋势 / 顺势加仓快照(三所统一).""" +from __future__ import annotations + +import json +from typing import Any + +from flask import flash, redirect, url_for + +from lib.strategy.strategy_snapshot_lib import ( + STRATEGY_SNAPSHOTS_MAX_ROWS, + dedupe_strategy_snapshots, + list_strategy_snapshots_split, +) + + +def load_strategy_records_page( + conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS +) -> dict[str, Any]: + try: + if dedupe_strategy_snapshots(conn): + conn.commit() + except Exception: + pass + trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit) + return { + "strategy_trend_records": trend, + "strategy_roll_records": roll, + "strategy_record_symbols": symbols, + "strategy_records_limit": limit, + "strategy_snapshots": trend + roll, + } + + +def register_strategy_records(app, cfg: dict[str, Any]) -> None: + login_required = cfg["login_required"] + get_db = cfg["get_db"] + + def _lr(f): + return login_required(f) + + @_lr + @app.route("/strategy/records") + def strategy_records_page(): + m = cfg.get("app_module") + fn = getattr(m, "render_main_page", None) + if not callable(fn): + flash("render_main_page 未配置") + return redirect(url_for("strategy_trading_page")) + return fn("strategy_records") + + @_lr + @app.route("/strategy/records/") + def strategy_records_detail(snap_id: int): + conn = get_db() + row = conn.execute( + "SELECT * FROM strategy_trade_snapshots WHERE id=?", + (int(snap_id),), + ).fetchone() + conn.close() + if not row: + flash("未找到该策略快照") + return redirect(url_for("strategy_records_page")) + try: + snap = json.loads(row["snapshot_json"] or "{}") + except Exception: + snap = {} + dca = snap.get("dca_levels") or [] + flash( + f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} " + f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)" + ) + return redirect(url_for("strategy_records_page")) diff --git a/lib/strategy/strategy_register.py b/lib/strategy/strategy_register.py new file mode 100644 index 0000000..00575fa --- /dev/null +++ b/lib/strategy/strategy_register.py @@ -0,0 +1,654 @@ +"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页).逻辑在 strategy_*_lib.""" +from __future__ import annotations + +from lib.paths import strategy_templates_dir + +import html as html_module +import os +import re +from typing import Any, Optional + +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll +from lib.strategy.strategy_roll_monitor_lib import ( + cancel_roll_pending_leg, + count_filled_roll_legs, + count_pending_roll_legs, + sync_roll_after_external_close, +) + + +def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None: + """启动时清理历史重复快照(同计划同结果仅保留最新一条).""" + get_db = cfg.get("get_db") + if not callable(get_db): + return + try: + from lib.strategy.strategy_snapshot_lib import dedupe_strategy_snapshots + + conn = get_db() + try: + removed = dedupe_strategy_snapshots(conn) + if removed: + conn.commit() + print( + f"[strategy] deduped {removed} duplicate strategy_trade_snapshots", + flush=True, + ) + finally: + conn.close() + except Exception as e: + print(f"[strategy] snapshot dedupe skipped: {e}", flush=True) + + +def install_strategy_trading(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> None: + """在 app.py 末尾调用(login_required 已定义后).仅注册 POST API;页面由各 app 的 render_main_page 渲染.""" + from lib.strategy.strategy_config import build_strategy_config + + build_kw.pop("render_trend_page", None) + attach_strategy_templates(app, repo_root) + cfg = build_strategy_config(app_module, **build_kw) + register_strategy_trading(app, cfg) + from lib.strategy.strategy_records_register import register_strategy_records + + register_strategy_records(app, cfg) + app.extensions["strategy_roll_cfg"] = cfg + _dedupe_strategy_snapshots_on_startup(cfg) + + +def attach_strategy_templates(app: Flask, repo_root: str) -> None: + strat_dir = strategy_templates_dir(repo_root) + if not os.path.isdir(strat_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(strat_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_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None: + """cfg 由各市面 app 注入回调(仅 API / DB 差异).""" + + login_required = cfg["login_required"] + + def _lr(f): + return login_required(f) + + @_lr + @app.route("/strategy/roll/preview", methods=["POST"]) + def strategy_roll_preview(): + data = request.get_json(silent=True) or request.form + err = _roll_preview_response(cfg, data, json_mode=request.is_json) + if request.is_json: + return jsonify(err) + if err.get("ok"): + p = err["preview"] + flash( + f"预览:约 {p.get('add_amount_display', '-')} 张," + f"合并均价 {p.get('avg_entry_after', '-')}," + f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U" + ) + else: + flash(err.get("msg") or "预览失败") + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/execute", methods=["POST"]) + def strategy_roll_execute(): + data = request.form + try: + ok, msg = _roll_execute(cfg, data) + except Exception as e: + fe = cfg.get("friendly_error") + msg = fe(e) if callable(fe) else str(e) + ok = False + flash(msg) + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/cancel/", methods=["POST"]) + def strategy_roll_cancel_leg(leg_id: int): + conn = cfg["get_db"]() + try: + init_strategy_tables(conn) + ok, msg = cancel_roll_pending_leg(cfg, conn, leg_id) + finally: + conn.close() + if request.is_json: + return jsonify({"ok": ok, "msg": msg}) + flash(msg) + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/docs") + def strategy_roll_docs(): + path = _resolve_roll_doc_path() + if not path: + flash("滚仓说明文档不存在") + return redirect(url_for("strategy_trading_page")) + with open(path, encoding="utf-8") as f: + raw = f.read() + return render_template( + "strategy_roll_docs.html", + doc_html=_roll_doc_markdown_to_html(raw), + exchange_display=cfg.get("exchange_display") or "", + ) + + +def _resolve_roll_doc_path() -> str | None: + """滚仓说明 md:优先包内,否则仓库根目录(顺势加仓滚仓说明.md).""" + here = os.path.dirname(os.path.abspath(__file__)) + name = "顺势加仓滚仓说明.md" + for path in ( + os.path.join(here, name), + os.path.normpath(os.path.join(here, "..", "..", name)), + ): + if os.path.isfile(path): + return path + return None + + +def _roll_doc_markdown_to_html(text: str) -> str: + """轻量 Markdown → HTML(仅供滚仓说明页).""" + lines = text.splitlines() + out: list[str] = [] + i = 0 + in_code = False + code_buf: list[str] = [] + + def flush_code() -> None: + nonlocal code_buf + if code_buf: + out.append( + "
        "
        +                + html_module.escape("\n".join(code_buf))
        +                + "
        " + ) + code_buf = [] + + def inline_md(s: str) -> str: + s = html_module.escape(s) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s + + while i < len(lines): + line = lines[i] + if line.strip().startswith("```"): + if in_code: + in_code = False + flush_code() + else: + in_code = True + i += 1 + continue + if in_code: + code_buf.append(line) + i += 1 + continue + if line.startswith("# "): + out.append(f"

        {inline_md(line[2:].strip())}

        ") + elif line.startswith("## "): + out.append(f"

        {inline_md(line[3:].strip())}

        ") + elif line.startswith("### "): + out.append(f"

        {inline_md(line[4:].strip())}

        ") + elif line.strip() == "---": + out.append("
        ") + elif line.startswith("|") and "|" in line[1:]: + rows: list[str] = [] + while i < len(lines) and lines[i].startswith("|"): + rows.append(lines[i]) + i += 1 + if len(rows) >= 2 and re.match(r"^\|[\s\-:|]+\|$", rows[1].strip()): + out.append("") + hdr = [c.strip() for c in rows[0].strip("|").split("|")] + out.append("" + "".join(f"" for c in hdr) + "") + for row in rows[2:]: + cells = [c.strip() for c in row.strip("|").split("|")] + out.append("" + "".join(f"" for c in cells) + "") + out.append("
        {inline_md(c)}
        {inline_md(c)}
        ") + continue + elif re.match(r"^[-*]\s+", line): + out.append("
          ") + while i < len(lines) and re.match(r"^[-*]\s+", lines[i]): + item = re.sub(r"^[-*]\s+", "", lines[i]) + out.append(f"
        • {inline_md(item)}
        • ") + i += 1 + out.append("
        ") + continue + elif line.strip(): + out.append(f"

        {inline_md(line.strip())}

        ") + i += 1 + flush_code() + return "\n".join(out) + + +def _row_to_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _count_active_trends(conn, cfg: dict) -> int: + fn = cfg.get("count_active_trend_plans") + if callable(fn): + return int(fn(conn) or 0) + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + +def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]: + try: + rp = float(mon.get("risk_percent") or cfg.get("default_risk_percent", 2)) + except (TypeError, ValueError): + return None, "监控单风险%无效" + if rp <= 0: + return None, "监控单风险%须大于0" + return rp, None + + +def _contract_size(cfg: dict, ex_sym: str) -> float: + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + return float(get_cs(ex_sym) or 1.0) + except Exception: + pass + return 1.0 + + +def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]: + m = cfg.get("app_module") + if m is not None: + try: + from lib.trade.position_sizing_lib import OPEN_SOURCE_ROLL, assert_open_source_allowed + + mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" + ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL) + if not ok_src: + return None, src_msg + except Exception: + pass + get_db = cfg["get_db"] + symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") + if not symbol: + return None, "请选择或填写币种" + direction = (data.get("direction") or "long").strip().lower() + validate_fn = getattr(m, "validate_trade_policy_open", None) if m is not None else None + if callable(validate_fn): + ok_pol, pol_msg = validate_fn(symbol, direction) + if not ok_pol: + return None, pol_msg + ex_sym = cfg["normalize_exchange_symbol"](symbol) + conn = get_db() + init_strategy_tables(conn) + if _count_active_trends(conn, cfg) > 0: + conn.close() + return None, "存在运行中的趋势回调计划,请先结束后再滚仓" + mon = _get_active_monitor(conn, cfg, symbol, direction) + if not mon: + conn.close() + return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓" + rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) + if pending > 0: + conn.close() + return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交" + conn_cap = get_db() + try: + capital = float(cfg["get_trading_capital_usdt"](conn_cap)) + finally: + conn_cap.close() + risk_pct, risk_err = _risk_from_monitor(mon, cfg) + if risk_err: + conn.close() + return None, risk_err + pos = cfg["get_position"](ex_sym, direction) + qty = float(pos.get("contracts") or 0) + if qty <= 0: + conn.close() + return None, "交易所无该方向持仓,无法滚仓" + entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) + if entry <= 0: + conn.close() + return None, "无法获取持仓均价" + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + mark = mark_fn(symbol) if callable(mark_fn) else cfg["get_price"](symbol) + ctx = { + "conn": conn, + "mon": mon, + "rg": rg, + "legs_done": legs_done, + "symbol": symbol, + "direction": direction, + "ex_sym": ex_sym, + "qty": qty, + "entry": entry, + "mark": float(mark) if mark else None, + "capital": capital, + "risk_pct": float(risk_pct), + "tp0": float(mon.get("take_profit") or rg.get("initial_take_profit") or 0), + "contract_size": _contract_size(cfg, ex_sym), + } + return ctx, None + + +def _parse_roll_form(data: dict, ctx: dict) -> tuple[Optional[dict], Optional[str]]: + add_mode = (data.get("add_mode") or MARKET_MODE).strip().lower() + raw_sl = data.get("new_stop_loss") or data.get("sl") + if raw_sl in (None, ""): + return None, "请填写新止损价" + try: + new_sl = float(raw_sl) + except (TypeError, ValueError): + return None, "止损价格式错误" + if new_sl <= 0: + return None, "止损价须大于0" + fib_u = fib_l = bp = None + try: + if data.get("fib_upper") not in (None, ""): + fib_u = float(data.get("fib_upper")) + if data.get("fib_lower") not in (None, ""): + fib_l = float(data.get("fib_lower")) + if data.get("breakthrough_price") not in (None, ""): + bp = float(data.get("breakthrough_price")) + except (TypeError, ValueError): + return None, "价格参数格式错误" + + add_price = ctx.get("mark") + if add_mode == MARKET_MODE: + if add_price is None or add_price <= 0: + return None, "无法获取市价快照" + elif add_mode in FIB_MODES: + if fib_u is None or fib_l is None: + return None, "斐波须填写上沿 H 与下沿 L" + elif add_mode == BREAKOUT_MODE: + if bp is None: + return None, "突破加仓须填写突破价" + add_price = ctx.get("mark") + else: + return None, "加仓方式无效" + + return { + "add_mode": add_mode, + "new_stop_loss": new_sl, + "fib_upper": fib_u, + "fib_lower": fib_l, + "breakthrough_price": bp, + "add_price": add_price, + }, None + + +def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: + ctx, err = _roll_context(cfg, data) + if err: + return {"ok": False, "msg": err} + parsed, perr = _parse_roll_form(data, ctx) + if perr: + ctx["conn"].close() + return {"ok": False, "msg": perr} + conn = ctx["conn"] + try: + preview, perr2 = preview_roll( + direction=ctx["direction"], + symbol=ctx["symbol"], + qty_existing=ctx["qty"], + entry_existing=ctx["entry"], + initial_take_profit=ctx["tp0"], + add_mode=parsed["add_mode"], + new_stop_loss=parsed["new_stop_loss"], + risk_percent=ctx["risk_pct"], + capital_base_usdt=ctx["capital"], + add_price=parsed["add_price"], + fib_upper=parsed["fib_upper"], + fib_lower=parsed["fib_lower"], + breakthrough_price=parsed["breakthrough_price"], + legs_done=ctx["legs_done"], + contract_size=ctx["contract_size"], + ) + finally: + conn.close() + if perr2: + return {"ok": False, "msg": perr2} + amt_raw = float(preview["add_amount_raw"]) + amt_p = cfg["amount_to_precision"](ctx["ex_sym"], amt_raw) + preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw + preview["risk_display"] = f"{ctx['risk_pct']:g}%≈{ctx['capital'] * ctx['risk_pct'] / 100:.2f}U" + price_fmt = cfg.get("price_fmt") + if callable(price_fmt): + preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"]) + preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"]) + preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"]) + return {"ok": True, "preview": preview} + + +def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: + get_db = cfg["get_db"] + conn = None + try: + ok_live, reason = cfg["ensure_live_ready"]() + if not ok_live: + return False, reason or "实盘未就绪" + prev = _roll_preview_response(cfg, data) + if not prev.get("ok"): + return False, prev.get("msg") or "预览失败" + preview = prev["preview"] + symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") + direction = preview["direction"] + ex_sym = cfg["normalize_exchange_symbol"](symbol) + add_mode = preview["add_mode"] + new_sl = float(preview["new_stop_loss"]) + tp0 = float(preview["initial_take_profit"]) + lev_fn = cfg.get("default_leverage") + if not callable(lev_fn): + lev_fn = lambda _s: 5 + leverage = int(data.get("leverage") or 0) or int(lev_fn(symbol)) + conn = get_db() + init_strategy_tables(conn) + mon = _get_active_monitor(conn, cfg, symbol, direction) + if not mon: + return False, "监控单已不存在" + rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) + if pending > 0: + return False, "已有监控中的滚仓腿,请先删除或等待结束" + if add_mode == MARKET_MODE: + amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"])) + if amount is None or amount <= 0: + return False, "加仓张数低于交易所最小精度" + order = cfg["market_add"](ex_sym, direction, amount, leverage) + fill = float( + cfg.get("resolve_fill_price", lambda o, s, p: p)( + order, ex_sym, preview["add_price"] + ) + or preview["add_price"] + ) + oid = str(order.get("id") or "") if isinstance(order, dict) else "" + cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon) + conn.execute( + """INSERT INTO roll_legs ( + roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, + breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id, + status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + rg["id"], + legs_done + 1, + preview["add_mode_label"], + preview.get("fib_upper"), + preview.get("fib_lower"), + None, + preview.get("breakthrough_price"), + fill, + amount, + new_sl, + oid, + "filled", + cfg["app_now_str"](), + ), + ) + conn.execute( + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (legs_done + 1, new_sl, cfg["app_now_str"](), rg["id"]), + ) + live_qty = float(mon.get("order_amount") or 0) + float(amount) + try: + from lib.hub.hub_position_metrics import contracts_qty_is_open, normalize_contracts_qty + + pos2 = cfg["get_position"](ex_sym, direction) or {} + q2 = normalize_contracts_qty(pos2.get("contracts") or 0) + if contracts_qty_is_open(q2): + live_qty = q2 + else: + live_qty = normalize_contracts_qty(live_qty) + except Exception: + from lib.hub.hub_position_metrics import normalize_contracts_qty + + live_qty = normalize_contracts_qty(live_qty) + conn.execute( + """UPDATE order_monitors SET stop_loss=?, order_amount=?, + breakeven_armed=0, breakeven_price=NULL WHERE id=?""", + (new_sl, live_qty, mon["id"]), + ) + conn.commit() + _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) + return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓" + # 程序监控:斐波 / 突破 + limit_px = None + if add_mode in FIB_MODES: + px_fn = cfg.get("price_to_precision") + limit_px = float(preview["add_price"]) + if callable(px_fn): + limit_px = float(px_fn(ex_sym, limit_px) or limit_px) + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"] + conn.execute( + """INSERT INTO roll_legs ( + roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, + breakthrough_price, new_stop_loss, last_mark_price, status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ( + rg["id"], + legs_done + 1, + preview["add_mode_label"], + preview.get("fib_upper"), + preview.get("fib_lower"), + limit_px, + preview.get("breakthrough_price"), + new_sl, + last_mark, + "pending", + cfg["app_now_str"](), + ), + ) + conn.commit() + _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) + return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损" + except Exception as e: + fe = cfg.get("friendly_error") + return False, fe(e) if callable(fe) else str(e) + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + +def _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, *, roll_is_new: bool) -> None: + if not roll_is_new: + return + try: + from lib.strategy.strategy_wechat_notify import notify_roll_group_started + + notify_roll_group_started( + cfg, + group_id=int(rg["id"]), + symbol=symbol, + direction=direction, + order_monitor_id=int(mon["id"]), + initial_take_profit=tp0, + initial_stop_loss=float(mon.get("stop_loss") or new_sl), + ) + except Exception: + pass + + +def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]: + row = conn.execute( + "SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1", + (symbol, direction), + ).fetchone() + return _row_to_dict(row) if row else None + + +def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, int, bool]: + """返回 (roll_group, filled_legs, pending_legs, is_new_group).""" + row = conn.execute( + "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1", + (mon["id"],), + ).fetchone() + if row: + d = _row_to_dict(row) + gid = int(d["id"]) + filled = count_filled_roll_legs(conn, gid) + pending = count_pending_roll_legs(conn, gid) + return d, filled, pending, False + now = mon.get("created_at") or "" + cur = conn.execute( + """INSERT INTO roll_groups ( + order_monitor_id, symbol, exchange_symbol, direction, + initial_take_profit, initial_stop_loss, current_stop_loss, + risk_percent, leg_count, status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + mon["id"], + mon["symbol"], + mon.get("exchange_symbol"), + mon["direction"], + mon.get("take_profit"), + mon.get("stop_loss"), + mon.get("stop_loss"), + mon.get("risk_percent") or 2, + 0, + "active", + now, + now, + ), + ) + gid = int(cur.lastrowid) + return ( + { + "id": gid, + "leg_count": 0, + "initial_take_profit": mon.get("take_profit"), + "initial_stop_loss": mon.get("stop_loss"), + "symbol": mon.get("symbol"), + "direction": mon.get("direction"), + }, + 0, + 0, + True, + ) + + +def roll_sync_after_external_close(cfg: dict, conn, symbol: str, direction: str) -> dict: + """供 hub / del_order 调用的滚仓同步入口.""" + return sync_roll_after_external_close( + cfg, conn, symbol, direction, reason="手动平仓,滚仓监控已结束" + ) + diff --git a/lib/strategy/strategy_roll_lib.py b/lib/strategy/strategy_roll_lib.py new file mode 100644 index 0000000..1979e64 --- /dev/null +++ b/lib/strategy/strategy_roll_lib.py @@ -0,0 +1,392 @@ +"""顺势加仓(滚仓):纯计算.人工触发;止盈锁定首仓;程序监控触价市价成交.""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from lib.key_monitor.fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark + +ROLL_MAX_LEGS_LONG = 3 +ROLL_MAX_LEGS_SHORT = 3 + +MARKET_MODE = "market" +FIB_MODES = frozenset({"fib_618", "fib_786"}) +BREAKOUT_MODE = "breakout" + +MODE_LABELS = { + MARKET_MODE: "市价加仓", + "fib_618": "斐波0.618", + "fib_786": "斐波0.786", + BREAKOUT_MODE: "突破加仓", +} + + +def fib_ratio_from_mode(mode: str) -> Optional[float]: + m = (mode or "").strip().lower() + if m in ("fib_618", "618", "0.618"): + return 0.618 + if m in ("fib_786", "786", "0.786"): + return 0.786 + return None + + +def mode_label(mode: str) -> str: + m = (mode or MARKET_MODE).strip().lower() + return MODE_LABELS.get(m, m) + + +def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]: + """H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧.""" + ratio = fib_ratio_from_mode(mode) + if ratio is None: + return None, "斐波档位无效" + h, l = float(upper), float(lower) + if h <= l: + return None, "上沿须大于下沿" + direction = (direction or "long").strip().lower() + if direction == "short": + plan = calc_fib_plan("short", h, l, ratio) + else: + plan = calc_fib_plan("long", h, l, ratio) + if not plan: + return None, "无法计算斐波限价" + entry, _sl, _tp = plan + return float(entry), None + + +def max_roll_legs(direction: str) -> int: + return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT + + +def avg_entry_after_add( + qty_existing: float, + entry_existing: float, + add_qty: float, + add_price: float, +) -> float: + q1 = float(qty_existing) + e1 = float(entry_existing) + q2 = float(add_qty) + e2 = float(add_price) + total = q1 + q2 + if total <= 0: + return 0.0 + return (q1 * e1 + q2 * e2) / total + + +def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float: + return float(capital_base_usdt) * (float(risk_percent) / 100.0) + + +def solve_add_amount_for_total_risk( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + new_stop: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """ + 合并持仓打到 new_stop 时总亏损 ≈ risk_budget(方案 C). + long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL) + short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2) + """ + try: + q1 = float(qty_existing) + e1 = float(entry_existing) + e2 = float(add_price) + sl = float(new_stop) + b = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0: + return None, "持仓或风险预算无效" + direction = (direction or "long").strip().lower() + if direction == "short": + denom = sl - e2 + numer = b / cs - q1 * (sl - e1) + if denom <= 0: + return None, "做空:新止损须高于加仓价" + else: + denom = e2 - sl + numer = b / cs - q1 * (e1 - sl) + if denom <= 0: + return None, "做多:新止损须低于加仓价" + q2 = numer / denom + if q2 <= 0: + return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)" + return q2, None + + +def loss_at_stop_usdt( + direction: str, + avg: float, + qty: float, + stop: float, + contract_size: float = 1.0, +) -> float: + cs = float(contract_size or 1.0) + direction = (direction or "long").strip().lower() + if direction == "short": + return (float(stop) - float(avg)) * float(qty) * cs + return (float(avg) - float(stop)) * float(qty) * cs + + +def reward_at_tp_usdt( + direction: str, + avg: float, + take_profit: float, + qty: float, + contract_size: float = 1.0, +) -> float: + cs = float(contract_size or 1.0) + direction = (direction or "long").strip().lower() + if direction == "short": + gross = (float(avg) - float(take_profit)) * float(qty) * cs + else: + gross = (float(take_profit) - float(avg)) * float(qty) * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, avg, take_profit, qty, cs) + return float(net) if net is not None else gross + except Exception: + return gross + + +def roll_fib_trigger_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + limit_price: float, +) -> bool: + """斐波:多=向下穿越限价;空=向上穿越限价.""" + try: + m = float(mark) + lv = float(limit_price) + pm = float(prev_mark) if prev_mark is not None else None + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + if pm is None: + return m <= lv + return pm > lv and m <= lv + if pm is None: + return m >= lv + return pm < lv and m >= lv + + +def roll_breakout_trigger_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + breakthrough_price: float, +) -> bool: + """突破:多=mark 在突破价之上;空=mark 在突破价之下. + + 提交时已校验 mark 在逆势侧(多低于突破价,空高于突破价),触价侧到达即成交. + 不再要求单 tick 内穿越,避免 mark 已破位但 last_mark 也落在突破价另一侧时永久漏触发. + """ + try: + m = float(mark) + bp = float(breakthrough_price) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + return m > bp + return m < bp + + +def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool: + """斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L).""" + return fib_invalidate_by_mark(direction, mark, upper, lower) + + +def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool: + """突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S).""" + try: + m = float(mark) + sl = float(stop_loss) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + return m <= sl + return m >= sl + + +def validate_roll_geometry( + direction: str, + add_mode: str, + *, + new_stop_loss: float, + add_price: Optional[float] = None, + fib_upper: Optional[float] = None, + fib_lower: Optional[float] = None, + breakthrough_price: Optional[float] = None, + entry_existing: float = 0.0, + initial_take_profit: float = 0.0, + mark_price: Optional[float] = None, +) -> Optional[str]: + direction = (direction or "long").strip().lower() + mode = (add_mode or MARKET_MODE).strip().lower() + try: + sl = float(new_stop_loss) + tp = float(initial_take_profit) + e1 = float(entry_existing or 0) + except (TypeError, ValueError): + return "止损/止盈格式错误" + if sl <= 0 or tp <= 0: + return "止损与首仓止盈须大于0" + if direction == "long": + if e1 > 0 and tp <= e1: + return "做多:首仓止盈须高于当前持仓均价" + else: + if e1 > 0 and tp >= e1: + return "做空:首仓止盈须低于当前持仓均价" + + if mode == MARKET_MODE: + if add_price is None or float(add_price) <= 0: + return "市价加仓需要有效参考价" + entry_add = float(add_price) + elif mode in FIB_MODES: + if fib_upper is None or fib_lower is None: + return "斐波须填写上沿 H 与下沿 L" + entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + if err: + return err + if entry_add is None or entry_add <= 0: + return "无法计算斐波限价" + elif mode == BREAKOUT_MODE: + if breakthrough_price is None: + return "突破加仓须填写突破价" + try: + bp = float(breakthrough_price) + except (TypeError, ValueError): + return "突破价格式错误" + if bp <= 0: + return "突破价须大于0" + entry_add = bp + if direction == "long": + if sl >= bp: + return "做多:止损须低于突破价" + if mark_price is not None and float(mark_price) >= bp: + return "做多:当前价须低于突破价(等待向上突破)" + else: + if sl <= bp: + return "做空:止损须高于突破价" + if mark_price is not None and float(mark_price) <= bp: + return "做空:当前价须高于突破价(等待向下跌破)" + else: + return "加仓方式无效" + + if mode != BREAKOUT_MODE: + entry_add = float(entry_add) # type: ignore[arg-type] + if direction == "long": + if sl >= entry_add: + return "做多:新止损须低于加仓价" + else: + if sl <= entry_add: + return "做空:新止损须高于加仓价" + return None + + +def preview_roll( + *, + direction: str, + symbol: str, + qty_existing: float, + entry_existing: float, + initial_take_profit: float, + add_mode: str, + new_stop_loss: Optional[float] = None, + risk_percent: float, + capital_base_usdt: float, + add_price: Optional[float] = None, + fib_upper: Optional[float] = None, + fib_lower: Optional[float] = None, + breakthrough_price: Optional[float] = None, + legs_done: int = 0, + contract_size: float = 1.0, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + direction = (direction or "long").strip().lower() + if legs_done >= max_roll_legs(direction): + return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限" + mode = (add_mode or MARKET_MODE).strip().lower() + if new_stop_loss is None: + return None, "请填写新止损价" + try: + sl = float(new_stop_loss) + except (TypeError, ValueError): + return None, "止损价格式错误" + if sl <= 0: + return None, "止损须大于0" + + geom_err = validate_roll_geometry( + direction, + mode, + new_stop_loss=sl, + add_price=add_price, + fib_upper=fib_upper, + fib_lower=fib_lower, + breakthrough_price=breakthrough_price, + entry_existing=entry_existing, + initial_take_profit=initial_take_profit, + mark_price=add_price if mode == BREAKOUT_MODE else add_price, + ) + if geom_err: + return None, geom_err + + if mode == MARKET_MODE: + entry_add = float(add_price) # validated + elif mode in FIB_MODES: + entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + entry_add = float(entry_add or 0) + else: + entry_add = float(breakthrough_price or 0) + + risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent) + q2_raw, err = solve_add_amount_for_total_risk( + direction, + qty_existing, + entry_existing, + entry_add, + sl, + risk_budget, + contract_size, + ) + if err: + return None, err + q2 = float(q2_raw) + new_qty = qty_existing + q2 + new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) + cs = float(contract_size or 1.0) + loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs) + reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs) + return { + "symbol": symbol, + "direction": direction, + "add_mode": mode, + "add_mode_label": mode_label(mode), + "add_price": round(entry_add, 10), + "new_stop_loss": round(sl, 10), + "breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None, + "initial_take_profit": float(initial_take_profit), + "risk_percent": float(risk_percent), + "risk_budget_usdt": round(risk_budget, 4), + "add_amount_raw": q2, + "qty_existing": float(qty_existing), + "entry_existing": float(entry_existing), + "qty_after": new_qty, + "avg_entry_after": round(new_avg, 10), + "loss_at_sl_usdt": round(loss_sl, 4), + "reward_at_tp_usdt": round(reward_tp, 4), + "legs_done": int(legs_done), + "leg_index_next": int(legs_done) + 1, + "fib_upper": fib_upper, + "fib_lower": fib_lower, + "contract_size": cs, + }, None diff --git a/lib/strategy/strategy_roll_monitor_lib.py b/lib/strategy/strategy_roll_monitor_lib.py new file mode 100644 index 0000000..6665ebc --- /dev/null +++ b/lib/strategy/strategy_roll_monitor_lib.py @@ -0,0 +1,562 @@ +"""滚仓程序监控:斐波/突破触价市价成交,失效,外部平仓同步(各所共用).""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.strategy.strategy_roll_lib import ( + BREAKOUT_MODE, + FIB_MODES, + MARKET_MODE, + mode_label, + roll_breakout_invalidate, + roll_breakout_trigger_crossed, + roll_fib_invalidate, + roll_fib_trigger_crossed, + calc_risk_budget_usdt, + max_roll_legs, + preview_roll, + solve_add_amount_for_total_risk, +) +from lib.strategy.strategy_db import init_strategy_tables + +ROLL_LEG_STATUS_LABELS = { + "pending": "监控中", + "filled": "已成交", + "cancelled": "已删除", + "invalidated": "已失效", +} + + +def roll_leg_status_label(status: Optional[str]) -> str: + s = (status or "").strip().lower() + return ROLL_LEG_STATUS_LABELS.get(s, status or "—") + + +def check_roll_monitors(cfg: dict[str, Any]) -> None: + get_db = cfg["get_db"] + conn = get_db() + try: + init_strategy_tables(conn) + _reconcile_roll_groups(conn, cfg) + _check_pending_roll_legs(conn, cfg) + conn.commit() + except Exception as e: + print(f"[roll_monitor] {e}", flush=True) + try: + conn.rollback() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + + +def sync_roll_after_external_close( + cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平" +) -> dict[str, Any]: + """中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史).""" + norm = cfg.get("normalize_symbol_input") + sym = norm(symbol) if callable(norm) else (symbol or "").strip() + if not sym: + return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0} + direction = (direction or "long").strip().lower() + init_strategy_tables(conn) + rows = conn.execute( + """SELECT g.* FROM roll_groups g + WHERE g.status='active' AND g.symbol=? AND g.direction=?""", + (sym, direction), + ).fetchall() + closed = cancelled = 0 + for row in rows: + g = _row_dict(row) + cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled") + cur = conn.execute( + "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", + (_now(cfg), int(g["id"])), + ) + if getattr(cur, "rowcount", 0): + closed += 1 + try: + from lib.strategy.strategy_wechat_notify import notify_roll_group_ended + + notify_roll_group_ended( + cfg, + group_id=int(g["id"]), + symbol=sym, + direction=direction, + reason=reason, + leg_count=int(g.get("leg_count") or 0), + ) + except Exception: + pass + try: + from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot + + save_roll_group_snapshot(cfg, conn, g, result_label="结束") + except Exception: + pass + return { + "ok": True, + "symbol": sym, + "direction": direction, + "closed_groups": closed, + "cancelled_legs": cancelled, + } + + +def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]: + """用户删除 pending 滚仓腿(不可修改,仅删除).""" + init_strategy_tables(conn) + row = conn.execute( + "SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l " + "INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?", + (int(leg_id),), + ).fetchone() + if not row: + return False, "滚仓腿不存在" + leg = _row_dict(row) + if (leg.get("status") or "").strip().lower() != "pending": + return False, "仅监控中的腿可删除" + _cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg) + conn.execute( + "UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'", + (int(leg_id),), + ) + conn.commit() + return True, "已删除滚仓监控" + + +def count_filled_roll_legs(conn, roll_group_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'", + (int(roll_group_id),), + ).fetchone() + return int(row[0] if row else 0) + + +def count_pending_roll_legs(conn, roll_group_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'", + (int(roll_group_id),), + ).fetchone() + return int(row[0] if row else 0) + + +def _row_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _now(cfg: dict) -> str: + fn = cfg.get("app_now_str") + return fn() if callable(fn) else "" + + +def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int: + gid = int(group["id"]) + n = 0 + for leg in conn.execute( + "SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'", + (gid,), + ).fetchall(): + ld = _row_dict(leg) + _cancel_roll_leg_order(cfg, group, ld) + conn.execute( + "UPDATE roll_legs SET status=? WHERE id=? AND status='pending'", + (status, ld["id"]), + ) + n += 1 + return n + + +def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None: + gid = int(group["id"]) + _cancel_pending_legs_for_group(conn, cfg, group, status="cancelled") + cur = conn.execute( + "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", + (_now(cfg), gid), + ) + if getattr(cur, "rowcount", 0): + try: + from lib.strategy.strategy_wechat_notify import notify_roll_group_ended + + notify_roll_group_ended( + cfg, + group_id=gid, + symbol=group.get("symbol") or "", + direction=group.get("direction") or "long", + reason=reason, + leg_count=int(group.get("leg_count") or 0), + ) + except Exception: + pass + try: + from lib.strategy.strategy_snapshot_lib import save_roll_group_snapshot + + save_roll_group_snapshot(cfg, conn, group, result_label="结束") + except Exception: + pass + + +def _reconcile_roll_groups(conn, cfg: dict) -> None: + from lib.hub.hub_position_metrics import contracts_qty_is_open, normalize_contracts_qty + + rows = conn.execute( + """SELECT g.*, m.status AS monitor_status + FROM roll_groups g + LEFT JOIN order_monitors m ON m.id = g.order_monitor_id + WHERE g.status='active'""" + ).fetchall() + for row in rows: + g = _row_dict(row) + symbol = g.get("symbol") or "" + direction = (g.get("direction") or "long").strip().lower() + ex_sym = g.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) + mon_ok = (row["monitor_status"] or "").strip().lower() == "active" + if not mon_ok: + _close_roll_group(conn, cfg, g, reason="下单监控已结案") + continue + pos = None + try: + pos = cfg["get_position"](ex_sym, direction) + except Exception: + pos = None + if pos is None: + continue + qty = normalize_contracts_qty(pos.get("contracts") or 0) + if not contracts_qty_is_open(qty): + try: + pos2 = cfg["get_position"](ex_sym, direction) or {} + qty = normalize_contracts_qty(pos2.get("contracts") or 0) + except Exception: + continue + if not contracts_qty_is_open(qty): + _close_roll_group(conn, cfg, g) + + +def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None: + oid = (leg.get("exchange_order_id") or "").strip() + if not oid: + return + symbol = group.get("symbol") or "" + ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) + cancel = cfg.get("cancel_limit_order") + if callable(cancel): + try: + cancel(ex_sym, oid) + except Exception: + pass + + +def _contract_size(cfg: dict, ex_sym: str) -> float: + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + return float(get_cs(ex_sym) or 1.0) + except Exception: + pass + return 1.0 + + +def _resolve_add_mode(leg: dict) -> str: + raw = (leg.get("add_mode") or "").strip().lower() + if raw in (MARKET_MODE, "market", "市价", "市价加仓"): + return MARKET_MODE + if "786" in raw or raw == "fib_786": + return "fib_786" + if "618" in raw or raw == "fib_618": + return "fib_618" + if raw in (BREAKOUT_MODE, "突破", "突破加仓"): + return BREAKOUT_MODE + if raw.startswith("fib"): + return raw.replace(".", "_").replace("0.", "0") + return raw or MARKET_MODE + + +def _check_pending_roll_legs(conn, cfg: dict) -> None: + rows = conn.execute( + """SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit, + g.order_monitor_id, g.risk_percent, g.leg_count + FROM roll_legs l + INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active' + WHERE l.status='pending'""" + ).fetchall() + for row in rows: + leg = _row_dict(row) + group = { + "id": leg["roll_group_id"], + "symbol": leg["symbol"], + "exchange_symbol": leg["exchange_symbol"], + "direction": leg["direction"], + "initial_take_profit": leg["initial_take_profit"], + "order_monitor_id": leg["order_monitor_id"], + "risk_percent": leg.get("risk_percent"), + "leg_count": leg.get("leg_count"), + } + _process_pending_roll_leg(conn, cfg, group, leg) + + +def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None: + symbol = group.get("symbol") or "" + direction = (group.get("direction") or "long").strip().lower() + ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + mark = mark_fn(symbol) if callable(mark_fn) else None + if mark is None: + return + mark_f = float(mark) + prev_mark = leg.get("last_mark_price") + try: + prev_f = float(prev_mark) if prev_mark not in (None, "") else None + except (TypeError, ValueError): + prev_f = None + + mode = _resolve_add_mode(leg) + sl = float(leg.get("new_stop_loss") or 0) + fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower") + bp = leg.get("breakthrough_price") + + if mode in FIB_MODES and fib_u is not None and fib_l is not None: + if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)): + _invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破") + return + elif mode == BREAKOUT_MODE and sl > 0: + if roll_breakout_invalidate(direction, mark_f, sl): + _invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破") + return + + triggered = False + if mode in FIB_MODES: + lp = leg.get("limit_price") + if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)): + triggered = True + elif mode == BREAKOUT_MODE and bp is not None: + if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)): + triggered = True + + conn.execute( + "UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'", + (mark_f, int(leg["id"])), + ) + + if triggered: + _execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f) + return + + +def _execute_pending_roll_leg( + conn, + cfg: dict, + group: dict, + leg: dict, + ex_sym: str, + direction: str, + mark: float, +) -> None: + from lib.hub.hub_position_metrics import contracts_qty_is_open, normalize_contracts_qty + + leg_id = int(leg["id"]) + gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"]) + mon_id = group.get("order_monitor_id") + mon = None + if mon_id: + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone() + mon = _row_dict(row) if row else None + if not mon or (mon.get("status") or "").strip().lower() != "active": + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效") + return + + pos = cfg["get_position"](ex_sym, direction) or {} + qty = normalize_contracts_qty(pos.get("contracts") or 0) + entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) + if not contracts_qty_is_open(qty) or entry <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓") + return + + filled = count_filled_roll_legs(conn, gid) + if filled >= max_roll_legs(direction): + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满") + return + + try: + risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2) + except (TypeError, ValueError): + risk_pct = 2.0 + conn_cap = cfg["get_db"]() + try: + capital = float(cfg["get_trading_capital_usdt"](conn_cap)) + finally: + conn_cap.close() + + cs = _contract_size(cfg, ex_sym) + sl = float(leg.get("new_stop_loss") or 0) + tp0 = float(group.get("initial_take_profit") or mon.get("take_profit") or 0) + mode = _resolve_add_mode(leg) + + q2_raw, err = solve_add_amount_for_total_risk( + direction, qty, entry, mark, sl, calc_risk_budget_usdt(capital, risk_pct), cs + ) + if err or q2_raw is None or float(q2_raw) <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason=err or "无法计算加仓张数") + return + + amount = cfg["amount_to_precision"](ex_sym, float(q2_raw)) + if amount is None or float(amount) <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="加仓张数低于交易所最小精度") + return + + lev_fn = cfg.get("default_leverage") + if not callable(lev_fn): + lev_fn = lambda _s: 5 + leverage = int(lev_fn(group.get("symbol") or "")) + + try: + order = cfg["market_add"](ex_sym, direction, float(amount), leverage) + fill = float( + cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, mark) or mark + ) + except Exception as e: + fe = cfg.get("friendly_error") + msg = fe(e) if callable(fe) else str(e) + _notify_roll_fail(cfg, group, leg, mark, msg) + return + + oid = str(order.get("id") or "") if isinstance(order, dict) else "" + try: + cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon) + except Exception as tpsl_err: + fe = cfg.get("friendly_error") + msg = fe(tpsl_err) if callable(fe) else str(tpsl_err) + conn.execute( + """UPDATE roll_legs SET status='error', exchange_order_id=?, fill_price=?, amount=? + WHERE id=? AND status='pending'""", + (oid, fill, float(amount), leg_id), + ) + _notify_roll_fail(cfg, group, leg, mark, f"加仓成交但止盈止损更新失败: {msg}") + return + + conn.execute( + """UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?, + new_stop_loss=? WHERE id=? AND status='pending'""", + (fill, float(amount), oid, sl, leg_id), + ) + conn.execute( + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (filled + 1, sl, _now(cfg), gid), + ) + live_qty = normalize_contracts_qty(qty + float(amount)) + try: + pos2 = cfg["get_position"](ex_sym, direction) or {} + q2 = normalize_contracts_qty(pos2.get("contracts") or 0) + if contracts_qty_is_open(q2): + live_qty = q2 + except Exception: + pass + conn.execute( + """UPDATE order_monitors SET stop_loss=?, order_amount=?, + breakeven_armed=0, breakeven_price=NULL + WHERE id=? AND status='active'""", + (sl, live_qty, mon["id"]), + ) + + notify = cfg.get("send_wechat") + if callable(notify): + sym = group.get("symbol") or "" + mode_lbl = leg.get("add_mode") or mode_label(mode) + fmt = cfg.get("format_price") + px_txt = fmt(sym, fill) if callable(fmt) else str(fill) + sl_txt = fmt(sym, sl) if callable(fmt) else str(sl) + acct = _wechat_account(cfg) + dir_txt = _wechat_dir(cfg, direction) + notify( + f"# ✅ {sym} 滚仓触价成交\n" + f"**账户:{acct}**\n" + f"- 方式:{mode_lbl}|{dir_txt}\n" + f"- 成交价:{px_txt}|张数:{amount}\n" + f"- 新止损:{sl_txt}(止盈仍为首仓)\n" + ) + + +def _invalidate_roll_leg( + conn, + cfg: dict, + group: dict, + leg: dict, + mark: float, + *, + reason: str = "", +) -> None: + leg_id = int(leg["id"]) + cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone() + if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"): + return + _cancel_roll_leg_order(cfg, group, leg) + conn.execute( + "UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'", + (leg_id,), + ) + _send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason) + + +def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None: + notify = cfg.get("send_wechat") + if not callable(notify): + return + sym = group.get("symbol") or "" + mode = leg.get("add_mode") or "滚仓" + acct = _wechat_account(cfg) + notify( + f"# ❌ {sym} 滚仓触价成交失败\n" + f"**账户:{acct}**\n" + f"- 方式:{mode}\n" + f"- 原因:{reason}\n" + ) + + +def _send_roll_invalidate_wechat( + cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = "" +) -> None: + notify = cfg.get("send_wechat") + if not callable(notify): + return + sym = group.get("symbol") or "" + direction = (group.get("direction") or "long").strip().lower() + mode = leg.get("add_mode") or "滚仓监控" + fmt = cfg.get("format_price") + mark_txt = fmt(sym, mark) if callable(fmt) else str(mark) + acct = _wechat_account(cfg) + dir_txt = _wechat_dir(cfg, direction) + detail = reason or "条件不满足" + notify( + f"# ⚠️ {sym} 滚仓监控失效\n" + f"**账户:{acct}**\n" + f"- 方式:{mode}|{dir_txt}\n" + f"- 标记价 {mark_txt}|{detail}\n" + f"- 本条监控已结案,可重新提交\n" + ) + + +def _wechat_account(cfg: dict) -> str: + fn = cfg.get("wechat_account_label") + if callable(fn): + try: + return str(fn()) + except Exception: + pass + return str(cfg.get("exchange_display") or "") + + +def _wechat_dir(cfg: dict, direction: str) -> str: + fn = cfg.get("wechat_direction_text") + if callable(fn): + try: + return str(fn(direction)) + except Exception: + pass + return "做多" if (direction or "long").strip().lower() == "long" else "做空" diff --git a/lib/strategy/strategy_roll_ui_lib.py b/lib/strategy/strategy_roll_ui_lib.py new file mode 100644 index 0000000..cef900f --- /dev/null +++ b/lib/strategy/strategy_roll_ui_lib.py @@ -0,0 +1,434 @@ +"""顺势加仓 UI:滚仓腿合并均价与止盈盈利展示(实例页 + 中控).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from flask import Flask + +FILLED_LEG_STATUSES = frozenset({"filled", "done", "complete"}) + + +def reward_at_tp_usdt( + direction: str, + avg_entry: float, + take_profit: float, + qty: float, + *, + contract_size: float = 1.0, +) -> Optional[float]: + """与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位净盈利(扣双边 taker 费).""" + try: + avg = float(avg_entry) + tp = float(take_profit) + q = float(qty) + cs = float(contract_size or 1.0) + except (TypeError, ValueError): + return None + if avg <= 0 or tp <= 0 or q <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + gross = (avg - tp) * q * cs + else: + gross = (tp - avg) * q * cs + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, avg, tp, q, cs) + return net + except Exception: + return gross + + +def leg_fill_price(leg: dict) -> Optional[float]: + if not isinstance(leg, dict): + return None + for key in ("fill_price", "limit_price"): + try: + v = float(leg.get(key) or 0) + if v > 0: + return v + except (TypeError, ValueError): + continue + return None + + +def leg_is_filled(leg: dict) -> bool: + st = str(leg.get("status") or "").strip().lower() + return st in FILLED_LEG_STATUSES + + +def infer_initial_position( + qty_live: float, + entry_live: float, + filled_legs: list[dict], + *, + monitor: dict | None = None, +) -> tuple[Optional[float], Optional[float]]: + """由当前持仓与各腿成交价反推首仓张数/均价.""" + try: + qty_live = float(qty_live) + entry_live = float(entry_live) + except (TypeError, ValueError): + qty_live = entry_live = 0.0 + from lib.hub.hub_position_metrics import normalize_contracts_qty + + qty_live = normalize_contracts_qty(qty_live) + legs = [ + lg + for lg in filled_legs or [] + if isinstance(lg, dict) and leg_is_filled(lg) and leg_fill_price(lg) and float(lg.get("amount") or 0) > 0 + ] + add_sum = sum(normalize_contracts_qty(lg.get("amount") or 0) for lg in legs) + leg_notional = sum( + normalize_contracts_qty(lg.get("amount") or 0) * float(leg_fill_price(lg) or 0) for lg in legs + ) + q0 = qty_live - add_sum + if q0 > 1e-12 and entry_live > 0 and qty_live > 0: + e0 = (entry_live * qty_live - leg_notional) / q0 + if e0 > 0: + return q0, e0 + mon = monitor if isinstance(monitor, dict) else {} + try: + trig = float(mon.get("trigger_price") or 0) + except (TypeError, ValueError): + trig = 0.0 + try: + mon_amt = float(mon.get("order_amount") or mon.get("amount") or 0) + except (TypeError, ValueError): + mon_amt = 0.0 + if trig > 0: + q_base = q0 if q0 > 1e-12 else (mon_amt if mon_amt > 0 else max(qty_live - add_sum, 0)) + if q_base > 0: + return q_base, trig + return None, None + + +def compute_roll_chain_metrics( + group: dict, + legs: list[dict], + *, + qty_live: Optional[float] = None, + entry_live: Optional[float] = None, + monitor: dict | None = None, + contract_size: float = 1.0, +) -> tuple[dict[Any, dict], dict]: + """ + 返回 (leg_metrics_by_id, group_metrics). + leg_metrics: leg id -> {avg_entry_after, reward_at_tp_usdt} + group_metrics: 最后一腿后的 {avg_entry, reward_at_tp_usdt} + """ + per_leg: dict[Any, dict] = {} + group_out: dict[str, Any] = { + "avg_entry": None, + "reward_at_tp_usdt": None, + "initial_qty": None, + "current_qty": None, + } + if not isinstance(group, dict): + return per_leg, group_out + direction = (group.get("direction") or "long").strip().lower() + try: + tp = float(group.get("initial_take_profit") or 0) + except (TypeError, ValueError): + tp = 0.0 + sorted_legs = sorted( + [lg for lg in legs or [] if isinstance(lg, dict)], + key=lambda x: int(x.get("leg_index") or 0), + ) + filled = [lg for lg in sorted_legs if leg_is_filled(lg)] + q0 = e0 = None + if qty_live is not None and entry_live is not None: + q0, e0 = infer_initial_position(float(qty_live), float(entry_live), filled, monitor=monitor) + if q0 is None or e0 is None: + return per_leg, group_out + qty = float(q0) + avg = float(e0) + group_out["initial_qty"] = round(qty, 2) + group_out["current_qty"] = round(qty, 2) + if tp > 0: + group_out["avg_entry"] = avg + group_out["reward_at_tp_usdt"] = reward_at_tp_usdt( + direction, avg, tp, qty, contract_size=contract_size + ) + for leg in sorted_legs: + if not leg_is_filled(leg): + continue + try: + amt = float(leg.get("amount") or 0) + except (TypeError, ValueError): + continue + px = leg_fill_price(leg) + if not px or amt <= 0: + continue + prev_qty = qty + qty = prev_qty + amt + avg = (prev_qty * avg + amt * px) / qty + reward = reward_at_tp_usdt(direction, avg, tp, qty, contract_size=contract_size) if tp > 0 else None + lid = leg.get("id") + if lid is None: + lid = f"{group.get('id')}|{leg.get('leg_index')}" + per_leg[lid] = { + "avg_entry_after": round(avg, 10), + "reward_at_tp_usdt": round(reward, 4) if reward is not None else None, + } + group_out["avg_entry"] = round(avg, 10) + group_out["reward_at_tp_usdt"] = round(reward, 4) if reward is not None else None + group_out["current_qty"] = round(qty, 2) + if qty_live is not None: + try: + live_qty = float(qty_live) + if live_qty > 0: + group_out["current_qty"] = round(live_qty, 2) + except (TypeError, ValueError): + pass + return per_leg, group_out + + +def _row_to_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _resolve_roll_live(cfg: dict, group: dict, monitor: dict | None) -> tuple[Optional[float], Optional[float], float]: + """读取交易所持仓张数,均价,contract_size.""" + m = cfg.get("app_module") + ex_sym = group.get("exchange_symbol") + sym = group.get("symbol") or "" + direction = (group.get("direction") or "long").strip().lower() + if not ex_sym and m is not None: + norm = getattr(m, "normalize_exchange_symbol", None) + if callable(norm): + try: + ex_sym = norm(sym) + except Exception: + ex_sym = sym + cs = 1.0 + get_cs = cfg.get("get_contract_size") + if not callable(get_cs) and m is not None: + get_cs = getattr(m, "get_contract_size", None) + if callable(get_cs): + try: + cs = float(get_cs(ex_sym or sym) or 1.0) + except Exception: + cs = 1.0 + get_pos = cfg.get("get_position") + if not callable(get_pos): + return None, None, cs + try: + pos = get_pos(ex_sym or sym, direction) or {} + from lib.hub.hub_position_metrics import normalize_contracts_qty + + qty = normalize_contracts_qty(pos.get("contracts") or 0) + entry = float(pos.get("entry_price") or 0) + if qty > 0 and entry > 0: + return qty, entry, cs + except Exception: + pass + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) if m else None + if callable(metrics_fn): + try: + met = metrics_fn(ex_sym or sym, direction) + if isinstance(met, dict): + qty = float(met.get("contracts") or met.get("size") or 0) + entry = float(met.get("entry_price") or 0) + if qty > 0 and entry > 0: + return qty, entry, cs + except Exception: + pass + if monitor: + try: + trig = float(monitor.get("trigger_price") or 0) + amt = float(monitor.get("order_amount") or monitor.get("amount") or 0) + if trig > 0 and amt > 0: + return amt, trig, cs + except (TypeError, ValueError): + pass + return None, None, cs + + +def enrich_roll_page_data(conn, page_data: dict, cfg: dict | None) -> dict: + """为 roll_groups / roll_legs 附加 avg_entry,reward_at_tp 展示字段.""" + if not isinstance(page_data, dict) or not cfg: + return page_data + groups = list(page_data.get("roll_groups") or []) + legs = list(page_data.get("roll_legs") or []) + if not groups: + return page_data + monitors_by_id: dict[int, dict] = {} + try: + for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall(): + od = _row_to_dict(row) + mid = od.get("id") + if mid is not None: + monitors_by_id[int(mid)] = od + except Exception: + pass + legs_by_gid: dict[int, list] = {} + for leg in legs: + if not isinstance(leg, dict): + continue + try: + gid = int(leg.get("roll_group_id")) + except (TypeError, ValueError): + continue + legs_by_gid.setdefault(gid, []).append(leg) + price_fmt = cfg.get("price_fmt") + for g in groups: + if not isinstance(g, dict) or g.get("id") is None: + continue + gid = int(g["id"]) + mon = monitors_by_id.get(int(g.get("order_monitor_id") or 0)) + qty, entry, cs = _resolve_roll_live(cfg, g, mon) + per_leg, group_metrics = compute_roll_chain_metrics( + g, + legs_by_gid.get(gid, []), + qty_live=qty, + entry_live=entry, + monitor=mon, + contract_size=cs, + ) + g["avg_entry"] = group_metrics.get("avg_entry") + g["reward_at_tp_usdt"] = group_metrics.get("reward_at_tp_usdt") + g["initial_qty"] = group_metrics.get("initial_qty") + g["current_qty"] = group_metrics.get("current_qty") + if callable(price_fmt) and g.get("avg_entry") is not None: + try: + g["avg_entry_display"] = price_fmt(g.get("symbol"), g["avg_entry"]) + except Exception: + pass + for leg in legs_by_gid.get(gid, []): + lid = leg.get("id") + if lid is None: + lid = f"{gid}|{leg.get('leg_index')}" + metrics = per_leg.get(lid) or per_leg.get(leg.get("id")) + if not metrics: + continue + leg["avg_entry_after"] = metrics.get("avg_entry_after") + leg["reward_at_tp_usdt"] = metrics.get("reward_at_tp_usdt") + if callable(price_fmt) and leg.get("avg_entry_after") is not None: + try: + leg["avg_entry_display"] = price_fmt(g.get("symbol"), leg["avg_entry_after"]) + except Exception: + pass + page_data["roll_groups"] = groups + page_data["roll_legs"] = legs + return page_data + + +def enrich_roll_groups_for_hub(rolls: list[dict], conn, cfg: dict | None) -> list[dict]: + """中控 monitor API:每组附带当前均价,止盈盈利与最近滚仓腿.""" + if not rolls or not cfg: + return rolls + out = [] + gid_list = [] + for g in rolls: + if isinstance(g, dict) and g.get("id") is not None: + try: + gid_list.append(int(g["id"])) + except (TypeError, ValueError): + pass + legs_by_gid: dict[int, list] = {gid: [] for gid in gid_list} + if gid_list: + placeholders = ",".join("?" for _ in gid_list) + try: + rows = conn.execute( + f"SELECT * FROM roll_legs WHERE roll_group_id IN ({placeholders}) ORDER BY id DESC", + gid_list, + ).fetchall() + for row in rows: + leg = _row_to_dict(row) + try: + legs_by_gid[int(leg.get("roll_group_id"))].append(leg) + except (TypeError, ValueError): + pass + except Exception: + pass + monitors_by_id: dict[int, dict] = {} + try: + for row in conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall(): + od = _row_to_dict(row) + if od.get("id") is not None: + monitors_by_id[int(od["id"])] = od + except Exception: + pass + price_fmt = cfg.get("price_fmt") + for g in rolls: + if not isinstance(g, dict): + continue + gd = dict(g) + try: + gid = int(gd.get("id")) + except (TypeError, ValueError): + out.append(gd) + continue + mon = monitors_by_id.get(int(gd.get("order_monitor_id") or 0)) + group_legs = legs_by_gid.get(gid, []) + qty, entry, cs = _resolve_roll_live(cfg, gd, mon) + per_leg, group_metrics = compute_roll_chain_metrics( + gd, + group_legs, + qty_live=qty, + entry_live=entry, + monitor=mon, + contract_size=cs, + ) + gd.update(group_metrics) + if callable(price_fmt) and gd.get("avg_entry") is not None: + try: + gd["avg_entry_display"] = price_fmt(gd.get("symbol"), gd["avg_entry"]) + except Exception: + pass + recent = [] + for leg in sorted(group_legs, key=lambda x: int(x.get("leg_index") or 0), reverse=True)[:6]: + ld = dict(leg) + lid = ld.get("id") + if lid is None: + lid = f"{gid}|{ld.get('leg_index')}" + metrics = per_leg.get(lid) or per_leg.get(ld.get("id")) + if metrics: + ld.update(metrics) + if callable(price_fmt) and ld.get("avg_entry_after") is not None: + try: + ld["avg_entry_display"] = price_fmt(gd.get("symbol"), ld["avg_entry_after"]) + except Exception: + pass + recent.append(ld) + gd["recent_legs"] = recent + out.append(gd) + return out + + +def patch_roll_hub_enrich(app: Flask, cfg: dict) -> None: + """hub_bridge install 后:/api/hub/monitor 的 rolls 附带均价/止盈盈利.""" + ctx = dict(app.config.get("HUB_CTX") or {}) + prev: Callable | None = ctx.get("enrich_monitor") + + def enrich_monitor(keys=None, orders=None, trends=None, rolls=None): + payload: dict[str, Any] = {} + if callable(prev): + try: + prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls) + if isinstance(prev_out, dict): + payload.update(prev_out) + except Exception: + pass + if rolls: + get_db = cfg.get("get_db") + if callable(get_db): + conn = get_db() + try: + payload["rolls"] = enrich_roll_groups_for_hub(list(rolls), conn, cfg) + finally: + try: + conn.close() + except Exception: + pass + return payload + + ctx["enrich_monitor"] = enrich_monitor + app.config["HUB_CTX"] = ctx diff --git a/lib/strategy/strategy_snapshot_lib.py b/lib/strategy/strategy_snapshot_lib.py new file mode 100644 index 0000000..baf9392 --- /dev/null +++ b/lib/strategy/strategy_snapshot_lib.py @@ -0,0 +1,529 @@ +"""策略结束快照:趋势回调 / 顺势加仓(三所共用).""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +STRATEGY_TREND = "trend_pullback" +STRATEGY_ROLL = "roll" +STRATEGY_SNAPSHOTS_MAX_ROWS = 100 +# 同一趋势计划只允许一条「结束类」快照(中控全平 + 监控止损 + 实例结束计划) +FINAL_TREND_CLOSE_RANK = { + "手动平仓": 3, + "止盈": 2, + "止损": 1, +} +FINAL_TREND_CLOSE_LABELS = tuple(FINAL_TREND_CLOSE_RANK.keys()) + +STRATEGY_SNAPSHOTS_SQL = """ +CREATE TABLE IF NOT EXISTS strategy_trade_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_type TEXT NOT NULL, + source_id INTEGER, + symbol TEXT, + exchange_symbol TEXT, + direction TEXT, + result_label TEXT, + status_at_close TEXT, + opened_at TEXT, + closed_at TEXT, + pnl_amount REAL, + snapshot_json TEXT NOT NULL, + created_at TEXT +) +""" + + +def init_strategy_snapshot_table(conn) -> None: + conn.execute(STRATEGY_SNAPSHOTS_SQL) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_closed " + "ON strategy_trade_snapshots(closed_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_strategy_snapshots_type " + "ON strategy_trade_snapshots(strategy_type, source_id)" + ) + + +def _row_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def _json_dumps(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def build_trend_dca_levels(plan: dict) -> list[dict]: + """首仓 + 补仓档位列表(供策略页 / 中控).""" + out: list[dict] = [] + p = plan or {} + try: + legs_done = int(p.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + try: + dca_legs = int(p.get("dca_legs") or 0) + except (TypeError, ValueError): + dca_legs = 0 + first_done = int(p.get("first_order_done") or 0) != 0 + try: + grid = json.loads(p.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + except Exception: + grid = [] + try: + leg_amounts = json.loads(p.get("leg_amounts_json") or "[]") + if not isinstance(leg_amounts, list): + leg_amounts = [] + except Exception: + leg_amounts = [] + + out.append( + { + "i": 0, + "leg_key": "first", + "label": "首仓", + "price": None, + "contracts": p.get("first_order_amount"), + "status": "done" if first_done else "pending", + "status_label": "已开仓" if first_done else "待开仓", + } + ) + n = max(len(grid), len(leg_amounts), dca_legs) + for idx in range(n): + leg_i = idx + 1 + price = grid[idx] if idx < len(grid) else None + contracts = leg_amounts[idx] if idx < len(leg_amounts) else None + done = leg_i <= legs_done + out.append( + { + "i": leg_i, + "leg_key": f"dca_{leg_i}", + "label": f"补仓{leg_i}", + "price": price, + "contracts": contracts, + "status": "done" if done else "pending", + "status_label": "已补仓" if done else "待补仓", + } + ) + return out + + +def attach_trend_dca_levels(plan: dict) -> dict: + from lib.strategy.strategy_trend_lib import enrich_trend_dca_levels_with_tp + + d = dict(plan or {}) + levels = build_trend_dca_levels(d) + d["dca_levels"] = enrich_trend_dca_levels_with_tp(d, levels) + return d + + +def _snapshot_key_exists( + conn, strategy_type: str, source_id: int, result_label: str +) -> bool: + if source_id <= 0: + return False + label = (result_label or "").strip() + row = conn.execute( + """SELECT 1 FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? AND result_label=? + LIMIT 1""", + (strategy_type, int(source_id), label), + ).fetchone() + return row is not None + + +def _final_trend_close_rank(result_label: str) -> int: + return int(FINAL_TREND_CLOSE_RANK.get((result_label or "").strip(), 0)) + + +def _purge_weaker_trend_final_snapshots( + conn, plan_id: int, result_label: str +) -> None: + """写入更高优先级结束快照时,删除同计划较弱的结束记录.""" + rank = _final_trend_close_rank(result_label) + if rank <= 0 or plan_id <= 0: + return + for label, lr in FINAL_TREND_CLOSE_RANK.items(): + if lr < rank: + conn.execute( + """DELETE FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? AND result_label=?""", + (STRATEGY_TREND, int(plan_id), label), + ) + + +def dedupe_strategy_snapshots(conn) -> int: + """删除重复快照:同结果去重 + 同计划仅保留最高优先级结束类记录.""" + init_strategy_snapshot_table(conn) + removed = 0 + cur = conn.execute( + """DELETE FROM strategy_trade_snapshots + WHERE id IN ( + SELECT s1.id FROM strategy_trade_snapshots s1 + INNER JOIN strategy_trade_snapshots s2 + ON s1.strategy_type = s2.strategy_type + AND s1.source_id = s2.source_id + AND s1.result_label = s2.result_label + AND s1.id < s2.id + )""" + ) + removed += int(getattr(cur, "rowcount", 0) or 0) + rows = conn.execute( + f"""SELECT id, source_id, result_label FROM strategy_trade_snapshots + WHERE strategy_type=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""", + (STRATEGY_TREND, *FINAL_TREND_CLOSE_LABELS), + ).fetchall() + by_plan: dict[int, list] = {} + for row in rows: + d = _row_dict(row) + try: + pid = int(d.get("source_id") or 0) + except (TypeError, ValueError): + pid = 0 + if pid <= 0: + continue + by_plan.setdefault(pid, []).append(d) + drop_ids: list[int] = [] + for snaps in by_plan.values(): + if len(snaps) <= 1: + continue + best = max( + snaps, + key=lambda s: ( + _final_trend_close_rank(str(s.get("result_label") or "")), + int(s.get("id") or 0), + ), + ) + keep_id = int(best.get("id") or 0) + for s in snaps: + sid = int(s.get("id") or 0) + if sid and sid != keep_id: + drop_ids.append(sid) + if drop_ids: + placeholders = ",".join("?" * len(drop_ids)) + cur2 = conn.execute( + f"DELETE FROM strategy_trade_snapshots WHERE id IN ({placeholders})", + drop_ids, + ) + removed += int(getattr(cur2, "rowcount", 0) or 0) + return removed + + +def save_trend_plan_snapshot( + cfg: dict, + conn, + plan_row: Any, + *, + result_label: str, + exit_price: float | None = None, + pnl_amount: float | None = None, + closed_at: str | None = None, +) -> None: + init_strategy_snapshot_table(conn) + row = _row_dict(plan_row) + plan_id = int(row.get("id") or 0) + if plan_id <= 0: + return + label = (result_label or "").strip() + close_rank = _final_trend_close_rank(label) + if close_rank > 0: + existing = conn.execute( + f"""SELECT result_label FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""", + (STRATEGY_TREND, plan_id, *FINAL_TREND_CLOSE_LABELS), + ).fetchall() + for ex in existing: + ex_label = str(_row_dict(ex).get("result_label") or "") + if _final_trend_close_rank(ex_label) >= close_rank: + return + _purge_weaker_trend_final_snapshots(conn, plan_id, label) + elif _snapshot_key_exists(conn, STRATEGY_TREND, plan_id, label): + return + m = cfg.get("app_module") + close_ts = (closed_at or "").strip() or ( + m.app_now_str() + if m is not None and hasattr(m, "app_now_str") + else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ) + payload = attach_trend_dca_levels(row) + payload["result_label"] = result_label + payload["exit_price"] = exit_price + payload["pnl_amount"] = pnl_amount + payload["status_at_close"] = row.get("status") + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + strategy_type, source_id, symbol, exchange_symbol, direction, + result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + STRATEGY_TREND, + plan_id, + row.get("symbol"), + row.get("exchange_symbol"), + row.get("direction"), + result_label, + row.get("status"), + row.get("opened_at"), + close_ts, + pnl_amount, + _json_dumps(payload), + close_ts, + ), + ) + prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS) + + +def save_roll_group_snapshot( + cfg: dict, + conn, + group: dict, + *, + result_label: str = "结束", + pnl_amount: float | None = None, +) -> None: + init_strategy_snapshot_table(conn) + g = dict(group or {}) + gid = int(g.get("id") or 0) + if gid <= 0: + return + label = (result_label or "结束").strip() + if _snapshot_key_exists(conn, STRATEGY_ROLL, gid, label): + return + legs = [] + for leg in conn.execute( + "SELECT * FROM roll_legs WHERE roll_group_id=? ORDER BY leg_index ASC, id ASC", + (gid,), + ).fetchall(): + ld = _row_dict(leg) + try: + from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label + + ld["status_label"] = roll_leg_status_label(ld.get("status")) + except Exception: + ld["status_label"] = ld.get("status") or "" + legs.append(ld) + m = cfg.get("app_module") + closed_at = ( + m.app_now_str() + if m is not None and hasattr(m, "app_now_str") + else datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ) + payload = { + "group": g, + "legs": legs, + "result_label": result_label, + "pnl_amount": pnl_amount, + } + conn.execute( + """INSERT INTO strategy_trade_snapshots ( + strategy_type, source_id, symbol, exchange_symbol, direction, + result_label, status_at_close, opened_at, closed_at, pnl_amount, snapshot_json, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + STRATEGY_ROLL, + gid, + g.get("symbol"), + g.get("exchange_symbol"), + g.get("direction"), + result_label, + g.get("status"), + g.get("created_at"), + closed_at, + pnl_amount, + _json_dumps(payload), + closed_at, + ), + ) + prune_strategy_snapshots(conn, keep=STRATEGY_SNAPSHOTS_MAX_ROWS) + + +def prune_strategy_snapshots(conn, *, keep: int = STRATEGY_SNAPSHOTS_MAX_ROWS) -> None: + """仅保留最近 keep 条策略快照(按 closed_at / id 倒序).""" + dedupe_strategy_snapshots(conn) + k = max(1, min(int(keep), 500)) + conn.execute( + """DELETE FROM strategy_trade_snapshots + WHERE id NOT IN ( + SELECT id FROM strategy_trade_snapshots + ORDER BY COALESCE(closed_at, created_at, '') DESC, id DESC + LIMIT ? + )""", + (k,), + ) + + +def _snapshot_pnl(row: dict, snap: dict) -> float | None: + for key in ("pnl_amount",): + v = row.get(key) + if v is not None and v != "": + try: + return float(v) + except (TypeError, ValueError): + pass + v = snap.get("pnl_amount") + if v is not None and v != "": + try: + return float(v) + except (TypeError, ValueError): + pass + return None + + +def _trend_dca_stats(snap: dict) -> dict: + levels = snap.get("dca_levels") or build_trend_dca_levels(snap) + dca_only = [ + lv + for lv in levels + if (lv.get("leg_key") or "") != "first" and (lv.get("label") or "") != "首仓" + ] + done = sum(1 for lv in dca_only if lv.get("status") == "done") + total = len(dca_only) + pending = total - done + if total <= 0: + tag = "na" + elif done <= 0: + tag = "no_dca" + elif done >= total: + tag = "dca_done" + else: + tag = "dca_partial" + return { + "dca_done": done, + "dca_total": total, + "dca_pending": pending, + "dca_tag": tag, + } + + +def _roll_leg_stats(snap: dict) -> dict: + legs = snap.get("legs") or [] + if not isinstance(legs, list): + legs = [] + filled = sum(1 for lg in legs if (lg.get("status") or "").lower() == "filled") + total = len(legs) + pending = total - filled + if total <= 0: + tag = "na" + elif filled <= 0: + tag = "no_dca" + elif filled >= total: + tag = "dca_done" + else: + tag = "dca_partial" + return { + "dca_done": filled, + "dca_total": total, + "dca_pending": pending, + "dca_tag": tag, + } + + +def enrich_strategy_snapshot_row(row: dict) -> dict: + d = dict(row or {}) + snap = d.get("snapshot") or {} + st = (d.get("strategy_type") or "").strip() + pnl = _snapshot_pnl(d, snap) + if pnl is not None: + if pnl > 1e-9: + d["filter_pnl"] = "profit" + elif pnl < -1e-9: + d["filter_pnl"] = "loss" + else: + d["filter_pnl"] = "flat" + else: + d["filter_pnl"] = "unknown" + snap_sym = "" + if isinstance(snap, dict): + snap_sym = (snap.get("symbol") or snap.get("exchange_symbol") or "").strip() + sym = (d.get("symbol") or d.get("exchange_symbol") or snap_sym or "").strip() + if sym: + d["symbol"] = d.get("symbol") or sym + d["exchange_symbol"] = d.get("exchange_symbol") or sym + d["filter_symbol"] = sym.upper().split("/")[0].split(":")[0] if sym else "" + closed = (d.get("closed_at") or d.get("created_at") or "").strip() + d["sort_ts"] = closed + if st == STRATEGY_TREND: + stats = _trend_dca_stats(snap) + d.update(stats) + legs_txt = ( + f"{stats['dca_done']}/{stats['dca_total']}" + if stats["dca_total"] > 0 + else "0/0" + ) + d["summary_dca"] = legs_txt + else: + stats = _roll_leg_stats(snap) + d.update(stats) + d["summary_dca"] = ( + f"{stats['dca_done']}/{stats['dca_total']}腿" + if stats["dca_total"] > 0 + else "—" + ) + return d + + +def list_strategy_snapshots(conn, *, limit: int = 200) -> list[dict]: + init_strategy_snapshot_table(conn) + rows = conn.execute( + "SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?", + (max(1, min(int(limit), 500)),), + ).fetchall() + out = [] + seen: dict[tuple[str, int, str], int] = {} + for r in rows: + d = _row_dict(r) + try: + d["snapshot"] = json.loads(d.get("snapshot_json") or "{}") + except Exception: + d["snapshot"] = {} + st = (d.get("strategy_type") or "").strip() + d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓" + enriched = enrich_strategy_snapshot_row(d) + try: + source_id = int(enriched.get("source_id") or 0) + except (TypeError, ValueError): + source_id = 0 + result_label = (enriched.get("result_label") or "").strip() + close_rank = _final_trend_close_rank(result_label) + if st == STRATEGY_TREND and source_id > 0 and close_rank > 0: + plan_key = (st, source_id) + snap_id = int(enriched.get("id") or 0) + prev = seen.get(plan_key) + if prev is not None: + prev_id, prev_rank = prev + if prev_rank > close_rank or (prev_rank == close_rank and prev_id >= snap_id): + continue + out = [x for x in out if int(x.get("id") or 0) != prev_id] + seen[plan_key] = (snap_id, close_rank) + out.append(enriched) + continue + key = (st, source_id, result_label) + snap_id = int(enriched.get("id") or 0) + prev = seen.get(key) + if prev is not None and prev[0] >= snap_id: + continue + if prev is not None: + out = [x for x in out if int(x.get("id") or 0) != prev[0]] + seen[key] = (snap_id, 0) + out.append(enriched) + return out + + +def list_strategy_snapshots_split( + conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS +) -> tuple[list[dict], list[dict], list[str]]: + """趋势 / 顺势分组,及筛选用币种列表.""" + all_rows = list_strategy_snapshots(conn, limit=limit) + trend = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_TREND] + roll = [r for r in all_rows if (r.get("strategy_type") or "") == STRATEGY_ROLL] + symbols = sorted({r.get("filter_symbol") or "" for r in all_rows if r.get("filter_symbol")}) + return trend, roll, symbols diff --git a/lib/strategy/strategy_trade_labels.py b/lib/strategy/strategy_trade_labels.py new file mode 100644 index 0000000..33bcb94 --- /dev/null +++ b/lib/strategy/strategy_trade_labels.py @@ -0,0 +1,192 @@ +"""策略交易写入 trade_records 时的类型与复盘开仓类型标注.""" +from __future__ import annotations + +from typing import Optional + +MONITOR_TYPE_TREND_PULLBACK = "趋势回调" +MONITOR_TYPE_ROLL = "顺势加仓" +ORDER_TYPE_MANUAL = "下单监控" +ORDER_TYPE_KEY = "关键位监控" + +ENTRY_REASON_TREND_PULLBACK = "趋势回调" +ENTRY_REASON_ROLL = "顺势加仓" + +JOURNAL_ORDER_TYPE_OPTIONS = ( + ORDER_TYPE_MANUAL, + ORDER_TYPE_KEY, + MONITOR_TYPE_TREND_PULLBACK, + MONITOR_TYPE_ROLL, +) + +STRATEGY_ENTRY_REASON_OPTIONS = ( + ENTRY_REASON_TREND_PULLBACK, + ENTRY_REASON_ROLL, +) + + +def normalize_journal_order_type(raw: Optional[str]) -> str: + s = (raw or "").strip() + if s in JOURNAL_ORDER_TYPE_OPTIONS: + 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 + +# 趋势回调保本移交下单监控:order_monitors.key_signal_type / 平仓备注 +TREND_HANDOFF_KEY_SIGNAL = ENTRY_REASON_TREND_PULLBACK +TREND_HANDOFF_TRADE_NOTE = "趋势回调计划" + + +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: + try: + oid = int(order_monitor_id) + except (TypeError, ValueError): + return False + if oid <= 0: + return False + try: + row = conn.execute( + """SELECT 1 FROM roll_legs l + INNER JOIN roll_groups g ON g.id = l.roll_group_id + WHERE g.order_monitor_id=? AND l.status='filled' + LIMIT 1""", + (oid,), + ).fetchone() + return row is not None + except Exception: + 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: + """实例页 / 中控 API:统一修正 order_monitors 展示用 monitor_type.""" + 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: + """平仓写入 trade_records 时:曾顺势加仓则标「顺势加仓」,否则沿用监控单来源类型.""" + oid = None + try: + keys = order_row.keys() if hasattr(order_row, "keys") else [] + if "id" in keys and order_row["id"] is not None: + oid = int(order_row["id"]) + except Exception: + oid = None + if oid and order_had_roll_fills(conn, oid): + return MONITOR_TYPE_ROLL + 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: + """趋势回调不计入 MAX_ACTIVE_POSITIONS;顺势加仓在已有持仓上操作,单独放行.""" + 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/strategy/strategy_trend_exchange.py b/lib/strategy/strategy_trend_exchange.py new file mode 100644 index 0000000..02be3ce --- /dev/null +++ b/lib/strategy/strategy_trend_exchange.py @@ -0,0 +1,97 @@ +"""趋势回调:各交易所止损刷新,市价加/平仓(通过 app 模块能力探测).""" +from __future__ import annotations + +import time +from typing import Any + + +def _m(cfg: dict) -> Any: + return cfg["app_module"] + + +def trend_refresh_stop_only(cfg: dict, exchange_symbol: str, direction: str, stop_loss: float) -> None: + m = _m(cfg) + if hasattr(m, "_gate_place_stop_loss_only_position"): + if hasattr(m, "cancel_gate_swap_trigger_orders"): + m.cancel_gate_swap_trigger_orders(exchange_symbol) + m._gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) + return + if hasattr(m, "_binance_place_stop_loss_only"): + m._binance_place_stop_loss_only(exchange_symbol, direction, stop_loss) + return + if hasattr(m, "_okx_place_stop_loss_only"): + m._okx_place_stop_loss_only(exchange_symbol, direction, stop_loss) + return + raise RuntimeError("当前实例未配置趋势回调止损挂单能力") + + +def trend_market_add(cfg: dict, exchange_symbol: str, direction: str, contracts: float, leverage: int): + m = _m(cfg) + ex = m.exchange + m.ensure_markets_loaded() + ex.set_leverage(int(leverage), exchange_symbol) + side = "buy" if direction == "long" else "sell" + if hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=False) + elif hasattr(m, "build_binance_order_params"): + params = m.build_binance_order_params(direction, reduce_only=False) + elif hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=False) + else: + params = {} + order_params = params if params is not None else {} + return ex.create_order(exchange_symbol, "market", side, float(contracts), None, order_params) + + +def trend_market_close(cfg: dict, exchange_symbol: str, direction: str, pos_qty: float, leverage: int): + m = _m(cfg) + ex = m.exchange + m.ensure_markets_loaded() + ex.set_leverage(int(leverage), exchange_symbol) + side = "sell" if direction == "long" else "buy" + amt = float(ex.amount_to_precision(exchange_symbol, float(pos_qty))) + if hasattr(m, "close_exchange_order"): + row = { + "exchange_symbol": exchange_symbol, + "symbol": exchange_symbol, + "direction": direction, + "order_amount": amt, + } + return m.close_exchange_order(row) + if hasattr(m, "build_gate_order_params"): + params = m.build_gate_order_params(direction, reduce_only=True) + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + if hasattr(m, "build_binance_order_params"): + for params in m._binance_market_close_param_candidates(direction): + try: + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + except Exception as e: + if not m._is_binance_close_param_retryable(str(e)): + raise + raise RuntimeError("平仓失败") + if hasattr(m, "build_okx_order_params"): + params = m.build_okx_order_params(direction, reduce_only=True) + return ex.create_order(exchange_symbol, "market", side, amt, None, params) + return ex.create_order(exchange_symbol, "market", side, amt, None, {"reduceOnly": True}) + + +def trend_replace_tpsl(cfg: dict, order_row: dict, stop_loss: float, take_profit: float) -> None: + """趋势保本移交:先撤条件单再挂保本止损 + 计划止盈(与下单监控一致).""" + m = _m(cfg) + fn = getattr(m, "replace_active_monitor_tpsl_on_exchange", None) + if not callable(fn): + raise RuntimeError("当前实例未配置止盈止损同步能力") + fn(order_row, float(stop_loss), float(take_profit)) + + +def cancel_symbol_orders(cfg: dict, exchange_symbol: str) -> None: + m = _m(cfg) + if hasattr(m, "cancel_all_open_orders_for_symbol"): + m.cancel_all_open_orders_for_symbol(exchange_symbol) + return + if hasattr(m, "cancel_gate_swap_trigger_orders"): + m.cancel_gate_swap_trigger_orders(exchange_symbol) + if hasattr(m, "cancel_binance_futures_open_orders"): + m.cancel_binance_futures_open_orders(exchange_symbol) + if hasattr(m, "cancel_okx_swap_open_orders"): + m.cancel_okx_swap_open_orders(exchange_symbol) diff --git a/lib/strategy/strategy_trend_lib.py b/lib/strategy/strategy_trend_lib.py new file mode 100644 index 0000000..f0dcad2 --- /dev/null +++ b/lib/strategy/strategy_trend_lib.py @@ -0,0 +1,701 @@ +"""趋势回调策略:纯计算与校验(无 ccxt / Flask).各所 adapter 负责张数精度与下单.""" +from __future__ import annotations + +import json +from typing import Any, Callable, Optional, Tuple + +AmountPreciseFn = Callable[[str, float], Optional[float]] + + +def calc_risk_fraction(direction: str, entry_price: float, stop_loss: float) -> Optional[float]: + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if (direction or "long").strip().lower() == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except (TypeError, ValueError): + return None + + +def trend_effective_margin_capital(plan: dict) -> float: + """按已开仓张数占计划总张数比例折算保证金(首仓/部分补仓时的盈亏估算).""" + try: + plan_margin = float(plan.get("plan_margin_capital") or 0) + target = float(plan.get("target_order_amount") or 0) + open_amt = float(plan.get("order_amount_open") or 0) + except (TypeError, ValueError): + return float((plan or {}).get("plan_margin_capital") or 0) + if plan_margin <= 0: + return 0.0 + if target > 0 and open_amt > 0: + return round(plan_margin * min(1.0, open_amt / target), 8) + try: + first = float(plan.get("first_order_amount") or 0) + except (TypeError, ValueError): + first = 0.0 + if target > 0 and first > 0: + return round(plan_margin * min(1.0, first / target), 8) + return plan_margin + + +def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool: + """做空:价升触达/越过档位即应补仓;做多:价跌触达/越过档位.""" + d = (direction or "long").strip().lower() + try: + pf = float(mark_price) + lv = float(level) + except (TypeError, ValueError): + return False + if d == "long": + return pf <= lv + return pf >= lv + + +def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]: + direction = (direction or "long").strip().lower() + if direction == "long": + if not (float(stop_loss) < float(add_upper)): + return "做多:止损价须低于补仓上沿" + else: + if not (float(stop_loss) > float(add_upper)): + return "做空:止损价须高于补仓下沿" + return None + + +def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]: + """在 (止损, 补仓区间远侧边界) 内生成 n_legs 个触发价(不含端点).""" + sl, upper = float(sl), float(upper) + out: list[float] = [] + if n_legs <= 0: + return out + direction = (direction or "long").strip().lower() + if direction == "long": + if upper <= sl: + return out + span = upper - sl + for i in range(1, n_legs + 1): + t = i / float(n_legs + 1) + out.append(sl + t * span) + out.sort(reverse=True) + else: + if sl <= upper: + return out + span = sl - upper + for i in range(1, n_legs + 1): + t = i / float(n_legs + 1) + out.append(upper + t * span) + out.sort() + return [round(p, 10) for p in out] + + +def pick_dca_legs_and_per_leg( + exchange_symbol: str, + remainder_total: float, + want_legs: int, + amount_precise: AmountPreciseFn, + min_amount: float = 0.0, +) -> Tuple[int, float]: + """按最小张数约束自动减少档位数.返回 (有效档数, 每档参考张数).""" + legs = max(1, int(want_legs)) + rem = float(remainder_total) + min_amt = float(min_amount or 0.0) + while legs >= 1: + per = rem / legs + per_p = amount_precise(exchange_symbol, per) + if per_p is None or per_p <= 0: + legs -= 1 + continue + if min_amt and per_p + 1e-12 < min_amt: + legs -= 1 + continue + return legs, per_p + one = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, 0.0 + return 1, one + + +def build_leg_amounts_json( + exchange_symbol: str, + remainder_total: float, + want_legs: int, + amount_precise: AmountPreciseFn, + min_amount: float = 0.0, +) -> Tuple[int, str, float]: + """拆分补仓张数 JSON.返回 (档位数, json列表, 每档参考).""" + rem = amount_precise(exchange_symbol, float(remainder_total)) + if rem is None or rem <= 0: + return 0, "[]", 0.0 + n, _ = pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs, amount_precise, min_amount) + if n <= 0: + return 0, "[]", 0.0 + if n <= 1: + one = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + unit = amount_precise(exchange_symbol, rem / n) + if unit is None or unit <= 0: + one = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts: list[float] = [] + acc = 0.0 + for _ in range(n - 1): + parts.append(unit) + acc += unit + last = amount_precise(exchange_symbol, max(0.0, rem - acc)) + if last is None or last <= 0: + one = amount_precise(exchange_symbol, rem) + if one is None or one <= 0: + return 0, "[]", 0.0 + return 1, json.dumps([one]), one + parts.append(last) + return n, json.dumps(parts), unit + + +def compute_trend_plan_core( + *, + direction: str, + stop_loss: float, + add_upper: float, + risk_percent: float, + snapshot_usdt: float, + leverage: int, + live_price: float, + target_order_amount: float, + exchange_symbol: str, + dca_legs: int, + amount_precise: AmountPreciseFn, + min_amount: float = 0.0, + full_margin_buffer_ratio: float = 0.95, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + """在已有 target_order_amount 时组装预览 payload(张数由调用方 prepare_order_amount 计算).""" + rf = calc_risk_fraction(direction, add_upper, stop_loss) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + risk_budget = float(snapshot_usdt) * (float(risk_percent) / 100.0) + notional = risk_budget / rf + margin_plan = notional / float(leverage) + margin_plan = min(margin_plan, float(snapshot_usdt) * float(full_margin_buffer_ratio)) + if margin_plan <= 0: + return None, "计划保证金过小" + first_amt = amount_precise(exchange_symbol, float(target_order_amount) * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = amount_precise(exchange_symbol, max(0.0, float(target_order_amount) - float(first_amt))) + if remainder_total is None: + remainder_total = 0.0 + n_legs, leg_json, per_ref = build_leg_amounts_json( + exchange_symbol, remainder_total, dca_legs, amount_precise, min_amount + ) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距" + grid = build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + payload = { + "direction": direction, + "stop_loss": float(stop_loss), + "add_upper": float(add_upper), + "risk_percent": float(risk_percent), + "snapshot_available_usdt": float(snapshot_usdt), + "live_price_ref": float(live_price), + "plan_margin_capital": float(margin_plan), + "target_order_amount": float(target_order_amount), + "first_order_amount": float(first_amt), + "remainder_total": float(remainder_total), + "dca_legs": int(n_legs), + "per_leg_amount": float(per_ref), + "grid_prices_json": json.dumps(grid), + "leg_amounts_json": leg_json, + "grid": grid, + "leg_amounts": leg_list, + } + return payload, None + + +def calc_planned_reward_risk_ratio( + direction: str, entry_price: float, stop_loss: float, take_profit: float +) -> Optional[float]: + """盈亏比(reward/risk),与三所 calc_rr_ratio 口径一致.""" + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + direction = (direction or "long").strip().lower() + 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 (TypeError, ValueError): + return None + + +def calc_take_profit_for_rr( + direction: str, entry_price: float, stop_loss: float, reward_risk_ratio: float +) -> Optional[float]: + """按统一止损与目标 RR 反推止盈价.""" + try: + entry = float(entry_price) + sl = float(stop_loss) + rr = float(reward_risk_ratio) + if entry <= 0 or sl <= 0 or rr <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + risk = sl - entry + if risk <= 0: + return None + return round(entry - rr * risk, 10) + risk = entry - sl + if risk <= 0: + return None + return round(entry + rr * risk, 10) + except (TypeError, ValueError): + return None + + +def calc_risk_budget_usdt(snapshot_usdt: float, risk_percent: float) -> Optional[float]: + """计划止损金额 U = 可用快照 × 风险比例.""" + try: + snap = float(snapshot_usdt) + rp = float(risk_percent) + if snap <= 0 or rp <= 0: + return None + return round(snap * rp / 100.0, 4) + except (TypeError, ValueError): + return None + + +def calc_money_reward_risk_ratio(profit_u: float, risk_u: float) -> Optional[float]: + """金额盈亏比 = 止盈盈利 U / 止损金额 U.""" + try: + r = float(risk_u) + p = float(profit_u) + if r <= 0: + return None + return round(p / r, 4) + except (TypeError, ValueError): + return None + + +def calc_tp_profit_usdt( + direction: str, + avg_entry: float, + take_profit_price: float, + contracts: float, + contract_size: float = 1.0, +) -> Optional[float]: + """到达止盈价时,按累计张数与加仓后均价的净盈利 U(扣双边 taker 费).""" + try: + from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt + from lib.trade.trade_fee_lib import net_pnl_after_fee + + gross = estimate_linear_swap_upnl_usdt( + direction, float(avg_entry), float(take_profit_price), float(contracts), float(contract_size) + ) + if gross is None: + return None + return net_pnl_after_fee( + gross, avg_entry, take_profit_price, contracts, contract_size + ) + except (TypeError, ValueError): + return None + + +def weighted_avg_entry(legs: list[tuple[float, float]]) -> Optional[float]: + """按 (成交价, 张数) 加权均价.""" + total = 0.0 + cost = 0.0 + for price, amount in legs or []: + try: + p = float(price) + a = float(amount) + except (TypeError, ValueError): + continue + if a <= 0: + continue + total += a + cost += p * a + if total <= 0: + return None + return cost / total + + +def parse_leg_fill_prices(plan: dict) -> list[float]: + """首仓 + 各档补仓实际成交价列表.""" + try: + raw = json.loads((plan or {}).get("leg_fill_prices_json") or "[]") + if not isinstance(raw, list): + return [] + out: list[float] = [] + for item in raw: + try: + out.append(float(item)) + except (TypeError, ValueError): + continue + return out + except Exception: + return [] + + +def append_leg_fill_price_json(existing_json: str | None, fill_px: float) -> str: + fills = parse_leg_fill_prices({"leg_fill_prices_json": existing_json}) + fills.append(float(fill_px)) + return json.dumps(fills, ensure_ascii=False, separators=(",", ":")) + + +def trend_leg_grid_price(plan: dict, leg_idx: int) -> Optional[float]: + """补仓 leg_idx(1..N) 的计划网格触发价;首仓返回 None.""" + if leg_idx <= 0: + return None + try: + grid = [float(x) for x in json.loads((plan or {}).get("grid_prices_json") or "[]")] + except Exception: + grid = [] + gi = leg_idx - 1 + if 0 <= gi < len(grid): + return float(grid[gi]) + return None + + +def trend_leg_display_price(plan: dict, leg_idx: int) -> Optional[float]: + """ + 三所统一:单档展示价 = leg_fill_prices_json 实际记录,否则计划网格(首仓用均价/参考价). + 禁止为凑均价反推虚构成交价. + """ + p = plan or {} + fills = parse_leg_fill_prices(p) + if len(fills) > leg_idx: + return float(fills[leg_idx]) + if leg_idx == 0: + try: + return float(p.get("avg_entry_price")) + except (TypeError, ValueError): + pass + try: + ref = p.get("live_price_ref") + if ref not in (None, ""): + return float(ref) + except (TypeError, ValueError): + pass + return None + return trend_leg_grid_price(p, leg_idx) + + +def reconcile_trend_leg_fill_prices(plan: dict) -> list[float]: + """首仓(0)+已补仓(1..legs_done) 展示价列表(三所共用 trend_leg_display_price).""" + p = plan or {} + if int(p.get("first_order_done") or 0) == 0: + return [] + try: + legs_done = int(p.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + result: list[float] = [] + for leg_idx in range(legs_done + 1): + px = trend_leg_display_price(p, leg_idx) + result.append(float(px) if px is not None else 0.0) + return result + + +def calc_trend_plan_money_metrics(plan: dict) -> dict: + """运行中计划头部:按快照风险金额计算盈亏比(止盈盈利 U / 风险 U).""" + out = {"money_rr": None, "risk_amount_u": None} + p = plan or {} + try: + direction = (p.get("direction") or "long").strip().lower() + user_tp = float(p.get("take_profit")) + avg = float(p.get("avg_entry_price")) + open_amt = float(p.get("order_amount_open") or p.get("first_order_amount") or 0) + snapshot = float(p.get("snapshot_available_usdt")) + risk_percent = float(p.get("risk_percent")) + except (TypeError, ValueError): + return out + if avg <= 0 or open_amt <= 0: + return out + risk_u = calc_risk_budget_usdt(snapshot, risk_percent) + if risk_u is None or risk_u <= 0: + return out + out["risk_amount_u"] = risk_u + try: + contract_size = float(p.get("contract_size") or 1.0) + if contract_size <= 0: + contract_size = 1.0 + except (TypeError, ValueError): + contract_size = 1.0 + profit_u = calc_tp_profit_usdt(direction, avg, user_tp, open_amt, contract_size) + out["money_rr"] = calc_money_reward_risk_ratio(profit_u, risk_u) + return out + + +def build_trend_preview_level_rows(preview: dict) -> tuple[dict, list[dict]]: + """ + 预览:表单止盈价下每档累计持仓的盈利 U;止损金额 = 快照×风险;盈亏比按金额对比. + 返回 (增强后的 preview 字段, 表格行列表,含首仓行). + """ + p = dict(preview or {}) + direction = (p.get("direction") or "long").strip().lower() + try: + ref = float(p.get("live_price_ref")) + sl = float(p.get("stop_loss")) + user_tp = float(p.get("take_profit")) + first_amt = float(p.get("first_order_amount")) + snapshot = float(p.get("snapshot_available_usdt")) + risk_percent = float(p.get("risk_percent")) + except (TypeError, ValueError): + return p, [] + + risk_u = calc_risk_budget_usdt(snapshot, risk_percent) + if risk_u is None or risk_u <= 0: + return p, [] + + try: + contract_size = float(p.get("contract_size") or 1.0) + if contract_size <= 0: + contract_size = 1.0 + except (TypeError, ValueError): + contract_size = 1.0 + + p["preview_risk_amount_u"] = risk_u + p["preview_take_profit_price"] = user_tp + p["preview_unified_stop_loss"] = sl + + try: + grid = json.loads(p.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + except Exception: + grid = [] + try: + leg_amounts = json.loads(p.get("leg_amounts_json") or "[]") + if not isinstance(leg_amounts, list): + leg_amounts = [] + except Exception: + leg_amounts = [] + + def _row_dict( + *, + i: int, + label: str, + price: float, + leg_contracts: float, + cum_contracts: float, + avg: float, + is_first: bool, + ) -> dict: + profit_u = calc_tp_profit_usdt(direction, avg, user_tp, cum_contracts, contract_size) + rr_money = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None + return { + "i": i, + "label": label, + "price": price, + "contracts": leg_contracts, + "cum_contracts": cum_contracts, + "avg_entry": avg, + "take_profit_price": user_tp, + "profit_u": profit_u, + "risk_u": risk_u, + "rr": rr_money, + "stop_loss_price": sl, + "take_profit": profit_u, + "stop_loss": risk_u, + "is_first": is_first, + } + + cum_contracts = first_amt + first_profit = calc_tp_profit_usdt(direction, ref, user_tp, cum_contracts, contract_size) + first_rr = calc_money_reward_risk_ratio(first_profit, risk_u) if first_profit is not None else None + p["preview_first_profit_u"] = first_profit + p["preview_target_rr"] = first_rr + p["preview_first_take_profit"] = user_tp + + rows: list[dict] = [ + _row_dict( + i=0, + label="首仓", + price=ref, + leg_contracts=first_amt, + cum_contracts=cum_contracts, + avg=ref, + is_first=True, + ) + ] + accumulated: list[tuple[float, float]] = [(ref, first_amt)] + for i, pair in enumerate(zip(grid, leg_amounts), 1): + try: + price = float(pair[0]) + leg_contracts = float(pair[1]) + except (TypeError, ValueError): + continue + accumulated.append((price, leg_contracts)) + avg = weighted_avg_entry(accumulated) + if avg is None: + continue + cum_contracts += leg_contracts + rows.append( + _row_dict( + i=i, + label=f"补仓{i}", + price=price, + leg_contracts=leg_contracts, + cum_contracts=cum_contracts, + avg=avg, + is_first=False, + ) + ) + return p, rows + + +def enrich_trend_dca_levels_with_tp(plan: dict, levels: list[dict]) -> list[dict]: + """ + 三所统一补仓表 enrich(实例策略页 + 中控 monitor 共用). + 触发价:实际成交价或计划网格;末档加仓后均价用持仓均价;禁止反推虚构成交价. + """ + if not levels: + return levels + p = plan or {} + direction = (p.get("direction") or "long").strip().lower() + try: + sl = float(p.get("stop_loss")) + user_tp = float(p.get("take_profit")) + first_amt = float(p.get("first_order_amount")) + snapshot = float(p.get("snapshot_available_usdt")) + risk_percent = float(p.get("risk_percent")) + except (TypeError, ValueError): + return levels + + risk_u = calc_risk_budget_usdt(snapshot, risk_percent) + if risk_u is None or risk_u <= 0: + return levels + + try: + legs_done = int(p.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + first_done = int(p.get("first_order_done") or 0) != 0 + try: + target_avg = float(p.get("avg_entry_price")) + except (TypeError, ValueError): + target_avg = None + + ref_raw = p.get("live_price_ref") + if ref_raw in (None, ""): + ref_raw = p.get("avg_entry_price") + try: + ref = float(ref_raw) + except (TypeError, ValueError): + return levels + + try: + contract_size = float(p.get("contract_size") or 1.0) + if contract_size <= 0: + contract_size = 1.0 + except (TypeError, ValueError): + contract_size = 1.0 + + out: list[dict] = [] + accumulated: list[tuple[float, float]] = [] + cum_contracts = 0.0 + for lv in levels: + row = dict(lv) + is_first = row.get("leg_key") == "first" or row.get("label") == "首仓" or row.get("i") == 0 + row_cum = cum_contracts + if is_first: + try: + amt_f = float(row.get("contracts") if row.get("contracts") is not None else first_amt) + except (TypeError, ValueError): + amt_f = first_amt + if first_done: + fill_px = trend_leg_display_price(p, 0) + if fill_px is None: + try: + fill_px = float(p.get("avg_entry_price") or ref) + except (TypeError, ValueError): + fill_px = ref + accumulated = [(float(fill_px), amt_f)] + cum_contracts = amt_f + row_cum = cum_contracts + row["price"] = fill_px + if target_avg is not None and legs_done == 0: + row["avg_entry"] = target_avg + else: + row["avg_entry"] = float(fill_px) + else: + accumulated = [(ref, amt_f)] + cum_contracts = amt_f + row_cum = cum_contracts + row["avg_entry"] = ref + else: + try: + leg_num = int(row.get("i") or 0) + except (TypeError, ValueError): + leg_num = 0 + grid_trigger = row.get("price") + try: + grid_trigger_f = float(grid_trigger) if grid_trigger is not None else None + except (TypeError, ValueError): + grid_trigger_f = None + try: + leg_contracts = float(row.get("contracts") or 0) + except (TypeError, ValueError): + leg_contracts = 0.0 + done = row.get("status") == "done" or (leg_num > 0 and leg_num <= legs_done) + if done and leg_contracts > 0: + fill_px = trend_leg_display_price(p, leg_num) + if fill_px is None: + fill_px = grid_trigger_f if grid_trigger_f is not None else ref + row["price"] = fill_px + accumulated.append((fill_px, leg_contracts)) + cum_contracts += leg_contracts + row_cum = cum_contracts + if leg_num == legs_done and target_avg is not None: + row["avg_entry"] = target_avg + else: + avg = weighted_avg_entry(accumulated) + if avg is not None: + row["avg_entry"] = avg + elif grid_trigger_f is not None and leg_contracts > 0: + row["price"] = grid_trigger_f + projected = accumulated + [(grid_trigger_f, leg_contracts)] + avg = weighted_avg_entry(projected) + if avg is not None: + row["avg_entry"] = avg + row_cum = cum_contracts + leg_contracts + elif grid_trigger_f is not None: + row["price"] = grid_trigger_f + + avg_entry = row.get("avg_entry") + if avg_entry is not None and row_cum > 0: + profit_u = calc_tp_profit_usdt( + direction, float(avg_entry), user_tp, row_cum, contract_size + ) + row["take_profit_price"] = user_tp + row["profit_u"] = profit_u + row["risk_u"] = risk_u + row["rr"] = calc_money_reward_risk_ratio(profit_u, risk_u) if profit_u is not None else None + row["take_profit"] = profit_u + row["stop_loss"] = risk_u + row["stop_loss_price"] = sl + out.append(row) + return out diff --git a/lib/strategy/strategy_trend_register.py b/lib/strategy/strategy_trend_register.py new file mode 100644 index 0000000..ef67e1e --- /dev/null +++ b/lib/strategy/strategy_trend_register.py @@ -0,0 +1,1972 @@ +"""趋势回调:路由,轮询,页面数据(三所共用,依赖各 app 模块交易所能力).""" +from __future__ import annotations + +import inspect +import json +import os +import time +import uuid +from typing import Any, Optional + +from flask import Flask, flash, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.strategy.strategy_config import resolve_trading_app_module +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_trend_exchange import ( + cancel_symbol_orders, + trend_market_add, + trend_market_close, + trend_refresh_stop_only, + trend_replace_tpsl, +) +from lib.strategy.strategy_trend_lib import ( + build_grid_prices, + build_leg_amounts_json, + calc_risk_fraction, + trend_dca_level_reached, + trend_effective_margin_capital, + validate_trend_bounds, +) +from lib.strategy.strategy_trade_labels import ( + ENTRY_REASON_TREND_PULLBACK, + MONITOR_TYPE_TREND_PULLBACK, + TREND_HANDOFF_KEY_SIGNAL, + TREND_HANDOFF_TRADE_NOTE, +) + +MONITOR_TYPE_TREND = MONITOR_TYPE_TREND_PULLBACK + +# 趋势回调:交易所报空仓需连续 N 次轮询确认,避免 OKX 等 API 瞬时误判立即结束计划 +_TREND_FLAT_STREAK: dict[int, int] = {} +TREND_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("TREND_FLAT_CONFIRM_POLLS", "5"))) +TREND_OPEN_GRACE_SEC = max(0, int(os.getenv("TREND_OPEN_GRACE_SEC", "180"))) +_TREND_LIVE_SKIP_LOG_TS = 0.0 +_TREND_POLL_STATE: dict[str, Any] = { + "updated_at": None, + "live_ok": True, + "live_reason": "", + "plans": {}, +} + + +def get_trend_poll_state() -> dict: + return dict(_TREND_POLL_STATE or {}) + + +def _log_trend_live_skip(reason: str) -> None: + global _TREND_LIVE_SKIP_LOG_TS + now = time.time() + if now - _TREND_LIVE_SKIP_LOG_TS < 60: + return + _TREND_LIVE_SKIP_LOG_TS = now + print(f"[trend_pullback] poll skipped (live not ready): {reason}", flush=True) + + +def _set_trend_poll_plan(plan_id: int, info: dict) -> None: + plans = dict(_TREND_POLL_STATE.get("plans") or {}) + plans[str(plan_id)] = info + _TREND_POLL_STATE["plans"] = plans + + +def summarize_trend_dca_probe(cfg: dict, row) -> dict: + """诊断单计划为何未补仓(供页面 / API).""" + m = _m(cfg) + d = _row(cfg, row) + plan_id = int(d.get("id") or 0) + sym = d.get("symbol") or "" + direction = (d.get("direction") or "long").lower() + ex_sym = d.get("exchange_symbol") or m.normalize_exchange_symbol(sym) + out: dict[str, Any] = { + "plan_id": plan_id, + "symbol": sym, + "mark_price": None, + "next_trigger": None, + "trigger_reached": False, + "legs_done": int(d.get("legs_done") or 0), + "first_order_done": int(d.get("first_order_done") or 0), + "block_reason": None, + } + try: + legs_done = int(d.get("legs_done") or 0) + grid = json.loads(d.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + leg_amounts = json.loads(d.get("leg_amounts_json") or "[]") + if not isinstance(leg_amounts, list): + leg_amounts = [] + except Exception: + grid = [] + leg_amounts = [] + legs_done = 0 + pf = _trend_poll_price(m, sym, ex_sym, direction) + out["mark_price"] = pf + ok_live, live_reason = m.ensure_exchange_live_ready() + out["live_ok"] = ok_live + if not ok_live: + out["block_reason"] = live_reason or "实盘未就绪" + if not int(d.get("first_order_done") or 0): + out["block_reason"] = out["block_reason"] or "首仓未完成" + return out + if legs_done >= len(grid) or legs_done >= len(leg_amounts): + out["block_reason"] = out["block_reason"] or "补仓档已全部完成或无 grid" + return out + try: + level = float(grid[legs_done]) + except (TypeError, ValueError, IndexError): + out["block_reason"] = out["block_reason"] or "无效补仓触发价" + return out + out["next_trigger"] = level + if pf is None: + out["block_reason"] = out["block_reason"] or "无法读取标记价" + return out + reached = trend_dca_level_reached(direction, float(pf), level) + out["trigger_reached"] = reached + if reached and not ok_live: + out["block_reason"] = live_reason or "LIVE_TRADING_ENABLED=false" + elif reached and ok_live: + pos = m.get_live_position_contracts(ex_sym, direction) + try: + local_open = float(d.get("order_amount_open") or 0) + except (TypeError, ValueError): + local_open = 0.0 + if pos is None and local_open > 0: + pos = local_open + if pos is None: + out["block_reason"] = "无法读取交易所持仓" + elif float(pos) <= 0: + out["block_reason"] = "交易所无持仓" + else: + out["block_reason"] = ( + "标记价已触达,轮询应自动下单;若仍未补请确认 PM2 进程 crypto_gate " + "(或对应所 Flask 进程)在运行,并查看 pm2 logs" + ) + elif not reached: + out["block_reason"] = f"标记价 {pf} 未触达下一档 {level}" + return out + + +def trend_add_zone_label(direction: str) -> str: + return "补仓下沿" if (direction or "long").strip().lower() == "short" else "补仓上沿" + + +def install_strategy_trend(app: Flask, repo_root: str, app_module: Any = None, **build_kw) -> dict: + from lib.strategy.strategy_register import attach_strategy_templates + + attach_strategy_templates(app, repo_root) + cfg = build_trend_config(app_module, **build_kw) + app.extensions["strategy_trend_cfg"] = cfg + register_trend_routes(app, cfg) + _patch_hub_monitor_enrich(app, cfg) + roll_cfg = app.extensions.get("strategy_roll_cfg") + if isinstance(roll_cfg, dict): + from lib.strategy.strategy_roll_ui_lib import patch_roll_hub_enrich + + patch_roll_hub_enrich(app, roll_cfg) + _patch_hub_trend_views(app) + + @app.context_processor + def _trend_ctx(): + return {"trend_add_zone_label": trend_add_zone_label} + + return cfg + + +def build_trend_config(app_module: Any = None, **kw) -> dict[str, Any]: + m = resolve_trading_app_module(app_module) + dca = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", kw.get("dca_legs", "5")))) + preview_ttl = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) + drift = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) + be_pct = float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + buf = float(getattr(m, "FULL_MARGIN_BUFFER_RATIO", 0.95)) + + def amount_precise(ex_sym, amt): + fn = getattr(m, "_safe_amount_to_precision", None) + if callable(fn): + return fn(ex_sym, amt) + try: + m.ensure_markets_loaded() + return float(m.exchange.amount_to_precision(ex_sym, float(amt))) + except Exception: + return None + + def send_wechat(content): + fn = getattr(m, "send_wechat_msg", None) + if callable(fn): + fn(content) + + def wechat_account_label(): + fn = getattr(m, "_wechat_account_label", None) + if callable(fn): + try: + return fn() + except Exception: + pass + return getattr(m, "EXCHANGE_DISPLAY_NAME", "") or "" + + def wechat_direction_text(direction): + fn = getattr(m, "_wechat_direction_text", None) + if callable(fn): + try: + return fn(direction) + except Exception: + pass + d = (direction or "long").strip().lower() + return "做多" if d == "long" else "做空" + + return { + "app_module": m, + "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), + "login_required": m.login_required, + "get_db": m.get_db, + "row_to_dict": m.row_to_dict, + "dca_legs": dca, + "preview_ttl": preview_ttl, + "drift_pct": drift, + "breakeven_offset_pct": be_pct, + "margin_buffer": buf, + "amount_precise": amount_precise, + "max_active_positions": int(getattr(m, "MAX_ACTIVE_POSITIONS", 1)), + "reset_hour": int(getattr(m, "TRADING_DAY_RESET_HOUR", 8)), + "monitor_type_trend": MONITOR_TYPE_TREND, + "send_wechat": send_wechat, + "format_price": getattr(m, "format_price_for_symbol", None), + "wechat_account_label": wechat_account_label, + "wechat_direction_text": wechat_direction_text, + } + + +def _m(cfg: dict): + return cfg["app_module"] + + +def _row(cfg, row) -> dict: + return cfg["row_to_dict"](row) + + +def precheck_trend_start(cfg: dict, conn, *, symbol: str = "", direction: str = "long") -> tuple[bool, str]: + m = _m(cfg) + mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" + try: + from lib.trade.position_sizing_lib import OPEN_SOURCE_TREND, assert_open_source_allowed + + ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_TREND) + if not ok_src: + return False, src_msg + except Exception: + pass + sym = (symbol or "").strip() + dir_l = (direction or "long").strip().lower() + validate_fn = getattr(m, "validate_trade_policy_open", None) + if callable(validate_fn) and sym: + ok_pol, pol_msg = validate_fn(sym, dir_l) + if not ok_pol: + return False, pol_msg + if sym and dir_l in ("long", "short") and hasattr(m, "precheck_risk"): + ok_risk, risk_msg = m.precheck_risk(conn, sym, dir_l) + if not ok_risk: + return False, risk_msg + else: + now = m.app_now() + if not m.trading_day_reset_allows_new_open(now): + return False, f"北京时间 {cfg['reset_hour']}:00 前不允许持仓" + from lib.trade.account_risk_lib import account_risk_blocks_trading, position_limit_reached + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=m.get_trading_day(now), + now=now, + fmt_local_ms=getattr(m, "ms_to_app_local_str", lambda _x: ""), + ) + if not ok_risk: + return False, risk_reason + reached, active_count, mx = position_limit_reached( + conn, max_active_positions=cfg["max_active_positions"] + ) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + from lib.trade.daily_open_limit_lib import check_daily_open_hard_limit + + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, + m.get_trading_day(now), + getattr(m, "DAILY_OPEN_HARD_LIMIT", 0), + cfg["reset_hour"], + ) + if not ok_daily: + return False, daily_reason + active = m.get_active_position_count(conn) + if active >= cfg["max_active_positions"]: + return ( + False, + f"已达最大持仓数({active}/{cfg['max_active_positions']})," + "请先结束「实盘下单」中的持仓,再启动趋势回调", + ) + trend_n = conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + if int(trend_n or 0) > 0: + return False, "已存在运行中的趋势回调计划" + return True, "" + + +def _cleanup_stale_previews(conn) -> None: + ms = int(time.time() * 1000) + stale = conn.execute( + "SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,) + ).fetchall() + for row in stale: + try: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='expired' " + "WHERE preview_id=? AND outcome='open'", + (row["id"],), + ) + except Exception: + pass + conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)) + + +def parse_trend_plan(cfg: dict, form_dict) -> tuple[Optional[dict], Optional[str]]: + m = _m(cfg) + d = form_dict or {} + symbol = m.normalize_symbol_input(d.get("symbol")) + if not symbol: + return None, "symbol 不能为空" + direction = (d.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向错误" + try: + stop_loss = float(d.get("sl")) + add_upper = float(d.get("add_upper")) + take_profit = float(d.get("take_profit")) + risk_percent = float(d.get("risk_percent") or "5") + except Exception: + return None, "价格或风险比例格式错误" + try: + lev_raw = m.parse_positive_float(d.get("leverage")) + leverage = int(lev_raw) if lev_raw is not None else m.infer_leverage(symbol) + except Exception: + return None, "杠杆格式错误" + if leverage <= 0 or risk_percent <= 0: + return None, "杠杆与风险比例必须大于0" + bound_err = validate_trend_bounds(direction, stop_loss, add_upper) + if bound_err: + return None, bound_err + snap = m.get_available_trading_usdt() + if snap is None or snap <= 0: + return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型" + live_price = m.get_price(symbol) + if live_price is None: + return None, "获取实时价格失败" + exchange_symbol = m.normalize_exchange_symbol(symbol) + rf = calc_risk_fraction(direction, add_upper, stop_loss) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + risk_budget = float(snap) * (risk_percent / 100.0) + notional = risk_budget / rf + margin_plan = notional / float(leverage) + margin_plan = min(margin_plan, float(snap) * cfg["margin_buffer"]) + if margin_plan <= 0: + return None, "计划保证金过小" + try: + target_amt, _ = m.prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price) + except Exception as e: + return None, str(e) + ap = cfg["amount_precise"] + first_amt = ap(exchange_symbol, float(target_amt) * 0.5) + if first_amt is None or first_amt <= 0: + return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" + remainder_total = ap(exchange_symbol, max(0.0, float(target_amt) - float(first_amt))) + if remainder_total is None: + remainder_total = 0.0 + m.ensure_markets_loaded() + market = m.exchange.market(exchange_symbol) + min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0) + n_legs, leg_json, per_ref = build_leg_amounts_json( + exchange_symbol, remainder_total, cfg["dca_legs"], ap, min_amt + ) + if n_legs <= 0: + return None, "剩余计划张数不足以拆出补仓档,请提高风险比例或放宽止损与补仓区间间距" + grid = build_grid_prices(direction, stop_loss, add_upper, n_legs) + if len(grid) != n_legs: + return None, "补仓网格生成失败" + opened_at = m.app_now_str() + try: + leg_list = json.loads(leg_json) + except Exception: + leg_list = [] + contract_size = float(market.get("contractSize") or 1) + return { + "symbol": symbol, + "exchange_symbol": exchange_symbol, + "direction": direction, + "leverage": leverage, + "stop_loss": stop_loss, + "add_upper": add_upper, + "take_profit": take_profit, + "risk_percent": risk_percent, + "snapshot_available_usdt": float(snap), + "snapshot_at": opened_at, + "live_price_ref": float(live_price), + "plan_margin_capital": float(margin_plan), + "target_order_amount": float(target_amt), + "first_order_amount": float(first_amt), + "remainder_total": float(remainder_total), + "dca_legs": int(n_legs), + "per_leg_amount": float(per_ref), + "grid_prices_json": json.dumps(grid), + "leg_amounts_json": leg_json, + "grid": grid, + "leg_amounts": leg_list, + "contract_size": contract_size, + }, None + + +def _insert_preview_snapshot(conn, preview_id: str, created: str, exp_ms: int, pl: dict) -> None: + conn.execute( + """INSERT INTO trend_pullback_preview_snapshots ( + preview_id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,preview_created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + preview_id, + pl["symbol"], + pl["exchange_symbol"], + pl["direction"], + pl["leverage"], + pl["stop_loss"], + pl["add_upper"], + pl["take_profit"], + pl["risk_percent"], + pl["snapshot_available_usdt"], + pl["snapshot_at"], + pl["live_price_ref"], + pl["plan_margin_capital"], + pl["target_order_amount"], + pl["first_order_amount"], + pl["remainder_total"], + pl["dca_legs"], + pl["per_leg_amount"], + pl["grid_prices_json"], + pl["leg_amounts_json"], + exp_ms, + created, + ), + ) + + +def _format_trend_price(cfg: dict, symbol: str, value) -> str: + if value in (None, ""): + return "—" + m = _m(cfg) + sym = symbol or "" + norm = getattr(m, "normalize_exchange_symbol", None) + if callable(norm): + try: + sym = norm(sym) or sym + except Exception: + pass + try: + m.ensure_markets_loaded() + return str(m.exchange.price_to_precision(sym, float(value))) + except Exception: + fn = getattr(m, "format_price_for_symbol", None) + if callable(fn): + return fn(symbol, value) + return str(value) + + +def _trend_add_leg_fields(cfg: dict, d: dict) -> dict: + """解析已补仓次数与已触达网格价(供策略页与中控 monitor 共用).""" + import json + + out = dict(d) + try: + legs_done = int(out.get("legs_done") or 0) + except (TypeError, ValueError): + legs_done = 0 + try: + dca_legs = int(out.get("dca_legs") or 0) + except (TypeError, ValueError): + dca_legs = 0 + try: + grid = json.loads(out.get("grid_prices_json") or "[]") + if not isinstance(grid, list): + grid = [] + except Exception: + grid = [] + add_prices: list[float] = [] + try: + from lib.strategy.strategy_trend_lib import trend_leg_display_price + + for i in range(1, legs_done + 1): + px = trend_leg_display_price(out, i) + if px is not None: + add_prices.append(float(px)) + except Exception: + pass + if not add_prices: + for x in grid[:legs_done]: + try: + add_prices.append(float(x)) + except (TypeError, ValueError): + pass + sym = out.get("exchange_symbol") or out.get("symbol") or "" + out["add_count"] = legs_done + out["add_count_total"] = dca_legs + out["add_prices"] = add_prices + out["add_prices_display"] = [_format_trend_price(cfg, sym, p) for p in add_prices] + for field in ("stop_loss", "take_profit", "add_upper", "avg_entry_price"): + if out.get(field) not in (None, ""): + out[f"{field}_display"] = _format_trend_price(cfg, sym, out.get(field)) + return out + + +def enrich_trend_plan_for_hub(cfg: dict, raw: dict) -> dict: + """中控 /api/hub/monitor:与策略页运行中计划卡片同字段(浮盈亏,标记价,盈亏比等).""" + d = enrich_trend_plan(cfg, dict(raw or {})) + d["monitor_source"] = "趋势回调计划" + m = _m(cfg) + try: + snap = float(d.get("snapshot_available_usdt") or 0) + margin = float(d.get("plan_margin_capital") or 0) + if snap > 0 and margin > 0: + d["position_ratio_pct"] = round(margin / snap * 100.0, 2) + except (TypeError, ValueError): + pass + return d + + +def _patch_hub_trend_views(app: Flask) -> None: + """将趋势回调路由注册进 HUB_CTX.views,供中控 /api/hub/trend/* 调用.""" + ctx = dict(app.config.get("HUB_CTX") or {}) + views = dict(ctx.get("views") or {}) + for name in ( + "preview_trend_pullback", + "execute_trend_pullback", + "stop_trend_pullback", + "trend_pullback_breakeven", + ): + vf = app.view_functions.get(name) + if vf is not None: + views[name] = vf + ctx["views"] = views + app.config["HUB_CTX"] = ctx + + +def patch_trend_hub_enrich(app: Flask, cfg: dict) -> None: + """hub_bridge install 之后调用:三所 /api/hub/monitor 趋势字段与策略页一致.""" + _patch_hub_monitor_enrich(app, cfg) + + +def _patch_hub_monitor_enrich(app: Flask, cfg: dict) -> None: + ctx = dict(app.config.get("HUB_CTX") or {}) + prev = ctx.get("enrich_monitor") + + def enrich_monitor(keys=None, orders=None, trends=None, rolls=None): + payload: dict[str, Any] = {} + if callable(prev): + try: + prev_out = prev(keys=keys, orders=orders, trends=trends, rolls=rolls) + if isinstance(prev_out, dict): + payload.update(prev_out) + except Exception: + pass + if trends: + payload["trends"] = [ + enrich_trend_plan_for_hub(cfg, t) for t in trends if isinstance(t, dict) + ] + return payload + + ctx["enrich_monitor"] = enrich_monitor + app.config["HUB_CTX"] = ctx + + +def enrich_trend_plan(cfg: dict, row) -> dict: + m = _m(cfg) + d = _row(cfg, row) + try: + d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0 + except Exception: + d["breakeven_applied"] = False + ex_sym = d.get("exchange_symbol") or m.normalize_exchange_symbol(d.get("symbol") or "") + direction = (d.get("direction") or "long").lower() + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) + met = None + if callable(metrics_fn): + try: + lev = int(d.get("leverage") or 0) or None + except (TypeError, ValueError): + lev = None + try: + met = metrics_fn(ex_sym, direction, order_leverage=lev) + except TypeError: + met = metrics_fn(ex_sym, direction) + if met and met.get("entry_price") is not None: + try: + live_entry = float(met["entry_price"]) + if live_entry > 0: + d["avg_entry_price"] = live_entry + except (TypeError, ValueError): + pass + if met and met.get("unrealized_pnl") is not None: + d["floating_pnl"] = float(met["unrealized_pnl"]) + elif ( + met + and met.get("mark_price") is not None + and d.get("avg_entry_price") is not None + ): + try: + from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt + + entry = float(d["avg_entry_price"]) + mark = float(met["mark_price"]) + qty = None + cs = 1.0 + get_qty = getattr(m, "get_live_position_contracts", None) + get_cs = getattr(m, "get_contract_size", None) + if callable(get_qty): + qty = get_qty(ex_sym, direction) + if callable(get_cs): + cs = float(get_cs(ex_sym)) + upnl = estimate_linear_swap_upnl_usdt( + direction, entry, mark, qty, cs + ) + d["floating_pnl"] = float(upnl) if upnl is not None else None + except (TypeError, ValueError): + d["floating_pnl"] = None + else: + d["floating_pnl"] = None + if met and met.get("mark_price") is not None: + d["floating_mark"] = float(met["mark_price"]) + else: + d["floating_mark"] = None + else: + d["floating_pnl"] = d["floating_mark"] = None + get_cs = getattr(m, "get_contract_size", None) + if callable(get_cs): + try: + d["contract_size"] = float(get_cs(ex_sym)) + except (TypeError, ValueError): + pass + d = _trend_add_leg_fields(cfg, d) + from lib.strategy.strategy_snapshot_lib import attach_trend_dca_levels + from lib.strategy.strategy_trend_lib import calc_trend_plan_money_metrics + + d = attach_trend_dca_levels(d) + money = calc_trend_plan_money_metrics(d) + if money.get("money_rr") is not None: + d["money_rr"] = money["money_rr"] + d["planned_rr"] = money["money_rr"] + if money.get("risk_amount_u") is not None: + d["risk_amount_u"] = money["risk_amount_u"] + try: + d["breakeven_default_offset_pct"] = float(cfg.get("breakeven_offset_pct", 0.3)) + except (TypeError, ValueError): + d["breakeven_default_offset_pct"] = 0.3 + return d + + +def _weighted_avg(old_avg, old_amt, fill_px, add_amt): + try: + oa, aa = float(old_amt), float(add_amt) + if oa <= 0: + return float(fill_px) + return (float(old_avg) * oa + float(fill_px) * aa) / (oa + aa) + except Exception: + return float(fill_px or 0) + + +def _plan_stop_status(result_label: str) -> str: + if result_label == "止盈": + return "stopped_tp" + if result_label == "止损": + return "stopped_sl" + return "stopped_manual" + + +def _call_insert_trade_record(m, plan_id: int, kwargs: dict) -> None: + """按各所 insert_trade_record 签名过滤参数,避免未知字段导致记账失败.""" + fn = getattr(m, "insert_trade_record", None) + if not callable(fn): + raise RuntimeError("app_module 缺少 insert_trade_record") + allowed = set(inspect.signature(fn).parameters.keys()) + call = {k: v for k, v in kwargs.items() if k in allowed} + if "trend_plan_id" in allowed: + call["trend_plan_id"] = int(plan_id) + fn(**call) + + +def _best_trend_close_snapshot(conn, plan_id: int) -> dict | None: + from lib.strategy.strategy_snapshot_lib import ( + FINAL_TREND_CLOSE_LABELS, + STRATEGY_TREND, + _final_trend_close_rank, + ) + + rows = conn.execute( + f"""SELECT * FROM strategy_trade_snapshots + WHERE strategy_type=? AND source_id=? + AND result_label IN ({",".join("?" * len(FINAL_TREND_CLOSE_LABELS))})""", + (STRATEGY_TREND, int(plan_id), *FINAL_TREND_CLOSE_LABELS), + ).fetchall() + if not rows: + return None + parsed = [_row_dict(row) for row in rows] + return max( + parsed, + key=lambda d: ( + _final_trend_close_rank(str(d.get("result_label") or "")), + int(d.get("id") or 0), + ), + ) + + +def _ensure_trend_plan_trade_record( + cfg: dict, conn, plan_id: int, *, prefer_label: str = "手动平仓" +) -> bool: + """计划已结束但 trade_records 缺失时,从策略快照补录一条.""" + if _trend_plan_trade_exists(conn, plan_id): + return True + m = _m(cfg) + plan = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=?", (int(plan_id),) + ).fetchone() + if not plan: + return False + plan_d = _row_dict(plan) + snap = _best_trend_close_snapshot(conn, plan_id) + if not snap: + return False + try: + payload = json.loads(snap.get("snapshot_json") or "{}") + except Exception: + payload = {} + sym = snap.get("symbol") or plan_d.get("symbol") or payload.get("symbol") + direction = snap.get("direction") or plan_d.get("direction") or "long" + result = (prefer_label or "").strip() or (snap.get("result_label") or "").strip() or "手动平仓" + opened_at = snap.get("opened_at") or plan_d.get("opened_at") + closed_at = snap.get("closed_at") + pnl_amount = snap.get("pnl_amount") + if pnl_amount is None: + pnl_amount = payload.get("pnl_amount") + avg_e = float(payload.get("avg_entry_price") or plan_d.get("avg_entry_price") or 0) + margin_cap = trend_effective_margin_capital(plan_d) + lev = int(plan_d.get("leverage") or 1) + hold_seconds = m.calc_hold_seconds( + opened_at or "", + m.parse_dt_for_trading_day(closed_at) or m.app_now(), + ) + res = m.normalize_result_with_pnl(result, float(pnl_amount or 0)) + risk_amt = m.calc_risk_amount_from_plan( + direction, + float(plan_d.get("add_upper") or 0), + float(plan_d.get("stop_loss") or 0), + float(plan_d.get("plan_margin_capital") or 0), + lev, + ) + planned_rr = m.calc_rr_ratio( + direction, + avg_e, + float(plan_d.get("stop_loss") or 0), + float(plan_d.get("take_profit") or 0), + ) + session_date = plan_d.get("session_date") or m.get_trading_day() + _bump_session_capital_no_commit(m, conn, session_date, float(pnl_amount or 0)) + _call_insert_trade_record( + m, + plan_id, + dict( + conn=conn, + symbol=sym, + monitor_type=MONITOR_TYPE_TREND, + direction=direction, + trigger_price=avg_e, + stop_loss=float(plan_d.get("stop_loss") or 0), + initial_stop_loss=float(plan_d.get("initial_stop_loss") or plan_d.get("stop_loss") or 0), + take_profit=float(plan_d.get("take_profit") or 0), + margin_capital=margin_cap, + leverage=lev, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style="trend_pullback", + risk_amount=risk_amt, + planned_rr=planned_rr, + actual_rr=m.calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + entry_reason=ENTRY_REASON_TREND_PULLBACK, + ), + ) + conn.commit() + return True + + +def sync_trend_plans_after_external_close( + cfg: dict, conn, symbol: str, direction: str +) -> dict[str, Any]: + """中控/外部全平后:结束仍 active 的同币种同向趋势计划(避免监控再记一条止损).""" + m = _m(cfg) + sym = m.normalize_symbol_input(symbol) if hasattr(m, "normalize_symbol_input") else (symbol or "").strip() + if not sym: + return {"ok": False, "msg": "symbol 无效", "finalized": 0} + direction = (direction or "long").strip().lower() + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' AND symbol=? AND direction=?", + (sym, direction), + ).fetchall() + finalized = 0 + for row in rows: + px = m.get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + before = _trend_plan_trade_exists(conn, int(row["id"])) + _finalize_plan(cfg, conn, row, "手动平仓", exit_p) + if not before: + finalized += 1 + return {"ok": True, "finalized": finalized, "symbol": sym, "direction": direction} + + +def _trend_plan_trade_exists(conn, plan_id: int) -> bool: + try: + return conn.execute( + "SELECT id FROM trade_records WHERE trend_plan_id=? LIMIT 1", + (int(plan_id),), + ).fetchone() is not None + except Exception: + return False + + +def _bump_session_capital_no_commit( + m, conn, session_date: str, pnl_amount: float +) -> float | None: + """更新当日资金,不单独 commit(与 _finalize_plan 同一事务).""" + try: + row = conn.execute( + "SELECT current_capital FROM trading_sessions WHERE session_date = ?", + (session_date,), + ).fetchone() + if not row: + start_cap = float(getattr(m, "DAILY_START_CAPITAL", 0) or 0) + if start_cap <= 0: + ensure = getattr(m, "ensure_session", None) + if callable(ensure): + ensured = ensure(conn, session_date) + row = ensured + else: + return None + else: + conn.execute( + "INSERT OR IGNORE INTO trading_sessions " + "(session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, start_cap, start_cap), + ) + row = conn.execute( + "SELECT current_capital FROM trading_sessions WHERE session_date = ?", + (session_date,), + ).fetchone() + if not row: + return None + new_capital = float(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), + ) + return round(new_capital, 4) + except Exception: + return None + + +def _apply_trend_user_risk_close(cfg: dict, conn, *, trade_record_id=None, closed_at_ms=None) -> None: + m = _m(cfg) + fn = getattr(m, "hub_user_initiated_close", None) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_TREND_STOP + + if callable(fn): + fn( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + count=1, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + ) + return + from lib.trade.account_risk_lib import on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=m.get_trading_day(), + now=m.app_now(), + count=1, + ) + + +def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float, *, user_initiated_risk: bool = False) -> None: + m = _m(cfg) + plan_id = int(row["id"]) + active = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", + (plan_id,), + ).fetchone() + if not active: + return + row = active + sym = row["symbol"] + direction = row["direction"] or "long" + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + closed_at = m.app_now_str() + opened_at = row["opened_at"] or closed_at + hold_seconds = m.calc_hold_seconds(opened_at, m.parse_dt_for_trading_day(closed_at) or m.app_now()) + plan_margin = float(row["plan_margin_capital"] or 0) + margin_cap = trend_effective_margin_capital(_row(cfg, row)) + lev = int(row["leverage"] or 1) + avg_e = float(row["avg_entry_price"] or 0) + pnl_amount = m.calc_pnl(direction, avg_e, float(exit_price), margin_cap, lev) + res = m.normalize_result_with_pnl(result_label, pnl_amount) + risk_amt = m.calc_risk_amount_from_plan( + direction, float(row["add_upper"]), float(row["stop_loss"]), plan_margin, lev + ) + try: + target = float(row["target_order_amount"] or 0) + open_amt = float(row["order_amount_open"] or 0) + if risk_amt is not None and target > 0 and open_amt > 0: + risk_amt = round(float(risk_amt) * min(1.0, open_amt / target), 6) + except (TypeError, ValueError): + pass + planned_rr = m.calc_rr_ratio(direction, avg_e, float(row["stop_loss"]), float(row["take_profit"])) + st = _plan_stop_status(result_label) + cur = conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=? WHERE id=? AND status='active'", + (st, res, plan_id), + ) + if not getattr(cur, "rowcount", 0): + return + try: + from lib.strategy.strategy_snapshot_lib import save_trend_plan_snapshot + + save_trend_plan_snapshot( + cfg, + conn, + row, + result_label=result_label, + exit_price=float(exit_price) if exit_price is not None else None, + pnl_amount=float(pnl_amount) if pnl_amount is not None else None, + closed_at=closed_at, + ) + except Exception: + pass + try: + cancel_symbol_orders(cfg, ex_sym) + except Exception: + pass + session_capital = None + trade_record_id = None + if not _trend_plan_trade_exists(conn, plan_id): + session_date = row["session_date"] or m.get_trading_day() + session_capital = _bump_session_capital_no_commit( + m, conn, session_date, pnl_amount + ) + _call_insert_trade_record( + m, + plan_id, + dict( + conn=conn, + symbol=sym, + monitor_type=MONITOR_TYPE_TREND, + direction=direction, + trigger_price=avg_e, + stop_loss=float(row["stop_loss"]), + initial_stop_loss=float(row.get("initial_stop_loss") or row["stop_loss"]), + take_profit=float(row["take_profit"]), + margin_capital=margin_cap, + leverage=lev, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style="trend_pullback", + risk_amount=risk_amt, + planned_rr=planned_rr, + actual_rr=m.calc_actual_rr(pnl_amount, risk_amt), + result=res, + opened_at=opened_at, + closed_at=closed_at, + entry_reason=ENTRY_REASON_TREND_PULLBACK, + ), + ) + try: + from lib.trade.account_risk_lib import insert_trade_record_id + + trade_record_id = insert_trade_record_id(conn) + except Exception: + trade_record_id = None + if user_initiated_risk: + closed_ms = None + to_ms = getattr(m, "_to_ms_with_fallback", None) + if callable(to_ms): + try: + closed_ms = to_ms(None, closed_at) + except Exception: + closed_ms = None + _apply_trend_user_risk_close( + cfg, + conn, + trade_record_id=trade_record_id, + closed_at_ms=closed_ms, + ) + conn.commit() + try: + from lib.strategy.strategy_wechat_notify import notify_trend_plan_ended + + notify_trend_plan_ended( + cfg, + plan_id=plan_id, + symbol=sym, + direction=direction, + end_type=result_label, + result_label=res, + exit_price=float(exit_price) if exit_price is not None else None, + pnl_amount=float(pnl_amount) if pnl_amount is not None else None, + ) + except Exception: + pass + extra = getattr(m, "build_wechat_close_message", None) + send = getattr(m, "send_wechat_msg", None) + if callable(extra) and callable(send): + send( + extra( + symbol=sym, + direction=direction, + result=f"{res}({MONITOR_TYPE_TREND})", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=avg_e, + current_price=float(exit_price), + stop_loss=float(row["stop_loss"]), + take_profit=float(row["take_profit"]), + close_order_id="-", + extra_note="计划本金口径:启动时合约可用余额快照;止盈由程序监控", + session_capital_fallback=session_capital, + ) + ) + + +def _trend_plan_open_age_sec(row, m) -> float: + opened_ms = None + try: + if "opened_at_ms" in row.keys() and row["opened_at_ms"]: + opened_ms = int(row["opened_at_ms"]) + except Exception: + opened_ms = None + to_ms = getattr(m, "_to_ms_with_fallback", None) + if callable(to_ms): + opened_ms = to_ms(opened_ms, row["opened_at"] if "opened_at" in row.keys() else None) + if opened_ms is None and "opened_at" in row.keys(): + opened_ms = to_ms(None, row["opened_at"]) + if not opened_ms: + return 0.0 + return max(0.0, (time.time() * 1000 - opened_ms) / 1000.0) + + +def _trend_hit_take_profit(direction: str, mark_price: float, take_profit: float, avg_entry: float) -> bool: + try: + pf = float(mark_price) + tp = float(take_profit) + entry = float(avg_entry) + except (TypeError, ValueError): + return False + if entry <= 0 or tp <= 0: + return False + direction = (direction or "long").lower() + if direction == "long": + return tp > entry and pf >= tp + return tp < entry and pf <= tp + + +def _trend_poll_price(m, sym: str, ex_sym: str, direction: str) -> Optional[float]: + """补仓/止盈判定用标记价(与页面「标记价」一致),无标记价时回退 last.""" + fn = getattr(m, "get_symbol_mark_price", None) + if callable(fn): + try: + px = fn(sym) + if px is not None and float(px) > 0: + return float(px) + except Exception: + pass + metrics_fn = getattr(m, "get_live_position_exchange_metrics", None) + if callable(metrics_fn): + try: + met = metrics_fn(ex_sym, direction) + if met and met.get("mark_price") is not None: + px = float(met["mark_price"]) + if px > 0: + return px + except Exception: + pass + px = m.get_price(sym) + try: + return float(px) if px is not None else None + except (TypeError, ValueError): + return None + + +def _should_finalize_trend_flat(row, pos, plan_id: int, m) -> bool: + """首仓后交易所报无仓:需过开仓宽限期 + 连续空仓轮询,避免误判止损.""" + if pos is None: + return False + if float(pos) > 0: + _TREND_FLAT_STREAK.pop(plan_id, None) + return False + if not int(row["first_order_done"] or 0): + return False + age = _trend_plan_open_age_sec(row, m) + if age < TREND_OPEN_GRACE_SEC: + _TREND_FLAT_STREAK.pop(plan_id, None) + return False + try: + local_open = float(row["order_amount_open"] or 0) + except (TypeError, ValueError): + local_open = 0.0 + required = TREND_FLAT_CONFIRM_POLLS + if local_open > 0 and age < TREND_OPEN_GRACE_SEC * 2: + required = max(required, TREND_FLAT_CONFIRM_POLLS * 2) + streak = int(_TREND_FLAT_STREAK.get(plan_id, 0)) + 1 + _TREND_FLAT_STREAK[plan_id] = streak + if streak >= required: + print( + f"[trend_pullback] flat finalize plan={plan_id} sym={row['symbol']} " + f"age={age:.0f}s streak={streak} local_open={local_open}", + flush=True, + ) + return True + return False + + +def check_trend_pullback_plans(cfg: dict) -> None: + m = _m(cfg) + ok_live, live_reason = m.ensure_exchange_live_ready() + _TREND_POLL_STATE["updated_at"] = time.time() + _TREND_POLL_STATE["live_ok"] = ok_live + _TREND_POLL_STATE["live_reason"] = live_reason or "" + if not ok_live: + _log_trend_live_skip(live_reason or "unknown") + conn = cfg["get_db"]() + try: + for row in conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active'" + ).fetchall(): + probe = summarize_trend_dca_probe(cfg, row) + if probe.get("trigger_reached"): + _set_trend_poll_plan(int(row["id"]), probe) + except Exception as e: + print(f"[trend_pullback] live-skip probe error: {e}", flush=True) + finally: + conn.close() + return + conn = cfg["get_db"]() + rows = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active'" + ).fetchall() + for row in rows: + try: + plan_id = int(row["id"]) + sym = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + sl = float(row["stop_loss"]) + tp = float(row["take_profit"]) + lev = int(row["leverage"] or 1) + try: + local_open = float(row["order_amount_open"] or 0) + except (TypeError, ValueError): + local_open = 0.0 + pf = _trend_poll_price(m, sym, ex_sym, direction) + if pf is None: + continue + last_p = row["last_mark_price"] + last_pf = float(last_p) if last_p is not None else pf + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is None: + if local_open > 0 and int(row["first_order_done"] or 0): + pos = local_open + else: + continue + elif float(pos) <= 0 and local_open > 0: + age = _trend_plan_open_age_sec(row, m) + if age < TREND_OPEN_GRACE_SEC * 2: + print( + f"[trend_pullback] pos fallback plan={plan_id} sym={sym} " + f"ex_pos=0 local_open={local_open} age={age:.0f}s", + flush=True, + ) + pos = local_open + legs_done = int(row["legs_done"] or 0) + try: + leg_amounts = [float(x) for x in json.loads(row["leg_amounts_json"] or "[]")] + except Exception: + leg_amounts = [] + try: + grid = json.loads(row["grid_prices_json"] or "[]") + except Exception: + grid = [] + avg_e = float(row["avg_entry_price"] or pf or 0) + hit_tp = _trend_hit_take_profit(direction, pf, tp, avg_e) + if hit_tp and pos > 0: + try: + close_resp = trend_market_close(cfg, ex_sym, direction, float(pos), lev) + exit_p = m.extract_trade_price_from_order(close_resp) or pf + except Exception as e: + if not m.is_no_position_error(str(e)): + continue + exit_p = pf + _finalize_plan(cfg, conn, row, "止盈", exit_p) + _TREND_FLAT_STREAK.pop(plan_id, None) + continue + if _should_finalize_trend_flat(row, pos, plan_id, m): + _finalize_plan(cfg, conn, row, "止损", pf) + _TREND_FLAT_STREAK.pop(plan_id, None) + continue + if int(row["first_order_done"] or 0) and legs_done < len(grid) and legs_done < len(leg_amounts): + while legs_done < len(grid) and legs_done < len(leg_amounts): + level = float(grid[legs_done]) + if not trend_dca_level_reached(direction, pf, level): + break + amt = float(m.exchange.amount_to_precision(ex_sym, leg_amounts[legs_done])) + if amt <= 0: + print( + f"[trend_pullback] dca skip plan={plan_id} leg={legs_done + 1} " + f"amt_precision=0 raw={leg_amounts[legs_done]}", + flush=True, + ) + break + try: + add_resp = trend_market_add(cfg, ex_sym, direction, amt, lev) + except Exception as e: + print( + f"[trend_pullback] dca order failed plan={plan_id} sym={sym} " + f"leg={legs_done + 1} level={level} mark={pf} err={e}", + flush=True, + ) + break + fill_px = m.extract_trade_price_from_order(add_resp) or pf + old_avg = float(row["avg_entry_price"] or fill_px) + old_open = float(row["order_amount_open"] or 0) + new_avg = _weighted_avg(old_avg, old_open, fill_px, amt) + legs_done += 1 + from lib.strategy.strategy_trend_lib import append_leg_fill_price_json + + fills_json = append_leg_fill_price_json( + row["leg_fill_prices_json"] if "leg_fill_prices_json" in row.keys() else None, + fill_px, + ) + conn.execute( + "UPDATE trend_pullback_plans SET legs_done=?, avg_entry_price=?, " + "order_amount_open=?, last_mark_price=?, leg_fill_prices_json=? WHERE id=?", + (legs_done, new_avg, old_open + amt, pf, fills_json, row["id"]), + ) + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=?", (row["id"],) + ).fetchone() + print( + f"[trend_pullback] dca filled plan={plan_id} leg={legs_done} " + f"fill={fill_px} avg={new_avg} open={old_open + amt}", + flush=True, + ) + try: + trend_refresh_stop_only(cfg, ex_sym, direction, sl) + except Exception: + pass + conn.execute( + "UPDATE trend_pullback_plans SET last_mark_price=? WHERE id=?", + (pf, row["id"]), + ) + probe = summarize_trend_dca_probe(cfg, row) + probe["last_poll_mark"] = pf + _set_trend_poll_plan(plan_id, probe) + if probe.get("trigger_reached") and probe.get("block_reason"): + print( + f"[trend_pullback] dca blocked plan={plan_id} sym={sym} " + f"mark={pf} next={probe.get('next_trigger')} reason={probe.get('block_reason')}", + flush=True, + ) + except Exception as e: + print( + f"[trend_pullback] poll error plan={row['id'] if row else '?'}: {e}", + flush=True, + ) + continue + conn.commit() + conn.close() + + +TREND_PLAN_STATUS_HANDOFF = "stopped_handoff" + + +def _order_monitor_manual_type(m) -> str: + return getattr(m, "ORDER_MONITOR_TYPE_MANUAL", None) or "下单监控" + + +def _insert_trend_handoff_order_monitor( + cfg: dict, + conn, + plan_row, + *, + new_sl: float, + pos_amt: float, +) -> int: + m = _m(cfg) + sym = plan_row["symbol"] + direction = (plan_row["direction"] or "long").lower() + ex_sym = plan_row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + plan_id = int(plan_row["id"]) + avg_e = float(plan_row["avg_entry_price"] or 0) + tp = float(plan_row["take_profit"] or 0) + lev = int(plan_row["leverage"] or 1) + margin_cap = float(plan_row["plan_margin_capital"] or 0) + init_sl = float( + plan_row["initial_stop_loss"] + if plan_row["initial_stop_loss"] not in (None, "") + else plan_row["stop_loss"] + or 0 + ) + risk_pct = float(plan_row["risk_percent"] or 5) + risk_amt = None + calc_risk = getattr(m, "calc_risk_amount_from_plan", None) + if callable(calc_risk): + try: + risk_amt = calc_risk(direction, avg_e, init_sl, margin_cap, lev) + except Exception: + risk_amt = None + be_rr = float(getattr(m, "BREAKEVEN_RR_TRIGGER", 1) or 1) + be_off = float(getattr(m, "BREAKEVEN_OFFSET_PCT", 0.3) or 0.3) + be_step = float(getattr(m, "BREAKEVEN_STEP_R", 1) or 1) + if direction == "short": + be_price = round(avg_e * (1 - be_off / 100.0), 8) + else: + be_price = round(avg_e * (1 + be_off / 100.0), 8) + rp = getattr(m, "round_price_to_exchange", None) + if callable(rp): + try: + be_price = float(rp(ex_sym, be_price) or be_price) + except Exception: + pass + opened_at = plan_row["opened_at"] or m.app_now_str() + to_ms = getattr(m, "_to_ms_with_fallback", None) + opened_ms = to_ms(plan_row["opened_at_ms"] if "opened_at_ms" in plan_row.keys() else None, opened_at) if callable(to_ms) else None + trading_day = plan_row["session_date"] or getattr(m, "get_trading_day", lambda: None)() + if not trading_day and callable(getattr(m, "get_trading_day", None)): + trading_day = m.get_trading_day() + notional = margin_cap * lev if margin_cap and lev else None + monitor_type = MONITOR_TYPE_TREND_PULLBACK + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, " + "breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, " + "opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, trend_plan_id) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + sym, + ex_sym, + direction, + avg_e, + new_sl, + init_sl, + tp, + margin_cap, + lev, + "trend_pullback_handoff", + risk_pct, + risk_amt, + be_rr, + be_off, + be_step, + 0, + be_price, + 0, + notional, + None, + None, + float(pos_amt), + "", + opened_at, + opened_ms, + trading_day, + monitor_type, + TREND_HANDOFF_KEY_SIGNAL, + plan_id, + ), + ) + new_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + persist = getattr(m, "try_persist_exchange_margin_for_order", None) + if callable(persist): + try: + persist(conn, new_id, ex_sym, direction, order_leverage=lev) + except Exception: + pass + return new_id + + +def apply_manual_breakeven(cfg: dict, conn, row, offset_pct=None) -> tuple[bool, Optional[str]]: + """保本:结束趋势计划,持仓移交下单监控(备注趋势回调),交易所同时挂保本止损与止盈.""" + m = _m(cfg) + if (row["status"] or "").strip() != "active": + return False, "计划已结束" + if not int(row["first_order_done"] or 0): + return False, "尚未完成首仓,无法保本" + avg_e = float(row["avg_entry_price"] or 0) + if avg_e <= 0: + return False, "缺少有效持仓均价" + direction = (row["direction"] or "long").lower() + sym = row["symbol"] + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(sym) + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is None or float(pos) <= 0: + return False, "交易所当前无该方向持仓" + pos_amt = float(pos) + dup = conn.execute( + "SELECT id FROM order_monitors WHERE status='active' AND symbol=? AND direction=? LIMIT 1", + (sym, direction), + ).fetchone() + if dup: + return False, "该币种已有运行中的下单监控,请先结束后再保本移交" + be_fn = getattr(m, "calc_trend_manual_breakeven_stop", None) + if not callable(be_fn): + pct = float(offset_pct if offset_pct is not None else cfg["breakeven_offset_pct"]) + if direction == "short": + new_sl_raw = avg_e * (1.0 - pct / 100.0) + else: + new_sl_raw = avg_e * (1.0 + pct / 100.0) + else: + new_sl_raw = be_fn(direction, avg_e, offset_pct) + if new_sl_raw is None: + return False, "保本价计算失败" + new_sl = m.round_price_to_exchange(ex_sym, new_sl_raw) + if new_sl is None: + return False, "保本价经交易所精度舍入后无效" + new_sl = float(new_sl) + tp = float(row["take_profit"] or 0) + if tp <= 0: + return False, "计划止盈价无效" + cur_sl = float(row["stop_loss"] or 0) + if direction == "long": + if new_sl <= cur_sl: + return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)" + else: + if new_sl >= cur_sl: + return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)" + ok_live, live_reason = m.ensure_exchange_live_ready() + if not ok_live: + return False, live_reason or "实盘未就绪" + plan_id = int(row["id"]) + try: + from lib.strategy.strategy_snapshot_lib import save_trend_plan_snapshot + + save_trend_plan_snapshot( + cfg, conn, row, result_label="保本移交", exit_price=None, pnl_amount=None + ) + except Exception: + pass + handoff_row = { + "symbol": sym, + "exchange_symbol": ex_sym, + "direction": direction, + "order_amount": pos_amt, + } + try: + trend_replace_tpsl(cfg, handoff_row, new_sl, tp) + except Exception as e: + fe = getattr(m, "friendly_exchange_error", None) + return False, fe(e) if callable(fe) else str(e) + now_s = m.app_now_str() + _TREND_FLAT_STREAK.pop(plan_id, None) + cur = conn.execute( + "UPDATE trend_pullback_plans SET status=?, message=?, stop_loss=?, " + "breakeven_applied=1, breakeven_applied_at=? WHERE id=? AND status='active'", + ( + TREND_PLAN_STATUS_HANDOFF, + f"保本移交下单监控({TREND_HANDOFF_TRADE_NOTE})", + new_sl, + now_s, + plan_id, + ), + ) + if not getattr(cur, "rowcount", 0): + return False, "计划状态更新失败(可能已被其他操作结束)" + try: + mon_id = _insert_trend_handoff_order_monitor( + cfg, conn, row, new_sl=new_sl, pos_amt=pos_amt + ) + except Exception as e: + conn.execute( + "UPDATE trend_pullback_plans SET status='active', message=? WHERE id=?", + (f"移交下单监控失败:{e}", plan_id), + ) + return False, f"移交下单监控失败:{e}" + pct_used = float( + offset_pct if offset_pct is not None else cfg["breakeven_offset_pct"] + ) + extra = getattr(m, "build_wechat_close_message", None) + send = getattr(m, "send_wechat_msg", None) + pf = getattr(m, "format_price_for_symbol", None) + fmt = (lambda s, p: pf(s, p)) if callable(pf) else (lambda _s, p: str(p)) + try: + from lib.strategy.strategy_wechat_notify import notify_trend_plan_ended + + notify_trend_plan_ended( + cfg, + plan_id=plan_id, + symbol=sym, + direction=direction, + end_type="保本移交", + result_label=TREND_HANDOFF_TRADE_NOTE, + extra=f"已移交下单监控 #{mon_id};止损 {fmt(sym, new_sl)} | 止盈 {fmt(sym, tp)}", + ) + except Exception: + pass + if callable(send): + lines = [ + f"# ✅ {sym} 趋势回调保本移交", + f"- 计划 ID:**{plan_id}** → 下单监控 **#{mon_id}**", + f"- 备注:**{TREND_HANDOFF_TRADE_NOTE}**", + f"- 保本止损:{fmt(sym, new_sl)} | 止盈:{fmt(sym, tp)}", + f"- 交易所:已挂止盈止损;平仓后将写入交易记录({ENTRY_REASON_TREND_PULLBACK})", + ] + wl = getattr(m, "_wechat_account_label", None) + if callable(wl): + lines.insert(1, f"**账户:{wl()}**") + send("\n".join(lines)) + return True, None + + +def load_trend_page_context(conn, request_obj, cfg: dict) -> dict[str, Any]: + m = _m(cfg) + _cleanup_stale_previews(conn) + trend_active = int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + or 0 + ) + trend_plans = [] + trend_dca_probes = [] + raw_plans = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall() + for r in raw_plans: + try: + enriched = enrich_trend_plan(cfg, r) + trend_plans.append(enriched) + except Exception: + enriched = _row(cfg, r) + trend_plans.append(enriched) + try: + probe = summarize_trend_dca_probe(cfg, r) + trend_dca_probes.append(probe) + if isinstance(enriched, dict): + enriched["dca_probe"] = probe + except Exception: + pass + now = m.app_now() + active_count = m.get_active_position_count(conn) + from lib.trade.daily_open_limit_lib import can_trade_new_open, count_opens_for_trading_day + + trading_day = m.get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + hard_limit = int(getattr(m, "DAILY_OPEN_HARD_LIMIT", 0) or 0) + can_trade_trend = can_trade_new_open( + time_allows=m.trading_day_reset_allows_new_open(now), + active_count=active_count, + max_active_positions=cfg["max_active_positions"], + opens_today=opens_today, + hard_limit=hard_limit, + extra_blocks=trend_active != 0, + ) + trend_preview = None + trend_preview_levels = [] + preview_expires_ms = None + trend_preview_expired = False + pid_arg = (request_obj.args.get("preview_id") or "").strip() + if pid_arg: + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid_arg,) + ).fetchone() + now_ms = int(time.time() * 1000) + if pr and int(pr["expires_at_ms"] or 0) >= now_ms: + from lib.strategy.strategy_trend_lib import build_trend_preview_level_rows + + trend_preview = _row(cfg, pr) + preview_expires_ms = int(pr["expires_at_ms"]) + get_cs = getattr(m, "get_contract_size", None) + if callable(get_cs) and not trend_preview.get("contract_size"): + try: + trend_preview["contract_size"] = float( + get_cs(trend_preview.get("exchange_symbol") or trend_preview.get("symbol") or "") + ) + except (TypeError, ValueError): + pass + trend_preview, trend_preview_levels = build_trend_preview_level_rows(trend_preview) + elif pr: + trend_preview_expired = True + return { + "trend_plans": trend_plans, + "trend_dca_probes": trend_dca_probes, + "trend_active": trend_active, + "can_trade_trend": can_trade_trend, + "trend_preview": trend_preview, + "trend_preview_levels": trend_preview_levels, + "preview_expires_ms": preview_expires_ms, + "trend_preview_expired": trend_preview_expired, + "trend_pullback_dca_legs": cfg["dca_legs"], + "trend_pullback_preview_ttl": cfg["preview_ttl"], + "trend_preview_max_drift_pct": cfg["drift_pct"], + "trend_manual_breakeven_offset_pct": cfg["breakeven_offset_pct"], + } + + +def register_trend_routes(app: Flask, cfg: dict) -> None: + lr = cfg["login_required"] + get_db = cfg["get_db"] + + def _redirect_trend(**kw): + return redirect(url_for("strategy_trading_page", **kw)) + + @app.route("/preview_trend_pullback", methods=["POST"]) + @lr + def preview_trend_pullback(): + conn = get_db() + init_strategy_tables(conn) + m = _m(cfg) + payload, err = parse_trend_plan(cfg, request.form) + if err: + conn.close() + flash(err) + return _redirect_trend() + okp, msg = precheck_trend_start( + cfg, + conn, + symbol=str(payload.get("symbol") or ""), + direction=str(payload.get("direction") or "long"), + ) + if not okp: + conn.close() + flash(msg) + return _redirect_trend() + ok_live, reason = m.ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason) + return _redirect_trend() + pid = str(uuid.uuid4()) + exp_ms = int(time.time() * 1000) + cfg["preview_ttl"] * 1000 + created = m.app_now_str() + conn.execute( + """INSERT INTO trend_pullback_previews ( + id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + pid, + payload["symbol"], + payload["exchange_symbol"], + payload["direction"], + payload["leverage"], + payload["stop_loss"], + payload["add_upper"], + payload["take_profit"], + payload["risk_percent"], + payload["snapshot_available_usdt"], + payload["snapshot_at"], + payload["live_price_ref"], + payload["plan_margin_capital"], + payload["target_order_amount"], + payload["first_order_amount"], + payload["remainder_total"], + payload["dca_legs"], + payload["per_leg_amount"], + payload["grid_prices_json"], + payload["leg_amounts_json"], + exp_ms, + created, + ), + ) + _insert_preview_snapshot(conn, pid, created, exp_ms, payload) + conn.commit() + conn.close() + flash(f"预览已生成,有效期 {cfg['preview_ttl']} 秒,请核对后点击「确认执行」.") + return _redirect_trend(preview_id=pid) + + @app.route("/execute_trend_pullback", methods=["POST"]) + @lr + def execute_trend_pullback(): + pid = (request.form.get("preview_id") or "").strip() + if not pid: + flash("缺少预览 ID") + return _redirect_trend() + conn = get_db() + init_strategy_tables(conn) + _cleanup_stale_previews(conn) + pr = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid,) + ).fetchone() + now_ms = int(time.time() * 1000) + if not pr or int(pr["expires_at_ms"] or 0) < now_ms: + conn.close() + flash("预览已过期或不存在,请重新生成预览") + return _redirect_trend() + okp, msg = precheck_trend_start( + cfg, + conn, + symbol=str(pr["symbol"] or ""), + direction=str(pr["direction"] or "long"), + ) + if not okp: + conn.close() + flash(msg) + return _redirect_trend(preview_id=pid) + m = _m(cfg) + ok_live, reason = m.ensure_exchange_live_ready() + if not ok_live: + conn.close() + flash(reason) + return _redirect_trend(preview_id=pid) + snap_prev = float(pr["snapshot_available_usdt"] or 0) + snap_now = m.get_available_trading_usdt() + if snap_now is None or snap_now <= 0: + conn.close() + flash("无法读取当前合约可用余额,请稍后重试") + return _redirect_trend(preview_id=pid) + drift = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0 + if drift > cfg["drift_pct"]: + conn.close() + flash( + f"当前可用余额与预览快照偏差 {drift:.2f}%,超过允许 {cfg['drift_pct']}%,请重新生成预览" + ) + return _redirect_trend(preview_id=pid) + symbol = pr["symbol"] + exchange_symbol = pr["exchange_symbol"] + direction = pr["direction"] or "long" + leverage = int(pr["leverage"] or 1) + stop_loss = float(pr["stop_loss"]) + first_amt = float(pr["first_order_amount"] or 0) + live_price = m.get_price(symbol) + if live_price is None: + conn.close() + flash("获取实时价格失败") + return _redirect_trend(preview_id=pid) + try: + o1 = m.place_exchange_order( + exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None + ) + fill1 = m.resolve_order_entry_price(o1, exchange_symbol, live_price) + try: + trend_refresh_stop_only(cfg, exchange_symbol, direction, stop_loss) + except Exception as sl_err: + from lib.strategy.strategy_trend_exchange import cancel_symbol_orders, trend_market_close + + try: + pos_qty = m.get_live_position_contracts(exchange_symbol, direction) or first_amt + trend_market_close(cfg, exchange_symbol, direction, float(pos_qty), leverage) + cancel_symbol_orders(cfg, exchange_symbol) + except Exception as close_err: + print(f"[trend_start] compensating close failed: {close_err}", flush=True) + raise sl_err + except Exception as e: + conn.close() + fe = getattr(m, "friendly_exchange_error", lambda x, **k: str(x)) + flash(fe(e, available_usdt=snap_now)) + return _redirect_trend(preview_id=pid) + trading_day = m.get_trading_day(m.app_now()) + opened_at = m.app_now_str() + opened_ms = getattr(m, "_to_ms_with_fallback", lambda a, b: None)(None, opened_at) + from lib.strategy.strategy_trend_lib import append_leg_fill_price_json + + fills_json = append_leg_fill_price_json(None, fill1) + cur = conn.execute( + """INSERT INTO trend_pullback_plans ( + status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_stop_loss,add_upper,take_profit,risk_percent, + snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, + dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message,leg_fill_prices_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + "active", + symbol, + exchange_symbol, + direction, + leverage, + stop_loss, + stop_loss, + float(pr["add_upper"]), + float(pr["take_profit"]), + float(pr["risk_percent"] or 5), + float(snap_now), + opened_at, + float(pr["plan_margin_capital"] or 0), + float(pr["target_order_amount"] or 0), + first_amt, + float(pr["remainder_total"] or 0), + int(pr["dca_legs"] or 0), + float(pr["per_leg_amount"] or 0), + pr["grid_prices_json"] or "[]", + pr["leg_amounts_json"] or "[]", + 0, + 1, + float(live_price), + fill1, + first_amt, + opened_at, + opened_ms, + trading_day, + f"预览ID:{pid[:8]}…", + fills_json, + ), + ) + new_id = int(cur.lastrowid) + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?", + (new_id, pid), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + try: + from lib.strategy.strategy_wechat_notify import notify_trend_plan_started + + notify_trend_plan_started( + cfg, + plan_id=new_id, + symbol=symbol, + direction=direction, + leverage=leverage, + stop_loss=stop_loss, + take_profit=float(pr["take_profit"]), + add_upper=float(pr["add_upper"]), + risk_percent=float(pr["risk_percent"] or 5), + dca_legs=int(pr["dca_legs"] or 0), + first_order_amount=first_amt, + avg_entry=fill1, + snapshot_usdt=float(snap_now), + ) + except Exception: + pass + conn.close() + flash("趋势回调已执行:首仓已成交并挂交易所止损,止盈由程序监控.") + return _redirect_trend() + + @app.route("/cancel_trend_pullback_preview", methods=["POST"]) + @lr + def cancel_trend_pullback_preview(): + pid = (request.form.get("preview_id") or "").strip() + conn = get_db() + if pid: + conn.execute( + "UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'", + (pid,), + ) + conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) + conn.commit() + conn.close() + flash("已取消预览") + return _redirect_trend() + + @app.route("/trend_pullback_breakeven/", methods=["POST"]) + @lr + def trend_pullback_breakeven(pid: int): + offset_pct = None + raw = (request.form.get("breakeven_offset_pct") or "").strip() + if raw: + try: + offset_pct = float(raw) + if offset_pct < 0: + raise ValueError + except ValueError: + flash("保本偏移% 格式无效") + return _redirect_trend() + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return _redirect_trend() + ok, err = apply_manual_breakeven(cfg, conn, row, offset_pct=offset_pct) + conn.commit() + conn.close() + flash( + "已保本:趋势计划已结束,持仓已移交下单监控并挂止盈止损;平仓后将写入交易记录" + if ok + else (err or "保本移交失败") + ) + return _redirect_trend() + + @app.route("/stop_trend_pullback/") + @lr + def stop_trend_pullback(pid: int): + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + stopped = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? " + "AND status IN ('stopped_sl','stopped_tp','stopped_manual')", + (pid,), + ).fetchone() + if stopped and not _trend_plan_trade_exists(conn, pid): + try: + if _ensure_trend_plan_trade_record(cfg, conn, pid, prefer_label="手动平仓"): + conn.close() + flash("计划已结束,已补录缺失的交易记录") + return _redirect_trend() + except Exception as e: + conn.close() + flash(f"补录交易记录失败:{e}") + return _redirect_trend() + conn.close() + flash("未找到运行中的趋势回调计划") + return _redirect_trend() + m = _m(cfg) + ex_sym = row["exchange_symbol"] or m.normalize_exchange_symbol(row["symbol"]) + direction = row["direction"] or "long" + lev = int(row["leverage"] or 1) + px = m.get_price(row["symbol"]) + exit_p = float(px) if px is not None else 0.0 + ok_live, _ = m.ensure_exchange_live_ready() + if ok_live: + pos = m.get_live_position_contracts(ex_sym, direction) + if pos is not None and pos > 0: + try: + close_resp = trend_market_close(cfg, ex_sym, direction, float(pos), lev) + ep = m.extract_trade_price_from_order(close_resp) + if ep: + exit_p = float(ep) + except Exception as e: + if not m.is_no_position_error(str(e)): + conn.close() + flash(f"平仓失败:{e}") + return _redirect_trend() + try: + cancel_symbol_orders(cfg, ex_sym) + except Exception: + pass + try: + _finalize_plan(cfg, conn, row, "手动平仓", exit_p, user_initiated_risk=True) + except Exception as e: + conn.execute( + "UPDATE trend_pullback_plans SET status='stopped_manual', message=? " + "WHERE id=? AND status='active'", + (f"结束异常:{e}", pid), + ) + conn.commit() + conn.close() + flash(f"计划已结束但记账可能不完整:{e}") + return _redirect_trend() + conn.close() + flash("已结束趋势回调计划") + return _redirect_trend() diff --git a/lib/strategy/strategy_ui.py b/lib/strategy/strategy_ui.py new file mode 100644 index 0000000..c6a7081 --- /dev/null +++ b/lib/strategy/strategy_ui.py @@ -0,0 +1,143 @@ +"""策略交易页:主站 index.html 所需数据(顺势加仓等).""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.strategy.strategy_db import init_strategy_tables +from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label + + +def _row_to_dict(row) -> dict: + if row is None: + return {} + try: + return dict(row) + except Exception: + return {} + + +def count_active_trend_plans(conn, count_fn: Optional[Callable] = None) -> int: + if callable(count_fn): + return int(count_fn(conn) or 0) + try: + return int( + conn.execute( + "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" + ).fetchone()[0] + ) + except Exception: + return 0 + + +def fetch_roll_page_data( + conn, + *, + default_risk_percent: float = 2.0, + count_active_trends: Optional[Callable] = None, + roll_cfg: dict | None = None, +) -> dict[str, Any]: + init_strategy_tables(conn) + monitors = [] + for row in conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + monitors.append(_row_to_dict(row)) + roll_groups = [] + for row in 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(): + roll_groups.append(_row_to_dict(row)) + active_gids = {int(g["id"]) for g in roll_groups if g.get("id") is not None} + roll_legs = [] + for row in conn.execute( + "SELECT * FROM roll_legs ORDER BY id DESC LIMIT 80" + ).fetchall(): + leg = _row_to_dict(row) + gid = leg.get("roll_group_id") + if gid is not None and int(gid) not in active_gids: + continue + leg["status_label"] = roll_leg_status_label(leg.get("status")) + roll_legs.append(leg) + roll_legs = roll_legs[:50] + out = { + "roll_monitors": monitors, + "roll_groups": roll_groups, + "roll_legs": roll_legs, + "roll_trend_active": count_active_trend_plans(conn, count_active_trends), + "default_risk_percent": default_risk_percent, + } + if roll_cfg: + from lib.strategy.strategy_roll_ui_lib import enrich_roll_page_data + + enrich_roll_page_data(conn, out, roll_cfg) + return out + + +DEFAULT_TREND_DISABLED_NOTE = ( + "趋势回调(预览,自动补仓,程序止盈)须在本实例 .env 设置 " + "`LIVE_TRADING_ENABLED=true` 并重启对应 PM2 进程(如 crypto_gate / crypto_okx / crypto_binance)." +) + + +def strategy_render_extras( + conn, + page: str, + *, + default_risk_percent: float = 2.0, + count_active_trends: Optional[Callable] = None, + trend_disabled_note: str = "", + request_obj=None, + trend_cfg: Optional[dict] = None, +) -> dict[str, Any]: + """render_main_page 策略相关页变量(含策略交易记录).""" + if page == "strategy_records": + from lib.strategy.strategy_records_register import load_strategy_records_page + + return load_strategy_records_page(conn) + return strategy_page_template_vars( + conn, + page, + default_risk_percent=default_risk_percent, + count_active_trends=count_active_trends, + trend_disabled_note=trend_disabled_note, + request_obj=request_obj, + trend_cfg=trend_cfg, + ) + + +def strategy_page_template_vars( + conn, + page: str, + *, + default_risk_percent: float = 2.0, + count_active_trends: Optional[Callable] = None, + trend_disabled_note: str = "", + request_obj=None, + trend_cfg: Optional[dict] = None, +) -> dict[str, Any]: + """render_main_page 在 conn.close() 前合并进 render_template 的变量.""" + if page not in ("strategy", "strategy_trend", "strategy_roll"): + return {} + roll_cfg = None + try: + from flask import current_app + + roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg") + except Exception: + roll_cfg = None + out = fetch_roll_page_data( + conn, + default_risk_percent=default_risk_percent, + count_active_trends=count_active_trends, + roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None, + ) + if trend_cfg and request_obj is not None: + from lib.strategy.strategy_trend_register import load_trend_page_context + + out.update(load_trend_page_context(conn, request_obj, trend_cfg)) + elif page == "strategy_trend": + out["trend_disabled_note"] = trend_disabled_note or DEFAULT_TREND_DISABLED_NOTE + return out diff --git a/lib/strategy/strategy_wechat_notify.py b/lib/strategy/strategy_wechat_notify.py new file mode 100644 index 0000000..5c45190 --- /dev/null +++ b/lib/strategy/strategy_wechat_notify.py @@ -0,0 +1,192 @@ +"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(三所共用).""" +from __future__ import annotations + +from typing import Any, Optional + +from lib.common.wechat_notify_lib import wechat_direction_label + + +def _send(cfg: dict[str, Any], content: str) -> None: + fn = cfg.get("send_wechat") + if callable(fn): + try: + fn(content) + return + except Exception: + pass + m = cfg.get("app_module") + if m is not None: + sw = getattr(m, "send_wechat_msg", None) + if callable(sw): + try: + sw(content) + except Exception: + pass + + +def _account(cfg: dict[str, Any]) -> str: + fn = cfg.get("wechat_account_label") + if callable(fn): + try: + return str(fn()).strip() or _exchange(cfg) + except Exception: + pass + return _exchange(cfg) + + +def _exchange(cfg: dict[str, Any]) -> str: + return str(cfg.get("exchange_display") or "").strip() or "交易账户" + + +def _dir_text(cfg: dict[str, Any], direction: str) -> str: + fn = cfg.get("wechat_direction_text") + if callable(fn): + try: + return str(fn(direction)) + except Exception: + pass + return wechat_direction_label(direction) + + +def _fmt_price(cfg: dict[str, Any], symbol: str, price: Any) -> str: + if price is None or price == "": + return "—" + fn = cfg.get("format_price") or cfg.get("price_fmt") + if callable(fn): + try: + return str(fn(symbol, price)) + except Exception: + pass + m = cfg.get("app_module") + pf = getattr(m, "format_price_for_symbol", None) if m else None + if callable(pf): + try: + return str(pf(symbol, price)) + except Exception: + pass + try: + return str(round(float(price), 8)) + except (TypeError, ValueError): + return str(price) + + +def _fmt_pnl(pnl: Any) -> str: + if pnl is None: + return "—" + try: + v = float(pnl) + return f"{'+' if v > 0 else ''}{round(v, 2)} U" + except (TypeError, ValueError): + return str(pnl) + + +def notify_trend_plan_started( + cfg: dict[str, Any], + *, + plan_id: int, + symbol: str, + direction: str, + leverage: int, + stop_loss: float, + take_profit: float, + add_upper: float, + risk_percent: float, + dca_legs: int, + first_order_amount: float, + avg_entry: Optional[float] = None, + snapshot_usdt: Optional[float] = None, +) -> None: + sym = symbol or "—" + lines = [ + f"# 🚀 {sym} 趋势回调计划已开始", + f"**账户:{_account(cfg)}**", + f"- 计划 ID:**{plan_id}**", + f"- 方向:{_dir_text(cfg, direction)}|杠杆 **{int(leverage or 1)}x**", + f"- 止损:{_fmt_price(cfg, sym, stop_loss)}|止盈:{_fmt_price(cfg, sym, take_profit)}", + f"- 补仓区:{_fmt_price(cfg, sym, add_upper)}|补仓档 **{int(dca_legs or 0)}** 档", + f"- 风险:**{risk_percent}%**|首仓张数:**{first_order_amount}**", + ] + if avg_entry is not None: + lines.append(f"- 首仓成交价:{_fmt_price(cfg, sym, avg_entry)}") + if snapshot_usdt is not None: + try: + lines.append(f"- 启动时合约可用:**{round(float(snapshot_usdt), 2)} U**") + except (TypeError, ValueError): + pass + lines.append("- 说明:交易所已挂止损;止盈由程序监控;结束/保本将另行推送") + _send(cfg, "\n".join(lines)) + + +def notify_trend_plan_ended( + cfg: dict[str, Any], + *, + plan_id: int, + symbol: str, + direction: str, + end_type: str, + result_label: Optional[str] = None, + exit_price: Optional[float] = None, + pnl_amount: Optional[float] = None, + extra: Optional[str] = None, +) -> None: + sym = symbol or "—" + res = (result_label or end_type or "—").strip() + lines = [ + f"# 🏁 {sym} 趋势回调计划已结束", + f"**账户:{_account(cfg)}**", + f"- 计划 ID:**{plan_id}**", + f"- 方向:{_dir_text(cfg, direction)}", + f"- 结束方式:**{end_type}**", + f"- 结果:**{res}**", + ] + if exit_price is not None: + lines.append(f"- 离场参考价:{_fmt_price(cfg, sym, exit_price)}") + if pnl_amount is not None: + lines.append(f"- 本单盈亏:**{_fmt_pnl(pnl_amount)}**") + if extra: + lines.append(f"- {extra}") + _send(cfg, "\n".join(lines)) + + +def notify_roll_group_started( + cfg: dict[str, Any], + *, + group_id: int, + symbol: str, + direction: str, + order_monitor_id: int, + initial_take_profit: Optional[float] = None, + initial_stop_loss: Optional[float] = None, +) -> None: + sym = symbol or "—" + lines = [ + f"# 🚀 {sym} 滚仓计划已开始", + f"**账户:{_account(cfg)}**", + f"- 滚仓组 ID:**{group_id}**|绑定下单监控 **#{order_monitor_id}**", + f"- 方向:{_dir_text(cfg, direction)}", + f"- 首仓止盈(锁定):{_fmt_price(cfg, sym, initial_take_profit)}", + f"- 当前止损:{_fmt_price(cfg, sym, initial_stop_loss)}", + "- 说明:顺势加仓为人工触发;组结束(无持仓/监控结案)将另行推送", + ] + _send(cfg, "\n".join(lines)) + + +def notify_roll_group_ended( + cfg: dict[str, Any], + *, + group_id: int, + symbol: str, + direction: str, + reason: str, + leg_count: int = 0, +) -> None: + sym = symbol or "—" + lines = [ + f"# 🏁 {sym} 滚仓计划已结束", + f"**账户:{_account(cfg)}**", + f"- 滚仓组 ID:**{group_id}**", + f"- 方向:{_dir_text(cfg, direction)}", + f"- 结束原因:**{reason}**", + f"- 已完成滚仓腿数:**{int(leg_count or 0)}**", + ] + _send(cfg, "\n".join(lines)) diff --git a/lib/strategy/templates/gate_transfer_block.html b/lib/strategy/templates/gate_transfer_block.html new file mode 100644 index 0000000..bedceb9 --- /dev/null +++ b/lib/strategy/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/strategy/templates/journal_form_fields.html b/lib/strategy/templates/journal_form_fields.html new file mode 100644 index 0000000..3ce3126 --- /dev/null +++ b/lib/strategy/templates/journal_form_fields.html @@ -0,0 +1,44 @@ +{# 复盘表单:首行按字段宽度比例;下单类型/开仓类型与离场触发同一行 #} +{% macro journal_form_fields(entry_reason_options, order_type_options) -%} +
        + + + + + + + +
        +
        + + + + + + +
        +{%- endmacro %} diff --git a/lib/strategy/templates/journal_upload_slots.html b/lib/strategy/templates/journal_upload_slots.html new file mode 100644 index 0000000..b99e470 --- /dev/null +++ b/lib/strategy/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/strategy/templates/key_focus_v2.html b/lib/strategy/templates/key_focus_v2.html new file mode 100644 index 0000000..3110112 --- /dev/null +++ b/lib/strategy/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/strategy/templates/key_monitor_panel.html b/lib/strategy/templates/key_monitor_panel.html new file mode 100644 index 0000000..9e8be2f --- /dev/null +++ b/lib/strategy/templates/key_monitor_panel.html @@ -0,0 +1,330 @@ + + +{% 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, trade_policy_direction with context %} + {{ trade_policy_symbol('symbol', 'key-symbol') }} + + {{ trade_policy_direction('direction', 'key-direction') }} + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('key-symbol-live-price', 'key-symbol', 'key-direction') }} + + + + + + + + + + + + + + +
        +
        + 关键位监控规则说明 +
        + {% 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/strategy/templates/key_monitor_rule_tips.html b/lib/strategy/templates/key_monitor_rule_tips.html new file mode 100644 index 0000000..b652431 --- /dev/null +++ b/lib/strategy/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/strategy/templates/order_focus_v2.html b/lib/strategy/templates/order_focus_v2.html new file mode 100644 index 0000000..a1d8234 --- /dev/null +++ b/lib/strategy/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/strategy/templates/order_monitor_rule_tips_binance.html b/lib/strategy/templates/order_monitor_rule_tips_binance.html new file mode 100644 index 0000000..ec63b41 --- /dev/null +++ b/lib/strategy/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/strategy/templates/order_monitor_rule_tips_gate.html b/lib/strategy/templates/order_monitor_rule_tips_gate.html new file mode 100644 index 0000000..7719874 --- /dev/null +++ b/lib/strategy/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/strategy/templates/order_monitor_rule_tips_gate_bot.html b/lib/strategy/templates/order_monitor_rule_tips_gate_bot.html new file mode 100644 index 0000000..ec88a25 --- /dev/null +++ b/lib/strategy/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/strategy/templates/order_monitor_rule_tips_okx.html b/lib/strategy/templates/order_monitor_rule_tips_okx.html new file mode 100644 index 0000000..737327a --- /dev/null +++ b/lib/strategy/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/strategy/templates/order_plan_preview_bar.html b/lib/strategy/templates/order_plan_preview_bar.html new file mode 100644 index 0000000..7fa3ffc --- /dev/null +++ b/lib/strategy/templates/order_plan_preview_bar.html @@ -0,0 +1,5 @@ +
        + 预估风险:— + 预估盈利:— + 预估盈亏比:— +
        diff --git a/lib/strategy/templates/strategy_records_page.html b/lib/strategy/templates/strategy_records_page.html new file mode 100644 index 0000000..fb27198 --- /dev/null +++ b/lib/strategy/templates/strategy_records_page.html @@ -0,0 +1,284 @@ +{% set mf = money_fmt|default(funds_fmt) %} + +
        +

        策略交易记录

        +

        + 数据库保留最近 {{ strategy_records_limit|default(100) }} 条结束快照(按结束时间排序). + 趋势回调与顺势加仓分栏展示;点击行展开详情.结束计划,保本移交,止盈止损会自动写入. +

        + +
        + + +
        + 筛选 + + + + + +
        +
        + +
        +
        +
        + 趋势回调记录 + {{ strategy_trend_records|length }} 条 +
        +
        + {% for s in strategy_trend_records %} + {% set snap = s.snapshot or {} %} + {% set dca = snap.dca_levels if snap.dca_levels is defined else [] %} + {% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %} + {% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %} +
        +
        + #{{ s.id }} {{ sym }} + {{ '做多' if s.direction == 'long' else '做空' }} + {{ s.result_label or '—' }} + {% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %} + 补仓 {{ s.summary_dca or '—' }} + {{ (s.closed_at or '')[:16] }} +
        +
        +
        +
        计划 ID
        {{ s.source_id or '—' }}
        +
        开仓
        {{ (s.opened_at or '')[:16] or '—' }}
        +
        结束
        {{ (s.closed_at or '')[:16] or '—' }}
        +
        均价
        {% if snap.avg_entry_price is not none %}{{ price_fmt(sym, snap.avg_entry_price) }}{% else %}—{% endif %}
        +
        止损
        {% if snap.stop_loss is not none %}{{ price_fmt(sym, snap.stop_loss) }}{% else %}—{% endif %}
        +
        止盈
        {% if snap.take_profit is not none %}{{ price_fmt(sym, snap.take_profit) }}{% else %}—{% endif %}
        +
        风险%
        {{ snap.risk_percent if snap.risk_percent is defined else '—' }}
        +
        杠杆
        {{ snap.leverage if snap.leverage is defined else '—' }}x
        +
        计划保证金
        {% if snap.plan_margin_capital is not none %}{{ mf(snap.plan_margin_capital) }}U{% else %}—{% endif %}
        +
        + {% if dca and dca|length %} + + + {% for lv in dca %} + + + + + + + {% endfor %} +
        档位触发价张数状态
        {{ lv.label or lv.leg_key }}{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}{% if lv.contracts is not none %}{{ lv.contracts }}{% else %}—{% endif %}{{ lv.status_label or '—' }}
        + {% endif %} +
        +
        + {% else %} +
        暂无趋势回调结束记录
        + {% endfor %} +
        +
        + +
        +
        + 顺势加仓记录 + {{ strategy_roll_records|length }} 条 +
        +
        + {% for s in strategy_roll_records %} + {% set snap = s.snapshot or {} %} + {% set group = snap.group if snap.group is defined else {} %} + {% set legs = snap.legs if snap.legs is defined else [] %} + {% set pnl = s.pnl_amount if s.pnl_amount is not none else snap.pnl_amount %} + {% set sym = s.symbol or s.exchange_symbol or snap.symbol or snap.exchange_symbol or '—' %} +
        +
        + #{{ s.id }} {{ sym }} + {{ '做多' if s.direction == 'long' else '做空' }} + {{ s.result_label or '—' }} + {% if pnl is not none %}{{ mf(pnl) }}U{% else %}—{% endif %} + 成交 {{ s.summary_dca or '—' }} + {{ (s.closed_at or '')[:16] }} +
        +
        +
        +
        组 ID
        {{ s.source_id or '—' }}
        +
        创建
        {{ (s.opened_at or group.created_at or '')[:16] or '—' }}
        +
        结束
        {{ (s.closed_at or '')[:16] or '—' }}
        +
        状态
        {{ s.status_at_close or group.status or '—' }}
        +
        杠杆
        {{ group.leverage if group.leverage is defined else '—' }}x
        +
        备注
        {{ group.message if group.message is defined else '—' }}
        +
        + {% if legs and legs|length %} + + + {% for leg in legs %} + + + + + + + {% endfor %} +
        腿次挂单价张数状态
        {{ leg.leg_index or loop.index }}{% if leg.limit_price is not none %}{{ price_fmt(sym, leg.limit_price) }}{% else %}—{% endif %}{% if leg.order_amount is not none %}{{ leg.order_amount }}{% else %}—{% endif %}{{ leg.status_label or leg.status or '—' }}
        + {% endif %} +
        +
        + {% else %} +
        暂无顺势加仓结束记录
        + {% endfor %} +
        +
        +
        +
        + diff --git a/lib/strategy/templates/strategy_roll.html b/lib/strategy/templates/strategy_roll.html new file mode 100644 index 0000000..e550a3b --- /dev/null +++ b/lib/strategy/templates/strategy_roll.html @@ -0,0 +1,19 @@ + + + + + + + 顺势加仓 · {{ exchange_display }} + + + + +
        + {% with messages = get_flashed_messages() %}{% if messages %}
        {{ messages[0] }}
        {% endif %}{% endwith %} + {% include 'strategy_roll_panel.html' %} +

        顺势加仓完整逻辑说明

        +
        + + + diff --git a/lib/strategy/templates/strategy_roll_docs.html b/lib/strategy/templates/strategy_roll_docs.html new file mode 100644 index 0000000..50cc01b --- /dev/null +++ b/lib/strategy/templates/strategy_roll_docs.html @@ -0,0 +1,23 @@ + + + + + + + 顺势加仓 · 详细说明 · {{ exchange_display }} + + + + +
        + +
        + {{ doc_html|safe }} +
        +
        + + diff --git a/lib/strategy/templates/strategy_roll_panel.html b/lib/strategy/templates/strategy_roll_panel.html new file mode 100644 index 0000000..faa9ac8 --- /dev/null +++ b/lib/strategy/templates/strategy_roll_panel.html @@ -0,0 +1,106 @@ +
        +

        顺势加仓

        +
        + 顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %} +
        + 仅人工提交;须先在「实盘下单」有同向持仓.仅以损定仓模式可用.
        + 做多/做空各最多滚仓 3 次(仅计已成交腿);止盈锁定首仓不变.
        + 风险比例读取所选监控单,不可手改;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%).
        + 斐波/突破为程序监控(交易所 mark 价),触价后市价加仓;填写后直接点「执行滚仓」(无需预览).同时仅允许 1 条监控中腿,提交后不可修改,可删除.
        + 手动平仓后滚仓监控自动结束;已成交腿历史保留供复盘.
        + → 顺势加仓完整逻辑说明
        + {% if roll_trend_active %}当前有运行中的趋势回调计划,请先结束后再滚仓.{% endif %} +
        +
        + +
        + 当前风险:请选择持仓币种 +
        + +
        + + + + + + + + + + + + + +
        + + + +

        活跃滚仓组

        +
        + + + {% for g in roll_groups %} + + + + + + + + + + + + + + {% else %} + + {% endfor %} +
        ID币种方向腿数首仓张数加仓后张数首仓TP当前SL当前均价止盈盈利U状态
        {{ g.id }}{{ g.symbol }}{{ g.direction }}{{ g.leg_count }}{% if g.initial_qty is not none %}{{ '%.2f'|format(g.initial_qty) }}{% else %}—{% endif %}{% if g.current_qty is not none %}{{ '%.2f'|format(g.current_qty) }}{% else %}—{% endif %}{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}{% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %}{% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %}滚仓中
        暂无
        +
        + +

        最近滚仓腿

        +
        + + + {% for leg in roll_legs %} + + + + + + + + + + + {% else %} + + {% endfor %} +
        #组方式张数触发/限价新SL状态操作
        {{ leg.leg_index }}{{ leg.roll_group_id }}{{ leg.add_mode }}{% if leg.amount %}{{ leg.amount }}{% else %}—{% endif %}{% if leg.limit_price %}{{ leg.limit_price }}{% elif leg.breakthrough_price %}{{ leg.breakthrough_price }}{% elif leg.fill_price %}{{ leg.fill_price }}{% else %}—{% endif %}{{ leg.new_stop_loss }}{{ leg.status_label or leg.status }} + {% if leg.status == 'pending' %} +
        + +
        + {% else %}—{% endif %} +
        暂无
        +
        +
        diff --git a/lib/strategy/templates/strategy_subnav.html b/lib/strategy/templates/strategy_subnav.html new file mode 100644 index 0000000..e91839d --- /dev/null +++ b/lib/strategy/templates/strategy_subnav.html @@ -0,0 +1,4 @@ + diff --git a/lib/strategy/templates/strategy_trading_page.html b/lib/strategy/templates/strategy_trading_page.html new file mode 100644 index 0000000..91320d9 --- /dev/null +++ b/lib/strategy/templates/strategy_trading_page.html @@ -0,0 +1,47 @@ + +
        +
        +
        + {% include 'strategy_trend_panel.html' %} +
        +
        +
        +
        + {% include 'strategy_roll_panel.html' %} +
        +
        +
        diff --git a/lib/strategy/templates/strategy_trend_disabled.html b/lib/strategy/templates/strategy_trend_disabled.html new file mode 100644 index 0000000..c56543e --- /dev/null +++ b/lib/strategy/templates/strategy_trend_disabled.html @@ -0,0 +1,20 @@ + + + + + 趋势回调 · {{ exchange_display }} + + + +

        ← 实盘下单 顺势加仓

        +
        +

        趋势回调

        +

        {{ trend_note }}

        +

        趋势回调含自动补仓档位,在三所实例(Binance / Gate / OKX)中均可启用,须配置 LIVE_TRADING_ENABLED=true.

        +
        + + diff --git a/lib/strategy/templates/strategy_trend_disabled_panel.html b/lib/strategy/templates/strategy_trend_disabled_panel.html new file mode 100644 index 0000000..5f56950 --- /dev/null +++ b/lib/strategy/templates/strategy_trend_disabled_panel.html @@ -0,0 +1,16 @@ +{% include 'strategy_subnav.html' %} +
        +

        趋势回调

        +
        + 趋势回调说明(本实例未启用) +
        + {{ trend_disabled_note }}

        + 趋势回调含自动补仓档位与预览执行,在 Binance / Gate / OKX 各实例的「策略交易 → 趋势回调」中运行. + 请访问对应实例同一菜单,或常用地址如 Gate :5000/strategy/trend. +
        +
        +

        + 返回实盘下单 + | 顺势加仓(本实例可用) +

        +
        diff --git a/lib/strategy/templates/strategy_trend_panel.html b/lib/strategy/templates/strategy_trend_panel.html new file mode 100644 index 0000000..ff09f61 --- /dev/null +++ b/lib/strategy/templates/strategy_trend_panel.html @@ -0,0 +1,207 @@ +{% set mf = money_fmt|default(funds_fmt) %} +{% macro amt_disp(sym, val) %}{% if amt_fmt is defined %}{{ amt_fmt(sym, val) }}{% else %}{{ val }}{% endif %}{% endmacro %} +
        +

        趋势回调

        +
        + 趋势回调规则说明 +
        + ① 生成预览:读取合约 USDT 可用余额快照并计算计划(不下单).预览有效期 {{ trend_pullback_preview_ttl }} 秒.
        + ② 确认执行:市价首仓 50% + 挂交易所止损;首仓后可手动保本(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿,做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控.
        + 确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览. +
        +
        + {% if trend_dca_probes %} + {% for p in trend_dca_probes %} + {% if p.trigger_reached and p.block_reason %} +
        + 计划 #{{ p.plan_id }} 标记价 {{ p.mark_price }} 已触达补仓触发价 {{ p.next_trigger }},但未自动补仓: + {{ p.block_reason }}. + {% if not live_trading_enabled %} + 请在当前实例 .env 设置 LIVE_TRADING_ENABLED=true 后重启对应 PM2 进程(如 crypto_gate,crypto_okx,crypto_binance). + {% endif %} +
        + {% endif %} + {% endfor %} + {% endif %} +
        + {% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %} + {{ trade_policy_symbol('symbol', 'trend-symbol', placeholder='BTC 或 ETH/USDT') }} + {{ trade_policy_direction('direction', 'trend-direction') }} + {% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %} + {{ symbol_live_price_hint('trend-symbol-live-price', 'trend-symbol', 'trend-direction') }} + + + + + + +
        + + + {% if trend_preview %} +
        +
        + 当前预览(剩余 {{ trend_pullback_preview_ttl }}s) + 倒计时加载中… +
        +
        + {{ trend_preview.symbol }} {{ '做多' if trend_preview.direction == 'long' else '做空' }} {{ trend_preview.leverage }}x | + 预览可用快照 {{ mf(trend_preview.snapshot_available_usdt) }} U | 参考价 {{ price_fmt(trend_preview.symbol, trend_preview.live_price_ref) }} | + 计划保证金≈{{ mf(trend_preview.plan_margin_capital) }} U | 总张≈{{ amt_disp(trend_preview.symbol, trend_preview.target_order_amount) }}(首仓 {{ amt_disp(trend_preview.symbol, trend_preview.first_order_amount) }} + 补仓 {{ amt_disp(trend_preview.symbol, trend_preview.remainder_total) }})
        + 止损价 {{ price_fmt(trend_preview.symbol, trend_preview.preview_unified_stop_loss or trend_preview.stop_loss) }} | 止损金额 {% if trend_preview.preview_risk_amount_u is not none %}{{ mf(trend_preview.preview_risk_amount_u) }}U{% else %}—{% endif %}(快照×风险{{ trend_preview.risk_percent }}%)| {{ trend_add_zone_label(trend_preview.direction) }} {{ price_fmt(trend_preview.symbol, trend_preview.add_upper) }} | 止盈价 {{ price_fmt(trend_preview.symbol, trend_preview.take_profit) }} | 首仓盈亏比 {% if trend_preview.preview_target_rr is not none %}{{ '%.2f'|format(trend_preview.preview_target_rr) }}{% else %}—{% endif %} +
        +
        + + + {% for row in trend_preview_levels %} + + + + + + + + + + {% endfor %} +
        档位触发/参考价张数加仓后均价止盈盈利(U)止损(U)盈亏比
        {{ row.label or row.i }}{{ price_fmt(trend_preview.symbol, row.price) }}{{ amt_disp(trend_preview.symbol, row.contracts) }}{% if row.avg_entry is not none %}{{ price_fmt(trend_preview.symbol, row.avg_entry) }}{% else %}—{% endif %}{% if row.profit_u is not none %}{{ mf(row.profit_u) }}{% else %}—{% endif %}{% if row.risk_u is not none %}{{ mf(row.risk_u) }}{% else %}—{% endif %}{% if row.rr is not none %}{{ '%.2f'|format(row.rr) }}{% else %}—{% endif %}
        +
        +
        +
        + + +
        +
        + + +
        +
        +
        + + {% elif trend_preview_expired %} +
        该预览已过期(超过 {{ trend_pullback_preview_ttl }} 秒),请重新点击「生成预览」.
        + {% endif %} + +
        +

        运行中的计划

        +
        + {% for t in trend_plans %} + {% set sym = t.exchange_symbol or t.symbol %} + {% set calc = namespace(pnlpct=None) %} + {% if t.floating_pnl is not none and t.plan_margin_capital is not none and t.plan_margin_capital|float > 0 %} + {% set calc.pnlpct = (t.floating_pnl|float) / (t.plan_margin_capital|float) * 100 %} + {% endif %} +
        +
        +
        + #{{ t.id }} {{ sym }} + {{ '做多' if t.direction == 'long' else '做空' }} +
        + 结束计划 +
        +
        + 来源: 趋势回调计划 | 风险: {% if t.risk_percent is not none %}{{ t.risk_percent }}%{% else %}—{% endif %} + | {{ trend_add_zone_label(t.direction) }} {{ price_fmt(sym, t.add_upper) }} + | 已补仓 {{ t.legs_done }}/{{ t.dca_legs }} +
        +
        +
        + 均价 + {% if t.avg_entry_price is not none %}{{ price_fmt(sym, t.avg_entry_price) }}{% else %}—{% endif %} +
        +
        + 止损 + {{ price_fmt(sym, t.stop_loss) }} +
        +
        + 止盈 + {{ price_fmt(sym, t.take_profit) }} +
        +
        + 盈亏比 + {% if t.money_rr is not none %}{{ '%.2f'|format(t.money_rr) }}:1{% elif t.planned_rr is not none %}{{ '%.2f'|format(t.planned_rr) }}:1{% else %}—{% endif %} +
        +
        + 标记价 + {% if t.floating_mark is not none %}{{ price_fmt(sym, t.floating_mark) }}{% else %}—{% endif %} +
        +
        + 浮盈亏 + + {% if t.floating_pnl is not none %} + {{ mf(t.floating_pnl) }}U{% if calc.pnlpct is not none %} ({{ '%+.2f'|format(calc.pnlpct) }}%){% endif %} + {% else %}—{% endif %} + +
        +
        + {% if t.dca_levels %} +
        +
        补仓计划明细
        + + + {% for lv in t.dca_levels %} + + + + + + + + + + + {% endfor %} +
        档位触发价张数加仓后均价止盈盈利(U)止损(U)盈亏比状态
        {{ lv.label }}{% if lv.price is not none %}{{ price_fmt(sym, lv.price) }}{% else %}—{% endif %}{% if lv.contracts is not none %}{{ amt_disp(sym, lv.contracts) }}{% else %}—{% endif %}{% if lv.avg_entry is not none %}{{ price_fmt(sym, lv.avg_entry) }}{% else %}—{% endif %}{% if lv.profit_u is not none %}{{ mf(lv.profit_u) }}{% else %}—{% endif %}{% if lv.risk_u is not none %}{{ mf(lv.risk_u) }}{% else %}—{% endif %}{% if lv.rr is not none %}{{ '%.2f'|format(lv.rr) }}{% else %}—{% endif %}{{ lv.status_label }}
        +
        + {% endif %} +
        +
        + + + {% if t.breakeven_applied %}已保本 {{ (t.breakeven_applied_at or '')[:16] }}{% endif %} +
        +
        +
        + 快照可用: {% if t.snapshot_available_usdt is not none %}{{ mf(t.snapshot_available_usdt) }}U{% else %}—{% endif %} + | 计划保证金≈{% if t.plan_margin_capital is not none %}{{ mf(t.plan_margin_capital) }}U{% else %}—{% endif %} + | 杠杆: {{ t.leverage }}x +
        +
        + {% else %} +
        暂无运行中的趋势回调计划
        + {% endfor %} +
        +
        +
        diff --git a/lib/strategy/templates/symbol_live_price_snippet.html b/lib/strategy/templates/symbol_live_price_snippet.html new file mode 100644 index 0000000..c746897 --- /dev/null +++ b/lib/strategy/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/strategy/templates/trade_policy_fields.html b/lib/strategy/templates/trade_policy_fields.html new file mode 100644 index 0000000..449c1ec --- /dev/null +++ b/lib/strategy/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/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..2ee1969 --- /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.strategy.strategy_trade_labels 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..110b704 --- /dev/null +++ b/lib/trade/entry_model_lib.py @@ -0,0 +1,509 @@ +"""趋势户开仓类型:反转·启动 / 顺势·大分歧 / 波段·小分歧;日内户单独 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 "" + + +# 日内复盘开仓类型:手动假破/结构突破 + 自动触价(与 KEY_ENTRY_REASON_TRIGGER_OPTIONS 一致) +_INTRADAY_JOURNAL_KEY_ENTRY_REASONS: Tuple[str, ...] = ( + "关键位回调触价开仓", + "关键位突破触价开仓", +) + + +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, ...]: + return trend_div_entry_reason_display_options() + TRADE_STYLE_FALLBACK_ENTRY_REASONS + tuple(strategy_options) + + +def build_intraday_entry_reason_options( + key_options: Sequence[str], + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + del strategy_options # 日内户无趋势回调/顺势加仓 + del key_options + return intraday_entry_reason_display_options() + _INTRADAY_JOURNAL_KEY_ENTRY_REASONS + + +def entry_reason_options_for_policy( + policy: TradePolicy, + key_options: Sequence[str], + strategy_options: Sequence[str], +) -> Tuple[str, ...]: + if is_intraday_trading_profile(policy): + return build_intraday_entry_reason_options(key_options, strategy_options) + return build_trend_div_entry_reason_options(strategy_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 hub_meta_entry_context(policy: TradePolicy) -> dict: + """供 /api/hub/meta:中控按 profile 隐藏平仓/委托等.""" + 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..9b23cc8 --- /dev/null +++ b/lib/trade/order_monitor_display_lib.py @@ -0,0 +1,452 @@ +"""实时持仓展示:开仓快照盈亏比,交易所止损是否已保本.""" +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.hub.hub_position_metrics 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: + from lib.strategy.strategy_roll_ui_lib import reward_at_tp_usdt + + 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.hub.hub_position_metrics 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..8f5ae28 --- /dev/null +++ b/lib/trade/position_sizing_lib.py @@ -0,0 +1,136 @@ +""" +三所共用:计仓模式 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" +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( + {OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL} +) + + +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_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/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/tests/test_hedge_exchange_pnl.py b/tests/test_hedge_exchange_pnl.py new file mode 100644 index 0000000..44e1cfa --- /dev/null +++ b/tests/test_hedge_exchange_pnl.py @@ -0,0 +1,130 @@ +"""对冲计划期权腿盈亏与交易所对齐.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan +from lib.hedge_plan.hedge_plan_settle_lib import ( + _parse_opened_ms, + backfill_hedge_option_legs_realized_pnl, + resolve_option_leg_realized_pnl, +) + + +class HedgeExchangePnlTest(unittest.TestCase): + def test_resolve_prefers_exchange(self): + leg = { + "inst_id": "ETH-USD_UM-260719-1850-P", + "opened_at": "2026-07-17 06:59:18", + "premium": 5.0, + } + hist = [ + { + "instId": "ETH-USD_UM-260719-1850-P", + "uTime": "1784400000000", + "realizedPnl": "12.00", + "closeAvgPx": "30", + } + ] + pnl, src = resolve_option_leg_realized_pnl( + leg=leg, hist_rows=hist, fallback=-5.0 + ) + self.assertEqual(src, "exchange") + self.assertEqual(pnl, 12.0) + + def test_opened_at_beijing_wall_clock_not_utc(self): + # 北京 09:19 开仓 → UTC 01:19; 到期结算北京 16:00:31 = UTC 08:00:31 + open_ms = _parse_opened_ms("2026-07-20 09:19:30") + self.assertEqual(open_ms, 1784510370000) + leg = { + "inst_id": "ETH-USD_UM-260720-1870-C", + "opened_at": "2026-07-20 09:19:30", + "premium": 6.9, + } + hist = [ + { + "instId": "ETH-USD_UM-260720-1870-C", + "uTime": "1784534431512", + "realizedPnl": "-7.1812815", + "pnl": "-6.9", + "type": "2", + } + ] + pnl, src = resolve_option_leg_realized_pnl( + leg=leg, hist_rows=hist, fallback=-6.9 + ) + self.assertEqual(src, "exchange") + self.assertAlmostEqual(pnl, -7.1813, places=4) + + def test_backfill_updates_plan_total(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "closed", + "underlying": "ETH", + "premium_total": 10, + "realized_pnl_options": 6.08, + "realized_pnl_total": 6.08, + "opened_at": "2026-07-17 06:59:18", + "closed_at": "2026-07-19 16:00:13", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260719-1890-C", + "opt_type": "C", + "strike": 1890, + "size": 10, + "premium": 5.92, + "status": "closed", + "realized_pnl": -5.92, + "opened_at": "2026-07-17 06:59:18", + "closed_at": "2026-07-19 16:00:13", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": "ETH-USD_UM-260719-1850-P", + "opt_type": "P", + "strike": 1850, + "size": 10, + "premium": 4.0, + "status": "closed", + "realized_pnl": 12.0, + "opened_at": "2026-07-17 06:59:18", + "closed_at": "2026-07-19 08:00:00", + }, + ) + hist = [ + { + "instId": "ETH-USD_UM-260719-1890-C", + "uTime": "1784476813000", + "realizedPnl": "-5.50", + }, + { + "instId": "ETH-USD_UM-260719-1850-P", + "uTime": "1784448000000", + "realizedPnl": "11.80", + }, + ] + out = backfill_hedge_option_legs_realized_pnl(conn, hist) + self.assertEqual(out["legs"], 2) + self.assertEqual(out["plans"], 1) + plan = get_plan(conn, pid) + self.assertAlmostEqual(float(plan["realized_pnl_options"]), 6.3, places=4) + self.assertAlmostEqual(float(plan["realized_pnl_total"]), 6.3, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_options_exclusive.py b/tests/test_hedge_options_exclusive.py new file mode 100644 index 0000000..3e132f8 --- /dev/null +++ b/tests/test_hedge_options_exclusive.py @@ -0,0 +1,103 @@ +"""对冲计划与单独期权互斥门控.""" +from __future__ import annotations + +import os +import sqlite3 +import unittest +from unittest import mock + +from lib.hedge_plan.hedge_options_exclusive_lib import ( + block_hedge_plan_start_msg, + block_standalone_option_open_msg, + has_standalone_option_position, + mutual_exclusive_enabled, +) +from lib.hedge_plan.hedge_plan_calc_lib import gate_status +from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables + + +class HedgeOptionsExclusiveTests(unittest.TestCase): + def test_mutual_default_true(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", None) + self.assertTrue(mutual_exclusive_enabled()) + + def test_mutual_can_disable(self): + with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}): + self.assertFalse(mutual_exclusive_enabled()) + + def test_block_open_when_active_plan(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + conn.execute( + "INSERT INTO hedge_plans (plan_type, underlying, status) " + "VALUES ('options_options', 'ETH', 'active')" + ) + conn.commit() + with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}): + msg = block_standalone_option_open_msg(conn) + self.assertIsNotNone(msg) + self.assertIn("对冲计划", msg or "") + with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}): + self.assertIsNone(block_standalone_option_open_msg(conn)) + conn.close() + + def test_standalone_position_detection(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "1"}] + with mock.patch( + "lib.instance.instance_dashboard_lib._resolve_options_source", + return_value=("option", "纯期权", None), + ): + self.assertTrue(has_standalone_option_position(conn, raw)) + with mock.patch( + "lib.instance.instance_dashboard_lib._resolve_options_source", + return_value=("options_options", "期期对冲", 2), + ): + self.assertFalse(has_standalone_option_position(conn, raw)) + conn.close() + + def test_block_hedge_start_msg(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "2"}] + with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}): + with mock.patch( + "lib.instance.instance_dashboard_lib._resolve_options_source", + return_value=("option", "纯期权", None), + ): + msg = block_hedge_plan_start_msg(conn, raw_positions=raw) + self.assertIsNotNone(msg) + self.assertIn("单独期权", msg or "") + conn.close() + + def test_gate_status_blocks_start(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="full_margin", + plan_type="options_options", + options_enabled=True, + live_order=True, + mutual_exclusive=True, + has_standalone_option=True, + ) + self.assertFalse(g["can_start"]) + self.assertTrue(any("单独期权" in r for r in g["reasons"])) + g2 = gate_status( + hedge_enabled=True, + sizing_mode="full_margin", + plan_type="options_options", + options_enabled=True, + live_order=True, + mutual_exclusive=False, + has_standalone_option=True, + ) + self.assertTrue(g2["can_start"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_partial_manual.py b/tests/test_hedge_partial_manual.py new file mode 100644 index 0000000..4f82d7d --- /dev/null +++ b/tests/test_hedge_partial_manual.py @@ -0,0 +1,45 @@ +"""半腿失败:手动补开 vs 自动平.""" +from __future__ import annotations + +import os +import unittest +from unittest import mock + +from lib.hedge_plan.hedge_plan_orders_lib import ( + manual_complete_on_partial, + partial_auto_close_enabled, +) + + +class PartialManualTests(unittest.TestCase): + def test_manual_default_forces_auto_close_off(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", None) + os.environ["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "true" + self.assertTrue(manual_complete_on_partial()) + self.assertFalse(partial_auto_close_enabled()) + + def test_manual_off_allows_auto_close(self): + with mock.patch.dict( + os.environ, + { + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "true", + }, + ): + self.assertFalse(manual_complete_on_partial()) + self.assertTrue(partial_auto_close_enabled()) + + def test_both_off(self): + with mock.patch.dict( + os.environ, + { + "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "false", + }, + ): + self.assertFalse(partial_auto_close_enabled()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_calc.py b/tests/test_hedge_plan_calc.py new file mode 100644 index 0000000..60f4d63 --- /dev/null +++ b/tests/test_hedge_plan_calc.py @@ -0,0 +1,236 @@ +"""对冲计划 P0 测算口径单测.""" +import unittest + +from lib.hedge_plan.hedge_plan_calc_lib import ( + build_options_options_preview, + build_perp_options_preview, + floor_contracts_to_precision, + gate_status, + option_expiry_pnl, + option_premium_total, + perp_pnl, + resolve_oo_budget_usdc, + suggest_oo_sheets, +) +from lib.hedge_plan.hedge_plan_monitor_lib import resolve_oo_rest_close_mode + + +class TestHedgePlanCalc(unittest.TestCase): + def test_perp_tp_accounting_is_profit_minus_premium(self): + p = build_perp_options_preview( + direction="long", + entry=3200, + tp=3400, + sl=3000, + contracts=50, + contract_size=0.01, + opt_type="P", + strike=3100, + sheets=10, + ct_mult=0.01, + premium_paid=8, + index_px=3200, + ) + self.assertEqual(p["summary"]["tp_total"], 92.0) + self.assertEqual(p["scenarios"][0]["options_pnl"], -8.0) + + def test_perp_sl_accounting_is_option_plus_perp_signed(self): + p = build_perp_options_preview( + direction="long", + entry=3200, + tp=3400, + sl=3000, + contracts=50, + contract_size=0.01, + opt_type="P", + strike=3100, + sheets=10, + ct_mult=0.01, + premium_paid=8, + index_px=3200, + ) + self.assertEqual(p["summary"]["sl_total"], -98.0) + self.assertEqual(p["summary"]["hedge_ratio_at_sl"], 2.0) + + def test_option_premium_and_expiry(self): + self.assertEqual(option_premium_total(ask=80, sheets=1, ct_mult=0.01), 0.8) + self.assertEqual( + option_expiry_pnl( + opt_type="P", strike=3100, spot=3000, sheets=10, ct_mult=0.01, premium_paid=8 + ), + 2.0, + ) + + def test_gate_perp_requires_full_margin_for_start_message(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="perp_options", + options_enabled=True, + ) + self.assertTrue(g["can_preview"]) + self.assertFalse(g["can_start"]) + self.assertTrue(any("全仓" in r for r in g["reasons"])) + + def test_gate_hidden_plan_type_blocks_preview(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="full_margin", + plan_type="perp_options", + options_enabled=True, + live_order=True, + live_trading=True, + show_perp_options=False, + ) + self.assertFalse(g["can_preview"]) + self.assertFalse(g["can_start"]) + self.assertTrue(any("隐藏" in r for r in g["reasons"])) + self.assertFalse(g["show_perp_options"]) + self.assertTrue(g["show_options_options"]) + + g2 = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="options_options", + options_enabled=True, + show_options_options=False, + ) + self.assertFalse(g2["can_preview"]) + self.assertTrue(any("期期" in r for r in g2["reasons"])) + + def test_oo_expiry_loss_flag(self): + a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + p = build_options_options_preview( + profit_rr=2, + index_px=3200, + leg_a=a, + leg_b=b, + ) + self.assertEqual(p["summary"]["premium_paid"], 10) + self.assertTrue(p["summary"]["expiry_is_loss"]) + self.assertEqual(p["summary"]["profit_rr"], 2) + self.assertEqual(p["summary"]["at_rr_a_full_total"], 15) # 盈利=2*10, 亏腿-5 + self.assertEqual(len(p["scenarios"]), 5) + self.assertEqual(p["scenarios"][0]["id"], "rr_leg_a_full") + self.assertEqual(p["scenarios"][1]["id"], "rr_leg_b_full") + # 到期实值反推:Call 盈利20 → 价值25 → 每币2500 → spot=3300+2500 + self.assertEqual(p["scenarios"][0]["spot"], 5800.0) + # Put 盈利20 → spot=3100-2500 + self.assertEqual(p["scenarios"][1]["spot"], 600.0) + self.assertEqual(p["scenarios"][2]["spot"], 5800.0) # 残值情景同腿A反推 + + def test_oo_legacy_single_target_still_works(self): + a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b) + self.assertEqual(p["target_price_up"], 3500) + self.assertEqual(p["target_price_down"], 3500) + + def test_perp_short_pnl(self): + self.assertEqual( + perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1), + 10, + ) + + def test_floor_contracts_to_precision(self): + self.assertEqual(floor_contracts_to_precision(4.569713, 4), 4.5697) + self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0) + self.assertEqual(floor_contracts_to_precision(0, 4), 0.0) + + def test_oo_budget_min_trading_and_cap(self): + b = resolve_oo_budget_usdc(trading_usdc=11.07, trade_budget_usdc=10, buffer_ratio=0.95) + self.assertTrue(b["ok"]) + self.assertAlmostEqual(b["trading_cap"], 11.07 * 0.95, places=4) + self.assertEqual(b["budget_usdc"], 10.0) + + def test_suggest_oo_same_sheets_default(self): + # cost_a=1, cost_b=1 → pair=2; budget=10 → n=5 + s = suggest_oo_sheets( + mode="same_sheets", + budget_usdc=10, + ask_a=100, + ct_mult_a=0.01, + ask_b=100, + ct_mult_b=0.01, + ) + self.assertEqual(s["mode"], "same_sheets") + self.assertEqual(s["sheets_a"], 5) + self.assertEqual(s["sheets_b"], 5) + self.assertTrue(s["ok"]) + + def test_suggest_oo_long_bias_budget(self): + # cost_call=1, cost_put=1; budget 10 → call 7U / put 3U → 7 / 3 + s = suggest_oo_sheets( + mode="long_bias", + budget_usdc=10, + ask_a=100, + ct_mult_a=0.01, + opt_type_a="C", + ask_b=100, + ct_mult_b=0.01, + opt_type_b="P", + bias_split_by="budget", + bias_ratio=0.7, + ) + self.assertEqual(s["mode"], "long_bias") + self.assertEqual(s["sheets_a"], 7) + self.assertEqual(s["sheets_b"], 3) + self.assertTrue(s["ok"]) + + def test_suggest_oo_short_bias_sheets(self): + # 同张数 n=5 → 总张数 10; short → put 7 / call 3 + s = suggest_oo_sheets( + mode="short_bias", + budget_usdc=10, + ask_a=100, + ct_mult_a=0.01, + opt_type_a="C", + ask_b=100, + ct_mult_b=0.01, + opt_type_b="P", + bias_split_by="sheets", + bias_ratio=0.7, + ) + self.assertEqual(s["mode"], "short_bias") + self.assertEqual(s["sheets_a"], 3) + self.assertEqual(s["sheets_b"], 7) + + def test_suggest_oo_bias_requires_call_put(self): + s = suggest_oo_sheets( + mode="long_bias", + budget_usdc=10, + ask_a=100, + ct_mult_a=0.01, + opt_type_a="C", + ask_b=100, + ct_mult_b=0.01, + opt_type_b="C", + bias_split_by="budget", + ) + self.assertFalse(s["ok"]) + self.assertIn("Call", s["msg"]) + + def test_suggest_oo_depth_cap(self): + s = suggest_oo_sheets( + mode="same_sheets", + budget_usdc=100, + ask_a=100, + ct_mult_a=0.01, + ask_sz_a=2, + ask_b=100, + ct_mult_b=0.01, + ask_sz_b=50, + ) + self.assertEqual(s["sheets_a"], 2) + self.assertEqual(s["sheets_b"], 2) + + def test_oo_rest_close_mode_default_close_all(self): + self.assertEqual(resolve_oo_rest_close_mode({"oo_close_mode": "close_all"}), "close_all") + self.assertEqual(resolve_oo_rest_close_mode({"oo_close_mode": "hold_expiry"}), "hold_expiry") + # 旧计划无字段:保持到期平,避免部署后误清残腿 + self.assertEqual(resolve_oo_rest_close_mode({}), "hold_expiry") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_end.py b/tests/test_hedge_plan_end.py new file mode 100644 index 0000000..b62f747 --- /dev/null +++ b/tests/test_hedge_plan_end.py @@ -0,0 +1,140 @@ +"""对冲计划:人工结束 + 未成交不显示 open.""" +from __future__ import annotations + +import sqlite3 +import unittest +from unittest import mock + +from lib.hedge_plan.hedge_plan_db import ( + get_plan, + get_plan_legs, + init_hedge_plan_tables, + insert_leg, + insert_plan, + legs_contract_summary, +) +from lib.hedge_plan.hedge_plan_orders_lib import ( + execute_manual_end_plan, + reconcile_unfilled_option_legs, +) + + +def _mem(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + return conn + + +class TestHedgePlanEnd(unittest.TestCase): + def test_legs_summary_marks_unfilled(self): + s = legs_contract_summary( + [ + {"leg_role": "option_a", "inst_id": "ETH-C", "status": "pending"}, + {"leg_role": "option_b", "inst_id": "ETH-P", "status": "open"}, + {"leg_role": "option_a", "inst_id": "X", "status": "cancelled"}, + ] + ) + self.assertIn("(待补)", s) + self.assertIn("(未成交)", s) + + def test_reconcile_ghost_open_to_pending(self): + conn = _mem() + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260720-1870-C", + "status": "open", + "exchange_ord_id": "1", + "avg_open": 0.01, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": "ETH-USD_UM-260720-1870-P", + "status": "open", + "exchange_ord_id": "2", + "avg_open": 0.02, + }, + ) + cfg = {"exchange_options": object()} + with mock.patch( + "lib.hedge_plan.hedge_plan_orders_lib._live_option_pos_sheets", + side_effect=lambda ex, inst: 0.0 if "C" in inst else 50.0, + ), mock.patch( + "lib.exchange.okx_options_lib.fetch_option_order", + return_value={"ok": True, "state": "canceled", "acc_fill_sz": 0}, + ): + notes = reconcile_unfilled_option_legs(cfg, conn, pid) + self.assertTrue(notes) + legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)} + self.assertEqual(legs["option_a"]["status"], "pending") + self.assertEqual(legs["option_b"]["status"], "open") + self.assertEqual(get_plan(conn, pid)["status"], "partial") + + def test_manual_end_no_flat(self): + conn = _mem() + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "partial", + "underlying": "ETH", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-C", + "status": "pending", + "exchange_ord_id": "9", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": "ETH-P", + "status": "open", + "exchange_ord_id": "8", + }, + ) + cfg = {"exchange_options": object()} + with mock.patch( + "lib.hedge_plan.hedge_plan_orders_lib.reconcile_unfilled_option_legs", + return_value=[], + ), mock.patch( + "lib.exchange.okx_options_lib.cancel_option_order", + return_value={"ok": True}, + ) as cancel, mock.patch( + "lib.hedge_plan.hedge_plan_notify_lib.notify_plan_end", + return_value=True, + ): + out = execute_manual_end_plan(cfg, conn, pid) + self.assertTrue(out.get("ok")) + self.assertEqual(get_plan(conn, pid)["status"], "closed") + self.assertEqual(get_plan(conn, pid)["close_reason"], "manual") + legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)} + self.assertEqual(legs["option_a"]["status"], "cancelled") + self.assertEqual(legs["option_b"]["status"], "open") # 已有持仓不动 + cancel.assert_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_history_stats.py b/tests/test_hedge_plan_history_stats.py new file mode 100644 index 0000000..e16dae4 --- /dev/null +++ b/tests/test_hedge_plan_history_stats.py @@ -0,0 +1,186 @@ +"""对冲计划历史删除与分类型统计.""" +import sqlite3 +import unittest + +from lib.hedge_plan.hedge_plan_db import ( + _metrics_from_pnls, + active_options_targets_by_inst, + delete_plan, + init_hedge_plan_tables, + insert_leg, + insert_plan, + legs_contract_summary, + stats_summary, +) + + +def _mem(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + return conn + + +class TestHedgeHistoryStats(unittest.TestCase): + def test_metrics_win_rate_pf_dd(self): + rows = [ + {"realized_pnl_total": 10, "closed_at": "2026-01-01", "premium_total": 1}, + {"realized_pnl_total": -4, "closed_at": "2026-01-02", "premium_total": 1}, + {"realized_pnl_total": 6, "closed_at": "2026-01-03", "premium_total": 1}, + {"realized_pnl_total": -12, "closed_at": "2026-01-04", "premium_total": 1}, + ] + m = _metrics_from_pnls(rows) + self.assertEqual(m["count"], 4) + self.assertEqual(m["wins"], 2) + self.assertAlmostEqual(m["win_rate"], 0.5) + # gross win 16 / gross loss 16 = 1 + self.assertAlmostEqual(m["profit_factor"], 1.0) + self.assertAlmostEqual(m["max_profit"], 10) + self.assertAlmostEqual(m["max_loss"], -12) + # equity: 10 → 6 → 12 → 0; peak 12, dd to 0 = 12 + self.assertAlmostEqual(m["max_drawdown"], 12) + + def test_stats_by_type_and_delete(self): + conn = _mem() + po = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "closed", + "underlying": "ETH", + "realized_pnl_total": 5, + "premium_total": 1, + "close_reason": "perp_tp", + "closed_at": "2026-07-01 10:00:00", + }, + ) + insert_leg( + conn, + { + "plan_id": po, + "leg_role": "perp", + "symbol": "ETH/USDT:USDT", + "status": "closed", + }, + ) + oo = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "closed", + "underlying": "ETH", + "realized_pnl_total": -2, + "premium_total": 0.02, + "close_reason": "oo_expiry_loss", + "closed_at": "2026-07-02 10:00:00", + }, + ) + insert_leg( + conn, + { + "plan_id": oo, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260715-1900-C", + "status": "closed", + }, + ) + active = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "active", + "underlying": "BTC", + "realized_pnl_total": None, + }, + ) + s = stats_summary(conn) + self.assertEqual(s["closed_count"], 2) + self.assertEqual(s["by_type"]["perp_options"]["count"], 1) + self.assertEqual(s["by_type"]["options_options"]["count"], 1) + self.assertAlmostEqual(s["by_type"]["perp_options"]["win_rate"], 1.0) + self.assertAlmostEqual(s["by_type"]["options_options"]["max_loss"], -2) + + bad = delete_plan(conn, active) + self.assertFalse(bad["ok"]) + ok = delete_plan(conn, oo) + self.assertTrue(ok["ok"]) + s2 = stats_summary(conn) + self.assertEqual(s2["closed_count"], 1) + + def test_contract_summary(self): + s = legs_contract_summary( + [ + {"leg_role": "perp", "symbol": "ETH/USDT:USDT"}, + {"leg_role": "option_hedge", "inst_id": "ETH-USD_UM-260715-1790-P"}, + ] + ) + self.assertIn("永续 ETH/USDT:USDT", s) + self.assertIn("ETH-USD_UM-260715-1790-P", s) + + def test_active_options_targets_are_read_only_plan_targets(self): + conn = _mem() + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "target_price_up": 1950, + "target_price_down": 1800, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260719-1890-C", + "opt_type": "C", + "status": "open", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": "ETH-USD_UM-260719-1850-P", + "opt_type": "P", + "status": "open", + }, + ) + + targets = active_options_targets_by_inst(conn) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["target_index"], 1950) + self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") + + def test_active_options_targets_profit_rr(self): + conn = _mem() + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "profit_rr": 2, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": "ETH-USD_UM-260719-1890-C", + "opt_type": "C", + "status": "open", + }, + ) + targets = active_options_targets_by_inst(conn) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["profit_rr"], 2) + self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["exit_mode"], "profit_rr") + self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_moneyness.py b/tests/test_hedge_plan_moneyness.py new file mode 100644 index 0000000..8c3599f --- /dev/null +++ b/tests/test_hedge_plan_moneyness.py @@ -0,0 +1,118 @@ +"""对冲计划虚实值选约与校验.""" +import unittest + +from lib.hedge_plan.hedge_plan_moneyness_lib import ( + is_atm_or_otm, + is_itm_or_atm, + parse_strike_from_inst, + pick_itm_or_atm_contract, + recommend_oo_legs, + validate_oo_legs_moneyness, + validate_po_option_moneyness, +) +from lib.hedge_plan.hedge_plan_orders_lib import validate_start_body + + +class TestHedgeMoneyness(unittest.TestCase): + def test_parse_strike(self): + self.assertEqual(parse_strike_from_inst("ETH-USD-260731-1800-P"), 1800.0) + self.assertIsNone(parse_strike_from_inst("bad")) + + def test_po_geometry(self): + # Call: K<=S 实/平 + self.assertTrue(is_itm_or_atm(opt_type="C", strike=3500, index_px=3510)) + self.assertFalse(is_itm_or_atm(opt_type="C", strike=3600, index_px=3510)) + # Put: K>=S + self.assertTrue(is_itm_or_atm(opt_type="P", strike=3600, index_px=3510)) + self.assertFalse(is_itm_or_atm(opt_type="P", strike=3400, index_px=3510)) + + def test_oo_geometry(self): + self.assertTrue(is_atm_or_otm(opt_type="C", strike=3600, index_px=3510)) + self.assertFalse(is_atm_or_otm(opt_type="C", strike=3400, index_px=3510)) + self.assertTrue(is_atm_or_otm(opt_type="P", strike=3400, index_px=3510)) + self.assertFalse(is_atm_or_otm(opt_type="P", strike=3600, index_px=3510)) + + def test_validate_po_rejects_otm(self): + err = validate_po_option_moneyness(opt_type="P", strike=1700, index_px=1800) + self.assertIsNotNone(err) + self.assertIn("虚值", err) + self.assertIsNone( + validate_po_option_moneyness(opt_type="P", strike=1800, index_px=1800) + ) + + def test_validate_oo_rejects_itm(self): + err = validate_oo_legs_moneyness( + {"opt_type": "C", "strike": 1700}, + {"opt_type": "P", "strike": 1900}, + index_px=1800, + ) + self.assertIsNotNone(err) + self.assertIn("实值", err) + self.assertIsNone( + validate_oo_legs_moneyness( + {"opt_type": "C", "strike": 1850}, + {"opt_type": "P", "strike": 1750}, + index_px=1800, + ) + ) + + def test_pick_itm_or_atm(self): + contracts = [ + {"inst_id": "E-C-3600", "opt_type": "C", "strike": 3600}, + {"inst_id": "E-C-3500", "opt_type": "C", "strike": 3500}, + {"inst_id": "E-C-3400", "opt_type": "C", "strike": 3400}, + ] + picked = pick_itm_or_atm_contract( + contracts, opt_type="C", index_px=3510, itm_max_dist=200 + ) + self.assertIsNotNone(picked) + self.assertEqual(picked["strike"], 3500) + + def test_recommend_oo(self): + contracts = [ + {"inst_id": "E-C-1800", "opt_type": "C", "strike": 1800}, + {"inst_id": "E-P-1800", "opt_type": "P", "strike": 1800}, + {"inst_id": "E-C-1900", "opt_type": "C", "strike": 1900}, + {"inst_id": "E-P-1700", "opt_type": "P", "strike": 1700}, + ] + pair = recommend_oo_legs(contracts, index_px=1800, template="atm_straddle") + self.assertIsNotNone(pair) + self.assertEqual(pair[0]["opt_type"], "C") + self.assertEqual(pair[1]["opt_type"], "P") + + def test_validate_start_body_po_otm(self): + err = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "ETH-USD-260731-1700-P", + "opt_type": "P", + "strike": 1700, + "index_px": 1800, + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNotNone(err) + self.assertIn("虚值", err) + + def test_validate_start_body_oo_itm(self): + err = validate_start_body( + "options_options", + { + "profit_rr": 2, + "index_px": 1800, + "leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700}, + "leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900}, + }, + ) + self.assertIsNotNone(err) + self.assertIn("实值", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_notify_settle.py b/tests/test_hedge_plan_notify_settle.py new file mode 100644 index 0000000..a6e8bbb --- /dev/null +++ b/tests/test_hedge_plan_notify_settle.py @@ -0,0 +1,220 @@ +"""对冲计划微信文案与到期结算.""" +import sqlite3 +import time +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +from lib.exchange.okx_options_lib import expiry_ms_from_inst_id +from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan +from lib.hedge_plan.hedge_plan_monitor_lib import _tick_oo_expiry, _settle_orphaned_after_tp +from lib.hedge_plan.hedge_plan_notify_lib import ( + build_hedge_end_message, + build_hedge_start_message, + notify_plan_end, + notify_plan_start, +) +from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot + + +def _mem_db(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_hedge_plan_tables(conn) + return conn + + +class TestHedgeNotify(unittest.TestCase): + def test_start_end_copy(self): + plan = { + "id": 7, + "plan_type": "perp_options", + "underlying": "ETH", + "direction": "long", + "entry_mark": 1800, + "tp": 1900, + "sl": 1700, + "perp_size": 2, + "leverage": 10, + "premium_total": 1.5, + "close_reason": "perp_tp", + "realized_pnl_total": 12.3, + "realized_pnl_perp": 15, + "realized_pnl_options": -1.5, + "opened_at": "2026-07-01 10:00:00", + "closed_at": "2026-07-01 12:00:00", + } + s = build_hedge_start_message(plan) + self.assertIn("启动 #7", s) + self.assertIn("永期", s) + e = build_hedge_end_message(plan) + self.assertIn("结束 #7", e) + self.assertIn("止盈", e) + + def test_idempotent_flags(self): + conn = _mem_db() + sent = [] + cfg = {"send_wechat": lambda c: sent.append(c)} + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "target_price": 2000, + "premium_total": 2.0, + "opened_at": "t0", + }, + ) + plan = get_plan(conn, pid) + self.assertTrue(notify_plan_start(cfg, conn, plan, [])) + plan = get_plan(conn, pid) + self.assertEqual(int(plan["wechat_start_sent"]), 1) + self.assertFalse(notify_plan_start(cfg, conn, plan, [])) + self.assertEqual(len(sent), 1) + + plan["status"] = "closed" + plan["close_reason"] = "oo_expiry_loss" + plan["realized_pnl_total"] = -2 + plan["closed_at"] = "t1" + self.assertTrue(notify_plan_end(cfg, conn, plan)) + plan = get_plan(conn, pid) + self.assertEqual(int(plan["wechat_end_sent"]), 1) + self.assertFalse(notify_plan_end(cfg, conn, plan)) + self.assertEqual(len(sent), 2) + + +class TestHedgeSettle(unittest.TestCase): + def test_put_expiry_otm(self): + pnl = settle_option_leg_at_spot( + {"opt_type": "P", "strike": 1700, "size": 2, "premium": 1.2, "ct_mult": 0.01}, + spot=1800, + ) + self.assertAlmostEqual(pnl, -1.2) + + def test_call_expiry_itm(self): + # intrinsic (1900-1800)*2*0.01 - 0.5 = 2 - 0.5 + pnl = settle_option_leg_at_spot( + {"opt_type": "C", "strike": 1800, "size": 2, "premium": 0.5, "ct_mult": 0.01}, + spot=1900, + ) + self.assertAlmostEqual(pnl, 1.5) + + def test_leg_expired_from_inst(self): + # past date in inst_id + past = datetime.now(timezone.utc) - timedelta(days=3) + yy = past.year % 100 + tag = f"ETH-USD-{yy:02d}{past.month:02d}{past.day:02d}-1800-P" + self.assertTrue(leg_is_expired({"inst_id": tag})) + future = datetime.now(timezone.utc) + timedelta(days=10) + tag2 = f"ETH-USD-{future.year % 100:02d}{future.month:02d}{future.day:02d}-1800-P" + self.assertFalse(leg_is_expired({"inst_id": tag2})) + self.assertIsNotNone(expiry_ms_from_inst_id(tag)) + + +class TestHedgeMonitorExpiry(unittest.TestCase): + def test_oo_expiry_loss_closes_plan(self): + conn = _mem_db() + past = datetime.now(timezone.utc) - timedelta(days=1) + tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1800-P" + pid = insert_plan( + conn, + { + "plan_type": "options_options", + "status": "active", + "underlying": "ETH", + "target_price": 2000, + "premium_total": 2.0, + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_a", + "inst_id": tag, + "opt_type": "P", + "strike": 1800, + "size": 1, + "premium": 1.0, + "status": "open", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_b", + "inst_id": tag.replace("-P", "-C").replace("1800", "1900"), + "opt_type": "C", + "strike": 1900, + "size": 1, + "premium": 1.0, + "status": "open", + }, + ) + sent = [] + cfg = { + "send_wechat": lambda c: sent.append(c), + "fetch_index_price": lambda ex, u: 1850.0, + "exchange_options": object(), + } + plan = get_plan(conn, pid) + legs = [ + dict(r) + for r in conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchall() + ] + r = _tick_oo_expiry(cfg, conn, plan, legs) + self.assertIsNotNone(r) + self.assertEqual(r["close_reason"], "oo_expiry_loss") + plan2 = get_plan(conn, pid) + self.assertEqual(plan2["status"], "closed") + self.assertLessEqual(float(plan2["realized_pnl_total"]), 0) + self.assertTrue(any("结束" in x for x in sent)) + + def test_orphaned_option_does_not_rewrite_plan_total(self): + conn = _mem_db() + past = datetime.now(timezone.utc) - timedelta(days=1) + tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1700-P" + pid = insert_plan( + conn, + { + "plan_type": "perp_options", + "status": "closed", + "underlying": "ETH", + "direction": "long", + "close_reason": "perp_tp", + "realized_pnl_total": 10.0, + "realized_pnl_options": -1.0, + "stats_bucket": "tp", + }, + ) + insert_leg( + conn, + { + "plan_id": pid, + "leg_role": "option_hedge", + "inst_id": tag, + "opt_type": "P", + "strike": 1700, + "size": 1, + "premium": 1.0, + "status": "hold_to_expiry", + "close_reason": "orphaned_after_tp", + }, + ) + cfg = { + "fetch_index_price": lambda ex, u: 1800.0, + "exchange_options": object(), + } + acted = _settle_orphaned_after_tp(cfg, conn) + self.assertEqual(len(acted), 1) + plan = get_plan(conn, pid) + self.assertAlmostEqual(float(plan["realized_pnl_total"]), 10.0) + leg = conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchone() + self.assertEqual(leg["status"], "closed") + self.assertEqual(leg["close_reason"], "expiry") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_option_primary.py b/tests/test_hedge_plan_option_primary.py new file mode 100644 index 0000000..7505dc5 --- /dev/null +++ b/tests/test_hedge_plan_option_primary.py @@ -0,0 +1,195 @@ +"""永期「以期权为主」定仓/方向/目标位/校验.""" +import unittest + +from lib.hedge_plan.hedge_plan_option_primary_lib import ( + PREMIUM_EXEC_FACTOR, + build_option_primary_preview, + estimate_combo_net_pnl, + floor2, + opt_type_for_view, + option_bid_liquidity_ok, + perp_direction_for_view, + pick_option_primary_candidate, + size_from_premium, + target_hit, + validate_option_primary_start, + validate_option_primary_watch, +) +from lib.hedge_plan.hedge_plan_orders_lib import build_po_path_plan, validate_start_body + + +class TestOptionPrimary(unittest.TestCase): + def test_direction_mapping(self): + self.assertEqual(opt_type_for_view("long"), "C") + self.assertEqual(opt_type_for_view("short"), "P") + self.assertEqual(perp_direction_for_view("long"), "short") + self.assertEqual(perp_direction_for_view("short"), "long") + + def test_size_from_premium_095_and_eth_2dp(self): + # ask=10 → 1 ETH 成本 10U; 预算 100 → usable 95 → eth=9.5 → sheets=950 (ct=0.01) + sized = size_from_premium( + premium_budget=100, + ask=10, + ct_mult=0.01, + ratio=2, + contract_size=0.01, + ) + self.assertTrue(sized["ok"]) + self.assertAlmostEqual(sized["usable_premium"], 95.0) + self.assertEqual(sized["eth_qty"], 9.5) + self.assertEqual(sized["sheets"], 950.0) + # perp_eth = 9.5/2=4.75; contracts=4.75/0.01=475 + self.assertAlmostEqual(sized["contracts"], 475.0) + self.assertEqual(PREMIUM_EXEC_FACTOR, 0.95) + + def test_floor2(self): + self.assertEqual(floor2(1.239), 1.23) + self.assertEqual(floor2(0.009), 0.0) + + def test_target_hit(self): + self.assertTrue(target_hit(view_side="long", index_px=1950, strike=1900, points=50)) + self.assertFalse(target_hit(view_side="long", index_px=1949, strike=1900, points=50)) + self.assertTrue(target_hit(view_side="short", index_px=1850, strike=1900, points=50)) + self.assertFalse(target_hit(view_side="short", index_px=1851, strike=1900, points=50)) + self.assertFalse(target_hit(view_side="long", index_px=1900, strike=1900, points=0)) + + def test_bid_liquidity(self): + ok, _ = option_bid_liquidity_ok(1.2, 10, need_sheets=5) + self.assertTrue(ok) + ok2, msg = option_bid_liquidity_ok(None, 10, need_sheets=1) + self.assertFalse(ok2) + self.assertIn("买一", msg) + + def test_net_pnl_uses_buy_fee_for_sell(self): + net = estimate_combo_net_pnl( + view_side="long", + strike=1900, + index_px=1950, + ask_open=20, + bid=30, + sheets=2, + ct_mult=0.01, + perp_direction="short", + perp_entry=1900, + perp_mark=1950, + contracts=10, + contract_size=0.01, + fee=0.001, + ) + # opt: proceeds=30*2*0.01=0.6; premium=0.4; fees=0.0004+0.0006; opt_net=0.6-0.4-0.001=0.199 + self.assertIn("net", net) + self.assertEqual(net["fee_rate"], 0.001) + + def test_path_option_primary_no_tpsl_options_first(self): + path = build_po_path_plan( + { + "option_primary": True, + "direction": "long", + "opt_inst_id": "ETH-USD-260831-1900-C", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "contracts": 1, + } + ) + self.assertEqual(path[0]["step"], "options_buy_limit") + self.assertEqual(path[1]["direction"], "short") + self.assertFalse(path[1]["attach_tpsl"]) + + def test_validate_option_primary_watch_and_start(self): + watch_body = { + "option_primary": True, + "watch_entry": 1, + "direction": "long", + "exchange_symbol": "ETH/USDT:USDT", + "premium_budget": 100, + "option_target_points": 50, + "perp_target_points": 30, + "option_perp_ratio": 4, + "option_leverage": 200, + "moneyness": "otm", + } + self.assertIsNone(validate_option_primary_watch(watch_body)) + self.assertIsNone(validate_start_body("perp_options", watch_body)) + + body = { + "option_primary": True, + "watch_entry": 0, + "direction": "long", + "contracts": 1, + "opt_inst_id": "ETH-USD-260831-1900-C", + "opt_type": "C", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "premium_budget": 100, + "option_target_points": 50, + "perp_target_points": 30, + "option_perp_ratio": 2, + "strike": 1900, + "index_px": 1905, + "ask": 10, + "moneyness": "atm", + "strike_interval": 15, + "min_option_hours": 36, + "hours_to_expiry": 40, + "option_leverage": 100, + } + self.assertIsNone(validate_option_primary_start(body)) + self.assertIsNone(validate_start_body("perp_options", body)) + bad = dict(body, opt_type="P") + self.assertIsNotNone(validate_start_body("perp_options", bad)) + + def test_pick_candidate_respects_leverage(self): + chain = { + "index_px": 1900, + "expiries": [ + { + "exp_time": 9_999_999_999_999, + "contracts": [ + {"inst_id": "LOW", "opt_type": "C", "strike": 1920, "ask": 20, "ct_mult": 0.01}, + {"inst_id": "OK", "opt_type": "C", "strike": 1925, "ask": 8, "ct_mult": 0.01}, + ], + } + ], + } + # 1900/20=95 < 200; 1900/8=237.5 ≥ 200 + picked = pick_option_primary_candidate( + chain, + direction="long", + moneyness="otm", + strike_interval=50, + min_hours=1, + min_opt_leverage=200, + ) + self.assertIsNotNone(picked) + self.assertEqual(picked["inst_id"], "OK") + + def test_preview_builds_scenarios(self): + body = { + "direction": "long", + "strike": 1900, + "option_target_points": 50, + "perp_target_points": 30, + "ask": 20, + "sheets": 10, + "ct_mult": 0.01, + "contracts": 5, + "contract_size": 0.01, + "entry": 1900, + "index_px": 1900, + "premium_budget": 100, + } + out = build_option_primary_preview(body) + self.assertTrue(out["option_primary"]) + self.assertEqual(len(out["scenarios"]), 2) + + def test_hours_to_expiry_from_ms(self): + from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms + + now = 1_700_000_000_000.0 + # +40h in ms + h = hours_to_expiry_from_ms(now + 40 * 3600 * 1000, now_ms=now) + self.assertAlmostEqual(h, 40.0, places=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_plan_orders.py b/tests/test_hedge_plan_orders.py new file mode 100644 index 0000000..b9c7384 --- /dev/null +++ b/tests/test_hedge_plan_orders.py @@ -0,0 +1,240 @@ +"""对冲计划下单路径校验(dry_run + 门禁).""" +import unittest +from unittest.mock import MagicMock + +from lib.hedge_plan.hedge_plan_calc_lib import gate_status +from lib.hedge_plan.hedge_plan_orders_lib import ( + build_oo_path_plan, + build_po_path_plan, + execute_options_options_start, + execute_perp_options_start, + validate_start_body, +) + + +class TestHedgePlanOrderPath(unittest.TestCase): + def test_po_path_options_first(self): + body = { + "opt_inst_id": "ETH-USD-260731-1800-P", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "direction": "long", + "contracts": 4.5, + "tp": 1900, + "sl": 1700, + } + path = build_po_path_plan(body) + self.assertEqual(path[0]["step"], "options_buy_limit") + self.assertEqual(path[0]["account"], "options") + self.assertEqual(path[1]["step"], "perp_market_open") + self.assertEqual(path[1]["account"], "swap") + self.assertTrue(path[1]["attach_tpsl"]) + + def test_oo_path_two_option_buys(self): + body = { + "leg_a": {"inst_id": "ETH-USD-260731-1800-C", "sheets": 1}, + "leg_b": {"inst_id": "ETH-USD-260731-1700-P", "sheets": 3}, + } + path = build_oo_path_plan(body) + self.assertEqual(len(path), 2) + self.assertEqual(path[0]["leg"], "a") + self.assertEqual(path[1]["sheets"], 3) + + def test_validate_body(self): + self.assertIsNotNone(validate_start_body("perp_options", {})) + ok = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "ETH-USD-260731-1800-P", + "opt_type": "P", + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNone(ok) + bad_type = validate_start_body( + "perp_options", + { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 1, + "opt_inst_id": "ETH-USD-260731-1800-C", + "opt_type": "C", + "sheets": 1, + "exchange_symbol": "ETH/USDT:USDT", + }, + ) + self.assertIsNotNone(bad_type) + + def test_gate_can_start_when_live(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="full_margin", + plan_type="perp_options", + options_enabled=True, + live_order=True, + live_trading=True, + active_count=0, + max_active=1, + ) + self.assertTrue(g["can_start"]) + self.assertEqual(g["reasons"], []) + + def test_gate_oo_without_live_trading(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="options_options", + options_enabled=True, + live_order=True, + live_trading=False, + active_count=0, + max_active=1, + ) + self.assertTrue(g["can_start"]) + + def test_dry_run_po_calls_quote_not_place(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 12.5, + "ask_sz": 10, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "strike": 1800, + "exp_time": 1, + "meta": {"optType": "P"}, + } + ) + place_opt = MagicMock() + place_perp = MagicMock() + cfg = { + "exchange_options": object(), + "exchange": object(), + "quote_option_contract": quote, + "place_option_limit_order": place_opt, + "place_exchange_order": place_perp, + "td_mode_for_option_buy": lambda x: "isolated", + "amount_to_precision": lambda s, a: a, + "ensure_okx_live_ready": lambda: (True, ""), + } + body = { + "direction": "long", + "entry": 1800, + "tp": 1900, + "sl": 1700, + "contracts": 4.5, + "opt_inst_id": "ETH-USD-260731-1800-P", + "sheets": 2, + "exchange_symbol": "ETH/USDT:USDT", + "leverage": 10, + "underlying": "ETH", + } + out = execute_perp_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"]) + self.assertTrue(out["dry_run"]) + place_opt.assert_not_called() + place_perp.assert_not_called() + quote.assert_called() + self.assertEqual(out["path"][0]["account"], "options") + self.assertEqual(out["path"][1]["account"], "swap") + + def test_dry_run_oo(self): + quote = MagicMock( + side_effect=lambda _ex, inst_id: { + "ok": True, + "ask": 10, + "ask_sz": 50, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "strike": 1800, + "meta": {"optType": "C" if inst_id == "A" else "P"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + "trade_budget_usdc": 10, + "budget_buffer": 0.95, + } + body = { + "profit_rr": 2, + "oo_sheets_mode": "same_sheets", + "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, + "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, + } + from unittest import mock + + with mock.patch( + "lib.exchange.okx_options_lib.fetch_options_trading_usdc", + return_value=100.0, + ): + out = execute_options_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"], out) + self.assertEqual(len(out["results"]), 2) + self.assertTrue(out.get("refresh", {}).get("ok")) + + def test_buy_rejects_without_ask_depth(self): + from lib.hedge_plan.hedge_plan_orders_lib import _buy_option + + quote = MagicMock( + return_value={ + "ok": True, + "ask": None, + "ask_sz": None, + "mark": 11.2, + "ref_ask": 11.2, + "can_open": False, + "open_block_msg": "暂无卖一深度,无法买入", + "ct_mult": 0.01, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + } + out = _buy_option(cfg, inst_id="ETH-USD_UM-260717-1900-C", sheets=1, dry_run=True) + self.assertFalse(out["ok"]) + self.assertIn("卖一", out["msg"]) + cfg["place_option_limit_order"].assert_not_called() + + def test_buy_rejects_when_ask_depth_below_request(self): + from lib.hedge_plan.hedge_plan_orders_lib import _buy_option + + quote = MagicMock( + return_value={ + "ok": True, + "ask": 10, + "ask_sz": 2, + "can_open": True, + "ct_mult": 0.01, + "tick_sz": "0.1", + "meta": {"optType": "C"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + } + out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True) + self.assertFalse(out["ok"]) + self.assertIn("不足请求", out["msg"]) + cfg["place_option_limit_order"].assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hedge_po_monitor_safety.py b/tests/test_hedge_po_monitor_safety.py new file mode 100644 index 0000000..cfb1327 --- /dev/null +++ b/tests/test_hedge_po_monitor_safety.py @@ -0,0 +1,101 @@ +"""永期监控:假平仓防护与 TP/SL 分类.""" +import unittest +from unittest.mock import MagicMock, patch + +from lib.hedge_plan.hedge_plan_monitor_lib import ( + _classify_po_flat_reason, + _tick_po, + _within_open_grace, +) + + +class TestPoMonitorSafety(unittest.TestCase): + def test_classify_sl_before_tp(self): + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=1690, tp=1900, sl=1700 + ), + "perp_sl", + ) + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=1910, tp=1900, sl=1700 + ), + "perp_tp", + ) + + def test_classify_ambiguous_prefers_sl(self): + # mark 在中间:偏 SL,避免误判 TP 跳过强平期权 + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=1800, tp=1900, sl=1700 + ), + "perp_sl", + ) + + def test_classify_unknown_without_mark(self): + self.assertEqual( + _classify_po_flat_reason( + direction="long", entry=1800, mark=None, tp=1900, sl=1700 + ), + "perp_flat_unknown", + ) + + def test_tick_po_skips_when_live_none(self): + cfg = {"get_live_position_contracts": lambda *_a, **_k: None} + plan = {"id": 1, "direction": "long", "entry_mark": 1800, "tp": 1900, "sl": 1700} + legs = [{"leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1}] + self.assertIsNone(_tick_po(cfg, MagicMock(), plan, legs)) + + def test_tick_po_skips_when_live_positive(self): + cfg = {"get_live_position_contracts": lambda *_a, **_k: 2.0} + plan = {"id": 1, "direction": "long", "entry_mark": 1800, "tp": 1900, "sl": 1700} + legs = [{"leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1}] + self.assertIsNone(_tick_po(cfg, MagicMock(), plan, legs)) + + def test_within_grace_true_without_opened_at(self): + self.assertTrue(_within_open_grace({})) + + def test_pending_sl_sticky(self): + """perp_sl_pending_opt 粘滞,不因 mark 反弹改判 TP.""" + cfg = { + "get_live_position_contracts": lambda *_a, **_k: 0.0, + "get_contract_size": lambda *_a, **_k: 0.01, + "exchange": None, + } + plan = { + "id": 9, + "direction": "long", + "entry_mark": 1800, + "tp": 1900, + "sl": 1700, + "premium_total": 5, + "opened_at": "2000-01-01 00:00:00", + "close_reason": "perp_sl_pending_opt", + } + legs = [ + {"id": 1, "leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1, "avg_open": 1800}, + { + "id": 2, + "leg_role": "option_hedge", + "status": "open", + "inst_id": "ETH-USD-260731-1700-P", + "size": 1, + "avg_open": 10, + }, + ] + with patch("lib.hedge_plan.hedge_plan_monitor_lib._sell_option") as sell: + sell.return_value = {"ok": False, "msg": "thin book", "fully_closed": False} + with patch("lib.hedge_plan.hedge_plan_monitor_lib.notify_hedge"): + with patch("lib.hedge_plan.hedge_plan_monitor_lib.update_plan") as up: + conn = MagicMock() + out = _tick_po(cfg, conn, plan, legs) + self.assertTrue(out and out.get("retry")) + up.assert_called() + self.assertEqual(up.call_args.kwargs.get("close_reason"), "perp_sl_pending_opt") + self.assertNotEqual(up.call_args.kwargs.get("status"), "closed") + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_hedge_start_refresh.py b/tests/test_hedge_start_refresh.py new file mode 100644 index 0000000..3e7ff65 --- /dev/null +++ b/tests/test_hedge_start_refresh.py @@ -0,0 +1,125 @@ +"""对冲启动:再拉卖一 + 对冲专用预算缓冲重算张数.""" +from __future__ import annotations + +import os +import unittest +from unittest import mock +from unittest.mock import MagicMock + +from lib.hedge_plan.hedge_plan_orders_lib import ( + _hedge_budget_buffer, + execute_options_options_start, + refresh_oo_sizing_before_start, + refresh_po_option_quote_before_start, +) + + +class TestHedgeStartRefresh(unittest.TestCase): + def test_hedge_budget_buffer_independent(self): + with mock.patch.dict( + os.environ, + { + "HEDGE_PLAN_BUDGET_BUFFER": "0.9", + "OKX_OPTIONS_BUDGET_BUFFER": "0.5", + }, + ): + self.assertAlmostEqual(_hedge_budget_buffer(None), 0.9) + self.assertAlmostEqual(_hedge_budget_buffer({"budget_buffer": 0.88}), 0.88) + + def test_refresh_oo_resizes_from_fresh_ask(self): + def quote(_ex, inst_id): + if inst_id == "A": + return { + "ok": True, + "ask": 20, + "ask_sz": 100, + "ct_mult": 0.01, + "meta": {"optType": "C"}, + } + return { + "ok": True, + "ask": 20, + "ask_sz": 100, + "ct_mult": 0.01, + "meta": {"optType": "P"}, + } + + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "trade_budget_usdc": 10, + "budget_buffer": 0.95, + } + body = { + "oo_sheets_mode": "same_sheets", + "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, + "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, + } + # unit cost = 20*0.01=0.2 each → pair 0.4; budget min(100*0.95,10)=10 → n=25 + with mock.patch( + "lib.exchange.okx_options_lib.fetch_options_trading_usdc", + return_value=100.0, + ): + out = refresh_oo_sizing_before_start(cfg, body) + self.assertTrue(out.get("ok"), out) + self.assertEqual(body["leg_a"]["sheets"], 25) + self.assertEqual(body["leg_b"]["sheets"], 25) + self.assertEqual(body["leg_a"]["ask"], 20) + self.assertEqual(out["sheets_a"], 25) + + def test_execute_oo_start_uses_refreshed_sheets(self): + quote = MagicMock( + side_effect=lambda _ex, inst_id: { + "ok": True, + "ask": 10, + "ask_sz": 50, + "ct_mult": 0.01, + "tick_sz": "0.1", + "meta": {"optType": "C" if inst_id == "A" else "P"}, + } + ) + cfg = { + "exchange_options": object(), + "quote_option_contract": quote, + "place_option_limit_order": MagicMock(), + "td_mode_for_option_buy": lambda x: "isolated", + "trade_budget_usdc": 4, + "budget_buffer": 0.95, + } + body = { + "oo_sheets_mode": "same_sheets", + "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, + "leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, + } + # cost 0.1+0.1=0.2; budget 4 → 20 sheets each + with mock.patch( + "lib.exchange.okx_options_lib.fetch_options_trading_usdc", + return_value=100.0, + ): + out = execute_options_options_start(cfg, body, dry_run=True) + self.assertTrue(out["ok"], out) + self.assertEqual(body["leg_a"]["sheets"], 20) + self.assertEqual(out["results"][0]["sheets"], 20) + self.assertIn("refresh", out) + self.assertTrue(out["refresh"]["ok"]) + + def test_refresh_po_keeps_sheets(self): + quote = MagicMock( + return_value={ + "ok": True, + "ask": 12.5, + "ask_sz": 8, + "ct_mult": 0.01, + "meta": {"optType": "P"}, + } + ) + cfg = {"exchange_options": object(), "quote_option_contract": quote} + body = {"opt_inst_id": "X", "sheets": 3} + out = refresh_po_option_quote_before_start(cfg, body) + self.assertTrue(out["ok"]) + self.assertEqual(body["ask"], 12.5) + self.assertEqual(body["sheets"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_okx_funding_balances.py b/tests/test_okx_funding_balances.py new file mode 100644 index 0000000..b148e19 --- /dev/null +++ b/tests/test_okx_funding_balances.py @@ -0,0 +1,39 @@ +"""OKX 资金账户余额(asset/balances).""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from lib.exchange.okx_options_lib import ( + fetch_funding_balances_via_asset_api, + fetch_options_balances, +) + + +class TestOkxFundingBalances(unittest.TestCase): + def test_asset_balances_parsed(self): + ex = MagicMock() + ex.private_get_asset_balances.return_value = { + "data": [ + {"ccy": "USDT", "availBal": "25.5", "bal": "30"}, + {"ccy": "USDC", "availBal": "10", "bal": "10"}, + ] + } + total, avail = fetch_funding_balances_via_asset_api(ex) + self.assertEqual(avail["USDT"], 25.5) + self.assertEqual(total["USDT"], 30.0) + self.assertEqual(avail["USDC"], 10.0) + + def test_fetch_options_balances_merges_asset_api(self): + ex = MagicMock() + ex.fetch_balance.return_value = {"free": {}, "total": {}} + ex.private_get_asset_balances.return_value = { + "data": [{"ccy": "USDT", "availBal": "18.2", "bal": "18.2"}] + } + bal = fetch_options_balances(ex, force=True) + self.assertEqual(bal["funding_usdt_avail"], 18.2) + self.assertEqual(bal["funding_usdt"], 18.2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_okx_position_metrics.py b/tests/test_okx_position_metrics.py new file mode 100644 index 0000000..6e83f58 --- /dev/null +++ b/tests/test_okx_position_metrics.py @@ -0,0 +1,38 @@ +"""OKX 持仓指标解析:未实现盈亏须支持负数.""" +from __future__ import annotations + +import unittest + + +class TestOkxPositionMetrics(unittest.TestCase): + def test_parse_unrealized_pnl_negative(self): + from crypto_monitor_okx.app import parse_ccxt_position_metrics + + pos = { + "side": "long", + "contracts": 10, + "markPrice": 0.43, + "unrealizedPnl": -1.25, + "info": {"upl": "-1.25", "markPx": "0.43"}, + } + out = parse_ccxt_position_metrics(pos, order_leverage=5) + self.assertIsNotNone(out) + self.assertAlmostEqual(out["unrealized_pnl"], -1.25) + self.assertAlmostEqual(out["mark_price"], 0.43) + + def test_parse_unrealized_pnl_zero(self): + from crypto_monitor_okx.app import parse_ccxt_position_metrics + + pos = { + "side": "long", + "contracts": 1, + "unrealizedPnl": 0, + "info": {"upl": "0"}, + } + out = parse_ccxt_position_metrics(pos) + self.assertIsNotNone(out) + self.assertEqual(out["unrealized_pnl"], 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_okx_spot_swap.py b/tests/test_okx_spot_swap.py new file mode 100644 index 0000000..eb2eaf7 --- /dev/null +++ b/tests/test_okx_spot_swap.py @@ -0,0 +1,60 @@ +"""OKX USDT/USDC 现货市价兑换参数.""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from lib.exchange.okx_options_lib import spot_market_swap_usdt_usdc + + +class TestOkxSpotSwap(unittest.TestCase): + def test_usdt_to_usdc_uses_quote_ccy(self): + ex = MagicMock() + ex.private_post_trade_order.return_value = { + "data": [{"sCode": "0", "ordId": "1"}], + } + result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=10) + self.assertTrue(result["ok"]) + body = ex.private_post_trade_order.call_args[0][0] + self.assertEqual(body["tgtCcy"], "quote_ccy") + self.assertEqual(body["side"], "buy") + + def test_usdc_to_usdt_uses_base_ccy(self): + ex = MagicMock() + ex.private_post_trade_order.return_value = { + "data": [{"sCode": "0", "ordId": "2"}], + } + result = spot_market_swap_usdt_usdc(ex, direction="usdc_to_usdt", amount=5) + self.assertTrue(result["ok"]) + body = ex.private_post_trade_order.call_args[0][0] + self.assertEqual(body["tgtCcy"], "base_ccy") + self.assertEqual(body["side"], "sell") + + def test_insufficient_balance_returns_chinese_message(self): + ex = MagicMock() + ex.private_post_trade_order.side_effect = Exception( + 'okx {"code":"1","data":[{"sCode":"51008","sMsg":"Order failed. Your available USDT balance is insufficient."}]}' + ) + result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20) + self.assertFalse(result["ok"]) + self.assertEqual(result["msg"], "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)") + self.assertNotIn("{", result["msg"]) + + def test_insufficient_usdc_message(self): + from lib.exchange.okx_options_lib import _okx_trade_error_message + + msg = _okx_trade_error_message( + resp={ + "data": [ + { + "sCode": "51008", + "sMsg": "Order failed. Insufficient USDC balance in account.", + } + ] + } + ) + self.assertEqual(msg, "交易账户 USDC 可用余额不足") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_okx_trade_mode.py b/tests/test_okx_trade_mode.py new file mode 100644 index 0000000..bb1e20e --- /dev/null +++ b/tests/test_okx_trade_mode.py @@ -0,0 +1,95 @@ +"""OKX_TRADE_MODE 三选一.""" + +import os +import unittest +from unittest.mock import patch + +from lib.hedge_plan.okx_trade_mode_lib import ( + MODE_OO, + MODE_OPTIONS, + MODE_PERP, + block_standalone_open_by_mode_msg, + get_okx_trade_mode, + hedge_module_enabled, + show_options_options, + show_perp_options, + standalone_options_open_allowed, +) + + +class OkxTradeModeTests(unittest.TestCase): + def test_explicit_modes(self): + for mode, hedge, po, oo in ( + (MODE_OPTIONS, False, False, False), + (MODE_PERP, True, True, False), + (MODE_OO, True, False, True), + ): + with patch.dict(os.environ, {"OKX_TRADE_MODE": mode}, clear=False): + self.assertEqual(get_okx_trade_mode(), mode) + self.assertEqual(hedge_module_enabled(), hedge) + self.assertEqual(show_perp_options(), po) + self.assertEqual(show_options_options(), oo) + self.assertEqual(standalone_options_open_allowed(), mode == MODE_OPTIONS) + + def test_legacy_infer_options(self): + with patch.dict( + os.environ, + {"HEDGE_PLAN_ENABLED": "false"}, + clear=False, + ): + os.environ.pop("OKX_TRADE_MODE", None) + self.assertEqual(get_okx_trade_mode(), MODE_OPTIONS) + + def test_legacy_infer_perp(self): + with patch.dict( + os.environ, + { + "HEDGE_PLAN_ENABLED": "true", + "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", + "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false", + }, + clear=False, + ): + os.environ.pop("OKX_TRADE_MODE", None) + self.assertEqual(get_okx_trade_mode(), MODE_PERP) + + def test_block_standalone_in_hedge_mode(self): + with patch.dict(os.environ, {"OKX_TRADE_MODE": MODE_OO}): + msg = block_standalone_open_by_mode_msg() + self.assertIsNotNone(msg) + self.assertIn("期期", msg or "") + + +class EnvUiModeSectionsTests(unittest.TestCase): + def test_options_mode_hides_hedge_section(self): + from lib.env.env_ui_manifest import ui_sections_for_exchange + + titles = [s["title"] for s in ui_sections_for_exchange("okx", mode="options")] + self.assertIn("期权/对冲模式", titles) + self.assertIn("期权账户", titles) + self.assertTrue(all("对冲" not in t for t in titles if t != "期权/对冲模式")) + + def test_perp_mode_shows_po_fields(self): + from lib.env.env_ui_manifest import ui_sections_for_exchange + + secs = {s["title"]: s["fields"] for s in ui_sections_for_exchange("okx", mode="perp_options")} + self.assertIn("对冲计划·永期", secs) + keys = {f[0] for f in secs["对冲计划·永期"]} + self.assertIn("MAX_ACTIVE_HEDGE_PLANS", keys) + self.assertIn("HEDGE_PLAN_OPEN_ORDER", keys) + self.assertNotIn("HEDGE_PLAN_OO_BIAS_RATIO", keys) + opt_keys = {f[0] for f in secs["期权账户"]} + self.assertNotIn("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", opt_keys) + + def test_oo_mode_shows_oo_fields(self): + from lib.env.env_ui_manifest import ui_sections_for_exchange + + secs = {s["title"]: s["fields"] for s in ui_sections_for_exchange("okx", mode="options_options")} + self.assertIn("对冲计划·期期", secs) + keys = {f[0] for f in secs["对冲计划·期期"]} + self.assertIn("HEDGE_PLAN_OO_BIAS_RATIO", keys) + self.assertNotIn("HEDGE_PLAN_OPEN_ORDER", keys) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_add_premium.py b/tests/test_options_add_premium.py new file mode 100644 index 0000000..a4e3f27 --- /dev/null +++ b/tests/test_options_add_premium.py @@ -0,0 +1,69 @@ +"""期权加仓后权利金汇总.""" +from __future__ import annotations + +import sqlite3 + +from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets + + +def _mem_db() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + return conn + + +def test_sum_open_premium_after_add(): + conn = _mem_db() + inst = "BTC-USD_UM-260717-65500-C" + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status) + VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 530, 5.3, 'open') + """, + (inst,), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status) + VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 140, 1.4, 'open') + """, + (inst,), + ) + conn.commit() + assert sum_open_premium_paid(conn, inst) == 6.7 + assert sum_open_sheets(conn, inst) == 2 + # 最新一笔单独是 1.4,汇总不能只取最新 + latest = conn.execute( + "SELECT premium_paid FROM options_trades WHERE inst_id=? AND status='open' ORDER BY id DESC LIMIT 1", + (inst,), + ).fetchone() + assert float(latest["premium_paid"]) == 1.4 + conn.close() + + +def test_sum_open_premium_ignores_closed(): + conn = _mem_db() + inst = "ETH-USD_UM-260101-2000-C" + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status) + VALUES (?, 'ETH-USD_UM', 'C', 1, 0.01, 2.0, 'closed') + """, + (inst,), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status) + VALUES (?, 'ETH-USD_UM', 'C', 2, 0.02, 3.5, 'open') + """, + (inst,), + ) + conn.commit() + assert sum_open_premium_paid(conn, inst) == 3.5 + assert sum_open_sheets(conn, inst) == 2 + conn.close() diff --git a/tests/test_options_btc_pnl_usdt.py b/tests/test_options_btc_pnl_usdt.py new file mode 100644 index 0000000..2ee1430 --- /dev/null +++ b/tests/test_options_btc_pnl_usdt.py @@ -0,0 +1,44 @@ +"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U).""" +from __future__ import annotations + +import unittest + +from lib.options.options_positions_lib import display_pnl_usdt_from_option_row + + +class DisplayPnlUsdtTests(unittest.TestCase): + def test_btc_coin_pnl_times_index(self): + row = { + "inst_id": "BTC-USD-260327-100000-P", + "premium_ccy": "BTC", + "margin_mode": "coin", + "idx_px": 100000.0, + "upl": -0.001, + "close_preview": {"bid_invalid": True}, + } + self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4) + + def test_btc_infers_ccy_when_premium_ccy_missing(self): + row = { + "inst_id": "BTC-USD-260327-100000-C", + "margin_mode": "coin", + "underlying": "BTC", + "idx_px": 95000.0, + "upl": 0.002, + "close_preview": {"bid_invalid": True}, + } + self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4) + + def test_usdc_unchanged(self): + row = { + "inst_id": "BTC-USD_UM-260327-100000-C", + "premium_ccy": "USDC", + "margin_mode": "usdc", + "upl": -12.5, + "close_preview": {"bid_invalid": True}, + } + self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_budget_full.py b/tests/test_options_budget_full.py new file mode 100644 index 0000000..21967f0 --- /dev/null +++ b/tests/test_options_budget_full.py @@ -0,0 +1,98 @@ +"""按可用余额打满 / 全仓复利定仓.""" +from __future__ import annotations + +import unittest + +from lib.options.options_pricing_lib import ( + resolve_budget_full_usdc, + resolve_compound_full_usdc, +) +from lib.options.options_position_limit_lib import ( + compound_full_single_position_block_msg, + count_live_option_positions, +) + + +class TestOptionsBudgetModes(unittest.TestCase): + def test_balance_above_budget_uses_budget(self): + self.assertEqual(resolve_budget_full_usdc(100.0, 10.0), 10.0) + + def test_balance_below_budget_uses_balance(self): + self.assertEqual(resolve_budget_full_usdc(5.0, 10.0), 5.0) + + def test_balance_equals_budget(self): + self.assertEqual(resolve_budget_full_usdc(10.0, 10.0), 10.0) + + def test_compound_full_no_cap_uses_all(self): + self.assertEqual( + resolve_compound_full_usdc(200.0, cap_enabled=False, cap_usdc=50.0), + 200.0, + ) + + def test_compound_full_cap_on(self): + self.assertEqual( + resolve_compound_full_usdc(200.0, cap_enabled=True, cap_usdc=50.0), + 50.0, + ) + self.assertEqual( + resolve_compound_full_usdc(30.0, cap_enabled=True, cap_usdc=50.0), + 30.0, + ) + + def test_compound_full_cap_invalid_falls_back_to_balance(self): + self.assertEqual( + resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=0), + 80.0, + ) + self.assertEqual( + resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=None), + 80.0, + ) + + def test_compound_full_blocks_when_position_open(self): + rows = [{"instId": "ETH-USD_UM-260812-1870-P", "pos": "1"}] + msg = compound_full_single_position_block_msg( + object(), fetch_positions=lambda _ex: rows + ) + self.assertIsNotNone(msg) + self.assertIn("1 笔", msg or "") + + def test_compound_full_allows_when_flat(self): + msg = compound_full_single_position_block_msg( + object(), fetch_positions=lambda _ex: [] + ) + self.assertIsNone(msg) + self.assertEqual(count_live_option_positions([]), 0) + + def test_normalize_size_mode_when_compound_off(self): + import os + from unittest.mock import patch + + from lib.options import options_register as reg + + with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "false"}): + mode, note = reg._normalize_size_mode("compound_full") + self.assertEqual(mode, "sheets") + self.assertIsNotNone(note) + mode2, note2 = reg._normalize_size_mode("budget_full") + self.assertEqual(mode2, "budget_full") + self.assertIsNone(note2) + mode3, _ = reg._normalize_size_mode("sheets") + self.assertEqual(mode3, "sheets") + + def test_normalize_size_mode_when_compound_on(self): + import os + from unittest.mock import patch + + from lib.options import options_register as reg + + with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "true"}): + mode, note = reg._normalize_size_mode("budget_full") + self.assertEqual(mode, "compound_full") + self.assertIsNone(note) + mode2, _ = reg._normalize_size_mode("compound_full") + self.assertEqual(mode2, "compound_full") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_close_gate_lib.py b/tests/test_options_close_gate_lib.py new file mode 100644 index 0000000..688b00b --- /dev/null +++ b/tests/test_options_close_gate_lib.py @@ -0,0 +1,191 @@ +"""期权平仓门控:USDT 口径(权利金×倍数 / 净盈亏阈值)且持续持有.""" +from __future__ import annotations + +import os +import unittest + +from lib.options.options_close_gate_lib import ( + clear_close_gate, + is_close_gate_passed, + mark_close_gate_passed, + update_close_gate, +) + + +class OptionsCloseGateTests(unittest.TestCase): + def setUp(self): + clear_close_gate() + self._env_backup = { + k: os.environ.get(k) + for k in ( + "OKX_OPTIONS_CLOSE_GATE_MODE", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", + "OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", + ) + } + for k in self._env_backup: + os.environ.pop(k, None) + + def tearDown(self): + clear_close_gate() + for k, v in self._env_backup.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_usdc_below_2x_not_ready(self): + g = update_close_gate( + "ETH-X", + recycle_usdc=15.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) + self.assertFalse(g["recycle_ok"]) + self.assertFalse(g["ready"]) + self.assertIn("U(估)", g["msg"]) + + def test_usdc_meets_2x_needs_hold(self): + g1 = update_close_gate( + "ETH-X", + recycle_usdc=20.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) + self.assertTrue(g1["recycle_ok"]) + self.assertFalse(g1["ready"]) + self.assertAlmostEqual(g1["remain_seconds"], 120.0) + + g2 = update_close_gate( + "ETH-X", + recycle_usdc=21.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1120.0, + ) + self.assertTrue(g2["ready"]) + + def test_coin_premium_gate_uses_usdt_and_default_105(self): + # 0.0384 ETH * 2500 = 96U; ×1.05 = 100.8U + g = update_close_gate( + "ETH-P", + recycle_usdc=0.036, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertFalse(g["recycle_ok"]) + self.assertAlmostEqual(g["premium_usdt"], 96.0) + self.assertAlmostEqual(g["need_recycle_usdt"], 100.8) + self.assertIn("U(估)", g["msg"]) + + g_ok = update_close_gate( + "ETH-P", + recycle_usdc=0.041, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertTrue(g_ok["recycle_ok"]) + self.assertAlmostEqual(g_ok["recycle_usdt"], 102.5) + + def test_net_pnl_gate_mode(self): + os.environ["OKX_OPTIONS_CLOSE_GATE_MODE"] = "net_pnl" + os.environ["OKX_OPTIONS_CLOSE_NET_PNL_MIN_U"] = "1" + g = update_close_gate( + "ETH-N", + recycle_usdc=0.039, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertTrue(g["recycle_ok"]) + self.assertAlmostEqual(g["net_pnl_usdt"], 1.5) + + g2 = update_close_gate( + "ETH-N2", + recycle_usdc=0.0385, + premium_paid=0.0384, + premium_ccy="ETH", + index_px=2500.0, + now=1000.0, + ) + self.assertFalse(g2["recycle_ok"]) + + def test_break_resets_timer(self): + update_close_gate( + "ETH-X", + recycle_usdc=20.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) + update_close_gate( + "ETH-X", + recycle_usdc=21.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1100.0, + ) + g_break = update_close_gate( + "ETH-X", + recycle_usdc=12.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1110.0, + ) + self.assertFalse(g_break["recycle_ok"]) + g_again = update_close_gate( + "ETH-X", + recycle_usdc=22.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1111.0, + ) + self.assertTrue(g_again["recycle_ok"]) + self.assertFalse(g_again["ready"]) + self.assertAlmostEqual(g_again["held_seconds"], 0.0) + + def test_passed_latches_after_ready(self): + update_close_gate( + "ETH-Y", + recycle_usdc=20.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1000.0, + ) + g_ready = update_close_gate( + "ETH-Y", + recycle_usdc=21.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1120.0, + ) + self.assertTrue(g_ready["ready"]) + self.assertTrue(g_ready["passed"]) + self.assertTrue(is_close_gate_passed("ETH-Y")) + g_drop = update_close_gate( + "ETH-Y", + recycle_usdc=5.0, + premium_paid=10.0, + premium_ccy="USDC", + now=1130.0, + ) + self.assertFalse(g_drop["recycle_ok"]) + self.assertTrue(g_drop["passed"]) + self.assertFalse(g_drop["auto_close_blocked"]) + + def test_mark_passed_manual(self): + mark_close_gate_passed("ETH-Z") + self.assertTrue(is_close_gate_passed("ETH-Z")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_history_coin.py b/tests/test_options_history_coin.py new file mode 100644 index 0000000..43470d5 --- /dev/null +++ b/tests/test_options_history_coin.py @@ -0,0 +1,50 @@ +"""期权历史/复盘币本位金额.""" + +from __future__ import annotations + +import unittest + +from lib.exchange.okx_options_lib import format_option_history_row +from lib.options.options_review_lib import convert_option_amounts_to_usdt + + +class TestOptionsHistoryCoin(unittest.TestCase): + def test_format_option_history_row_coin_premium_fmt(self) -> None: + raw = { + "instId": "ETH-USD-260822-2250-C", + "openAvgPx": "0.02", + "closeAvgPx": "0.03", + "closeTotalPos": "2", + "realizedPnl": "0.002", + "pnlRatio": "0.5", + "type": "2", + "uTime": "1724146497000", + "cTime": "1724126855000", + "posId": "123", + "uly": "ETH-USD", + } + row = format_option_history_row(raw, tick_sz="0.0001", ct_mult=0.1) + self.assertEqual(row["margin_mode"], "coin") + self.assertEqual(row["premium_ccy"], "ETH") + self.assertAlmostEqual(float(row["premium_paid"]), 0.004, places=6) + self.assertNotEqual(row["premium_paid_fmt"], "0.00") + self.assertIn("0.004", str(row["premium_paid_fmt"])) + + def test_convert_option_amounts_to_usdt(self) -> None: + out = convert_option_amounts_to_usdt( + { + "inst_id": "ETH-USD-260822-2250-C", + "premium_ccy": "ETH", + "margin_mode": "coin", + "premium_paid": 0.004, + "realized_pnl": 0.002, + }, + index_px=2000.0, + ) + self.assertEqual(out["pnl_quote_ccy"], "USDT") + self.assertEqual(out["premium_paid"], 8.0) + self.assertEqual(out["realized_pnl"], 4.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_hub_lib.py b/tests/test_options_hub_lib.py new file mode 100644 index 0000000..5c51671 --- /dev/null +++ b/tests/test_options_hub_lib.py @@ -0,0 +1,54 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from lib.options.options_hub_lib import build_options_hub_snapshot + + +class OptionsHubLibTests(TestCase): + def test_build_options_hub_snapshot_disabled(self): + out = build_options_hub_snapshot({"enabled": False}) + self.assertFalse(out["enabled"]) + self.assertTrue(out["ok"]) + + @patch("lib.options.options_history_lib.load_options_history") + @patch("lib.options.options_positions_lib.build_display_option_positions") + def test_build_options_hub_snapshot_positions(self, mock_positions, mock_history): + mock_positions.return_value = [ + { + "inst_id": "ETH-USD_UM-260703-1800-C", + "pos": 2, + "upl": 9.9, + "mark_px": 0.1, + "close_preview": {"estimated_pnl": 1.5}, + } + ] + conn = MagicMock() + conn.__enter__ = MagicMock(return_value=conn) + conn.__exit__ = MagicMock(return_value=False) + cfg = { + "enabled": True, + "exchange_options": object(), + "options_api_ready": lambda ex: (True, ""), + "fetch_option_positions": lambda ex: [ + {"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"} + ], + "fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0}, + "get_db": MagicMock(return_value=conn), + "trade_budget": 10, + "account_label": "OKX期权", + } + with patch("lib.options.options_target_lib.list_active_targets", return_value=[]): + with patch("lib.options.options_target_lib.list_closing_targets", return_value=[]): + with patch("lib.options.options_target_lib.targets_by_inst", return_value={}): + with patch( + "lib.hedge_plan.hedge_plan_db.active_options_targets_by_inst", + return_value={}, + ): + out = build_options_hub_snapshot(cfg) + self.assertTrue(out["ok"], out.get("msg")) + self.assertEqual(out["position_count"], 1) + self.assertEqual(out["upl_total_usdc"], 1.5) + self.assertEqual(out["trading_usdc"], 9.5) + self.assertEqual(out.get("target_monitors"), []) + self.assertEqual(out.get("stats"), {}) + mock_history.assert_not_called() diff --git a/tests/test_options_margin_mode.py b/tests/test_options_margin_mode.py new file mode 100644 index 0000000..0f17cfe --- /dev/null +++ b/tests/test_options_margin_mode.py @@ -0,0 +1,126 @@ +"""币本位模式预算与合约族单测.""" +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + + +class TestOptionsMarginMode(unittest.TestCase): + def test_normalize_mode(self): + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + + self.assertEqual(normalize_options_margin_mode("usdc"), "usdc") + self.assertEqual(normalize_options_margin_mode("coin"), "coin") + self.assertEqual(normalize_options_margin_mode("币本位"), "coin") + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OKX_OPTIONS_MARGIN_MODE", None) + self.assertEqual(normalize_options_margin_mode(None), "coin") + self.assertEqual(normalize_options_margin_mode(""), "coin") + + def test_inst_family(self): + from lib.options.options_margin_mode_lib import inst_family_for_underlying + + self.assertEqual(inst_family_for_underlying("ETH", margin_mode="usdc"), "ETH-USD_UM") + self.assertEqual(inst_family_for_underlying("ETH", margin_mode="coin"), "ETH-USD") + + def test_margin_mode_from_inst_id(self): + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id + + self.assertEqual(margin_mode_from_inst_id("ETH-USD_UM-260701-2500-C"), "usdc") + self.assertEqual(margin_mode_from_inst_id("ETH-USD-260701-2500-C"), "coin") + + def test_coin_budget_compound(self): + from lib.options.options_margin_mode_lib import compute_coin_budget_usdt + + with patch.dict(os.environ, {}, clear=False): + r = compute_coin_budget_usdt( + 20.0, + compound=True, + buffer=0.95, + max_enabled=False, + ) + self.assertTrue(r["ok"]) + self.assertAlmostEqual(r["budget_usdt"], 19.0, places=6) + + def test_coin_budget_max_cap(self): + from lib.options.options_margin_mode_lib import compute_coin_budget_usdt + + r = compute_coin_budget_usdt( + 100.0, + compound=True, + buffer=0.95, + max_enabled=True, + max_usdt=50.0, + ) + self.assertTrue(r["ok"]) + self.assertAlmostEqual(r["budget_usdt"], 50.0, places=6) + self.assertTrue(r["capped_by_max"]) + + def test_coin_budget_fixed(self): + from lib.options.options_margin_mode_lib import compute_coin_budget_usdt + + r = compute_coin_budget_usdt( + 100.0, + compound=False, + buffer=0.95, + fixed_budget_usdt=10.0, + max_enabled=False, + ) + self.assertAlmostEqual(r["budget_usdt"], 9.5, places=6) + + def test_sheets_from_coin(self): + from lib.options.options_margin_mode_lib import calc_sheets_from_coin_balance + + # ask 0.01 ETH per 1 ETH, ctMult 0.01 → 每张 0.0001 ETH; 0.01 ETH×0.97 缓冲可开 97 张 + r = calc_sheets_from_coin_balance( + quote_per_unit=0.01, + ct_mult=0.01, + min_sz=1, + coin_available=0.01, + ) + self.assertTrue(r["ok"]) + self.assertEqual(r["sheets"], 97) + + def test_spot_buy_buffer_normalize(self): + from lib.options.options_margin_mode_lib import normalize_coin_spot_buy_buffer + + self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.10), 1.10) + self.assertAlmostEqual(normalize_coin_spot_buy_buffer(0.10), 1.10) + self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.25), 1.25) + + def test_plan_coin_open_by_budget(self): + from lib.options.options_margin_mode_lib import plan_coin_open_by_budget + + # ask 0.01, ct 0.1 → 单张权利金 0.001 ETH;×1.1=0.0011;×指数 2000 → 2.2 USDT/张 + r = plan_coin_open_by_budget( + quote_per_unit=0.01, + ct_mult=0.1, + min_sz=1, + budget_usdt=10.0, + index_px=2000.0, + ask_sz=100, + spot_buy_buffer=1.10, + ) + self.assertTrue(r["ok"], r.get("msg")) + self.assertEqual(r["sheets"], 4) # floor(10/2.2)=4 + self.assertAlmostEqual(r["buy_usdt"], 4 * 0.01 * 0.1 * 1.10 * 2000, places=4) + self.assertLess(r["buy_usdt"], 10.0) + + one = plan_coin_open_by_budget( + quote_per_unit=0.01, + ct_mult=0.1, + min_sz=1, + budget_usdt=10.0, + index_px=2000.0, + ask_sz=100, + spot_buy_buffer=1.10, + target_sheets=1, + ) + self.assertTrue(one["ok"], one.get("msg")) + self.assertEqual(one["sheets"], 1) + self.assertAlmostEqual(one["buy_usdt"], 0.01 * 0.1 * 1.10 * 2000, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_net_pnl_sum.py b/tests/test_options_net_pnl_sum.py new file mode 100644 index 0000000..3c8cdda --- /dev/null +++ b/tests/test_options_net_pnl_sum.py @@ -0,0 +1,71 @@ +"""期权净盈亏汇总与持仓卡口径一致.""" +from unittest import TestCase +from unittest.mock import patch + +from lib.options.options_positions_lib import ( + display_pnl_from_option_row, + net_pnl_from_display_row, + sum_options_net_pnl_usdc, +) + + +class OptionsNetPnlSumTests(TestCase): + def test_net_pnl_from_display_row(self): + self.assertEqual( + net_pnl_from_display_row({"close_preview": {"estimated_pnl": -2.8}, "premium_paid": 4.95}), + -2.8, + ) + self.assertIsNone( + net_pnl_from_display_row({"close_preview": {"bid_invalid": True, "estimated_pnl": -1}}) + ) + self.assertEqual( + net_pnl_from_display_row( + { + "close_preview": {"total_received": 2.15, "covered_sheets": 1}, + "premium_paid": 4.95, + } + ), + round(2.15 - 4.95, 4), + ) + # bid 无效时 total_received=0 不得算出 −权利金 + self.assertIsNone( + net_pnl_from_display_row( + { + "close_preview": { + "bid_invalid": True, + "total_received": 0.0, + "covered_sheets": 0, + }, + "premium_paid": 9.49, + "upl": -4.2, + } + ) + ) + + def test_display_pnl_falls_back_to_exchange_upl(self): + self.assertEqual( + display_pnl_from_option_row( + { + "close_preview": {"bid_invalid": True, "total_received": 0.0}, + "premium_paid": 9.49, + "upl": -4.2, + } + ), + -4.2, + ) + + @patch("lib.options.options_positions_lib.build_display_option_positions") + def test_sum_options_net_pnl_usdc(self, mock_build): + mock_build.return_value = [ + {"close_preview": {"estimated_pnl": -2.8}}, + {"close_preview": {"estimated_pnl": 1.0}}, + {"close_preview": {"bid_invalid": True, "estimated_pnl": 9}, "upl": -0.5}, + ] + cfg = {"fetch_option_positions": lambda ex: [{"instId": "X"}]} + self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -2.3) + + +if __name__ == "__main__": + from unittest import main + + main() diff --git a/tests/test_options_notify_lib.py b/tests/test_options_notify_lib.py new file mode 100644 index 0000000..2d1d557 --- /dev/null +++ b/tests/test_options_notify_lib.py @@ -0,0 +1,86 @@ +"""期权开平仓微信文案.""" + +from __future__ import annotations + +import unittest + +from lib.options.options_notify_lib import ( + build_options_close_message, + build_options_open_message, +) + + +class TestOptionsNotify(unittest.TestCase): + def test_open_close_messages_usdc(self) -> None: + open_msg = build_options_open_message( + account_label="OKX期权", + inst_id="ETH-USD_UM-250725-3200-C", + underlying="ETH", + opt_type="C", + sheets=2, + premium_paid=8.5, + open_quote=0.01, + target_index=3400, + signal_note="假突破", + trade_id=12, + premium_ccy="USDC", + margin_mode="usdc", + ) + self.assertIn("【OKX期权·开仓】", open_msg) + self.assertIn("ETH-USD_UM-250725-3200-C", open_msg) + self.assertIn("目标指数:3400", open_msg) + self.assertIn("USDC", open_msg) + + close_msg = build_options_close_message( + account_label="OKX期权", + inst_id="ETH-USD_UM-250725-3200-C", + reason="手动平仓", + underlying="ETH", + opt_type="C", + sheets=2, + premium_paid=8.5, + premium_received=12.0, + realized_pnl=3.5, + premium_ccy="USDC", + ) + self.assertIn("【OKX期权·平仓】", close_msg) + self.assertIn("手动平仓", close_msg) + self.assertIn("3.5000", close_msg) + self.assertIn("USDC", close_msg) + + def test_open_close_messages_coin(self) -> None: + open_msg = build_options_open_message( + account_label="OKX期权", + inst_id="ETH-USD-250725-3200-C", + underlying="ETH", + opt_type="C", + sheets=1, + premium_paid=0.001234, + open_quote=0.01234, + trade_id=99, + premium_ccy="ETH", + margin_mode="coin", + ) + self.assertIn("本位:币本位", open_msg) + self.assertIn("ETH", open_msg) + self.assertNotIn("USDC", open_msg) + + close_msg = build_options_close_message( + account_label="OKX期权", + inst_id="ETH-USD-250725-3200-C", + reason="翻倍出场(1倍)", + underlying="ETH", + sheets=1, + premium_paid=0.001234, + premium_received=0.0025, + realized_pnl=0.001266, + premium_ccy="ETH", + margin_mode="coin", + ) + self.assertIn("本位:币本位", close_msg) + self.assertIn("翻倍出场", close_msg) + self.assertIn("ETH", close_msg) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_pending_lib.py b/tests/test_options_pending_lib.py new file mode 100644 index 0000000..d393ddd --- /dev/null +++ b/tests/test_options_pending_lib.py @@ -0,0 +1,63 @@ +"""期权挂单超时撤单单测.""" +from unittest import TestCase + +from lib.options.options_pending_lib import ( + cancel_stale_close_pending_orders, + enrich_pending_orders, + is_close_pending_order, + order_age_seconds, +) + + +class OptionsPendingLibTests(TestCase): + def test_order_age_and_close_detect(self): + now = 1_700_000_600_000 + age = order_age_seconds({"c_time": now - 90_000}, now_ms=now) + self.assertAlmostEqual(age, 90.0, places=3) + self.assertTrue(is_close_pending_order({"side": "sell"})) + self.assertTrue(is_close_pending_order({"side": "buy", "reduce_only": True})) + self.assertFalse(is_close_pending_order({"side": "buy"})) + + def test_enrich_expire(self): + now = 1_700_000_600_000 + rows = enrich_pending_orders( + [ + {"ord_id": "1", "inst_id": "A", "side": "sell", "c_time": now - 700_000}, + {"ord_id": "2", "inst_id": "B", "side": "buy", "c_time": now - 700_000}, + {"ord_id": "3", "inst_id": "C", "side": "sell", "c_time": now - 30_000}, + ], + ttl_seconds=600, + now_ms=now, + ) + by_id = {r["ord_id"]: r for r in rows} + self.assertTrue(by_id["1"]["stale"]) + self.assertTrue(by_id["1"]["auto_cancel_enabled"]) + self.assertFalse(by_id["2"]["auto_cancel_enabled"]) + self.assertFalse(by_id["3"]["stale"]) + self.assertAlmostEqual(by_id["3"]["expire_in_sec"], 570.0, places=0) + + def test_cancel_stale_only_close(self): + now = 1_700_000_600_000 + pending = [ + {"ord_id": "s1", "inst_id": "A", "side": "sell", "c_time": now - 700_000}, + {"ord_id": "b1", "inst_id": "B", "side": "buy", "c_time": now - 700_000}, + {"ord_id": "s2", "inst_id": "C", "side": "sell", "c_time": now - 10_000}, + ] + cancelled = [] + + def fetch(_ex=None): + return pending + + def cancel(_ex=None, inst_id=None, ord_id=None): + cancelled.append((inst_id, ord_id)) + return {"ok": True} + + out = cancel_stale_close_pending_orders( + fetch_pending=fetch, + cancel_order=cancel, + ttl_seconds=60, + now_ms=now, + ex=object(), + ) + self.assertEqual(out["cancelled"], 1) + self.assertEqual(cancelled, [("A", "s1")]) diff --git a/tests/test_options_position_limit.py b/tests/test_options_position_limit.py new file mode 100644 index 0000000..94cc951 --- /dev/null +++ b/tests/test_options_position_limit.py @@ -0,0 +1,101 @@ +"""OKX 期权持仓笔数上限.""" + +import os +import unittest +from unittest.mock import patch + +from lib.options.options_position_limit_lib import ( + count_live_option_positions, + option_position_limit_block_msg, + options_max_active_positions, +) + + +class OptionsPositionLimitTests(unittest.TestCase): + def test_default_unlimited(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", None) + self.assertEqual(options_max_active_positions(), 0) + + def test_parse_max(self): + with patch.dict(os.environ, {"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "2"}): + self.assertEqual(options_max_active_positions(), 2) + + def test_count_live(self): + rows = [{"instId": "A", "pos": "1"}, {"instId": "B", "pos": "0"}, {"instId": "C", "pos": "-2"}] + self.assertEqual(count_live_option_positions(rows), 2) + + def test_block_when_at_limit(self): + rows = [{"instId": "ETH-C", "pos": "1"}, {"instId": "ETH-P", "pos": "2"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-NEW", + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNotNone(msg) + self.assertIn("上限", msg or "") + + def test_allow_add_to_existing(self): + rows = [{"instId": "ETH-C", "pos": "1"}, {"instId": "ETH-P", "pos": "2"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-C", + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNone(msg) + + def test_allow_under_limit(self): + rows = [{"instId": "ETH-C", "pos": "1"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-P", + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNone(msg) + + def test_oo_needs_two_slots_when_max_one(self): + msg = option_position_limit_block_msg( + object(), + opening_inst_ids=["ETH-C", "ETH-P"], + max_active=1, + fetch_positions=lambda _ex: [], + ) + self.assertIsNotNone(msg) + self.assertIn("期期", msg or "") + + def test_oo_ok_when_max_two_empty(self): + msg = option_position_limit_block_msg( + object(), + opening_inst_ids=["ETH-C", "ETH-P"], + max_active=2, + fetch_positions=lambda _ex: [], + ) + self.assertIsNone(msg) + + def test_oo_block_when_one_slot_left(self): + rows = [{"instId": "OTHER", "pos": "1"}] + msg = option_position_limit_block_msg( + object(), + opening_inst_ids=["ETH-C", "ETH-P"], + max_active=2, + fetch_positions=lambda _ex: rows, + ) + self.assertIsNotNone(msg) + self.assertIn("期期", msg or "") + + def test_fail_closed_when_fetch_none(self): + msg = option_position_limit_block_msg( + object(), + opening_inst_id="ETH-C", + max_active=1, + fetch_positions=lambda _ex: None, + ) + self.assertIsNotNone(msg) + self.assertIn("无法获取", msg or "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py new file mode 100644 index 0000000..a6ee8e2 --- /dev/null +++ b/tests/test_options_pricing.py @@ -0,0 +1,464 @@ +"""期权定价单测.""" +from lib.options.options_pricing_lib import ( + calc_order_size, + premium_per_sheet, + sheets_from_eth_amount, + total_premium, +) +from lib.exchange.okx_options_lib import format_option_px, inst_family_from_inst_id, round_option_px + + +def test_inst_family_from_inst_id(): + assert inst_family_from_inst_id("ETH-USD_UM-260707-1790-C") == "ETH-USD_UM" + assert inst_family_from_inst_id("BTC-USD-260925-60000-C") == "BTC-USD" + + +def test_round_option_px(): + assert round_option_px(14.9184, "0.2", "sell") == 14.8 + assert round_option_px(14.81, "0.2", "buy") == 15.0 + assert format_option_px(14.8, "0.2") == "14.8" + # BTC 期权 tickSz=5: 整数末尾 0 必须保留 (1370 不能显成 137) + assert format_option_px(1370, "5") == "1370" + assert format_option_px(1160, 5) == "1160" + assert format_option_px(1000, "5") == "1000" + # 无 tick 时不得透出浮点毛刺 + assert format_option_px(482.4881990066513, None) == "482.4882" + + +def test_premium_per_sheet(): + assert abs(premium_per_sheet(15.6, 0.01) - 0.156) < 1e-9 + + +def test_total_premium_half_eth(): + assert abs(total_premium(15.6, 0.5) - 7.8) < 1e-9 + + +def test_sheets_from_eth(): + assert sheets_from_eth_amount(0.5, 0.01) == 50 + + +def test_calc_order_size_budget(): + r = calc_order_size( + quote_per_unit=15.6, + ct_mult=0.01, + min_sz=1, + budget_usdc=10, + budget_buffer=0.95, + budget_cap=10, + ) + assert r["ok"] is True + assert r["sheets"] >= 1 + assert r["total_premium"] <= 10 + + +def test_calc_order_size_sheets(): + r = calc_order_size( + quote_per_unit=15.6, + ct_mult=0.01, + min_sz=1, + sheets=3, + budget_cap=10, + ) + assert r["ok"] is True + assert r["sheets"] == 3 + assert abs(r["total_premium"] - 0.468) < 1e-9 + + +def test_option_moneyness(): + from lib.options.options_pricing_lib import option_moneyness, option_moneyness_label + + assert option_moneyness(opt_type="C", strike=1700, index_px=1800) == "itm" + assert option_moneyness(opt_type="C", strike=1900, index_px=1800) == "otm" + assert option_moneyness_label("itm") == "实值" + assert option_moneyness_label("otm") == "虚值" + + +def test_equivalent_contract_leverage(): + from lib.options.options_pricing_lib import equivalent_contract_leverage + + # index 1768, 0.2 ETH, premium 2.44 -> ~144.9x + lev = equivalent_contract_leverage(index_px=1768, eth_amount=0.2, total_premium=2.44) + assert lev == 144.9 + + +def test_straddle_pricing(): + from lib.options.options_pricing_lib import ( + format_straddle_band, + straddle_ask_per_unit, + straddle_breakeven_band, + straddle_premium_total, + ) + + assert straddle_ask_per_unit(0.148, 16.2) == 16.348 + assert straddle_premium_total(0.148, 16.2, 1.0) == 16.35 + lo, hi = straddle_breakeven_band(1800, 16.348) + assert lo == 1783.65 + assert hi == 1816.35 + assert format_straddle_band(1800, 16.348) == "1784 ~ 1816" + assert straddle_ask_per_unit(0.148, None) is None + + +def test_estimate_expiry_value_and_profit_at_index(): + from lib.options.options_pricing_lib import ( + estimate_expiry_profit_at_index, + estimate_expiry_value_at_index, + ) + + value = estimate_expiry_value_at_index( + opt_type="C", strike=1800, target_idx=2000, eth_amount=1.0 + ) + assert value == 200.0 + + profit = estimate_expiry_profit_at_index( + opt_type="C", + strike=1800, + target_idx=2000, + entry_px=0.148, + eth_amount=1.0, + total_premium=14.8, + ) + assert profit == 185.2 + + # Call 1780, ask 12.2, 0.01 ETH, target 1793 -> value 0.13, profit 0.01 + v = estimate_expiry_value_at_index( + opt_type="C", strike=1780, target_idx=1793, eth_amount=0.01 + ) + assert v == 0.13 + p = estimate_expiry_profit_at_index( + opt_type="C", + strike=1780, + target_idx=1793, + entry_px=12.2, + eth_amount=0.01, + total_premium=0.122, + ) + assert p == 0.01 + # OTM call loses premium + p2 = estimate_expiry_profit_at_index( + opt_type="C", + strike=1780, + target_idx=1770, + entry_px=12.2, + eth_amount=0.01, + total_premium=0.122, + ) + assert p2 == -0.12 + + +def test_resolve_chain_quote_otm_no_quote(): + from lib.exchange.okx_options_lib import _resolve_chain_quote + + q = _resolve_chain_quote( + ticker={}, + meta={"tickSz": "0.2"}, + opt_type="C", + strike=1800, + index_px=1776, + ) + assert q["ask"] is None + assert q["bid"] is None + assert q["ask_estimated"] is False + + +def test_resolve_chain_quote_estimated_ask(): + from lib.exchange.okx_options_lib import _resolve_chain_quote + + q = _resolve_chain_quote( + ticker={"bidPx": "0.2", "bidSz": "3500"}, + meta={"tickSz": "0.2"}, + opt_type="C", + strike=1650, + index_px=1776, + ) + assert q["ask_estimated"] is True + assert q["ask"] is not None + assert q["ask"] >= 120 + + +def test_format_quote_liquidity(): + from lib.options.options_pricing_lib import format_quote_liquidity + + assert format_quote_liquidity(17.2, 150) == "17.2/150" + assert format_quote_liquidity(817.6, 11) == "817.6/11" + assert format_quote_liquidity(15.6, None) == "15.6" + assert format_quote_liquidity(None, 10) is None + + +def test_estimate_close_by_bids_full_depth(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + # 多档估算需显式 max_levels;默认只估买一 + out = estimate_close_by_bids( + [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}], + 4, + ct_mult=0.01, + premium_paid=0.4, + max_levels=5, + ) + assert out["covered_sheets"] == 4 + assert out["uncovered_sheets"] == 0 + assert out["total_received"] == 0.488 + assert out["avg_px"] == 12.2 + assert out["estimated_pnl"] == 0.088 + assert out["estimated_pnl_ratio_pct"] == 22.0 + assert [x["sheets"] for x in out["levels"]] == [2, 2] + + bid1 = estimate_close_by_bids( + [{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}], + 4, + ct_mult=0.01, + premium_paid=0.4, + ) + assert bid1["covered_sheets"] == 2 + assert bid1["uncovered_sheets"] == 2 + assert [x["sheets"] for x in bid1["levels"]] == [2] + + +def test_estimate_close_by_bids_partial_depth(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids([{"px": 10, "sz": 1}], 3, ct_mult=0.01, premium_paid=0.6) + assert out["covered_sheets"] == 1 + assert out["uncovered_sheets"] == 2 + assert out["total_received"] == 0.1 + # 净盈亏 = 回收 − 全部权利金(不按覆盖比例摊薄) + assert out["estimated_pnl"] == -0.5 + assert out["estimated_pnl_ratio_pct"] == round(-0.5 / 0.6 * 100, 2) + + +def test_estimate_close_by_bids_empty(): + from lib.options.options_pricing_lib import estimate_close_by_bids + + out = estimate_close_by_bids([], 2) + assert out["covered_sheets"] == 0 + assert out["uncovered_sheets"] == 2 + assert out["avg_px"] is None + + +def test_stub_bid_blocks_auto_close_estimate(): + from lib.options.options_pricing_lib import estimate_close_by_bids, is_stub_bid_px + + stub, reason = is_stub_bid_px(0.2, mark_px=42.0) + assert stub is True + assert "残档" in reason or "无效" in reason or "远低于" in reason + + out = estimate_close_by_bids( + [{"px": 0.2, "sz": 3500}], + 66, + ct_mult=0.01, + premium_paid=9.37, + mark_px=42.0, + ) + assert out["auto_close_blocked"] is True + assert out["bid_invalid"] is True + assert out["estimated_pnl"] is None + assert out["levels"] == [] + + ok, _ = is_stub_bid_px(30.0, mark_px=42.0) + assert ok is False + good = estimate_close_by_bids( + [{"px": 30.0, "sz": 100}], + 10, + ct_mult=0.01, + premium_paid=1.0, + mark_px=42.0, + ) + assert good["auto_close_blocked"] is False + assert good["covered_sheets"] == 10 + + +def test_intrinsic_px_coin_vs_usdc_units(): + from lib.options.options_pricing_lib import intrinsic_px_per_unit, is_stub_bid_px + + # USDC / 默认:美元点差 + assert intrinsic_px_per_unit("C", 2250, 2274) == 24.0 + assert intrinsic_px_per_unit("C", 2250, 2274, margin_mode="usdc") == 24.0 + + # 币本位:与盘口同单位的币报价 (S−K)/S + coin_iv = intrinsic_px_per_unit("C", 2250, 2274, quote_in_coin=True) + assert coin_iv is not None + assert abs(coin_iv - 24.0 / 2274.0) < 1e-12 + assert abs( + intrinsic_px_per_unit("C", 2250, 2274, inst_id="ETH-USD-260822-2250-C") - 24.0 / 2274.0 + ) < 1e-12 + # USD_UM 仍为点差 + assert intrinsic_px_per_unit("C", 2250, 2274, inst_id="ETH-USD_UM-260822-2250-C") == 24.0 + + # 复现线上误杀:把点差当内在价值会把正常买一判残档 + wrong_stub, _ = is_stub_bid_px(0.023, mark_px=0.0241, intrinsic_px=23.58) + assert wrong_stub is True + # 币报价内在价值后,买一贴近标记价应有效 + ok_stub, _ = is_stub_bid_px(0.023, mark_px=0.0241, intrinsic_px=coin_iv) + assert ok_stub is False + + put_iv = intrinsic_px_per_unit("P", 2300, 2274, quote_in_coin=True) + assert put_iv is not None + assert abs(put_iv - 26.0 / 2274.0) < 1e-12 + + +def test_expiry_breakeven_from_ask(): + from lib.options.options_pricing_lib import expiry_breakeven_from_ask + + assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=15.6) == 1775.6 + assert expiry_breakeven_from_ask(opt_type="P", strike=1760, ask_px=15.6) == 1744.4 + assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=None, mark_px=14.2) == 1774.2 + + +def test_calc_order_size_too_small(): + r = calc_order_size( + quote_per_unit=2000.0, + ct_mult=0.01, + min_sz=1, + budget_usdc=10, + budget_buffer=0.95, + budget_cap=10, + ) + assert r["ok"] is False + + +def test_expiry_breakeven_from_api(): + from lib.options.options_pricing_lib import expiry_breakeven_px + + assert expiry_breakeven_px( + opt_type="C", strike=3500, avg_px=15.6, be_px_api=3516.2 + ) == 3516.2 + + +def test_expiry_breakeven_call_put(): + from lib.options.options_pricing_lib import expiry_breakeven_px + + assert expiry_breakeven_px(opt_type="C", strike=3500, avg_px=15.6) == 3515.6 + assert expiry_breakeven_px(opt_type="P", strike=3500, avg_px=15.6) == 3484.4 + + +def test_expiry_breakeven_coin_margin(): + from lib.options.options_pricing_lib import expiry_breakeven_from_ask, expiry_breakeven_px + + # ETH-USD 币本位:卖一 0.0165 → 到期平衡 K/(1-p),非 K+p + assert expiry_breakeven_px( + opt_type="C", strike=2390, avg_px=0.0165, margin_mode="coin" + ) == round(2390 / (1 - 0.0165), 2) + assert expiry_breakeven_px( + opt_type="P", strike=2450, avg_px=0.0161, margin_mode="coin" + ) == round(2450 / (1 + 0.0161), 2) + assert expiry_breakeven_from_ask( + opt_type="C", + strike=2425, + ask_px=0.0187, + inst_id="ETH-USD-260823-2425-C", + ) == round(2425 / (1 - 0.0187), 2) + + +def test_strike_distance_to_be(): + from lib.options.options_pricing_lib import strike_distance_to_be + + assert strike_distance_to_be(2390, 2430, opt_type="C") == 40.0 + assert strike_distance_to_be(2450, 2411, opt_type="P") == 39.0 + + +def test_straddle_breakeven_band_coin(): + from lib.options.options_pricing_lib import straddle_breakeven_band + + lo, hi = straddle_breakeven_band( + 2425, + quote_in_coin=True, + call_ask=0.0187, + put_ask=0.0253, + ) + assert lo == round(2425 / (1 + 0.0253), 2) + assert hi == round(2425 / (1 - 0.0187), 2) + + +def test_close_breakeven_at_mark_equals_avg(): + from lib.options.options_pricing_lib import close_breakeven_idx + + assert close_breakeven_idx( + opt_type="C", idx_px=3480, mark_px=15.6, avg_px=15.6 + ) == 3480.0 + assert close_breakeven_idx( + opt_type="P", idx_px=3480, mark_px=15.6, avg_px=15.6 + ) == 3480.0 + + +def test_close_breakeven_with_delta(): + from lib.options.options_pricing_lib import close_breakeven_idx + + # mark below avg, delta 0.5 ETH on 0.5 ETH position -> slope 1 + be = close_breakeven_idx( + opt_type="C", + idx_px=3480, + mark_px=14.6, + avg_px=15.6, + delta_pa=0.5, + pos=50, + ct_mult=0.01, + ) + assert be == 3481.0 + + +def test_format_options_breakeven_line(): + from lib.options.options_pricing_lib import format_options_breakeven_line + + s = format_options_breakeven_line( + expiry_be_px=3515.6, close_be_px=3498.0, idx_px=3480.0 + ) + assert "到期平衡3516" in s + assert "平掉回本3498" in s + assert "指数3480" in s + + +def test_format_position_row_premium_and_inst_parse(): + from lib.exchange.okx_options_lib import format_position_row + + row = format_position_row( + { + "instId": "ETH-USD_UM-260709-1700-P", + "pos": "20", + "avgPx": "6.2", + "markPx": "6.3241", + "idxPx": "1746", + "upl": "0.0248", + "uplRatio": "0.02", + } + ) + assert row["opt_type"] == "P" + assert row["strike"] == 1700.0 + assert row["premium_paid"] == 1.24 + assert row["exp_time_ms"] is not None + assert row["exp_time_ms"] > 0 + + +def test_expiry_ms_from_inst_id(): + from lib.exchange.okx_options_lib import expiry_ms_from_inst_id, normalize_option_exp_ms + + ms = expiry_ms_from_inst_id("ETH-USD_UM-260709-1700-P") + assert ms is not None + from datetime import datetime, timezone + + dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) + assert dt.year == 2026 and dt.month == 7 and dt.day == 9 and dt.hour == 8 + assert normalize_option_exp_ms(None, "ETH-USD_UM-260709-1700-P") == ms + + +def test_format_position_row_breakeven(): + from lib.exchange.okx_options_lib import format_position_row + + row = format_position_row( + { + "instId": "ETH-USD_UM-260703-1800-C", + "pos": "50", + "avgPx": "15.6", + "markPx": "16.2", + "idxPx": "3480", + "bePx": "3515.6", + "optType": "C", + "stk": "3500", + "deltaPA": "0.45", + "upl": "0.3", + "uplRatio": "0.02", + } + ) + assert row["expiry_be_px"] == 3515.6 + assert row["idx_px"] == 3480.0 + assert row["close_be_px"] is not None + assert row["dist_expiry_be"] == 15.6 diff --git a/tests/test_options_profit_exit.py b/tests/test_options_profit_exit.py new file mode 100644 index 0000000..944991a --- /dev/null +++ b/tests/test_options_profit_exit.py @@ -0,0 +1,65 @@ +"""单独期权翻倍出场命中条件.""" +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from lib.options.options_db import init_options_tables +from lib.options.options_profit_exit_lib import ( + normalize_profit_exit_mult, + profit_exit_by_inst, + profit_exit_hit, + required_recycle_usdc, + set_profit_exit, +) + + +class TestOptionsProfitExit(unittest.TestCase): + def test_hit_one_x_means_profit_equals_premium(self): + # 1倍:盈利=权利金 ⇒ 回收≥2×权利金 + self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=20.0, mult=1.0)) + self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=19.9, mult=1.0)) + self.assertEqual(required_recycle_usdc(10.0, 1.0), 20.0) + + def test_hit_two_x(self): + self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=30.0, mult=2.0)) + self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=29.9, mult=2.0)) + + def test_normalize_mult(self): + self.assertEqual(normalize_profit_exit_mult(None), 1.0) + self.assertEqual(normalize_profit_exit_mult(0), 1.0) + self.assertEqual(normalize_profit_exit_mult("1.5"), 1.5) + + def test_set_and_clear(self): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "t.db" + conn = sqlite3.connect(str(db)) + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status) + VALUES ('ETH-X', 'ETH', 'C', 1, 0.01, 10.0, 'open') + """ + ) + conn.commit() + out = set_profit_exit(conn, inst_id="ETH-X", enabled=True, mult=1.5) + self.assertTrue(out["ok"]) + conn.commit() + m = profit_exit_by_inst(conn) + self.assertTrue(m["ETH-X"]["profit_exit_enabled"]) + self.assertEqual(m["ETH-X"]["profit_exit_mult"], 1.5) + self.assertEqual(m["ETH-X"]["required_recycle"], 25.0) + out2 = set_profit_exit(conn, inst_id="ETH-X", enabled=False, mult=1.5) + self.assertTrue(out2["ok"]) + conn.commit() + m2 = profit_exit_by_inst(conn) + self.assertNotIn("ETH-X", m2) + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_profit_exit_recycle.py b/tests/test_options_profit_exit_recycle.py new file mode 100644 index 0000000..cf71929 --- /dev/null +++ b/tests/test_options_profit_exit_recycle.py @@ -0,0 +1,39 @@ +"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断).""" +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit + + +class ProfitExitHitTests(unittest.TestCase): + def test_hit_at_0_3_mult(self): + self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3)) + self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3)) + + def test_estimate_uses_full_sheets_not_depth(self): + pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01} + preview = { + "bid_invalid": False, + "levels": [{"px": 0.2, "sheets": 10}], + "total_received": 0.02, + } + + def fake_attach(cfg, ex, row, premium_paid=None): + row["close_preview"] = preview + row["ct_mult"] = 0.01 + row["pos"] = 100 + return row + + with patch( + "lib.options.options_positions_lib.attach_close_preview", + side_effect=fake_attach, + ): + recv = _estimate_recycle({}, object(), pos, 1.0) + self.assertAlmostEqual(float(recv), 0.2, places=6) + self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_review_chart_async_lib.py b/tests/test_options_review_chart_async_lib.py new file mode 100644 index 0000000..fb55dde --- /dev/null +++ b/tests/test_options_review_chart_async_lib.py @@ -0,0 +1,157 @@ +"""期权复盘自动 K 线:单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest +from unittest.mock import MagicMock, patch + +from lib.options.options_review_chart_async_lib import ( + OPTIONS_REVIEW_AUTO_CHART_TF, + build_options_review_chart_filename, + merge_auto_chart_into_images, + schedule_options_review_exchange_chart, + underlying_to_okx_perp_symbol, +) +from lib.options.options_review_db import init_options_review_tables +from lib.options.options_review_lib import save_review_entry, upsert_option_history_row + + +def _conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + init_options_review_tables(c) + return c + + +class OptionsReviewChartAsyncTests(unittest.TestCase): + def test_underlying_to_okx_perp_symbol(self): + self.assertEqual( + underlying_to_okx_perp_symbol( + "ETH-USD", + normalize_symbol_input_fn=lambda s: f"{s}/USDT", + normalize_okx_symbol_fn=lambda s: f"{s.split('/')[0]}/USDT:USDT" + if "/" in s + else s, + ), + "ETH/USDT:USDT", + ) + + def test_merge_auto_chart_keeps_manual_slots(self): + merged = merge_auto_chart_into_images( + [ + {"tf": "auto", "file": "old.png"}, + {"tf": "15m", "file": "m15.png"}, + ], + "new_auto.png", + ) + self.assertEqual(merged[0]["tf"], OPTIONS_REVIEW_AUTO_CHART_TF) + self.assertEqual(merged[0]["file"], "new_auto.png") + self.assertEqual(merged[1], {"tf": "15m", "file": "m15.png"}) + + def test_chart_filename(self): + self.assertEqual( + build_options_review_chart_filename(12), "options_journal_chart_12.png" + ) + + @patch("lib.options.options_review_chart_async_lib.threading.Thread") + def test_schedule_starts_daemon_thread(self, mock_thread_cls): + thread = MagicMock() + mock_thread_cls.return_value = thread + schedule_options_review_exchange_chart( + trade_id=7, + exchange_symbol="ETH/USDT:USDT", + title_prefix="t", + journal_tfs=["15m", "1h"], + journal_limit=100, + marker_payload={}, + upload_folder="/tmp", + generate_chart_fn=MagicMock(), + get_db_fn=MagicMock(), + now_str_fn=lambda: "2026-09-15 12:00:00", + ) + mock_thread_cls.assert_called_once() + self.assertTrue(mock_thread_cls.call_args.kwargs.get("daemon")) + thread.start.assert_called_once() + + def test_writeback_merges_auto_image(self): + import tempfile + from pathlib import Path + + from lib.options.options_review_images_lib import parse_options_review_images_json + + with tempfile.TemporaryDirectory() as td: + db_path = Path(td) / "t.db" + + def get_db(): + c = sqlite3.connect(str(db_path)) + c.row_factory = sqlite3.Row + return c + + conn = get_db() + init_options_review_tables(conn) + upsert_option_history_row( + conn, + { + "history_key": "ex:chart1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": 1.0, + "created_at": "2026-09-01 10:00:00", + "closed_at": "2026-09-01 12:00:00", + }, + ) + trade_id = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key='ex:chart1'" + ).fetchone()["id"] + save_review_entry( + conn, + trade_id, + { + "strategy_tag": "测试", + "images": [{"tf": "15m", "file": "options_journal_x_15m.png"}], + }, + ) + conn.commit() + conn.close() + + chart_name = build_options_review_chart_filename(trade_id) + generate = MagicMock(return_value=chart_name) + + with patch( + "lib.options.options_review_chart_async_lib.threading.Thread" + ) as mock_thread_cls: + thread = MagicMock() + mock_thread_cls.return_value = thread + schedule_options_review_exchange_chart( + trade_id=trade_id, + exchange_symbol="ETH/USDT:USDT", + title_prefix="ETH review", + journal_tfs=["15m", "1h"], + journal_limit=200, + marker_payload={"entry_ts_ms": 1, "exit_ts_ms": 2}, + upload_folder=td, + generate_chart_fn=generate, + get_db_fn=get_db, + now_str_fn=lambda: "2026-09-15 12:00:00", + ) + target = mock_thread_cls.call_args.kwargs["target"] + target() + + conn = get_db() + try: + row = conn.execute( + "SELECT images_json, image FROM options_review_entries WHERE trade_id=?", + (trade_id,), + ).fetchone() + self.assertEqual(row["image"], chart_name) + images = parse_options_review_images_json(row["images_json"]) + self.assertEqual(images[0]["tf"], "auto") + self.assertEqual(images[0]["file"], chart_name) + self.assertTrue(any(x.get("tf") == "15m" for x in images)) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_review_lib.py b/tests/test_options_review_lib.py new file mode 100644 index 0000000..5f0f5ee --- /dev/null +++ b/tests/test_options_review_lib.py @@ -0,0 +1,408 @@ +"""期权复盘(含对冲)单元测试:导入去重、双计防护、复盘不被覆盖、统计.""" +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan +from lib.options.options_review_db import SOURCE_OPTION, SOURCE_PERP_OPTIONS, init_options_review_tables +from lib.options.options_review_images_lib import ( + build_options_review_slot_filename, + is_valid_options_review_file, + options_review_upload_dir, + save_options_review_slot_file, +) +from lib.options.options_review_lib import ( + compute_review_stats, + list_review_trades, + save_review_entry, + sync_hedge_plans_closed, + sync_options_from_exchange, + upsert_option_history_row, +) + + +def _conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + init_options_review_tables(c) + init_hedge_plan_tables(c) + return c + + +class _FakeFile: + def __init__(self, name: str, data: bytes = b"img"): + self.filename = name + self._data = data + + def save(self, path: str) -> None: + Path(path).write_bytes(self._data) + + +class OptionsReviewTests(unittest.TestCase): + def test_option_upsert_idempotent(self): + conn = _conn() + row = { + "history_key": "ex:pos1", + "pos_id": "pos1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "strike": 2000, + "sheets": 10, + "open_avg_px": 0.01, + "close_avg_px": 0.02, + "premium_paid": 1.0, + "realized_pnl": 5.5, + "created_at": "2026-03-01 10:00:00", + "closed_at": "2026-03-01 12:00:00", + "status_label": "已平", + } + self.assertEqual(upsert_option_history_row(conn, row), "inserted") + row["realized_pnl"] = 6.0 + self.assertEqual(upsert_option_history_row(conn, row), "updated") + n = conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"] + self.assertEqual(n, 1) + pnl = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:pos1'" + ).fetchone()["realized_pnl_total"] + self.assertEqual(float(pnl), 6.0) + + def test_entry_not_overwritten_by_resync(self): + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:p2", + "inst_id": "ETH-USD-260328-1800-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": 1.0, + "created_at": "2026-03-02 10:00:00", + "closed_at": "2026-03-02 11:00:00", + }, + ) + tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"] + save_review_entry( + conn, + tid, + {"strategy_tag": "突破追涨", "note": "keep-me", "images": []}, + ) + upsert_option_history_row( + conn, + { + "history_key": "ex:p2", + "inst_id": "ETH-USD-260328-1800-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": 2.0, + "created_at": "2026-03-02 10:00:00", + "closed_at": "2026-03-02 11:00:00", + }, + ) + note = conn.execute( + "SELECT note, strategy_tag FROM options_review_entries WHERE trade_id=?", + (tid,), + ).fetchone() + self.assertEqual(note["note"], "keep-me") + self.assertEqual(note["strategy_tag"], "突破追涨") + pnl = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE id=?", (tid,) + ).fetchone()["realized_pnl_total"] + self.assertEqual(float(pnl), 2.0) + + def test_hedge_import_and_double_count_guard(self): + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:leg1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": -3.0, + "created_at": "2026-03-03 09:00:00", + "closed_at": "2026-03-03 18:00:00", + }, + ) + plan_id = insert_plan( + conn, + { + "plan_type": SOURCE_PERP_OPTIONS, + "status": "closed", + "underlying": "ETH", + "direction": "long", + "realized_pnl_perp": 20.0, + "realized_pnl_options": -3.0, + "realized_pnl_total": 17.0, + "close_reason": "tp", + "opened_at": "2026-03-03 09:00:00", + "closed_at": "2026-03-03 18:00:00", + "premium_total": 3.0, + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "perp", + "symbol": "ETH-USDT-SWAP", + "status": "closed", + "realized_pnl": 20.0, + }, + ) + insert_leg( + conn, + { + "plan_id": plan_id, + "leg_role": "option_hedge", + "inst_id": "ETH-USD-260328-2000-C", + "opt_type": "C", + "status": "closed", + "realized_pnl": -3.0, + }, + ) + out = sync_hedge_plans_closed(conn) + self.assertTrue(out["ok"]) + self.assertEqual(out["inserted"], 1) + + listed = list_review_trades(conn, include_hedge_legs=False) + types = {r["source_type"] for r in listed} + self.assertIn(SOURCE_PERP_OPTIONS, types) + self.assertNotIn(SOURCE_OPTION, types) + + listed_all = list_review_trades(conn, include_hedge_legs=True) + self.assertEqual(len(listed_all), 2) + + stats = compute_review_stats(conn, include_hedge_legs=False) + self.assertEqual(stats["kpi"]["total"], 1) + self.assertEqual(stats["kpi"]["pnl_sum"], 17.0) + + def test_sync_options_from_mock_exchange(self): + conn = _conn() + + def fetch(_ex, limit=500): + return [ + { + "instId": "ETH-USD-260328-2100-C", + "posId": "mock1", + "openAvgPx": "0.01", + "closeAvgPx": "0.02", + "closeTotalPos": "5", + "realizedPnl": "1.23", + "type": "2", + "cTime": "1700000000000", + "uTime": "1700003600000", + "uly": "ETH-USD", + } + ] + + def fmt(raw, tick_sz=None, ct_mult=0.01): + return { + "history_key": f"ex:{raw['posId']}", + "pos_id": raw["posId"], + "inst_id": raw["instId"], + "underlying": "ETH", + "opt_type": "C", + "sheets": 5, + "open_avg_px": 0.01, + "close_avg_px": 0.02, + "premium_paid": 0.5, + "realized_pnl": float(raw["realizedPnl"]), + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 01:00:00", + "status_label": "已平", + } + + result = sync_options_from_exchange( + conn, object(), limit=10, fetch_fn=fetch, format_fn=fmt + ) + self.assertTrue(result["ok"]) + self.assertEqual(result["inserted"], 1) + row = conn.execute( + "SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:mock1'" + ).fetchone() + self.assertEqual(float(row["realized_pnl_total"]), 1.23) + + def test_hide_trade_persists_across_local_sync(self): + conn = _conn() + from lib.options.options_db import init_options_tables + from lib.options.options_review_lib import ( + hide_review_trade, + sync_options_from_local_trades, + ) + + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, status, realized_pnl, created_at, closed_at) + VALUES ('ETH-USD-1-C','ETH','C',2000,1,0.01,0.01,0.2,'closed',1.0, + '2026-03-01 10:00:00','2026-03-01 11:00:00') + """ + ) + sync_options_from_local_trades(conn) + tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"] + out = hide_review_trade(conn, tid) + self.assertTrue(out["ok"]) + self.assertEqual( + conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"], + 0, + ) + sync_options_from_local_trades(conn) + self.assertEqual( + conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"], + 0, + ) + + def test_local_options_trades_import(self): + conn = _conn() + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, status, realized_pnl, created_at, closed_at) + VALUES ('ETH-USD-260328-2000-C','ETH','C',2000,2,0.02,0.01,0.5,'closed',3.2, + '2026-03-01 10:00:00','2026-03-01 12:00:00') + """ + ) + from lib.options.options_review_lib import sync_options_from_local_trades + + out = sync_options_from_local_trades(conn) + self.assertTrue(out["ok"]) + self.assertEqual(out["inserted"], 1) + row = conn.execute( + "SELECT history_key, realized_pnl_total, source_type FROM options_review_trades" + ).fetchone() + self.assertTrue(str(row["history_key"]).startswith("local_opt:")) + self.assertEqual(float(row["realized_pnl_total"]), 3.2) + self.assertEqual(row["source_type"], SOURCE_OPTION) + + def test_image_paths_resolve_legacy_journal_root(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + # 误存到 UPLOAD 根目录的 journal_* + legacy = root / "journal_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_5m.png" + legacy.write_bytes(b"img") + # 正常 options_journal 子目录 + sub = root / "options_journal" + sub.mkdir() + modern = sub / "options_journal_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_5m.png" + modern.write_bytes(b"img2") + from lib.options.options_review_images_lib import options_review_image_paths + + class Row: + images_json = ( + '[{"tf":"5m","file":"journal_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_5m.png"},' + '{"tf":"5m","file":"options_journal_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_5m.png"}]' + ) + image = None + + paths = options_review_image_paths(Row(), str(root)) + self.assertEqual(len(paths), 2) + self.assertTrue(any(p.endswith(legacy.name) for p in paths)) + self.assertTrue(any(p.endswith(modern.name) for p in paths)) + + def test_image_namespace(self): + with tempfile.TemporaryDirectory() as tmp: + folder = options_review_upload_dir(tmp) + fname = build_options_review_slot_filename( + "a" * 32, "5m", ".png", secure_filename_fn=lambda x: x + ) + self.assertTrue(fname.startswith("options_journal_")) + self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "5m")) + item = save_options_review_slot_file( + _FakeFile("x.png"), + "a" * 32, + "5m", + folder, + secure_filename_fn=lambda x: x, + ) + self.assertIsNotNone(item) + self.assertTrue((Path(folder) / item["file"]).is_file()) + + def test_strategy_stats_only_tagged(self): + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:a", + "inst_id": "ETH-USD-1-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": 10, + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 02:00:00", + }, + ) + upsert_option_history_row( + conn, + { + "history_key": "ex:b", + "inst_id": "ETH-USD-2-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": -4, + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 05:00:00", + }, + ) + tid = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key='ex:a'" + ).fetchone()["id"] + save_review_entry(conn, tid, {"strategy_tag": "假破", "images": []}) + stats = compute_review_stats(conn) + self.assertEqual(len(stats["by_strategy"]), 1) + self.assertEqual(stats["by_strategy"][0]["key"], "假破") + self.assertEqual(stats["kpi"]["total"], 2) + + def test_q_search_btcusdt_matches_btc_pending(self): + from lib.options.options_review_lib import count_review_trades + + conn = _conn() + upsert_option_history_row( + conn, + { + "history_key": "ex:btc1", + "inst_id": "BTC-USD-260328-90000-C", + "underlying": "BTC", + "opt_type": "C", + "realized_pnl": 1.2, + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 02:00:00", + }, + ) + upsert_option_history_row( + conn, + { + "history_key": "ex:eth1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": 2.0, + "created_at": "2026-01-01 00:00:00", + "closed_at": "2026-01-01 03:00:00", + }, + ) + # 旧精确 strategy_tag 会把待复盘滤成空 + self.assertEqual( + count_review_trades(conn, strategy_tag="BTCUSDT", reviewed="0"), + 0, + ) + self.assertEqual( + count_review_trades(conn, q="BTCUSDT", reviewed="0"), + 1, + ) + rows = list_review_trades(conn, q="BTCUSDT", reviewed="0") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["underlying"], "BTC") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_stats_lib.py b/tests/test_options_stats_lib.py new file mode 100644 index 0000000..aa78b6e --- /dev/null +++ b/tests/test_options_stats_lib.py @@ -0,0 +1,104 @@ +"""期权统计单测.""" +import sqlite3 +from datetime import datetime, timedelta +from unittest import TestCase + +from lib.options.options_db import init_options_tables +from lib.options.options_stats_lib import compute_options_stats, compute_options_stats_from_history + + +class OptionsStatsLibTests(TestCase): + def _conn(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + return conn + + def test_compute_options_stats_empty(self): + conn = self._conn() + out = compute_options_stats(lambda: conn) + self.assertEqual(out["total_closed"], 0) + self.assertEqual(out["open_count"], 0) + self.assertIsNone(out["avg_hold_sec"]) + + def test_compute_options_stats_hold_times(self): + conn = self._conn() + now = datetime.now() + win_open = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S") + win_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + loss_open = (now - timedelta(hours=4)).strftime("%Y-%m-%d %H:%M:%S") + loss_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + open_at = (now - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, status, + realized_pnl, created_at, closed_at) + VALUES ('A', 'ETH', 'C', 1800, 1, 0.01, 'closed', 1.2, ?, ?) + """, + (win_open, win_close), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, status, + realized_pnl, created_at, closed_at) + VALUES ('B', 'ETH', 'P', 1700, 1, 0.01, 'closed', -1.0, ?, ?) + """, + (loss_open, loss_close), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, status, created_at) + VALUES ('C', 'BTC', 'C', 62000, 1, 0.01, 'open', ?) + """, + (open_at,), + ) + conn.commit() + out = compute_options_stats(lambda: conn) + self.assertEqual(out["total_closed"], 2) + self.assertEqual(out["win_count"], 1) + self.assertEqual(out["loss_count"], 1) + self.assertEqual(out["win_rate"], 50.0) + self.assertAlmostEqual(out["avg_win"], 1.2, places=4) + self.assertAlmostEqual(out["avg_loss"], 1.0, places=4) + self.assertAlmostEqual(out["avg_win_hold_sec"], 3600.0, delta=5.0) + self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0) + self.assertEqual(out["open_count"], 1) + self.assertGreater(out["avg_open_hold_sec"], 1700.0) + + def test_compute_options_stats_from_history_exchange_rows(self): + history = [ + {"status": "open", "created_at": "2026-07-11 08:08:38"}, + {"status": "closed", "realized_pnl": -3.99, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 16:00:35"}, + {"status": "closed", "realized_pnl": 0.87, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 09:55:34"}, + {"status": "closed", "realized_pnl": -1.33, "created_at": "2026-07-08 02:32:44", "closed_at": "2026-07-09 16:00:26"}, + ] + out = compute_options_stats_from_history(history) + self.assertEqual(out["total_closed"], 3) + self.assertEqual(out["win_count"], 1) + self.assertEqual(out["loss_count"], 2) + self.assertAlmostEqual(out["avg_win"], 0.87, places=4) + self.assertAlmostEqual(out["avg_loss"], 2.66, places=2) + self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2) + self.assertEqual(out["open_count"], 1) + self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4) + + def test_compute_options_stats_coin_to_usdt(self): + history = [ + { + "status": "closed", + "realized_pnl": 0.00078, + "premium_ccy": "ETH", + "margin_mode": "coin", + "inst_id": "ETH-USD-260822-2250-C", + "idx_px": 2280, + "created_at": "2026-08-20 08:00:00", + "closed_at": "2026-08-20 13:32:00", + } + ] + out = compute_options_stats_from_history(history) + self.assertEqual(out["pnl_unit"], "U") + self.assertEqual(out["total_closed"], 1) + self.assertAlmostEqual(out["net_realized_pnl"], 0.00078 * 2280, places=4) diff --git a/tests/test_options_sync.py b/tests/test_options_sync.py new file mode 100644 index 0000000..c6e8d5f --- /dev/null +++ b/tests/test_options_sync.py @@ -0,0 +1,310 @@ +"""期权平仓/到期状态同步单测.""" +import sqlite3 + +from lib.exchange.okx_options_lib import ( + format_option_history_row, + format_usdc_amount, + is_option_full_close_history, + resolve_option_close_from_history, +) +from lib.options.options_db import init_options_tables +from lib.options.options_monitor_lib import sync_open_options_trades + + +def test_format_usdc_amount(): + assert format_usdc_amount(4.896) == "4.90" + assert format_usdc_amount(4.9) == "4.90" + assert format_usdc_amount(4.0) == "4.00" + + +def test_is_option_full_close_history(): + assert is_option_full_close_history({"type": "2"}) + assert is_option_full_close_history({"type": "3"}) + assert not is_option_full_close_history({"type": "1"}) + assert not is_option_full_close_history({"type": "5"}) + + +def test_format_option_history_row(): + raw = { + "instId": "BTC-USD_UM-260710-62000-P", + "openAvgPx": "380", + "closeAvgPx": "0", + "closeTotalPos": "1", + "openMaxPos": "1", + "realizedPnl": "-3.99", + "pnlRatio": "-1.049", + "type": "2", + "cTime": "1784000000000", + "uTime": "1784088035000", + "posId": "pos-btc", + } + row = format_option_history_row(raw, tick_sz="0.1", ct_mult=0.01) + assert row["inst_id"] == "BTC-USD_UM-260710-62000-P" + assert row["sheets"] == 1 + assert row["realized_pnl"] == -3.99 + assert row["status_label"] == "已平" + assert row["open_avg_px_fmt"] == "380" + assert row["premium_paid_fmt"] == "3.80" + assert row["history_key"] == "ex:pos-btc:1784088035000" + + +def test_resolve_option_close_from_history_picks_latest(): + rows = [ + {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"}, + {"instId": "ETH-USD_UM-260709-1700-P", "uTime": "2000", "realizedPnl": "-1.24", "closeAvgPx": "0", "posId": "9"}, + ] + got = resolve_option_close_from_history(rows, open_ms=500) + assert got is not None + assert got["realized_pnl"] == -1.24 + assert got["pos_id"] == "9" + + +def test_resolve_option_close_from_history_matches_open_and_sheets(): + rows = [ + { + "instId": "ETH-USD_UM-260806-1875-C", + "cTime": "1785932775047", + "uTime": "1785933936733", + "realizedPnl": "-3.036", + "closeAvgPx": "12.4", + "closeTotalPos": "57", + "posId": "same-pos", + }, + { + "instId": "ETH-USD_UM-260806-1875-C", + "cTime": "1785938392445", + "uTime": "1785957764279", + "realizedPnl": "16.937", + "closeAvgPx": "41.0", + "closeTotalPos": "66", + "posId": "same-pos", + }, + ] + # 本地时间相对交易所偏 8h 时,仍应按 cTime/张数对齐到正确一笔 + early = resolve_option_close_from_history( + rows, open_ms=1785902775000, close_ms=1785903937000, sheets=57 + ) + late = resolve_option_close_from_history( + rows, open_ms=1785908392000, close_ms=1785927764000, sheets=66 + ) + assert early is not None and early["realized_pnl"] == -3.036 + assert early["close_quote"] == 12.4 + assert late is not None and late["realized_pnl"] == 16.937 + assert late["close_quote"] == 41.0 + + +def test_sync_open_options_trades_marks_expired_closed(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status) + VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open') + """, + ("ETH-USD_UM-260709-1700-P",), + ) + conn.commit() + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=lambda _inst: [], + ) + assert n == 1 + row = conn.execute("SELECT status, premium_received, realized_pnl, signal_note FROM options_trades").fetchone() + assert row["status"] == "closed" + assert row["premium_received"] == 0.0 + assert row["realized_pnl"] == -1.24 + assert "到期结算" in (row["signal_note"] or "") + + +def test_sync_open_options_trades_skips_without_close_evidence(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, created_at) + VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'open', '2026-08-05 08:00:00') + """, + ("BTC-USD_UM-261231-62000-P",), + ) + conn.commit() + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=lambda _inst: [], + ) + assert n == 0 + row = conn.execute("SELECT status FROM options_trades").fetchone() + assert row["status"] == "open" + + +def test_reconcile_live_open_trades_reopens_sync_artifact(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, closed_at) + VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'closed', '2026-07-09 09:10:34') + """, + ("BTC-USD_UM-260710-62000-P",), + ) + conn.commit() + + from lib.options.options_monitor_lib import reconcile_live_open_trades + + n = reconcile_live_open_trades(conn, live_inst_ids={"BTC-USD_UM-260710-62000-P"}) + assert n == 1 + row = conn.execute("SELECT status, closed_at FROM options_trades").fetchone() + assert row["status"] == "open" + assert row["closed_at"] is None + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, premium_paid, status, created_at) + VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open', '2026-07-08 02:32:44') + """, + ("ETH-USD_UM-260709-1700-P",), + ) + conn.commit() + + def _hist(_inst): + return [ + { + "instId": "ETH-USD_UM-260709-1700-P", + "uTime": "1784000000000", + "realizedPnl": "-0.5", + "closeAvgPx": "0.1", + "posId": "pos-1", + } + ] + + n = sync_open_options_trades( + conn, + live_inst_ids=set(), + fetch_history_fn=_hist, + ) + assert n == 1 + row = conn.execute( + "SELECT status, premium_received, realized_pnl, close_ord_id FROM options_trades" + ).fetchone() + assert row["status"] == "closed" + assert row["realized_pnl"] == -0.5 + assert row["premium_received"] == 0.74 + assert row["close_ord_id"] == "pos-1" + + +def test_backfill_closed_options_realized_pnl_from_history(): + from lib.options.options_monitor_lib import backfill_closed_options_realized_pnl_from_history + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, close_quote, premium_paid, premium_received, realized_pnl, + status, created_at, closed_at, close_ord_id) + VALUES (?, 'ETH', 'C', 1860, '', 43, 0.43, 22.4, 50.6, 9.632, 21.758, 12.126, + 'closed', '2026-07-20 08:02:14', '2026-07-21 01:38:43', 'ord-1') + """, + ("ETH-USD_UM-260721-1860-C",), + ) + conn.commit() + hist = [ + { + "instId": "ETH-USD_UM-260721-1860-C", + "uTime": "1784564323000", + "realizedPnl": "11.64", + "closeAvgPx": "48.5", + "closeTotalPos": "43", + "posId": "pos-x", + } + ] + n = backfill_closed_options_realized_pnl_from_history(conn, hist) + assert n == 1 + row = conn.execute( + "SELECT realized_pnl, premium_received, close_quote FROM options_trades WHERE id=1" + ).fetchone() + assert row["realized_pnl"] == 11.64 + assert abs(float(row["premium_received"]) - (9.632 + 11.64)) < 1e-6 + assert float(row["close_quote"]) == 48.5 + # idempotent + assert backfill_closed_options_realized_pnl_from_history(conn, hist) == 0 + + +def test_backfill_does_not_overwrite_earlier_close_with_later_pnl(): + from lib.options.options_monitor_lib import backfill_closed_options_realized_pnl_from_history + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, close_quote, premium_paid, realized_pnl, status, created_at, closed_at) + VALUES (?, 'ETH', 'C', 1875, '', 57, 0.57, 16.6, 41.0, 9.462, 16.9368, + 'closed', '2026-08-05 12:26:15', '2026-08-05 12:45:37') + """, + ("ETH-USD_UM-260806-1875-C",), + ) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, + open_quote, close_quote, premium_paid, realized_pnl, status, created_at, closed_at) + VALUES (?, 'ETH', 'C', 1875, '', 66, 0.66, 14.2, 41.0, 9.372, 16.9368, + 'closed', '2026-08-05 13:59:52', '2026-08-05 19:22:45') + """, + ("ETH-USD_UM-260806-1875-C",), + ) + conn.commit() + hist = [ + { + "instId": "ETH-USD_UM-260806-1875-C", + "uTime": "1785933936733", + "cTime": "1785932775047", + "realizedPnl": "-3.03616314", + "closeAvgPx": "12.4", + "closeTotalPos": "57", + "posId": "3806091806281486337", + }, + { + "instId": "ETH-USD_UM-260806-1875-C", + "uTime": "1785957764279", + "cTime": "1785938392445", + "realizedPnl": "16.9368375", + "closeAvgPx": "41.0", + "closeTotalPos": "66", + "posId": "3806091806281486337", + }, + ] + n = backfill_closed_options_realized_pnl_from_history(conn, hist) + assert n >= 1 + rows = { + int(r["id"]): r + for r in conn.execute( + "SELECT id, realized_pnl, close_quote FROM options_trades ORDER BY id" + ).fetchall() + } + assert abs(float(rows[1]["realized_pnl"]) - (-3.0362)) < 1e-3 + assert float(rows[1]["close_quote"]) == 12.4 + assert abs(float(rows[2]["realized_pnl"]) - 16.9368) < 1e-3 + assert float(rows[2]["close_quote"]) == 41.0 diff --git a/tests/test_options_target_lib.py b/tests/test_options_target_lib.py new file mode 100644 index 0000000..2962f1d --- /dev/null +++ b/tests/test_options_target_lib.py @@ -0,0 +1,169 @@ +"""期权目标位委托单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest + +from lib.options.options_target_lib import ( + ensure_target_tables, + list_active_targets, + list_closing_targets, + run_options_target_closes, + target_hit, + upsert_target_monitor, +) + + +class OptionsTargetLibTests(unittest.TestCase): + def test_target_hit_call_put(self): + self.assertTrue(target_hit(opt_type="C", index_px=2000, target_index=1950)) + self.assertFalse(target_hit(opt_type="C", index_px=1900, target_index=1950)) + self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850)) + self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850)) + + def test_upsert_and_trigger_close(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + out = upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260717-1900-C", + target_index=1880, + opt_type="C", + sheets=1, + ) + self.assertTrue(out["ok"]) + self.assertEqual(len(list_active_targets(conn)), 1) + + closed = [] + + def close_fn(inst_id: str): + closed.append(inst_id) + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 1.2, + "close_ord_id": "oid1", + "fully_closed": True, + "remaining_sheets": 0, + } + + n = run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260717-1900-C", "idx_px": 1885, "opt_type": "C"}], + close_fn=close_fn, + ) + self.assertEqual(n, 1) + self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"]) + self.assertEqual(len(list_active_targets(conn)), 0) + + def test_partial_fill_notifies_once_then_closing_retry_silent(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260715-1870-P", + target_index=1872, + opt_type="P", + sheets=1, + ) + conn.commit() + notices: list[str] = [] + calls = {"n": 0} + + def close_fn(inst_id: str): + calls["n"] += 1 + if calls["n"] == 1: + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 0.032, + "close_ord_id": "oid-a", + "fully_closed": False, + "remaining_sheets": 1, + "stopped_reason": "order_not_filled", + } + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 0.032, + "close_ord_id": "oid-b", + "fully_closed": True, + "remaining_sheets": 0, + "already_flat": True, + } + + pos = [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1867.5, "opt_type": "P"}] + n1 = run_options_target_closes( + conn, + pos, + close_fn=close_fn, + send_wechat=notices.append, + account_label="主账户·期权", + ) + self.assertEqual(n1, 1) + self.assertEqual(len(notices), 1) + self.assertEqual(len(list_active_targets(conn)), 0) + self.assertEqual(len(list_closing_targets(conn)), 1) + + # 模拟后续 sync 异常也不会再推:closing 重试静默 + n2 = run_options_target_closes( + conn, + pos, + close_fn=close_fn, + send_wechat=notices.append, + account_label="主账户·期权", + ) + self.assertEqual(n2, 0) + self.assertEqual(len(notices), 1) + self.assertEqual(len(list_closing_targets(conn)), 0) + + def test_commit_before_wechat_survives_later_rollback(self): + """状态在推送前已 commit,外层异常回滚不应让委托回到 active.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + ensure_target_tables(conn) + upsert_target_monitor( + conn, + inst_id="ETH-USD_UM-260715-1870-P", + target_index=1872, + opt_type="P", + ) + conn.commit() + notices: list[str] = [] + + def close_fn(inst_id: str): + return { + "ok": True, + "submitted_sheets": 1, + "premium_received": 0.03, + "close_ord_id": "oid1", + "fully_closed": True, + "remaining_sheets": 0, + } + + run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}], + close_fn=close_fn, + send_wechat=notices.append, + ) + # 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit + conn.rollback() + self.assertEqual(len(notices), 1) + self.assertEqual(len(list_active_targets(conn)), 0) + + # 下一轮不应再次触发推送 + n2 = run_options_target_closes( + conn, + [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}], + close_fn=close_fn, + send_wechat=notices.append, + ) + self.assertEqual(n2, 0) + self.assertEqual(len(notices), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_options_time_lib.py b/tests/test_options_time_lib.py new file mode 100644 index 0000000..6b26944 --- /dev/null +++ b/tests/test_options_time_lib.py @@ -0,0 +1,92 @@ +"""期权业务时区与 UTC 墙钟迁移单测.""" +from __future__ import annotations + +import sqlite3 +import unittest +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from lib.options.options_db import init_options_tables +from lib.options.options_review_db import init_options_review_tables +from lib.options.options_review_lib import get_sync_state, upsert_option_history_row +from lib.options.options_time_lib import ( + migrate_options_utc_wall_clock_to_app, + ms_to_options_app_str, + options_wall_clock_to_ms, + utc_wall_clock_to_app_str, +) + + +class OptionsTimeLibTests(unittest.TestCase): + def test_ms_to_shanghai_wall_clock(self): + # 2026-09-13 21:41:57 UTC → 2026-09-14 05:41:57 Asia/Shanghai + ms = int( + datetime(2026, 9, 13, 21, 41, 57, tzinfo=timezone.utc).timestamp() * 1000 + ) + self.assertEqual(ms_to_options_app_str(ms), "2026-09-14 05:41:57") + + def test_utc_wall_to_app(self): + self.assertEqual( + utc_wall_clock_to_app_str("2026-09-13 21:41:57"), + "2026-09-14 05:41:57", + ) + + def test_wall_clock_to_ms_roundtrip(self): + raw = "2026-09-14 05:41:57" + ms = options_wall_clock_to_ms(raw) + self.assertIsNotNone(ms) + dt = datetime.fromtimestamp(ms / 1000.0, tz=ZoneInfo("Asia/Shanghai")) + self.assertEqual(dt.strftime("%Y-%m-%d %H:%M:%S"), raw) + + def test_migrate_once(self): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + init_options_review_tables(conn) + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status, + created_at, closed_at + ) VALUES ('ETH-USD-260917-2525-P', 'ETH', 'P', 1, 0.01, 1, 'closed', + '2026-09-13 21:41:57', '2026-09-14 02:31:00') + """ + ) + upsert_option_history_row( + conn, + { + "history_key": "local_opt:1", + "inst_id": "ETH-USD-260917-2525-P", + "underlying": "ETH", + "opt_type": "P", + "realized_pnl": 0.5, + "created_at": "2026-09-13 21:41:57", + "closed_at": "2026-09-14 02:31:00", + }, + ) + conn.commit() + + first = migrate_options_utc_wall_clock_to_app(conn) + self.assertEqual(first["skipped"], 0) + self.assertGreaterEqual(first["trades"], 1) + self.assertGreaterEqual(first["review"], 1) + + row = conn.execute( + "SELECT created_at, closed_at FROM options_trades WHERE id=1" + ).fetchone() + self.assertEqual(row["created_at"], "2026-09-14 05:41:57") + self.assertEqual(row["closed_at"], "2026-09-14 10:31:00") + + rev = conn.execute( + "SELECT opened_at, closed_at FROM options_review_trades WHERE history_key='local_opt:1'" + ).fetchone() + self.assertEqual(rev["opened_at"], "2026-09-14 05:41:57") + self.assertEqual(rev["closed_at"], "2026-09-14 10:31:00") + self.assertEqual(get_sync_state(conn, "options_ts_utc_wall_to_app_done"), "1") + + second = migrate_options_utc_wall_clock_to_app(conn) + self.assertEqual(second["skipped"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/使用说明.md b/使用说明.md new file mode 100644 index 0000000..e27782b --- /dev/null +++ b/使用说明.md @@ -0,0 +1,138 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_okx`(OKX USDT 本位永续).** +功能,界面与 **Gate.io USDT 永续版**(目录 `crypto_monitor_gate`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`OKX_*` / `GATE_*`),文末有对照. + +**部署,代理,PM2 等**请参考本仓库说明或 **`crypto_monitor_gate`** 下的 **`部署文档.md`**(该文以 Gate + SSH SOCKS 为例;OKX 侧将 API 与密钥改为 `OKX_*` 即可类比). +**关键位自动开仓的规则,RR,结案原因**见本目录 **`关键位自动下单说明.md`**. + +--- + +## 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..a8ca535 --- /dev/null +++ b/关键位自动下单说明.md @@ -0,0 +1,192 @@ +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用:Gate / Binance / OKX 三所实例(共用 `lib/key_monitor/key_auto_order_lib.py`)** + +## 环境开关 `KEY_AUTO_ORDER_ENABLED`(默认 `false`) + +| 计仓模式 | 开关 | 关键位程序自动单 | +|----------|------|------------------| +| `risk`(以损定仓) | `false` | **全部关闭**(含触价);支撑/阻力微信提醒仍可用 | +| `risk` | `true` | 箱体/收敛/斐波/假突破/触价均可自动(旧行为) | +| `full_margin`(全仓) | `false` | 全部关闭(含触价) | +| `full_margin` | `true` | **仅触价**自动;箱体/斐波等仍禁止 | + +**不受本开关影响:** 人工实盘下单,关键支撑/阻力提醒,**顺势加仓**(`risk` 下),趋势回调(`risk` 下).全仓模式下策略自动仍禁止. + +修改 `.env` 后须 **重启 PM2**.复盘「开仓类型」与统计分段会随开关联动隐藏关键位选项. + +--- + +**适用:`crypto_monitor_gate`(Gate U 本位永续)** +Binance / OKX 见各自目录下同名文档;共享逻辑在 `lib/key_monitor/`. + +本文档与 `.env`,`check_key_monitors`,`add_key`,`_key_hard_checks`,`_process_key_rs_level_alert` 一致. + +--- + +## 一,监控类型总览 + +| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 | +|----------|--------------|--------------|------------| +| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed` → **一次性删除** | +| **收敛突破** | **必选** 多/空 | **是**(同上) | 同上 | +| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | +| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | +| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **回调触价开仓** | **必选** 多/空 | **程序盯价 → 回调触 E 后市价** | 见下文 **§四** | +| **突破触价开仓** | **必选** 多/空 | **程序盯价 → 穿越 E 立即市价** | 见下文 **§四** | + +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位). + +--- + +## 二,关键阻力位 / 关键支撑位(人工盯盘) + +### 2.1 录入 + +- 填写 **上沿 `upper`** 与 **下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空). +- 页面 **不显示,不要求** 方向;库中 `direction` 初始为 `watch`,**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿). + +### 2.2 触发(极简) + +- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线). +- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒. +- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒. +- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿,下沿谁先满足用谁,同根 K 仅可能满足一侧). + +**不参与:** 量能,二确 K,越过幅度下限,日成交排名(运行时),计划 RR,自动开仓. + +### 2.3 微信提醒次数 + +| 配置 | 默认 | 含义 | +|------|------|------| +| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 | +| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 | + +- 第 1 次:首次检测到突破的当次轮询(若已闭合 5m 满足条件). +- 第 2,3 次:仅按间隔推送(**不要求**价格仍在箱外). +- 第 3 次推送后:写入 `key_monitor_history`,`close_reason=**key_level_alert_done**`,从 `key_monitors` **删除**. + +### 2.4 与箱体/收敛的区别 + +| 项目 | 阻力/支撑 | 箱体/收敛 | +|------|-----------|-----------| +| 方向 | 程序推断 | 人工选择 | +| K 线根数 | 1 根闭合 5m | 2 根(突破 K + 确认 K) | +| 提醒次数 | 3 次后结案 | 自动单:触发后 1 次业务推送并结案 | + +--- + +## 三,箱体突破 / 收敛突破(自动开仓) + +### 3.1 K 线结构(默认索引) + +| 角色 | 环境变量 | 默认 | 含义 | +|------|----------|------|------| +| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K | +| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K | + +### 3.2 硬门控(须全部通过) + +1. **有效突破(收盘越界)** + - 多:`突破 K 收盘 > upper` + - 空:`突破 K 收盘 < lower` + +2. **突破越过幅度(仅下限)** + - 多:`(突破 K 收盘 − upper) / upper × 100 > KEY_BREAKOUT_AMP_MIN_PCT`(默认 **0.03%**) + - 空:`(lower − 突破 K 收盘) / lower × 100 >` 同上 + - **无上限**;突破过猛由 **计划 RR** 过滤. + - **不再**使用 K 线实体占开盘价比例;`KEY_BREAKOUT_AMP_MAX_PCT` **已不参与门控**. + +3. **确认 K 不进箱体** + - 多:确认 K 收盘 **`> upper`**(不得在 `[lower, upper]` 内) + - 空:确认 K 收盘 **`< lower`** + +4. **量能:** 突破 K 成交量 > 前 `KEY_VOLUME_MA_BARS`(默认 20)根均量 × `KEY_VOLUME_RATIO_MIN`(默认 1.3) + +5. **日成交量排名:** 运行时仍须前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) + +6. **计划 RR(最后经济门控):** 按确认 K 收盘 **E** 计算 SL/TP 后,`RR` **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)才市价开仓 + +### 3.3 止损 / 止盈(确认 K 收盘为 E) + +箱体高 **H = |upper − lower|**.止损锚在 **突破 K 极值** 外侧: + +| 方向 | 止损(标准/趋势方案) | +|------|------------------------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | + +止盈方案见下表(与改版前一致): + +| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP | +|------|--------------|-------------|-------------| +| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **E−H** | +| 箱体 1R·止盈 1.5H | `box_1p5` | **E−H** / **E+1.5×H** | **E+H** / **E−1.5×H** | +| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** | + +### 3.4 一次性结案(`close_reason`) + +| `close_reason` | 含义 | +|----------------|------| +| `box_opposite_break` | 标记价先突破反向边界(多:≤下沿;空:≥上沿) | +| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 | +| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 | +| `auto_opened` | RR 达标且市价开仓成功 | +| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 | + +--- + +## 四,回调 / 突破触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- **回调触价开仓**:方向必选多/空;填写 **计划入场价 E**,**止损 SL**,**止盈 TP**(做多须 `SL < E < TP`). +- **突破触价开仓**:同上;添加时当前价须在突破方向一侧(做多:价低于 E;做空:价高于 E). +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5). +- 可选移动保本,时间平仓;**全仓杠杆模式**下可用. + +### 4.2 触发与结案 + +| 类型 | 触发条件(标记价) | +|------|-------------------| +| **回调触价** | 做多 `≤ E`;做空 `≥ E` → 下一轮询市价开仓 | +| **突破触价** | 做多**向上穿越** E;做空**向下穿越** E → **立即**市价开仓 | + +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`. +- **突破触价**另:未穿越 E 先触 **SL 侧** → `trigger_sl_invalidate`. +- **24h** 未触发 → `trigger_entry_expired`. +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`. + +### 4.3 计仓与占位 + +- **以损定仓**:按 E,SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x,其它 5x. +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条触价监控(含回调/突破). + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`. + +--- + +## 五,环境与参数(`.env` 摘要) + +| 变量 | 箱体/收敛 | 阻力/支撑 | +|------|-----------|-----------| +| `KEY_BREAKOUT_AMP_MIN_PCT` | 突破越过下限(默认 0.03) | 不用 | +| `KEY_BREAKOUT_AMP_MAX_PCT` | **已废弃门控** | 不用 | +| `KEY_VOLUME_*` / `KEY_CONFIRM_*` | 用 | 不用 | +| `KEY_AUTO_MIN_PLANNED_RR` | 用 | 不用 | +| `KEY_ALERT_MAX_TIMES` / `KEY_ALERT_INTERVAL_MINUTES` | 不用 | 用(默认 3 次 / 5 分钟) | +| `KEY_DAILY_VOLUME_RANK_MAX` | 添加时 + 运行时 | **仅添加时** | + +--- + +## 六,相关代码 + +| 说明 | 位置 | +|------|------| +| 共享判定 | `key_monitor_lib.py` | +| 主循环 | `check_key_monitors` | +| 自动门控 | `_key_hard_checks` | +| 阻力/支撑提醒 | `_process_key_rs_level_alert` | +| 录入 | `add_key` | +| 开仓 | `_market_open_for_key_monitor` | diff --git a/更新文档.md b/更新文档.md new file mode 100644 index 0000000..aaa80a8 --- /dev/null +++ b/更新文档.md @@ -0,0 +1,98 @@ +# 界面与风控更新说明(OKX 实例) + +与 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..caab727 --- /dev/null +++ b/部署文档.md @@ -0,0 +1,367 @@ +# `crypto_monitor_okx` 部署文档(Ubuntu) + +**功能与页面操作** 见同目录 **[使用说明.md](./使用说明.md)**.Ubuntu 环境(Python / Node / PM2)见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**.策略与 AI 见 **[策略交易说明.md](../策略交易说明.md)**,**[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**. + +--- + +# 本地部署 + SSH SOCKS 转发 + PM2 启动指南(Ubuntu) + +本文面向:**本地 Ubuntu 机器运行项目**,但 **本机直连 OKX 会被 TLS/SNI reset** 的场景.解决思路是: + +- 本机启动 `ssh -D` 动态转发,把 **SOCKS5 出口**放到你可正常访问 OKX 的 VPS 上 +- 项目通过环境变量 `OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080` 让 `ccxt` 走 SOCKS +- **SSH 隧道**用 `ssh -D` 常驻(可用 tmux / autossh);**Flask 应用** 仅用 **PM2** 托管(见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)) + +> 安全提醒:不要把 `.env`,私钥 `.pem`,OKX API Key 提交到 Git;文档里只用占位符. + +--- + +## 0. 你需要准备的东西 + +- 一台 **Ubuntu** 本地机器(下文称“本机”) +- 一台可 SSH 登录,且 **能正常访问 OKX** 的 VPS(示例公网 IP:`47.76.87.111`,用户:`root`) +- VPS 登录方式:**SSH 私钥**(推荐)或密码(不推荐用于无人值守) +- 本机已安装: + - `python3`,`python3-venv`,`pip`(或 `python3-pip`) + - `git`(可选) + - `curl`,`ssh` + - `node` + `npm`(用于安装 `pm2`) + +--- + +## 1. 从云服务器把项目同步到本地(推荐:打包下载) + +在云服务器项目目录(包含 `app.py` 的目录)执行: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +# 可选:清理 Python 缓存,减少小文件传输 +find . -type d -name __pycache__ -prune -exec rm -rf {} + +find . -type f -name "*.pyc" -delete + +tar -czf crypto_monitor.tgz . +``` + +下载 `crypto_monitor.tgz` 到本机后解压: + +```bash +mkdir -p /opt/crypto_monitor/crypto_monitor_okx +cd /opt/crypto_monitor +tar -xzf crypto_monitor.tgz -C crypto_monitor_okx +cd crypto_monitor_okx +cp -n .env.example .env # 若尚无 .env +``` + +--- + +## 2. 配置 SSH 私钥与 `~/.ssh/config`(推荐) + +把私钥放到本机(示例:`~/.ssh/vps1.pem`),并设置权限: + +```bash +mkdir -p ~/.ssh +chmod 700 ~/.ssh +mv ~/Downloads/vps1.pem ~/.ssh/vps1.pem +chmod 600 ~/.ssh/vps1.pem +``` + +编辑 `~/.ssh/config`(没有就创建),添加: + +```sshconfig +Host okx-vps + HostName 47.76.87.111 + User root + IdentityFile ~/.ssh/vps1.pem + IdentitiesOnly yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes + BatchMode yes +``` + +测试: + +```bash +ssh okx-vps true +``` + +> 如果你还没完全切到密钥登录(还会交互要密码),先把 `BatchMode yes` 注释掉,等密钥登录稳定后再打开. + +--- + +## 3. 先手工验证:SSH SOCKS + OKX API + +### 3.1 开一个本地 SOCKS(1080) + +```bash +ssh -N -D 127.0.0.1:1080 okx-vps +``` + +保持该进程运行(另开终端继续下面步骤). + +### 3.2 验证 OKX 走 SOCKS 可用 + +```bash +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +看到 `HTTP/2 200`(或至少 TLS 握手成功且返回 JSON)即 OK. + +--- + +## 4. Python 虚拟环境(venv) + +在本机项目目录: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +python3 -m venv .venv +source .venv/bin/activate + +python -m pip install -U pip +pip install flask requests ccxt werkzeug PySocks Pillow +``` + +> 说明:本仓库当前没有 `requirements.txt`.如果你希望“完全复刻云服务器依赖”,可以在云服务器项目环境里执行 `pip freeze > requirements.txt` 带回本机再 `pip install -r requirements.txt`(记得删掉明显无关/体积巨大的包). + +建议减少 `.pyc` 垃圾文件(可选): + +```bash +export PYTHONDONTWRITEBYTECODE=1 +``` + +--- + +## 5. 配置环境变量(`.env.example` → `.env`) + +| 文件 | 是否进 Git | 说明 | +|------|------------|------| +| **`.env.example`** | ✅ 是 | 变量模板与注释,可随 `git pull` 更新 | +| **`.env`** | ❌ 否 | 本机真实配置;`app.py` **只读此文件** | + +### 5.1 首次配置 + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +cp -n .env.example .env # 已存在 .env 时不覆盖 +nano .env +``` + +### 5.2 备份与 `git pull` + +- **`.env` 不在 Git 中**:`git pull` **不会**覆盖本地 `.env`. +- 远端若更新 **`.env.example`**,pull 后请**手动**把新增变量补进你的 `.env`. +- **升级前备份**:`cp .env .env.backup.$(date +%Y%m%d)`;恢复:`cp .env.backup.YYYYMMDD .env`. +- **换机**:`scp` 复制 `.env`,或新机 `cp .env.example .env` 后重填. + +**AI 复盘**:三所共用根目录 **`ai_client.py`**.默认 **`AI_PROVIDER=openai`**,网关 `https://op.bz121.com/v1`,模型 `gemma4:e4b`;或改 **`ollama`** 走本机 Ollama.PM2 须 **`PYTHONPATH=..`**.详见 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**. + +### 5.3 必填项检查(OKX + 代理) + +至少确认/填写这些关键项(示例): + +```env +APP_HOST=127.0.0.1 +APP_PORT=5000 + +# OKX(如需实盘) +LIVE_TRADING_ENABLED=false +OKX_API_KEY=... +OKX_API_SECRET=... +OKX_API_PASSPHRASE=... + +# OKX 出口:走本机 SSH 动态转发 SOCKS +OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# DAILY_OPEN_ALERT_THRESHOLD=5 +# DAILY_OPEN_HARD_LIMIT=0 +# 说明见仓库 docs/daily-open-limit.md + +# AI 复盘(默认 OpenAI 兼容网关;与 Ollama 二选一) +AI_PROVIDER=openai +AI_TIMEOUT_SECONDS=120 +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +# 本机 Ollama(仅 AI_PROVIDER=ollama) +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=你的模型名 +``` + +> 完整说明见仓库根 **[AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)**.`OPENAI_API_KEY` 在 [op.bz121.com](https://op.bz121.com/) 的 `gateway.json` 获取. + +> `OKX_SOCKS_PROXY` 使用 `socks5h`:让 SOCKS 侧做域名解析(更贴近你 `curl --proxy socks5h://...` 的成功路径). + +--- + +## 6. 本机手工启动(验证 Flask) + +确保: + +1. SOCKS 隧道已运行(127.0.0.1:1080) +2. 虚拟环境已 `activate` +3. `.env` 已配置 + +启动: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx +source .venv/bin/activate +python app.py +``` + +浏览器访问:`http://127.0.0.1:5000`(或你在 `.env` 配的端口). + +--- + +## 7. 安装 PM2(Node) + +```bash +sudo npm i -g pm2 +pm2 -v +``` + +--- + +## 8. 用 PM2 启动 SSH SOCKS 隧道(推荐:密钥免交互) + +### 8.1 启动隧道进程 + +```bash +pm2 start "ssh" --name okx-socks-tunnel -- \ + -N -D 127.0.0.1:1080 okx-vps \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes -o BatchMode=yes +``` + +查看日志: + +```bash +pm2 logs okx-socks-tunnel --lines 200 +``` + +### 8.2 仍然验证 OKX + +```bash +curl -4 -Iv --max-time 15 --proxy socks5h://127.0.0.1:1080 https://www.okx.com/api/v5/public/time +``` + +### 8.3 开机自启(可选) + +```bash +pm2 save +pm2 startup +``` + +--- + +## 9. 用 PM2 启动 Flask(`app.py`) + +`pm2` 管理 Python 的常用方式是直接启动解释器: + +```bash +cd /opt/crypto_monitor/crypto_monitor_okx + +pm2 start /opt/crypto_monitor/crypto_monitor_okx/.venv/bin/python --name crypto-monitor -- \ + /opt/crypto_monitor/crypto_monitor_okx/app.py +``` + +> 若项目目录与上文不一致,请替换为实际绝对路径;或用 `readlink -f app.py` 得到绝对路径. + +查看日志: + +```bash +pm2 logs crypto-monitor --lines 200 +``` + +保存进程列表: + +```bash +pm2 save +``` + +--- + +## 10. 常见问题排查(高频) + +### 10.1 OKX 仍然失败:先看隧道是否在 + +```bash +ss -lntp | grep 1080 || true +pm2 status +``` + +### 10.2 `pm2` 里的 `ssh` 立刻退出 + +常见原因: + +- 私钥权限不对(`chmod 600`) +- `~/.ssh/config` 写错 `HostName/User/IdentityFile` +- 开了 `BatchMode yes` 但仍需要密码(会失败) + +### 10.3 `ccxt` SOCKS 报错 / 代理不生效 + +本机 Python 依赖通常需要: + +```bash +source .venv/bin/activate +pip install PySocks +``` + +### 10.4 `.pyc` 很多导致同步慢 + +`.pyc` 是缓存,删除不影响功能: + +```bash +find . -type d -name __pycache__ -prune -exec rm -rf {} + +find . -type f -name "*.pyc" -delete +``` + +--- + +## 11. 推荐的启动顺序(固定习惯) + +1. `pm2` 启动 `okx-socks-tunnel` +2. `curl --proxy socks5h://127.0.0.1:1080 ...` 验证 OKX +3. `pm2` 启动 `crypto-monitor` + +--- + +## 12. 免责声明 + +交易所有合规与地区政策要求.请确保你的使用方式符合当地法律法规与交易所条款.本文仅描述网络与工程部署技术路径. + + + + +写好了,脚本路径: + +- `scripts/fix_breakeven_labels.py` + +你在 Ubuntu 上这样用: + +1) 先预览(不写库): +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --dry-run +``` + +2) 确认后执行: +```bash +python scripts/fix_breakeven_labels.py --db ./crypto.db --apply +``` + +默认修复条件就是你要的: +- `monitor_type='下单监控'` +- `result='止损'` +- `pnl_amount > 0` +- 改成 `result='保本止盈'` + +如果你想,我还可以再给你一条“先自动备份 DB 再执行”的一键命令. \ No newline at end of file