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 <cursoragent@cursor.com>
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Auto LF for shell scripts (avoid CRLF breaking bash on Linux)
|
||||
*.sh text eol=lf
|
||||
deploy/**/*.sh text eol=lf
|
||||
@@ -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/
|
||||
@@ -0,0 +1,47 @@
|
||||
# CoinOX(币本对冲)
|
||||
|
||||
OKX **币本位期权** + **U 本位永续对冲** 独立实例。
|
||||
|
||||
远程仓库:<https://git.bz121.com/dekun/CoinOX.git>
|
||||
|
||||
## 产品范围
|
||||
|
||||
| 保留 | 说明 |
|
||||
|------|------|
|
||||
| 单独期权 | 仅币本位(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` 前请核对。
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#22d3ee"/>
|
||||
<stop offset="100%" stop-color="#34d399"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="url(#g)" stroke-width="12"/>
|
||||
<path d="M120 320 L200 248 L280 272 L392 168" fill="none" stroke="url(#g)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="392" cy="168" r="18" fill="#34d399"/>
|
||||
<rect x="168" y="268" width="28" height="64" rx="6" fill="#f87171"/>
|
||||
<line x1="182" y1="248" x2="182" y2="340" stroke="#f87171" stroke-width="10" stroke-linecap="round"/>
|
||||
<rect x="268" y="220" width="28" height="96" rx="6" fill="#34d399"/>
|
||||
<line x1="282" y1="200" x2="282" y2="340" stroke="#34d399" stroke-width="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 127 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 301 B |
|
After Width: | Height: | Size: 389 B |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="#F0B90B" stroke-width="12"/>
|
||||
<polygon points="256,150 362,256 256,362 150,256" fill="#F0B90B"/><polygon points="256,210 302,256 256,302 210,256" fill="#1a160a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 265 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 507 B |
|
After Width: | Height: | Size: 747 B |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="#17E6A1" stroke-width="12"/>
|
||||
<text x="256" y="310" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="220" font-weight="700" fill="#17E6A1">G</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
|
After Width: | Height: | Size: 162 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 612 B |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#22d3ee"/>
|
||||
<stop offset="100%" stop-color="#34d399"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="url(#g)" stroke-width="12"/>
|
||||
<path d="M120 320 L200 248 L280 272 L392 168" fill="none" stroke="url(#g)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="392" cy="168" r="18" fill="#34d399"/>
|
||||
<rect x="168" y="268" width="28" height="64" rx="6" fill="#f87171"/>
|
||||
<line x1="182" y1="248" x2="182" y2="340" stroke="#f87171" stroke-width="10" stroke-linecap="round"/>
|
||||
<rect x="268" y="220" width="28" height="96" rx="6" fill="#34d399"/>
|
||||
<line x1="282" y1="200" x2="282" y2="340" stroke="#34d399" stroke-width="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 108 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 290 B |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="#FFFFFF" stroke-width="12"/>
|
||||
<rect x="168" y="168" width="72" height="72" rx="10" fill="#FFFFFF"/><rect x="272" y="168" width="72" height="72" rx="10" fill="#FFFFFF"/><rect x="168" y="272" width="72" height="72" rx="10" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;开仓仍以当场卖一深度为准.
|
||||
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 选约约束,与本文件一致。
|
||||
@@ -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/<id>/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/<id>` | 详情 = 计划 + legs + note + preview |
|
||||
| PATCH | `/api/hedge-plan/<id>/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` / `<id>` / `stats`
|
||||
- `PATCH /api/hedge-plan/<id>/note` — 复盘短评
|
||||
- `POST /api/hedge-plan` — 保存草稿/启动
|
||||
- `POST /api/hedge-plan/<id>/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. 免责与边界
|
||||
|
||||
- 双账户非原子成交,存在半腿风险.
|
||||
- 止盈结束账上按权利金全额计成本;期权若仍持仓,后续行情 **不再改计划合计**,属设计意图.
|
||||
- 止盈后期权可能继续损耗直至到期,与「计划已结束」并存.
|
||||
- 买方权利金可能全部损失;期期到期无盈利记总亏损,属设计意图.
|
||||
- 本文不构成投资建议.
|
||||
@@ -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 页可见「对冲计划」分组
|
||||
@@ -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) — 实例操作与门禁总手册
|
||||
@@ -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)
|
||||
@@ -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:还能主↔子划转吗?**
|
||||
- 已移除.只保留同账户内划转与币种兑换.
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
"""crypto_monitor shared libraries."""
|
||||
@@ -0,0 +1 @@
|
||||
"""实例账户流水(交易所资金/交易账户账单)."""
|
||||
@@ -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()
|
||||
@@ -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 ""),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,72 @@
|
||||
{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #}
|
||||
<div class="card full account-ledger-card" id="account-ledger-root" data-account-ledger="1">
|
||||
<div class="account-ledger-head">
|
||||
<div>
|
||||
<h2 style="margin-bottom:4px">账户流水</h2>
|
||||
<p class="muted account-ledger-desc">拉取交易所资金账户与交易账户账单 · 时间跟随顶栏 UTC 预设 · 约 2 分钟自动同步</p>
|
||||
</div>
|
||||
<div class="account-ledger-head-actions">
|
||||
<span class="muted" id="account-ledger-sync">—</span>
|
||||
<button type="button" class="btn-sm" id="account-ledger-refresh">立即同步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-ledger-tabs" role="tablist">
|
||||
<button type="button" class="account-ledger-tab active" data-ledger-account="funding" role="tab" aria-selected="true">资金账户</button>
|
||||
<button type="button" class="account-ledger-tab" data-ledger-account="trading" role="tab" aria-selected="false">交易账户</button>
|
||||
</div>
|
||||
<p class="muted account-ledger-status" id="account-ledger-status"></p>
|
||||
<div class="account-ledger-table-wrap panel-scroll">
|
||||
<table class="account-ledger-table" id="account-ledger-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间(北京)</th>
|
||||
<th>币种</th>
|
||||
<th>类型</th>
|
||||
<th>变动</th>
|
||||
<th>余额</th>
|
||||
<th>合约/备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="account-ledger-tbody">
|
||||
<tr><td colspan="6" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="account-ledger-pager" id="account-ledger-pager">
|
||||
<button type="button" class="btn-sm" id="account-ledger-prev" disabled>上一页</button>
|
||||
<span class="muted" id="account-ledger-page-info">—</span>
|
||||
<button type="button" class="btn-sm" id="account-ledger-next" disabled>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.account-ledger-card { grid-column: 1 / -1; }
|
||||
.account-ledger-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
|
||||
}
|
||||
.account-ledger-head-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.account-ledger-tabs {
|
||||
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.account-ledger-tab {
|
||||
border: 1px solid rgba(140,160,200,.35);
|
||||
background: transparent; color: #c5cbe0;
|
||||
border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: .9rem;
|
||||
}
|
||||
.account-ledger-tab.active {
|
||||
background: #1f3a5a; border-color: #3d6f9c; color: #e8f1ff;
|
||||
}
|
||||
.account-ledger-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.account-ledger-table th, .account-ledger-table td {
|
||||
padding: 8px 10px; border-bottom: 1px solid rgba(120,130,160,.2); text-align: left;
|
||||
}
|
||||
.account-ledger-table th { color: #9aa3bd; font-weight: 600; }
|
||||
.account-ledger-amt-pos { color: #3ecf8e; }
|
||||
.account-ledger-amt-neg { color: #f07178; }
|
||||
.account-ledger-pager {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.account-ledger-status { min-height: 1.2em; margin: 0 0 8px; }
|
||||
.account-ledger-table-wrap { max-height: min(60vh, 560px); overflow: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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()}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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, ">")
|
||||
.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 '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items
|
||||
.map(function (it) {
|
||||
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtBj(it.ts_ms)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.ccy || "") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.kind_label || it.kind || "") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtAmt(it.amount) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtBal(it.balance_after)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(note || "—") +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
|
||||
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function parseInline(raw) {
|
||||
var s = escapeHtml(raw);
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
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("</ul>");
|
||||
inUl = false;
|
||||
}
|
||||
if (inOl) {
|
||||
html.push("</ol>");
|
||||
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("<h" + level + ">" + parseInline(hm[2]) + "</h" + level + ">");
|
||||
return;
|
||||
}
|
||||
var ulm = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (ulm) {
|
||||
if (!inUl) {
|
||||
closeLists();
|
||||
html.push("<ul>");
|
||||
inUl = true;
|
||||
}
|
||||
html.push("<li>" + parseInline(ulm[1]) + "</li>");
|
||||
return;
|
||||
}
|
||||
var boldOl = trimmed.match(/^\*\*(\d+)\.\s*(.+)\*\*$/);
|
||||
if (boldOl) {
|
||||
if (!inOl) {
|
||||
closeLists();
|
||||
html.push("<ol>");
|
||||
inOl = true;
|
||||
}
|
||||
html.push("<li>" + parseInline(trimmed) + "</li>");
|
||||
return;
|
||||
}
|
||||
var olm = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (olm) {
|
||||
if (!inOl) {
|
||||
closeLists();
|
||||
html.push("<ol>");
|
||||
inOl = true;
|
||||
}
|
||||
html.push("<li>" + parseInline(olm[1]) + "</li>");
|
||||
return;
|
||||
}
|
||||
closeLists();
|
||||
if (/^📎\s*\*\*原始记录\*\*/.test(trimmed) || /^原始记录:/.test(trimmed)) {
|
||||
html.push('<div class="md-raw-block-title">' + parseInline(trimmed) + "</div>");
|
||||
return;
|
||||
}
|
||||
html.push("<p>" + parseInline(trimmed) + "</p>");
|
||||
});
|
||||
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);
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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, ">")
|
||||
.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 '<span class="' + cls + '">' + sign + n.toFixed(2) + "U</span>";
|
||||
}
|
||||
|
||||
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 '<span class="' + cls + '">' + n.toFixed(2) + "</span>";
|
||||
}
|
||||
|
||||
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 '<td class="' + cls + '">' + escapeHtml(label) + "</td>";
|
||||
}
|
||||
|
||||
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 (
|
||||
'<span class="opt-expiry-cd" data-opt-exp-ms="' +
|
||||
escapeHtml(String(Math.floor(n))) +
|
||||
'">' +
|
||||
escapeHtml(fallback) +
|
||||
"</span>"
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
'<div class="inst-dash-table-wrap">' +
|
||||
'<table class="inst-dash-table">' +
|
||||
"<thead><tr>" +
|
||||
headers
|
||||
.map(function (h) {
|
||||
return "<th>" + escapeHtml(h) + "</th>";
|
||||
})
|
||||
.join("") +
|
||||
"</tr></thead>" +
|
||||
"<tbody>" +
|
||||
rowsHtml +
|
||||
"</tbody></table></div>"
|
||||
);
|
||||
}
|
||||
|
||||
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))
|
||||
? '<span class="pos-tp-profit">' + Number(it.tp_profit).toFixed(2) + "U</span>"
|
||||
: "—";
|
||||
return (
|
||||
"<tr" +
|
||||
rowClickAttrs(it.tab || "trade") +
|
||||
">" +
|
||||
'<td class="td-symbol"><span class="inst-dash-sym-link">' +
|
||||
escapeHtml(sym) +
|
||||
"</span></td>" +
|
||||
dirCell(it) +
|
||||
"<td>" +
|
||||
fmtNum(it.entry) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
mark +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtNum(it.contracts) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
tpProfit +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtPnlPlain(it.float_pnl) +
|
||||
"</td>" +
|
||||
"<td>—</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return tableWrap(
|
||||
["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"],
|
||||
rows
|
||||
);
|
||||
}
|
||||
|
||||
function renderKeysTable(items) {
|
||||
const rows = items
|
||||
.map(function (it) {
|
||||
return (
|
||||
"<tr" +
|
||||
rowClickAttrs(it.tab || "key_monitor") +
|
||||
">" +
|
||||
"<td>" +
|
||||
escapeHtml(it.symbol || "-") +
|
||||
"</td>" +
|
||||
dirCell(it) +
|
||||
"<td>" +
|
||||
escapeHtml(it.subtitle || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtNum(it.upper) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtNum(it.lower) +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows);
|
||||
}
|
||||
|
||||
function renderStrategyTable(items) {
|
||||
const rows = items
|
||||
.map(function (it) {
|
||||
const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略";
|
||||
return (
|
||||
"<tr" +
|
||||
rowClickAttrs(it.tab || "strategy") +
|
||||
">" +
|
||||
"<td>" +
|
||||
escapeHtml(kindLabel) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.symbol || "-") +
|
||||
"</td>" +
|
||||
dirCell(it) +
|
||||
"<td>" +
|
||||
escapeHtml(it.status || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtNum(it.entry) +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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 (
|
||||
"<tr" +
|
||||
rowClickAttrs(it.tab || "options") +
|
||||
">" +
|
||||
"<td>" +
|
||||
escapeHtml(it.inst_id || it.title || "-") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.source_label || "纯期权") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(opt) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtNum(it.pos) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtExpiry(it.exp_time_ms) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.target_monitor || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtPnl(it.pnl) +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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 (
|
||||
"<tr" +
|
||||
rowClickAttrs(it.tab || "hedge_plan") +
|
||||
">" +
|
||||
"<td>#" +
|
||||
escapeHtml(it.id != null ? it.id : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.underlying || "-") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.plan_type_label || it.plan_type || "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
stCls +
|
||||
'">' +
|
||||
escapeHtml(stText) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.contracts_summary || it.subtitle || "—") +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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 (
|
||||
'<section class="inst-dash-section" data-dash-section="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
'<div class="inst-dash-section-head">' +
|
||||
"<h3>" +
|
||||
escapeHtml(sec.title || key) +
|
||||
' <span class="inst-dash-count">' +
|
||||
count +
|
||||
"</span></h3>" +
|
||||
'<button type="button" class="btn-sm inst-dash-goto" data-dash-tab="' +
|
||||
escapeHtml(sec.tab || "") +
|
||||
'">打开</button>' +
|
||||
"</div>" +
|
||||
renderTable(key, items) +
|
||||
"</section>"
|
||||
);
|
||||
}
|
||||
|
||||
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 || '<p class="inst-dash-empty muted">当前无活跃监控与持仓</p>';
|
||||
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);
|
||||
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>.
|
||||
* 各 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
@@ -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("")
|
||||
: '<div class="journal-empty-msg">暂无交易记录</div>';
|
||||
|
||||
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);
|
||||
@@ -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 = '<span class="err">' + (e.message || "加载失败") + "</span>";
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div class="env-config-loading muted">加载配置中…</div>';
|
||||
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 = '<span class="err">' + (e.message || "加载失败") + "</span>";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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 =
|
||||
'<style id="inst-nav-guard">html,body{background:' +
|
||||
bg +
|
||||
"!important;color-scheme:" +
|
||||
t +
|
||||
';}</style>';
|
||||
if (out.includes("</head>")) {
|
||||
out = out.replace("</head>", guard + "</head>");
|
||||
} else {
|
||||
out = guard + out;
|
||||
}
|
||||
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
|
||||
if (/data-theme=/i.test(attrs)) {
|
||||
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
|
||||
}
|
||||
return "<html" + attrs + ' data-theme="' + t + '">';
|
||||
});
|
||||
const overlay =
|
||||
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
|
||||
bg +
|
||||
';opacity:1;pointer-events:auto"></div>';
|
||||
if (/<body[^>]*>/i.test(out)) {
|
||||
out = out.replace(/<body([^>]*)>/i, "<body$1>" + 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 三所实例共用 UI:复盘详情,盈亏着色等.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.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 ? `<span class="${cls}">${text}</span>` : 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("<br>");
|
||||
}
|
||||
|
||||
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 (
|
||||
'<div class="journal-detail-img-cell">' +
|
||||
'<span class="journal-detail-img-label">' +
|
||||
label +
|
||||
"</span>" +
|
||||
'<img class="journal-detail-img-thumb" src="' +
|
||||
src +
|
||||
'" alt="' +
|
||||
label +
|
||||
'" onclick="showImage(this.src)">' +
|
||||
"</div>"
|
||||
);
|
||||
})
|
||||
.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
|
||||
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
|
||||
: `<span class="mrr-muted">-</span>`;
|
||||
const id = escapeHtml(o.id);
|
||||
return `<div class="mobile-record-row-wrap">
|
||||
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
|
||||
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
|
||||
<span class="mrr-dir">${dirHtml}</span>
|
||||
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
|
||||
</div>`;
|
||||
})
|
||||
.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
|
||||
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
|
||||
: "-";
|
||||
return `<tr id="journal-row-${id}">
|
||||
<td>${escapeHtml(o.coin || "-")}</td>
|
||||
<td>${escapeHtml(o.tf || "-")}</td>
|
||||
<td>${dirHtml}</td>
|
||||
<td>${escapeHtml(o.order_type || "-")}</td>
|
||||
<td>${escapeHtml(o.entry_reason || "-")}</td>
|
||||
<td><span class="${pnlCls}">${escapeHtml(pnlTxt)}</span></td>
|
||||
<td>${escapeHtml((o.open_datetime || "-").toString().slice(0, 16))}</td>
|
||||
<td>${escapeHtml((o.close_datetime || "-").toString().slice(0, 16))}</td>
|
||||
<td>${escapeHtml(o.hold_duration || "-")}</td>
|
||||
<td>${escapeHtml(mood)}</td>
|
||||
<td>
|
||||
<button type="button" class="table-del" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" onclick="openJournalDetail('${id}')">查看详情</button>
|
||||
<button type="button" class="table-del" onclick="deleteJournal('${id}')">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="table-wrap"><table class="rr-journals-table">
|
||||
<thead><tr>
|
||||
<th>品种</th><th>周期</th><th>方向</th><th>下单类型</th><th>开仓类型</th>
|
||||
<th>盈亏U</th><th>开仓时间</th><th>平仓时间</th><th>持仓</th><th>心态标签</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
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 `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
|
||||
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
|
||||
<span class="mrr-dir">${row.directionHtml}</span>
|
||||
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function tradeDetailRow(label, valueHtml) {
|
||||
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
|
||||
}
|
||||
|
||||
function buildTradeRecordDetailHtml(row) {
|
||||
return `<div class="trade-record-detail">${
|
||||
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)
|
||||
}</div>`;
|
||||
}
|
||||
|
||||
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(
|
||||
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
|
||||
);
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 + ":<strong>" + valueText + "</strong>";
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
|
||||
}
|
||||
|
||||
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 '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
|
||||
}
|
||||
|
||||
function expiryCdHtml(expMs) {
|
||||
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||
if (!ms) return "—";
|
||||
return '<span class="opt-expiry-cd" data-opt-exp-ms="' + ms + '">—</span>';
|
||||
}
|
||||
|
||||
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 =
|
||||
'<div class="pos-head-actions">' +
|
||||
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
|
||||
"</div>";
|
||||
}
|
||||
const pnlCells = hidePnl
|
||||
? ""
|
||||
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
|
||||
(net == null ? "—" : fmtNetPnlDual(net, p)) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
|
||||
return (
|
||||
'<div class="pos-card-head">' +
|
||||
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
|
||||
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
|
||||
sourceBadgeHtml(p) +
|
||||
"</div>" +
|
||||
headActions +
|
||||
"</div>" +
|
||||
'<div class="pos-meta">' +
|
||||
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
|
||||
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
|
||||
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
|
||||
(expAttr
|
||||
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
|
||||
: "") +
|
||||
"</div>" +
|
||||
'<div class="pos-grid">' +
|
||||
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
|
||||
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
|
||||
pnlCells +
|
||||
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
|
||||
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
|
||||
(closePreview.bid_invalid
|
||||
? '<span class="muted">暂无有效买盘</span>'
|
||||
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "</span></div>" +
|
||||
"</div>" +
|
||||
(function () {
|
||||
const hint = closeGateHint(closePreview);
|
||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||
})() +
|
||||
(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
|
||||
? ""
|
||||
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
|
||||
return (
|
||||
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
||||
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
||||
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
|
||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
|
||||
profitSpan +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
||||
"</span></div>"
|
||||
);
|
||||
})()
|
||||
: "")
|
||||
);
|
||||
}
|
||||
|
||||
function renderCard(p, opts) {
|
||||
opts = opts || {};
|
||||
const hub = !!opts.hub;
|
||||
const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card";
|
||||
return (
|
||||
'<div class="pos-card' + extraCls + '" data-inst="' + (p.inst_id || "") + '">' +
|
||||
renderCardInner(p, opts) +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
global.OptionsPositionCards = {
|
||||
renderCardInner: renderCardInner,
|
||||
renderCard: renderCard,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||