61 Commits

Author SHA1 Message Date
dekun c73e36309e Show perpetual-options calculator results with two decimal places.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:59:38 +08:00
dekun 21c80f2ac9 Clarify perp-options points mode: scenario B focuses on portfolio net target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:57:09 +08:00
dekun a908dccaba Add ratio-to-move-points mode for hub perpetual-options calculator.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:47:17 +08:00
dekun 4bcf88b5cb Add hub perpetual-options hedge calculator tab and sizing formula.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:37:51 +08:00
dekun f360242188 Document snapshot/20260727 after mobile shell and copyright or hosted-service docs. 2026-07-27 11:55:39 +08:00
dekun f53f2814ab Add service and pricing guide: self-use first, full-time traders only. 2026-07-27 11:51:43 +08:00
dekun e5051fb309 Contract: one dedicated server per customer, no multi-tenant sharing. 2026-07-27 11:42:07 +08:00
dekun 1dc7914701 Rewrite contract template for hosted SaaS: server, domain, deploy, usage fees, no source delivery. 2026-07-27 11:37:52 +08:00
dekun 58e2bf3e8b Add private software license contract template alongside copyright notice. 2026-07-27 11:32:06 +08:00
dekun 19debee581 Add repository copyright notice for 马建军. 2026-07-27 11:27:11 +08:00
dekun 1755f67eca Phone tabbar: 下单/关键位/期权, hide options tab when unavailable. 2026-07-27 07:48:11 +08:00
dekun 6886de0bad Phone-only: funds strip, hide list filter, slim options columns, fix order dialog. 2026-07-27 07:26:34 +08:00
dekun f04a91efe6 Fix instance phone layout: tabbar padding, form stack, options table scroll. 2026-07-27 07:20:08 +08:00
dekun b43e33e24f Register instance_mobile_nav.js in shared static asset routes. 2026-07-27 07:13:20 +08:00
dekun b5a061e758 Add instance phone shell with bottom tabbar and more sheet. 2026-07-27 07:13:04 +08:00
dekun 4ef3b40353 Document snapshot/20260726-2 after playbook XMind binary fixes. 2026-07-26 10:43:53 +08:00
dekun 4a79e010c4 Strip XMind thumbnail so Gitea raw download does not corrupt CRLF in PNG. 2026-07-26 10:36:44 +08:00
dekun 791cc750da Treat XMind files as binary so Git LF conversion does not corrupt them.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:30:57 +08:00
dekun c8231ea194 Save manually polished business-style playbook XMind.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:27:35 +08:00
dekun aaccdcfc16 Replace harsh red XMind markers with calmer business info/flag icons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:12:03 +08:00
dekun f993a89a21 Clean central topic on playbook XMind: remove cluttered root markers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:10:03 +08:00
dekun 9dc363270e Restyle playbook XMind with business theme, markers, and labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:08:07 +08:00
dekun 9a83dfe209 Add rightward XMind mind map for playbook v2 and behavior rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:03:24 +08:00
dekun 32c42b8447 Document snapshot/20260726 after transfer and options budget-full fixes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:58:56 +08:00
dekun a2075ba73e Cap options budget-full sizing at min(balance, trade budget) with UI hint.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:54:38 +08:00
dekun 846f3de525 Keep transfer settings sub-tab after embed soft-reload of manual transfer.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:42:01 +08:00
dekun a7b75895e6 Preserve settings transfer sub-tab after manual transfer in embed shell.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:37:26 +08:00
dekun d870178b83 Show auto-transfer account and currency as selects with defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:34:02 +08:00
dekun 7ebe1671b2 Keep settings on transfer tab after manual USDT transfer redirect.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:27:15 +08:00
dekun cb4f6aaa4b Normalize TRANSFER_CCY to uppercase so Gate wallet transfers do not fail.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 08:24:26 +08:00
dekun eb175820e9 Document snapshot/20260724 after playbook v2 and options archive work.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:59:22 +08:00
dekun 890659f173 Add key monitor and live trade toggles to instance nav display prefs.
Defaults stay on; users can hide them like other top-bar tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:55:31 +08:00
dekun ca499c6104 Send WeChat alerts on OKX options open and close.
Cover manual, target, and exchange/expiry sync with idempotent sent flags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:48:15 +08:00
dekun 54f1857fa2 Sync OKX options closed trades into hub archive with a separate tab.
Mirror perpetual archive flow into archive_options_trade_cache for offline calendar and review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:39:59 +08:00
dekun 6f1ae14b3d Add display toggles to hide monitor cards and strategy tabs.
Keep unused exchanges/docs out of the UI without disabling accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:24:00 +08:00
dekun 29d59d6a53 Add playbook v2 without hedge as the primary strategy guide.
Wire hub strategy tabs and coach brief to 1H→space→structure→risk/reward→options/perp only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 00:09:40 +08:00
dekun 58a4dafe9a Document snapshot/20260723-2 after strategy compare work.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:20:00 +08:00
dekun 9e0591c676 Increase strategy compare card padding so content is not flush.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 14:59:37 +08:00
dekun ed3033d793 Add hub strategy compare page for perp vs options vs 7:3 hedge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 14:39:59 +08:00
dekun b6156e0049 Apply account-PnL display pref to dashboard KPI and position tables.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:44:36 +08:00
dekun 8e3c00641f Hide options PnL/ROI and daily float when account-PnL pref is off.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:39:43 +08:00
dekun f11f89e760 Show options funding, trading, and float PnL in monitor account stats.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:34:23 +08:00
dekun 0096467d14 Keep Cursor project rules local-only.
Ignore .cursor/ and stop tracking rules so habits stay on this machine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:05:52 +08:00
dekun 8a9dee267f Add open-trade three-check behavior guidelines.
Document signal/process/emotion firewall, expose it in hub strategy tabs, and brief the AI coach.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 09:15:47 +08:00
dekun 910c938d0a Throttle OKX amp-stats candle pagination and retry on 429.
Add page pauses, exponential backoff, and cooldown before swap fallback to avoid rate limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:57:24 +08:00
dekun 0a9e3aa95c Fix amp-stats long-range candles via OKX history endpoints.
Recent candles cap near 60d; continue with history-index/history candles and color profit green/red.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:53:36 +08:00
dekun b64c742fc9 Add weekend filter, take-profit, and profit column to amp stats.
Long-straddle effective move uses TP on path hit (>=) else abs change; mark Sat/Sun on settlement days.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:47:20 +08:00
dekun 789ab43dbe Add long-straddle premium overlay to hub amp stats.
Configurable bilateral premium with exceed counts/ratios and settlement PnL for buying volatility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:33:23 +08:00
dekun 61e8da1e8b Add hub-only OKX amp stats for ETH/BTC session windows.
Read-only 1H index candles, point amplitude metrics, history save and CSV export; no order-path changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:06:21 +08:00
dekun 40be3a5ab7 Fix snapshot tag commit hash in docs table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:01:22 +08:00
dekun 4ccfb838f6 Record pre-amp-stats snapshot and freeze amp-stats plan.
Tag baseline before hub-only amplitude statistics feature work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 02:01:21 +08:00
dekun 58e9c8f85e Feed options positions and playbook brief into trading coach.
Coach context previously omitted options_snapshot details; also inject a short 执行手册 summary each turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:28:16 +08:00
dekun eb0eddbc9d Tighten mobile monitor stats to two lines and hide ops fold.
Desktop refresh/emergency-close and expanded stats layout stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 22:17:14 +08:00
dekun c5f40cba2b Align snapshot/20260721-2 hash with tag target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:24:02 +08:00
dekun a7216428ab Fix snapshot tag commit hash in docs table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:22:29 +08:00
dekun 77f66bf200 Fill snapshot/20260721-2 commit hash placeholder.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:22:29 +08:00
dekun 488b931959 Fix snapshot tag commit hash in docs table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:22:05 +08:00
dekun b89cba3b6e Record evening git snapshot snapshot/20260721-2.
Document playbook hub tab, daily loss freeze, and trading handbook state on main.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:22:04 +08:00
dekun e7f8e9201e Show trading playbook in hub strategy docs.
Add an 执行手册 tab that renders docs/交易执行手册-期权与Gate.md as the default strategy view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:16:54 +08:00
dekun 301a464f29 Add daily loss-count freeze for account risk cooldown.
RISK_DAILY_LOSS_LIMIT (default 2, 0 disables) freezes new opens after N losing closes in the trading day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:13:38 +08:00
dekun a4be294c06 Add personal options-and-Gate trading playbook doc.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:04:46 +08:00
88 changed files with 9500 additions and 259 deletions
-18
View File
@@ -1,18 +0,0 @@
---
description: After each completed code change, commit, push origin/main, and deploy to zk.hyf2.cc
alwaysApply: true
---
# Auto push & deploy
When a user-facing code change is **finished** (not mid-debug / not "先不要改代码"):
1. Commit only the relevant files (skip unrelated CRLF-only docs noise).
2. `git push origin main` to `https://git.bz121.com/dekun/crypto_monitor.git`.
3. Deploy to production `zk.hyf2.cc`:`cd /opt/crypto_monitor && git pull && bash deploy/pull_and_restart.sh`.
4. Confirm PM2 processes are online; briefly report commit hash + deploy status.
Do **not** wait for the user to say "推送并部署" again unless they cancel this habit.
SSH: Prefer key auth; if BatchMode fails, use existing Paramiko root login path used in this project.
Do not print or put passwords in user-facing replies.
+3
View File
@@ -3,5 +3,8 @@
deploy/** text eol=lf
# 文档统一 LF,避免 Windows 编辑后产生 CRLF 脏 diff
docs/** text eol=lf
# XMind 为 ZIP 二进制;须覆盖上面 docs/** 的 text/eol,否则入库会损坏打不开
*.xmind -text -diff -merge -eol
docs/**/*.xmind -text -diff -merge -eol
# .env 模板统一 LF,避免 Linux PM2 source 报 $'\r': command not found
**/.env.example text eol=lf
+5
View File
@@ -15,12 +15,17 @@
**/.env.backup*
**/.env.bak
**/.env.local
# Cursor 本机规则/配置(勿提交;只留本地)
.cursor/
manual_trading_hub/hub_settings.json
manual_trading_hub/hub_backup_state.json
manual_trading_hub/hub_fund_history.json
manual_trading_hub/hub_supervisor_state.json
manual_trading_hub/hub_ai_summaries.json
manual_trading_hub/hub_ai_chat.json
manual_trading_hub/amp_stats_history.json
manual_trading_hub/hub_ai_fund_history.json
manual_trading_hub/data/
backups/
+2
View File
@@ -158,6 +158,8 @@ 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
# 资金与仓位刷新周期(秒)
+14 -3
View File
@@ -411,7 +411,7 @@ _APP_STARTED_AT = time.time()
_RECONCILE_FLAT_STREAK = {}
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
@@ -2751,6 +2751,17 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -9859,7 +9870,7 @@ def manual_transfer():
amount = float(request.form.get("amount", "0"))
except Exception:
flash("划转金额格式错误")
return redirect("/settings")
return redirect("/settings?settings_tab=transfer")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
@@ -9874,7 +9885,7 @@ def manual_transfer():
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else:
flash(f"手动划转失败:{msg}")
return redirect("/settings")
return redirect("/settings?settings_tab=transfer")
def _journal_ai_chart_builder(row):
+2
View File
@@ -160,6 +160,8 @@ 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
# 资金与仓位刷新周期(秒)
+14 -3
View File
@@ -404,7 +404,7 @@ KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
_APP_STARTED_AT = time.time()
_RECONCILE_FLAT_STREAK = {}
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
@@ -2445,6 +2445,17 @@ def insert_trade_record(
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -9716,7 +9727,7 @@ def manual_transfer():
amount = float(request.form.get("amount", "0"))
except Exception:
flash("划转金额格式错误")
return redirect("/settings")
return redirect("/settings?settings_tab=transfer")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
@@ -9731,7 +9742,7 @@ def manual_transfer():
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else:
flash(f"手动划转失败:{msg}")
return redirect("/settings")
return redirect("/settings?settings_tab=transfer")
def _journal_ai_chart_builder(row):
+2
View File
@@ -219,6 +219,8 @@ 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
# 资金与仓位刷新周期(秒)
+14 -3
View File
@@ -384,7 +384,7 @@ BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(
_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {}
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP")
EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip()
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
@@ -2364,6 +2364,17 @@ def insert_trade_record(
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -9444,7 +9455,7 @@ def manual_transfer():
amount = float(request.form.get("amount", "0"))
except Exception:
flash("划转金额格式错误")
return redirect("/settings")
return redirect("/settings?settings_tab=transfer")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
@@ -9466,7 +9477,7 @@ def manual_transfer():
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else:
flash(f"手动划转失败:{msg}")
return redirect("/settings")
return redirect("/settings?settings_tab=transfer")
def _journal_ai_chart_builder(row):
+262
View File
@@ -0,0 +1,262 @@
# 标的时段振幅统计 — 开发方案
> 状态:**方案冻结**(按本文实现;改需求先改本文).
> 范围:**中控**新增只读统计工具;不改开平仓、不接 AI 教练(首版).
> 数据源:**仅 OKX**.
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)
---
## 1. 目标
在中控提供 **自定义时段、固定 16:00 收窗** 的历史振幅档案:
- **标的下拉**:`ETH` / `BTC`(默认 ETH)
- 按整点起点 + **终点固定北京时间 16:00** 切出每日统计窗
- 回溯周期可选(1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义)
- 日表明细分页展示;下方为汇总统计
- 每次有效计算可写入 **历史**;支持 **下载**(明细 + 统计摘要)
定位:服务一天期期权开仓前的「空间」判断(已实现波动点数档案),**不算 IV / 权利金 / Greeks**.
---
## 2. 不做(首版外)
- 币安 / Gate 等非 OKX 价源
- 百分比振幅列(可后加「参考 %」,不进必须统计)
- 未完成窗(当天尚未到 16:00)计入样本
- 自动推送企业微信 / 注入交易教练
- 中控代下单或改期权仓
---
## 3. 时间与样本规则
### 3.1 时区与终点
- 时区:**Asia/Shanghai(北京时间)**
- **到期/收窗时刻固定 `16:00`**,不可改
- 起点时刻:**仅整点** `00:00``23:00`(下拉选择)
### 3.2 跨天切窗(结算日 D)
对每个结算日 **D**(窗终点 = `D 日 16:00`):
| 起点整点 T | 窗起点 | 窗终点 |
|------------|--------|--------|
| `T >= 16:00` | **D-1 日 T:00** | D 日 16:00 |
| `T < 16:00` | **D 日 T:00** | D 日 16:00 |
示例:
| 用户选择 | 某一结算日 D 的实际窗 |
|----------|------------------------|
| 16:00 → 16:00 | D-1 16:00 → D 16:00 |
| 22:00 → 16:00 | D-1 22:00 → D 16:00 |
| 08:00 → 16:00 | D 08:00 → D 16:00 |
### 3.3 回溯周期
| 选项 | 含义(完整收窗个数,约) |
|------|------------------------|
| 1 个月 | 约 30 个结算日 |
| 2 个月 | 约 60 个结算日(默认推荐) |
| 3 个月 | 约 90 个结算日 |
| 半年 | 约 180 个结算日 |
| 1 年 | 约 365 个结算日 |
| 自定义 | 用户输入天数 N(`7``400`,可配置上下限) |
说明:
- 「月」按 **日历回溯 + 完整 16:00 收窗** 计数,不足整天的末日不入样
- 仅纳入 **已结束** 的窗(`now >= D 16:00`);进行中的今天不入样
### 3.4 标的与价源(OKX)
| UI 下拉 | 价源(优先) | 降级(仅指数失败时) |
|---------|------------|---------------------|
| **ETH** | OKX **ETH-USD 指数** | OKX `ETH/USDT` 永续标记 |
| **BTC** | OKX **BTC-USD 指数** | OKX `BTC/USDT` 永续标记 |
约束:
- **交易所固定 OKX**,UI 不提供其它所
- 具体指数/合约符号以实现时 OKX 接口与 `hub_ohlcv` 对齐为准;结果与下载须标注 `exchange=okx` + 实际价源
- K 线粒度:**1H**(与整点起止对齐,优先);同一作业内不得混用粒度.若后续要更细高低点,可升 5m/1m(P2)
---
## 4. 指标口径(点数,非百分比)
全部为 **绝对价格点数**(标的报价差;BTC/ETH 各自用自身价格刻度).
设窗内:
- `O` = 起点时刻价(或起点分钟 K 的 open)
- `H` = 窗内最高
- `L` = 窗内最低
- `C` = 终点 16:00 价(或该分钟 close)
| 字段 | 算法 | 例(O=2000,H=2500,L=1800) |
|------|------|---------------------------|
| 开盘价 | `O` | 2000 |
| 最高价 | `H` | 2500 |
| 最低价 | `L` | 1800 |
| 收盘/窗末价 | `C` | (另算) |
| 开→高距离 | `H O` | **500** |
| 开→低距离 | `O L` | **200** |
| **振幅** | `(HO)+(OL)` = **`HL`** | **700** |
| 涨跌值 | `C O`(可正负) | 可选列,首版建议保留 |
**必须统计(汇总层):**
- **最大振幅**(值 + 对应结算日)
- **开→高距离**:最大、均值(建议)
- **开→低距离**:最大、均值(建议)
可选汇总(首版建议带上,成本低):
- 振幅均值 / 中位数
- 上涨窗占比(`C>O`)、下跌窗占比
- 振幅 ≥ 用户阈值 X 点数的天数(X 可填,默认空=不算)
---
## 5. 界面(中控)
### 5.1 入口
- 顶栏新增导航项:**「振幅统计」**或 **「期权统计」**(最终文案实现时定一处;设置里可隐藏)
- 手机端进「更多」
### 5.2 Tab
| Tab | 作用 |
|-----|------|
| **统计** | 配参数 → 计算 → 看日表+汇总 → 下载 / 存历史 |
| **历史** | 过往作业列表;打开复看;再下载 |
### 5.3 「统计」页布局
1. **参数区**
- **标的**:下拉 `ETH` / `BTC`(默认 ETH)
- 数据源:只读展示 `OKX`
- 起点整点:下拉 `00``23`(默认 `16`)
- 终点:固定展示 `16:00`(不可改)
- 周期:单选 `1月 / 2月 / 3月 / 半年 / 1年 / 自定义`
- 自定义天数:仅自定义时显示
- 按钮:`计算` · `保存到历史` · `下载`
2. **日表明细**(分页,如每页 20 行;排序默认结算日倒序)
3. **下方汇总区**(本次全样本,不是当前页)
### 5.4 「历史」页
每条记录至少:
- 创建时间、**标的**、起点整点、周期/天数、价源(OKX+指数/标记)、样本数
- 最大振幅(+日期)
- 操作:查看 / 下载 / 删除
**写入规则(建议):** 用户点击 **「保存到历史」** 才入库;仅点「计算」不自动灌历史(避免误点刷屏).若产品坚持「输入一次就算进历史」,可改为计算成功自动写入——实现前在本文改为冻结口径.
> 当前方案冻结倾向:**显式「保存到历史」**.
---
## 6. 下载
格式:优先 **CSV**(UTF-8 BOM,Excel 可开);或单文件双段.
必须包含:
1. **日表明细**(本次全部结算日,非当前页)
2. **统计摘要**:标的、交易所 OKX、价源、最大振幅(+日)、开→高最大/均值、开→低最大/均值、样本数、起点整点、终点 16:00、周期、生成时间
文件名示例:`okx_eth_amp_22to16_60d_20260723.csv` / `okx_btc_amp_16to16_90d_20260723.csv`
---
## 7. 数据与实现要点
### 7.1 复用
- 优先复用中控 `hub_ohlcv` / `hub_kline_store`,按 `exchange_key=okx` + 标的对应指数/合约拉齐历史 K 线并本地缓存
- 首次 1 年 × 1m 数据量较大:计算前检查缓存覆盖;缺口再增量拉取;UI 显示进度/耗时提示
- BTC / ETH 缓存键分离
### 7.2 后端模块(建议)
| 路径 | 职责 |
|------|------|
| `lib/hub/amp_stats_lib.py` | 标的映射、切窗、算日行、汇总 |
| `manual_trading_hub/` 路由 + 静态页 | UI / API |
| `manual_trading_hub/amp_stats_history.json`(或 sqlite) | 历史作业 |
### 7.3 API 草稿
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/amp-stats/compute` | body: `symbol`(eth\|btc), start_hour, period\|days → 日表+汇总 |
| `GET` | `/api/amp-stats/history` | 历史列表(可按 symbol 筛选) |
| `POST` | `/api/amp-stats/history` | 保存当前结果 |
| `GET` | `/api/amp-stats/history/{id}` | 详情 |
| `DELETE` | `/api/amp-stats/history/{id}` | 删除 |
| `GET` | `/api/amp-stats/export` | query 或 history id → 文件下载 |
### 7.4 性能
- 2 个月 × 1m:可接受同步(数十秒级需有 loading)
- 1 年:建议异步任务或分块拉齐后再算;首版可限制「自定义 > 180 天」需确认二次点击
---
## 8. 验收清单
- [ ] 标的下拉 ETH / BTC 可切换;数据源固定 OKX
- [ ] 起点仅整点;终点 UI 固定 16:00
- [ ] `22→16` / `16→16` / `08→16` 跨天规则与 §3.2 一致
- [ ] 周期六档 + 自定义天数生效;默认 2 个月
- [ ] 日表含:开高低收、开→高、开→低、振幅(点数)、涨跌值
- [ ] 例:O=2000,H=2500,L=1800 → 开→高 500、开→低 200、振幅 700
- [ ] 汇总含最大振幅(+日)、开→高/开→低统计
- [ ] 分页只影响展示;汇总与下载用全样本
- [ ] 未到 16:00 的当日不入样
- [ ] 保存历史含标的字段 / 回看 / 删除
- [ ] 下载含明细 + 统计摘要(含标的与 OKX)
- [ ] 电脑与手机均可完成计算与下载(手机下载走系统分享/保存即可)
---
## 9. 分期
| 阶段 | 内容 |
|------|------|
| **P0** | 统计 Tab:标的下拉(ETH/BTC) + 参数 + 计算 + 日表分页 + 汇总 + 下载(不经历史) |
| **P1** | 历史 Tab:保存 / 列表 / 回看 / 再下载 / 删除 |
| **P2** | 缓存加速、长周期异步、振幅阈值天数、可选 % 参考列 |
---
## 10. 待冻结(实现前确认)
| # | 问题 | 当前倾向 |
|---|------|----------|
| 1 | 历史写入:自动 vs 点保存 | **点保存** |
| 2 | 下载 CSV vs Excel | **CSV** |
| 3 | 价源 | **OKX 指数优先**(ETH-USD / BTC-USD);失败再降级永续标记 |
| 4 | K 线 1m vs 5m vs 1H | **1H**(整点窗) |
| 5 | 导航文案 | **「振幅统计」** |
**已冻结(开工口径):** 点保存进历史 · CSV · OKX 指数优先 · **1H K 线**(整点对齐,降低拉取量;与整点窗一致) · 导航「振幅统计」.
确认后将本文状态改为 **方案冻结**,再开工实现.
---
## 11. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 初稿:中控 ETH 时段振幅统计;点数口径;周期档位;16:00 固定收窗;历史+下载 |
| 2026-07-23 | 支持 BTC/ETH 下拉;数据源固定 OKX 指数(可降级永续标记);模块/API 改名为 amp-stats |
+7 -1
View File
@@ -41,6 +41,7 @@
|------|------|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
| 平仓亏损达 `RISK_DAILY_LOSS_LIMIT` 次(同一交易日) | **日冻结**(默认 2 次;`0`=不启用) |
| 复盘勾选任意情绪标签 | **日冻结** |
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
@@ -77,11 +78,15 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
TRADING_DAY_RESET_HOUR=8
APP_TIMEZONE=Asia/Shanghai
```
- `RISK_DAILY_LOSS_LIMIT`:任意已平仓交易若盈亏 < 0 计 1 次(含止损/止盈后仍亏损等);达上限当日冻结开仓;`0` 表示不因亏损次数冻结.
- `RISK_MANUAL_CLOSE_DAILY_LIMIT`:仅计**用户主动平仓**次数(与亏损次数独立).
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控).
## API 与 `risk_status` 字段
@@ -102,6 +107,7 @@ APP_TIMEZONE=Asia/Shanghai
| `can_trade` | 是否允许新开仓(仅风控维度) |
| `reason` | 悬停提示文案 |
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` |
| `daily_loss_count` / `daily_loss_limit` | 当日亏损笔数与上限(`0` 上限表示未启用) |
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
@@ -123,7 +129,7 @@ APP_TIMEZONE=Asia/Shanghai
## 相关代码
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`,`on_closed_trade_pnl`
- `hub_bridge.py``/api/hub/account-risk/user-close`
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
- `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控
+1
View File
@@ -131,6 +131,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 手动平仓冷静(小时) | |
| 复盘情绪冷静(小时) | |
| 日手动平仓次数上限 | |
| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 |
| 情绪标签日冻结 | |
详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
+9
View File
@@ -4,6 +4,15 @@
「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**.左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易.
顶栏有 **永续 / 期权** 品种切换:
| 品种 | 数据 | 说明 |
|------|------|------|
| **永续** | 三所 `trade_records``archive_trade_cache` | 含犯病标签、K 线 |
| **期权** | OKX `options_review_trades``archive_options_trade_cache` | 独立 Tab;同步进中控库后离线可看;默认排除对冲腿 |
同步:「同步」按钮与后台 4h 任务会同时拉永续与期权(仅 `capabilities``options` 的账户).
与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留.
## 页面布局
+3
View File
@@ -4,6 +4,9 @@
| 文档 | 实例 | 状态 |
|------|------|------|
| [交易执行手册-v2-期权与合约.md](../交易执行手册-v2-期权与合约.md) | 中控「策略说明」·执行手册v2 | **现行**:无对冲;1H→空间→结构→定损盈→期权/合约 |
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册v1 | 含对冲;历史对照 |
| [交易行为准则-开单三检.md](../交易行为准则-开单三检.md) | 中控「策略说明」·行为准则 | 开单前信号/流程/情绪三检 |
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 |
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 |
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
+32
View File
@@ -0,0 +1,32 @@
{
"exchange": "behavior",
"title": "开单三检清单",
"version": "v0.1",
"groups": [
{
"title": "信号判断",
"items": [
"最核心、最明确的一个点位/结构确认已写清",
"该确认本身足够清晰(不是靠一长串宏大叙事)",
"已过主链条:1H方向 → 空间 → 结构 → 定损盈 → 选工具(期权/合约,无对冲);不够格则空仓"
]
},
{
"title": "流程确认",
"items": [
"账户资金与当日额度符合要求",
"单笔风险 / 组合敞口在手册预算内",
"无跳步;超限则暂停开单"
]
},
{
"title": "情绪自检",
"items": [
"心态是「符合系统所以做」,不是「证明自己」",
"无怕踏空 → 否则放弃",
"无回本 / 报复交易念头 → 否则放弃",
"不需要再找更多开单理由"
]
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"exchange": "playbook_v2",
"title": "执行手册 v2 开仓清单(无对冲)",
"version": "v0.1",
"groups": [
{
"title": "主链条",
"items": [
"1H 方向清楚(含明显 N 字);跟的是 1H 波段",
"空间足够(支撑/阻力;至少约 ≥2%)",
"结构已出现且量级够(约 8h+ / 48 根 15m",
"止损按模型:结构突破=外沿;假突破=针尖;止盈与 RR 已接受",
"工具只在「期权 / 合约」中选择;未开对冲"
]
},
{
"title": "账户与仓位",
"items": [
"只动 OKX 期权或 Gate 合约;其它账户零操作",
"期权:约 10U、一次一仓;合约:止损约 5U、本位置次数未超两次",
"合计最坏风险可接受(约 ≤20U 量级)"
]
},
{
"title": "离场与心态",
"items": [
"期权离场只认规则止盈或到期;开仓后中间不手平",
"不是「今天也要开点期权」;过检才开,不过则空仓",
"已过开单三检(信号 / 流程 / 情绪)"
]
}
]
}
@@ -0,0 +1,147 @@
# 交易执行手册 v2(期权 / 合约 · 无对冲)
> 个人开单纪律第二版(2026-07-24 起)。
> **相对 v1:去掉期期对冲 / 偏置对冲;工具只留期权与合约。**
> 目标:少而精、珍惜机会、样本干净;**不保证收益**。
> 旧版(含对冲)见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md);本手册管怎么做单。
---
## 1. 主链条(强制)
```
1H 方向 → 空间 → 结构 → 定损盈 → 选工具(期权 / 合约)
```
任一步不过 → **空仓等待**,不为开单找理由。
| 步骤 | 做什么 | 否决 |
|------|--------|------|
| **1H 方向** | 趋势周期以 **1H** 为准;1H 上要有明显 **N 字**。跟 1H 波段,不跟 4H 打架硬做。例:4H 多、1H 空 → 做 1H 空头波段 | 1H 方向不清、无 N 字 |
| **空间** | 做空看下方支撑,做多看上方阻力;至少约 **≥2%** 才值得谈(常期望更大空间,如 ~5%) | 空间不够、贴着墙 |
| **结构** | 方向与空间过关后,在 **15m / 5m** 等结构;结构量级至少约 **8h+**(约 **48 根 15m**)。形态:收敛 / 两段式回调 / 箱体 / 假突破等 | 结构未出现、磨不够就抢跑 |
| **定损盈** | 结构出现后定义止损、止盈,算盈亏比。结构突破 → 止损在 **结构外沿**;假突破 → 止损在 **假突破针尖** | 损盈说不清、RR 不接受 |
| **选工具** | 只在上四步都齐之后选:**期权** 或 **合约**。波段有足够时间考虑,不急着下手 | 用对冲、或「每天都要开点期权」 |
**丢掉对冲。** 对冲易带来「有保护就能多做」的幻觉;本版不做期期对冲、不做偏置对冲壳。
---
## 2. 总原则
1. **工具只有期权与合约**;同一时段尽量只让一边「说话」。
2. **看不懂不做**;过滤比频率重要。日更不是目标,过检才是。
3. 动手前先过 **开单三检**(信号 → 流程 → 情绪);不过 → 空仓。
4. 玩法必须走完主链条;不够格 → 空仓。
5. 期权离场只认:**系统/规则止盈** 与 **到期**;**开仓后中间不手动平仓**(紧急例外不进策略样本)。
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满。
---
## 3. 账户与分工
| 账户 | 角色 | 说明 |
|------|------|------|
| OKX 期权 | **主业之一** | 方向单(虚值等);**不做对冲腿** |
| Gate 合约 | **主业之一** | 结构清楚时的波段;与期权尽量错开 |
| 其它 | 暂不做 | 减少分心与样本污染 |
**到期选择(期权)**
- 方向单默认 **一天期**
- 尽量在 **北京时间下午 4 点后****次日到期**,覆盖较完整的美盘 + 亚盘 + 欧盘窗口。
- 更长故事优先考虑合约,不强行拉长期权。
---
## 4. 入场逻辑(两类工具)
开仓前先判断:当前是 **买方向的期权表达**,还是 **合约波段**
### 4.1 方向明确 · 结构到位 → 期权
- **条件**:主链条全部过关;常用结构突破或假突破模型在 15m/5m 成立。
- **工具**:**一天期期权方向单**(空间够时优先考虑 **虚值**:同止损口径下盈亏比往往更高)。
- **离场**:规则止盈或到期;不手平。
- **默认**:先只开期权,不上合约。
### 4.2 结构到位 · 更适合合约 → 合约
- **条件**:主链条过关;位置极明确;同一位置机会计数见 Gate 纪律。
- **工具**:Gate 合约波段;止损挂在模型对应位置(外沿 / 针尖)。
- **独立假突破**(没有先开突破期权时):优先 **只做合约****空仓**,勿与「突破期权后再加仓」混用同一套仓。
### 4.3 明确不做
- 横盘「买波动」的 **期期对冲**Call+Put)。
- 任何「对冲壳 + 偏置」伪装成单边。
- 为了「今天也开点期权」而破主链条。
---
## 5. 仓位与风险预算
**总资金参考:约 800U。**
| 项目 | 规则 |
|------|------|
| 单笔期权 | 约 **10U** 权利金预算;**一次只持有一个期权仓位** |
| Gate 合约 | 日内保证金约 **50U**、约 **10 倍**;有单才用,无单为 0 |
| 合约止损 | 一般约 **5U**;单笔最大亏损不超过约 **10U** |
| 日损失心理框 | 期权+合约若都错:合计大约 **≤20U**;都对时期望可到 **40U+**(理想情形,非每日目标) |
相对 800U:单笔约 **1.25%** 量级;全错一天约 **2.5%** 量级——防守优先。
**叠加红线**
- 期权一仓 + 合约同日存在时,按合计风险接受最坏约 20U,且尽量少「同向双开」。
- 不为「好像有保护」放大仓位(本版已无对冲保护叙事)。
---
## 6. 合约日纪律(Gate
1. 只做 **很明确的位置**;不明确基本不做。
2. 动手前想清:**如何进场**(假突破 / 结构突破)。
3. **同一位置最多两次机会**:结构突破、假突破。
4. **两次都错 → 当日不再做单**(即使后面更「看起来清楚」也留到明天)。
5. 止损约 **5U**;波段规则开仓前想清。
6. 离场以结构止盈/止损为准。
---
## 7. 期权日纪律(OKX
1. **不手动平仓**;只等规则止盈或到期(紧急手平标记为非策略样本)。
2. 一次一仓;约 10U 权利金。
3. **不做对冲**;不做「每天默认开期权」。
4. 结构突破 / 假突破用期权表达时,损位跟模型:外沿 / 针尖。
5. 默认一天期;优先完整会话窗口再开。
---
## 8. 开仓前自检清单
- [ ] 今天是否只动「期权 / 合约」,其它账户零操作?是否 **未开对冲**
- [ ] **1H 方向**是否清楚(含 N 字)?
- [ ] **空间**是否足够(支撑/阻力,至少约 ≥2%)?
- [ ] **结构**是否出现且量级够(约 8h+ / 48×15m)?
- [ ] **止损 / 止盈**是否按模型定好(外沿或针尖)?RR 是否接受?
- [ ] **工具**选的是期权还是合约?理由是否写清?
- [ ] 期权:止盈条件与「接受到期」是否写清?
- [ ] 合约:本位置第几次机会?止损约 5U 设好了吗?今日两次是否已用完?
---
## 9. 一句话版本
> **1H 定方向 → 量空间 → 等够级别的结构 → 按模型定损盈 → 只在期权与合约里选工具;不对冲;期权不手平;一位置两次,错完收工;珍惜机会,日更不是目标。**
---
## 10. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-24 | v2 初版:去掉对冲;主链条 1H→空间→结构→定损盈→期权/合约;吸收假突破针尖 / 结构外沿止损口径 |
+149
View File
@@ -0,0 +1,149 @@
# 交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)
> 个人开单纪律与仓位规则(2026-07 起)。**本版保留对冲,仅作历史/对照。**
> **现行主版本请用** [交易执行手册-v2-期权与合约.md](./交易执行手册-v2-期权与合约.md)(无对冲:1H→空间→结构→定损盈→期权/合约)。
> 目标:少而精、可控回撤、样本干净;**不保证收益**。
> 工具:OKX 期权(主)+ Gate 合约(辅);其它账户暂不做。
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md)(信号 / 流程 / 情绪);本手册管怎么做单。
---
## 1. 总原则
1. **主做期权,合约为辅**;同一时段尽量只让一边「说话」。
2. **看不懂不做**;过滤比频率重要。
3. 动手前先过 **开单三检**(信号判断 → 流程确认 → 情绪自检);不过 → 空仓。详见 [行为准则](./交易行为准则-开单三检.md)。
4. 开仓前再过玩法三关:**方向 → 空间 → 值不值得**。不够格 → 空仓。
5. 期权离场只认:**止盈(规则触发)** 与 **到期**;**不手动平仓**(紧急例外单不算策略样本)。
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满理想上限。
---
## 2. 账户与分工
| 账户 | 角色 | 说明 |
|------|------|------|
| OKX 期权 | **主业** | 横盘对冲 / 方向单 / 偏置对冲 |
| Gate 合约 | **辅业** | 结构清楚时的波段;与期权尽量错开 |
| 其它 | 暂不做 | 减少分心与样本污染 |
**到期选择(期权)**
- 方向单、对冲默认 **一天期**
- 尽量在 **北京时间下午 4 点后****次日到期**,覆盖较完整的美盘 + 亚盘 + 欧盘窗口。
- Gate 波段样本里最长持仓约十余小时量级 → 一天期权通常够表达;更长故事优先考虑合约,不强行拉长期权。
---
## 3. 入场逻辑(三类)
开仓前先判断:当前是 **买波动** 还是 **买方向**
### 3.1 横盘 → 期期对冲
- **条件**:横盘已持续较久(例如满约 12 小时),方向不明。
- **工具**:一天期 Call + Put(对冲);总权利金预算见仓位章。
- **意图**:买接下来的波动,不赌单边。
- **期间**:一般 **不再开 Gate 方向单**(已在买波动,勿叠同一宏观暴露)。
### 3.2 方向明确 · 结构突破 → 期权
- **条件**:方向、空间、值不值得均过关;结构突破成立。
- **工具**:**一天期期权方向单**(或明显顺势结构)。
- **离场**:目标止盈或到期;不手平。
- **默认**:先只开期权,不上合约。
### 3.3 结构突破后 · 反向假突破确认 → 可加合约
- **条件**:已有结构突破的期权表达;随后出现反向假突破且确认失败、续原方向。
- **工具**Gate 合约 **小仓加强**(止损纪律见下)。
- **注意**:BTC 合约与 ETH 期权高度相关,属加重暴露,不是分散;仓位按「一笔故事」计风险。
- **假突破定义**需事先写死(相对哪段结构、如何确认收回),避免临场随便加仓。
### 3.4 独立假突破(没有先开突破期权时)
- 按「假破专用」处理:优先 **只做合约****空仓**,勿与「突破后再假破加仓」混用同一套仓。
---
## 4. 对冲偏好(偏置对冲)
在「尽量用对冲」的前提下:
- 对冲内常带 **做多/做空比例**;若略偏多,则 **做多一侧比例更高**
- 顺势侧尽量用 **实值(或更实)**
- 方向对了:可能 **少赚一点**(相对纯单边);
- 方向错了:争取 **不亏或少亏**(相对虚值双买两边磨光)。
- **总权利金仍锁在对冲预算内**(见仓位);偏置只调张数/行权远近,不偷偷加预算。
- **偏置有度**(例如勿极端到名存实亡的单边);完全没方向时更接近均分/近平值;方向非常明确时应走单边期权,不必硬套对冲壳。
- 复盘建议区分:**中性对冲** vs **偏多/偏空对冲**,以便检验偏置是否真压低亏损。
---
## 5. 仓位与风险预算
**总资金参考:约 800U。**
| 项目 | 规则 |
|------|------|
| 单笔期权 | 约 **10U** 权利金预算;**一次只持有一个期权仓位** |
| 期期对冲 | **合计约 10U**(两腿加总,不是各 10 |
| Gate 合约 | 日内保证金约 **50U**、约 **10 倍**;有单才用,无单为 0 |
| 合约止损 | 一般约 **5U**;单笔最大亏损不超过约 **10U** |
| 日损失心理框 | 期权+合约若都错:合计大约 **≤20U**;都对时期望可到 **40U+**(理想情形,非每日目标) |
相对 800U:单笔约 **1.25%** 量级;全错一天约 **2.5%** 量级——防守优先。
**叠加红线**
- 期权一仓 + 合约加仓同日存在时,按合计风险接受最坏约 20U,且尽量少「同向双开」。
- 不因「期权偏置可能少亏」而放大合约。
---
## 6. 合约日纪律(Gate
1. 只做 **很明确的位置**;不明确基本不做。
2. 动手前想清:**如何进场**。
3. **同一位置最多两次机会**:结构突破、假突破。
4. **两次都错 → 当日不再做单**(即使后面更「看起来清楚」也留到明天)。
5. 止损约 **5U**;波段规则(含是否时间离场)开仓前想清。
6. 已关闭「强制清仓」误伤策略意图时,离场以结构止盈/止损为准;历史里「强制清仓但盈利」按规则结果理解,复盘看盈亏与结构。
---
## 7. 期权日纪律(OKX
1. **不手动平仓**;只等规则止盈或到期(紧急手平标记为非策略样本)。
2. 一次一仓;对冲共 10U。
3. 横盘对冲期间一般不开 Gate 方向单。
4. 结构突破用期权表达;假破加强才考虑合约。
5. 默认一天期;优先完整会话窗口再开。
---
## 8. 开仓前自检清单
- [ ] 今天是否只动「期权 / Gate」,其它账户零操作?
- [ ] 买波动还是买方向?工具选对了吗?
- [ ] 方向 / 空间 / 值不值得是否都过关?
- [ ] 期权:止盈条件与「接受到期」是否写清?
- [ ] 对冲:比例与实值偏置是否有度?总预算是否仍 ≤10U?
- [ ] 合约:本位置第几次机会?止损约 5U 设好了吗?
- [ ] 若加合约:是否已有突破期权且假破确认?是否当成一笔故事控总风险?
- [ ] 今日合约两点机会是否已用完?(用完则收工)
---
## 9. 一句话版本
> **横盘对冲(可偏置实值);突破用一天期权;假破确认后小仓合约加强;先过方向/空间/值不值得;期权不手平;一位置两次,错完收工;单笔小亏、组合回撤可控。**
---
## 10. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-21 | 初版:根据实盘讨论整理(期权为主、Gate 为辅、仓位与日停手规则) |
| 2026-07-23 | 挂钩开单三检行为准则 |
| 2026-07-24 | 标注为 v1(含对冲);现行纪律迁至执行手册 v2 |
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
# 交易行为准则(开单三检)
> 个人强制思维动作 · 初级版(2026-07)。
> **不是策略**,是开单前的「交易防火墙」:保证动作在可控轨道上,**不判断这笔会不会赚钱**。
> 来源:中控 AI 复盘对话(2026-07-22)与本人归纳。
> 仓位与玩法细则见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
---
## 1. 一句话
> **信号够不够清晰?流程有没有跑通?情绪是不是在证明自己?三检不过 → 不开。**
复盘成败的第一标准:**三检是否完整完成**,而不是这笔盈亏。
---
## 2. 总循环
```
信号判断 → 流程确认 → 情绪自检 → 全部通过
→ 开仓 → 等待系统结果(止盈 / 止损 / 到期)
→ 本次结束 → 复盘整环 → 等待下一个信号
```
任一步否决 → **空仓离开**,不找补丁理由硬开。
---
## 3. 开单前:三秒停顿
手要动之前,强制停顿,把注意力从宏大叙事拉回内部三点:
1. 我的**核心信号**是什么?
2. **安全流程**跑通了吗?
3. 我现在是冷静执行,还是急着证明 / 怕踏空 / 想回本?
---
## 4. 三检细则
### 4.1 信号判断(Signal Judgment
**问:** 这次入场,最核心、最明确的那一个点位 / 结构确认是什么?它本身够不够清晰?
| 通过 | 否决 |
|------|------|
| 能用一句话说清「唯一核心确认」 | 说不清、要靠一长串宏观故事才能自圆其说 |
| 点位 / 结构本身已经够清楚 | 「好像有戏」但确认点模糊 |
| 只描述事实与系统条件 | 堆细节证明自己分析很厉害 |
对照执行手册时:先过 **1H 方向 → 空间 → 结构 → 定损盈 → 选工具(期权/合约)**;不够格 → 空仓(见手册 v2)。
### 4.2 流程确认(Process Confirmation
**问:** 决定执行前,有没有按设定步骤检查资金与风险敞口?内部安全流程跑通了吗?
| 通过 | 否决 / 暂停 |
|------|-------------|
| 账户资金与当日额度符合要求 | 资金或次数已触限 |
| 单笔风险 / 组合敞口在手册预算内 | 单笔或日最坏超限 → **暂停开单** |
| 该走的检查项没有跳步 | 「先开了再说」 |
细则数字以执行手册仓位章为准(单笔期权、对冲总权利金、Gate 止损与日停手等)。
### 4.3 情绪自检(Emotional Self-Check
**问:** 看到复杂结构与逻辑时,内心是什么?是「必须证明分析是对的」,还是「符合系统要求,所以做」?
| 通过 | 否决(果断放弃) |
|------|------------------|
| 「符合系统信号 + 账户没问题 → 开」 | 「怕踏空」 |
| 不需要再找更多开单理由 | 「上回亏了,这单要回本」 |
| 旁观者视角、可接受空仓 | 「必须证明我是对的」 |
**原则:** 不为开单找理由;情绪红灯亮了,信号再好看也不开。
---
## 5. 开仓后纪律(与手册一致)
- 开仓后:**等待系统结果**(规则止盈 / 止损 / 到期),不靠情绪手平(紧急例外不算策略样本)。
- 持仓期盯的是「程序与纪律是否正常」,不是浮盈浮亏数字本身。
- 无信号时的空档也算训练:反复在脑子里空跑三检,比硬找单更重要。
---
## 6. 复盘只记什么
每次交易(含未开成的冲动)建议只记:
1. 信号判断:做了吗?核心确认写了什么?是否清晰?
2. 流程确认:资金 / 敞口是否过关?有无跳步?
3. 情绪自检:当时心态是哪一类?有无怕踏空 / 回本?
4. 结果:止盈 / 止损 / 到期 / 未开 — **结果不推翻「三检是否完成」这一评分。**
---
## 7. 与执行手册的分工
| 文档 | 管什么 |
|------|--------|
| **本准则** | 能不能动手(防火墙 / 操作系统) |
| **执行手册** | 怎么做单(期权 / Gate、仓位、离场) |
先过本准则三检,再谈手册里的玩法与仓位。
---
## 8. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 初级版:三检 + 总循环 + 红线;对齐 AI 复盘与本人总结 |
| 2026-07-24 | 信号检对齐执行手册 v2 主链条(1H→空间→结构→定损盈→期权/合约) |
+9 -2
View File
@@ -6,7 +6,14 @@
| 标签 | 指向提交 | 说明 |
|------|----------|------|
| `snapshot/20260721` | `2a60d47` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/关键位/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
| `snapshot/20260726-2` | `4a79e01` | 2026-07-26 午:执行手册脑图(业务主题)、`.xmind` 按二进制入库、去掉缩略图避免 Gitea raw 换行损坏 |
| `snapshot/20260726` | `a2075ba` | 2026-07-26:Gate划转币种大写修复、系统设置划转页签停留、自动划转账户/币种下拉默认、期权「按可用余额打满」=min(余额,单笔预算)及说明 |
| `snapshot/20260724` | `890659f` | 2026-07-24:执行手册v2(无对冲)、监控/策略页签显隐、内照明心期权档案同步、期权开平仓微信必发、实例导航显隐关键位/实盘下单等 |
| `snapshot/20260723-2` | `9e0591c` | 2026-07-23:策略对比页(合约/单期权/期期7:3)、监控与看板隐藏浮盈偏好、对比页卡片内边距等 |
| `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 |
| `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 |
| `snapshot/20260721` | `1a163c0` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
## 历史标签(节选)
@@ -25,7 +32,7 @@
git tag -l 'snapshot/*'
# 检出快照(只读查看,勿在此分支直接开发)
git checkout snapshot/20260721
git checkout snapshot/20260727
# 回到主线
git checkout main
+111
View File
@@ -0,0 +1,111 @@
# 振幅统计(中控)
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
> **不改下单链路**;不算 IV / 权利金。
---
## 入口
- 顶栏 **振幅统计**`/amp-stats`
- 手机端:**更多 → 振幅统计**
- 可在系统设置里隐藏该导航
---
## 怎么用
1. 打开 **统计** Tab
2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
3. **起点整点**0023);终点固定 **16:00**
4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
5.**计算** → 下方看汇总 + 分页日表
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
**跨天例子**
| 起点 | 含义(结算日 D |
|------|------------------|
| 22:00 | 昨天 22:00 → 今天 16:00 |
| 16:00 | 昨天 16:00 → 今天 16:00 |
| 08:00 | 今天 08:00 → 今天 16:00 |
未到当日 16:00 的「今天」不入样本。
---
## 指标(点数)
设开盘 O、最高 H、最低 L、收盘 C:
| 字段 | 算法 |
|------|------|
| 开→高 | `H O` |
| 开→低 | `O L` |
| **振幅** | `H L`= 开→高 + 开→低) |
| 涨跌值 | `C O` |
例:O=2000H=2500L=1800 → 开→高 500,开→低 200,振幅 **700**
汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。
K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。
近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`
分页带间隔,遇 OKX **429** 会自动退避重试(长周期首次会慢一些)。
---
## 买跨对照(赌波动)
表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空):
| 汇总项 | 口径 |
|--------|------|
| 开→高超过权利金 | `HO > 权利金` 的天数与占比 |
| 开→低超过权利金 | `OL > 权利金` 的天数与占比 |
| \|涨跌\|超过权利金 | `\|CO\| > 权利金` 的天数与占比 |
| 有效波动 | 若设止盈且 `开→高≥止盈``开→低≥止盈` → 用止盈点;否则用 `\|CO\|` |
| 买跨收益 | `有效波动 权利金`(日表「收益」列同口径) |
- 方向:**买跨**
- 权利金越过:严格 **`>`**;止盈触达:**`≥`**
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
### 周末
- 下拉:**全部**(默认)/ **排除周末** / **仅周末**
-**结算日** 北京时间星期判断;表中六、日带标注并高亮
---
## 历史 Tab
-**保存到历史** 后出现(不会一算就自动入库)
- 可查看、再下载、删除
- 数据文件:`manual_trading_hub/amp_stats_history.json`(勿当密钥提交)
---
## 相关代码
| 路径 | 说明 |
|------|------|
| `lib/hub/amp_stats_lib.py` | 切窗、汇总、OKX 拉取、CSV |
| `manual_trading_hub/amp_stats_routes.py` | API |
| `manual_trading_hub/amp_stats_store.py` | 历史 JSON |
| `manual_trading_hub/static/amp_stats.js` | 前端 |
| `tests/test_amp_stats_lib.py` | 单元测试 |
---
## 修订
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 首版上线说明 |
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 |
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 |
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 |
+148
View File
@@ -0,0 +1,148 @@
# 服务说明与报价说明
> 本文说明本系统的定位、适用对象、托管方式与参考报价。
> 配套文件:`著作权声明.md`、`软件使用授权合同-模板.md`(托管服务与软件使用合同)。
> **本系统以著作权人自用为主**;对外托管属个案合作,并非标准化「卖工具」业务。
---
## 1. 这是什么
`crypto_monitor`(加密货币交易监控与中控系统)由著作权人 **马建军** 历时约三个月持续开发,用于自身实盘交易中的:
- 多交易所实例监控与下单辅助
- 风控与纪律约束(如日亏冻结、执行规则落地到系统)
- 复盘、关键位、期权/合约相关流程(以实际开通功能为准)
- 中控统一查看与管理
开发目的首先是:**把交易习惯钉进系统,减少情绪单与随意操作**,而不是面向市场量产销售的通用软件商品。
---
## 2. 定位与适用对象
### 2.1 定位
| 是 | 不是 |
|----|------|
| 全职(或准全职)交易者的执行与纪律系统 | 兼职「玩玩」的下单插件 |
| 规则、限制、复盘一起用的工作台 | 帮你加杠杆、追涨杀跌的「发财工具」 |
| 著作权人自用为主;对外仅少量托管 | 开源产品或标准化 SaaS 大卖场 |
### 2.2 适合
- 以交易为主要工作、愿意按规则执行的人
- 认同执行手册与系统内限制(含开仓限制、冻结等)
- 接受「一户一机、不交付源码、按期付费」的托管方式
- 账户规模与付费意愿匹配(服务费不应明显高于可承受的交易成本)
### 2.3 不适合(一般不承接)
- 兼职、偶尔开几单的小散
- 只想要更快开仓、更高杠杆,不愿接受纪律约束
- 要求交付源码、私有仓库权限或「买断随便改」
- 希望多人共用一台服务器以压低费用
**说明:** 不适合不等于否定任何人,而是产品与服务形态不匹配;强行上线往往浪费双方时间。
---
## 3. 对外怎么提供(若合作)
默认且唯一推荐的方式:
1. **著作权人提供专属服务器**(一用户一服务器,不与其他客户共用)
2. **部署中控与实例**,配置域名 / HTTPS
3. 客户仅获得 **访问地址 + 登录账号**
4. **不交付源代码**、不开放 Git、不移交服务器 root(由甲方代持运维)
合作前建议:先阅读相关执行/行为说明,确认认同纪律设计,再谈部署与费用。
正式合作须签署《托管服务与软件使用合同》(见合同模板)。
---
## 4. 费用构成
费用分四项,建议在报价单中分列,避免被理解成「只卖服务器」:
| 费用 | 含义 | 通常周期 |
|------|------|----------|
| 服务器费用 | 该客户专属云主机、带宽、磁盘等 | 月 / 年 |
| 域名费用 | 域名注册或续费(代持或客户自带域名) | 年 |
| 部署费用 | 首次装机、证书、上线、基础培训 | 一次性 |
| 程序使用费 | 软件托管使用权、基础更新与运维响应 | 月 / 年 |
续费年一般不再收部署费(大改版或迁移可另议)。
---
## 5. 参考报价(非标价,可协商)
以下为**面向全职交易者、个案托管**的参考区间(人民币)。
因以自用为主、名额有限,实际以当时口头/书面报价为准,可高于下列下限。
### 5.1 分项参考
| 项目 | 参考区间 | 备注 |
|------|----------|------|
| 服务器费用 | **200400 元/月** | 按机型实报或固定档;专属机,不共用 |
| 域名费用 | **60120 元/年** | 实报实销;客户自带域名可减免 |
| 部署费用 | **2,0005,000 元** | 一次性;含上线与基础使用说明 |
| 程序使用费 | **1,0002,500 元/月****10,00025,000 元/年** | 年付可相当于少收 1~2 个月 |
### 5.2 首年打包示意(便于沟通)
| 档位 | 首年大约量级 | 思路 |
|------|--------------|------|
| 协作档 | 约 **1.52.5 万** | 部署中档 + 服务器 + 使用费中低 |
| 标准档 | 约 **24 万** | 部署与使用费取中高,含优先响应 |
**不提供:** 低价引流套餐、兼职小资金特惠、源码买断(若极少数个案谈源码,须另签合同且价格远高于年使用费,默认不做)。
### 5.3 付款与停服
- 部署费 + 首周期费用:签约后约定日内支付,到账后排期部署
- 续费:到期前支付;逾期可暂停访问,严重逾期可停服并释放专属服务器
- 细节以合同条款为准
---
## 6. 服务边界(简要)
**甲方(马建军)合理范围内可提供:**
- 专属机上的首次部署与基础运维
- 程序常规更新、进程异常处理
- 约定范围内的使用说明
**一般不包含(除非另议):**
- 代客交易、代管资金、投资建议
- 保证盈利或胜率
- 7×24 即时响应当成「专职客服」
- 按客户要求无限改需求而不另计定制费
交易盈亏由客户自行承担;系统为辅助与纪律工具。
---
## 7. 知识产权
- 软件与文档著作权归 **马建军** 所有,见 `著作权声明.md`
- 托管仅授权约定范围内的使用权,**不转移著作权、不交付源码**
- 仓库为私有保存;私有不影响著作权主张
---
## 8. 联系
- 著作权人 / 服务提供方:马建军
- 电话:18364911125
意向合作请说明:交易经验与是否全职、大致账户规模(可不精确)、希望开通的交易所、是否接受系统纪律限制。
**谢绝:** 仅询源码价格、要求多人共用一台服务器、明确表示不接受任何交易限制的需求。
---
*文档版本:与仓库同步维护;报价为参考,最终以双方确认的报价单与合同为准。*
+7 -2
View File
@@ -74,10 +74,15 @@ OKX_OPTIONS_API_PASSPHRASE=...
## 5. 微信提醒
当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次).
需已配置 `WECHAT_WEBHOOK`.
| 场景 | 标题 | 说明 |
|------|------|------|
| **开仓** | 【OKX期权·开仓】 | 下单成功并写入本地后必发(幂等) |
| **平仓** | 【OKX期权·平仓】 | 手动全平 / 目标位全平 / 到期或交易所平仓同步后必发(幂等) |
| 浮盈翻倍 | 【OKX期权·翻倍提醒】 | 未实现盈亏 ≥ 已付权利金约 100%,同一笔只提醒一次 |
| 挂单超时撤销 | 【OKX期权·挂单超时撤销】 | 平仓挂单超时被系统撤销 |
## 6. 与永续 / 对冲计划的关系
| | 永续(子账户) | 期权(主账户) |
+62
View File
@@ -0,0 +1,62 @@
# 策略对比说明
中控独立页 **策略对比**`/compare`):在同一风险额 `R` 下,对比三种工具的止盈能力与止损/踏空路径。
## 用途
回答两件事:
1. **盈利时谁更厉害**:干净止盈路径下各赚多少 U
2. **谁更易亏 / 更易踏空**:合约止损后踏空;期权/对冲最坏亏满权利金,但踏空路径下常仍可持有到目标
不是精确概率模型。到期「小盈/小亏」与 4 点收盘相关,**未纳入主表与推荐**。
## 入口
- 顶栏「策略对比」;设置 → 显示与导航可隐藏(`show_nav_compare`
- API`POST /api/compare/calc`(页面即时调用,价格均为手填)
## 输入
| 区块 | 字段 |
|------|------|
| 公共 | 标的 ETH/BTC、方向、入场价、风险 R、统一止损、止盈 |
| 单期权 | Call/Put、行权价、卖一(每币)、可选目标价 |
| 期期 | 主腿/次腿 各自行权与卖一;预算固定 **7:3** |
卖一口径与对冲计划一致:`单张成本 = 卖一 × ct_mult`(默认 `ct_mult=0.01`)。
## 仓位
- **合约**`张数 = floor(R / (|入场−止损| × 面值))`,默认面值 0.01
- **单期权**`张数 = floor(R / 单张成本)`
- **期期**:主预算 `0.7R`、次预算 `0.3R`,各自 `floor(预算/单张成本)`
## 主情景(A/B/C
| 路径 | 合约 | 单期权 / 期期 |
|------|------|----------------|
| A 干净止盈 | 入场→止盈盈亏 | 目标价内在价值 − 已付权利金(近似) |
| B 打止损 | −实际止损额(≈R) | 止损价处内在−权利金;并注最坏 −权利金 |
| C 先止损再去止盈 | **本单仍为止损亏损**;旁注踏空未拿到的原止盈空间 | **仍持有**至目标价,结果同 A(抗踏空对照) |
期权止盈按**内在价值近似**,不是盘口卖出价。
## 推荐规则(可解释)
1. 比较三者 A / R
2. 若合约止盈明显高于另两者(≥1.15×)→ 倾向合约,并提示踏空
3. 否则若存在踏空对照(合约亏、期权类 C 仍为正)→ 倾向单期权或期期(期期与单腿接近时优先期期)
4. 平局:抗踏空优先期权类,赔付碾压则合约
## 手测示例
`ETH` 做多,入场 3500,止损 3400,止盈 3700R=10;单 Call 行权 3600 卖一 50;对冲主 Call 3600/50、次 Put 3400/30
- 合约约 10 张,止损 −10U,止盈约 +20U,踏空未拿到约 +20U
- 单期权约 20 张,权利金 10U,止盈约 +10U,最坏 −10U
- 期期主 14 / 次 10 张
## 不做
实盘下单、拉交易所卖一(二期可选)、历史回测入库。
+50
View File
@@ -0,0 +1,50 @@
# 著作权声明
## 作品信息
| 项 | 内容 |
|----|------|
| 作品名称 | crypto_monitor(加密货币交易监控与中控系统) |
| 作品形式 | 计算机软件及相关技术文档 |
| 著作权人 | 马建军 |
| 联系电话 | 18364911125 |
| 权利主张起始 | 2026 年(以本仓库首次提交及后续持续开发为准) |
## 权利声明
本仓库所含下列内容之著作权归 **马建军** 所有:
1. 源代码、脚本、配置模板与部署相关文件;
2. 界面文案、说明文档、执行手册、策略与设计类文档;
3. 由著作权人创作并纳入本仓库的图表、脑图及其他配套材料。
**Copyright © 2026 马建军. 保留所有权利。**
未经著作权人书面许可,任何单位或个人不得擅自:
- 复制、传播、公开披露本仓库全部或部分内容;
- 出售、出租、赠与或以任何方式向第三方提供本软件或其衍生版本;
- 删除或篡改本声明及表明著作权归属的标识。
本仓库计划以私有方式保存;私有并不影响著作权人对本作品享有的权利。
## 证明与版本痕迹
本作品的创作过程以 Git 提交历史、远程私有仓库记录及快照标签(如 `snapshot/*`)为时间线依据。著作权主张以本声明与上述开发痕迹为准。
## 免责(与著作权并列说明)
本软件及相关文档仅供著作权人授权范围内的交易辅助与内部使用。市场有风险,交易决策与盈亏由使用者自行承担;本声明不构成任何投资建议。
## 对外提供方式
著作权人对外提供本软件的**默认方式**为:由著作权人为每位客户提供**专属服务器**(一用户一服务器,不与其他客户共用同一台机器)与部署,客户通过访问地址与账号使用,并缴纳服务器费、域名费、部署费及程序使用费;**不交付源代码**。
对外托管或授权使用时,请签署《托管服务与软件使用合同》(模板见同目录 `软件使用授权合同-模板.md`)。服务定位、适用对象与参考报价见 `服务说明与报价说明.md`。未签署有效合同的,除著作权人本人外,任何人均无权使用、复制或传播本软件。
## 联系
- 著作权人:马建军
- 电话:18364911125
本声明随仓库版本一并维护;如有更新,以仓库中最新文本为准。
+192
View File
@@ -0,0 +1,192 @@
# 托管服务与软件使用合同(模板)
> 说明:本文为**合同模板**,适用于甲方(马建军)提供**专属服务器**与部署、乙方通过网页/账号使用软件、**不交付源码**的托管模式。
> **一用户一服务器**:每位客户单独一台(套)服务器,不与其他客户共用同一台服务器。
> 与仓库内《著作权声明》配套:声明主张权利;本合同约定服务范围、费用与使用边界。
> 签署前请双方核对条款;金额较大或长期合作,建议再请律师审阅。
---
**合同编号:** ________________
**签订日期:** ______________
**签订地点:** ________________
## 甲方(服务提供方 / 著作权人)
| 项 | 内容 |
|----|------|
| 姓名 | 马建军 |
| 联系电话 | 18364911125 |
| 身份证件号码 | ________________(签署时填写) |
| 住址 | ________________(签署时填写,选填) |
## 乙方(客户 / 使用方)
| 项 | 内容 |
|----|------|
| 姓名 / 名称 | ________________ |
| 证件类型及号码 | ________________ |
| 联系电话 | ________________ |
| 住址 / 住所地 | ________________(选填) |
甲乙双方就甲方在其控制的服务器上部署、运维 `crypto_monitor`(加密货币交易监控与中控系统,以下称「本软件」),并向乙方提供**托管使用服务**,经协商一致,订立本合同。
---
## 第一条 服务内容与交付方式
1.1 **服务模式**:甲方为乙方提供**专属**云服务器(或等价专属托管环境)、域名解析(或子域名)、程序部署与运行维护;乙方通过甲方提供的 **访问地址与账号** 使用本软件,**不交付、不提供** 源代码、私有仓库权限、部署脚本全集或可用于独立重建系统的技术资料。
1.2 **一用户一服务器**:本合同项下服务器**仅供乙方使用**,不与其他客户共用同一台服务器、同一操作系统实例或同一套生产部署环境。甲方不得将其他客户的程序、数据或账号部署于本合同约定的专属服务器上。
1.3 **交付物**(勾选实际提供项):
- [ ] 专属服务器标识 / 实例 ID(选填):________________
- [ ] 中控访问地址:________________
- [ ] 实例访问地址(交易所):________________ / ________________ / ________________
- [ ] 登录账号:________________(或另行发放)
- [ ] 使用说明 / 培训(____ 次,每次 ____ 分钟,选填)
1.4 **不包含**(除非另签书面补充协议并另付费):源码转让、源码只读权限、独立私有化部署包、二次开发源代码交付、数据库完整镜像导出用于迁移至第三方系统、服务器 root/控制台账号移交(服务器由甲方代持运维)。
1.5 本软件著作权及部署架构归甲方所有。专属服务器的云账号/机器所有权或租赁关系由甲方管理,乙方取得的是**该服务器上本软件的有限使用权**,不转让著作权、商标权、服务器所有权及其他知识产权。
---
## 第二条 授权范围与使用限制
2.1 **授权性质**:普通、非独占、不可再许可;仅限本合同约定的**专属服务器**及域名/访问地址范围内使用。
2.2 **使用主体**:仅限乙方本人及经甲方书面确认的 ______ 名操作人员;账号不得转借、共享给合同外第三方。
2.3 **使用目的**:仅限乙方自身交易辅助、内部监控与运营;不得将本软件或实质相同的功能作为产品/服务向不特定公众或第三方收费提供。
2.4 **服务期限**
-______________ 日起,至 ______________ 日止;
- 期满前 ______ 日双方可协商续签;期满未续费且未书面延期的,甲方有权停服并回收该专属服务器资源。
2.5 乙方不得实施下列行为:
1. 要求或试图获取源码、Git 仓库、服务器 root/云控制台权限(合同另有约定的除外);
2. 复制、传播、截图外传足以重建系统的架构说明、配置全集或程序文件;
3. 对系统进行反向工程、抓包重建、或委托他人仿制同类托管产品对外经营;
4. 将访问账号、域名、API 密钥用于合同约定外的用途或转售;
5. 攻击、扫描本合同专属服务器或甲方其他基础设施。
---
## 第三条 费用与支付
3.1 乙方按下列项目向甲方支付费用(勾选并填写金额;可打包为「标准套餐价」并在备注中列明分项):
| 费用项目 | 说明 | 金额(元) | 计费周期 |
|----------|------|------------|----------|
| 服务器费用 | **乙方专属**云主机、带宽、磁盘等(不与其他客户分摊同一台机器) | ¥ ______ | □月付 □年付 |
| 域名费用 | 域名注册/续费(域名归属:□甲方代持 □乙方自有,解析由甲方配置) | ¥ ______ | □年付 |
| 部署费用 | 在专属服务器上首次环境搭建、证书、实例与中控上线(一次性) | ¥ ______ | 一次性 |
| 程序使用费 | 本软件托管使用权、日常更新与基础运维 | ¥ ______ | □月付 □年付 |
3.2 **合计**(首年 / 首月应付):人民币(大写)________________ 元整(¥ ________)。
3.3 **支付方式与时间**________________(如:签约后 ____ 日内付部署费+首周期费用;之后每 ____ 提前 ____ 日支付续费)。
3.4 **续费**:服务期满前,乙方按 3.1 约定支付下一周期费用;逾期超过 ______ 日未付的,甲方有权暂停服务;逾期超过 ______ 日仍未付的,甲方有权解除合同并停服,已付未消费部分按实际服务天数抵扣后退还(部署费是否退还:□不退 □按约定 ________________)。
3.5 **价格调整**:续签时,因云厂商涨价、域名涨价或功能范围扩大,甲方可提前 ______ 日书面通知调整后续周期价格;乙方不同意调整的,可在当前周期结束后不再续签。
3.6 [ ] 本次为试用 / 友情托管:期限至 ______,费用减免 ________________,乙方仍须遵守第二条全部限制。
---
## 第四条 部署、运维与更新
4.1 **甲方责任**(合理范围内):
- 按约定完成首次部署并使乙方可以登录使用;
- 程序版本更新、安全补丁、PM2/进程异常重启等**基础运维**(具体 SLA________________,如「工作日 24 小时内响应」);
- 因交易所 API 变更导致的**常规适配**(重大重构另议)。
4.2 **乙方责任**
- 提供合法有效的交易所 API 等密钥信息,并保证账户使用合规;
- 妥善保管登录密码;因乙方泄露导致的损失由乙方承担;
- 按约定及时支付各项费用。
4.3 **数据**:乙方在系统中的交易记录、配置等业务数据归属乙方,并存放于本合同专属服务器;甲方为运维可接触相关数据,但不得用于合同约定外的目的,亦不得将乙方数据混存于其他客户服务器。合同终止后,乙方可申请导出**业务数据**(格式:________________,费用:________________);**不包含**源码与部署环境镜像。
4.4 **停服与备份**:甲方在停服前 ______ 日通知乙方(因乙方欠费紧急停服除外);停服后该专属服务器上的数据保留 ______ 日,逾期可删除并释放服务器资源。
---
## 第五条 保密
5.1 乙方对知悉的本软件存在、界面逻辑、非公开功能、报价及甲方技术方案负有保密义务。
5.2 甲方对乙方的 API 密钥、账户信息负有保密义务,除运维必需与法律要求外不得向第三方披露。
5.3 保密期限:合同存续期间及终止后 ______ 年(未填则视为 5 年)。
---
## 第六条 免责与风险提示
6.1 本软件为交易辅助工具,不构成投资建议。市场有风险,乙方交易决策与盈亏自行承担。
6.2 因行情、交易所接口变更、网络故障、云厂商故障、乙方误操作等导致的交易或间接损失,在法律允许范围内甲方不承担责任;因甲方故意或重大过失造成的服务长时间不可用除外(可约定:连续不可用超过 ____ 小时按比例退还当期程序使用费)。
6.3 甲方保证其有权提供本托管服务并享有本软件著作权;乙方保证身份信息及资金账户来源合法。
---
## 第七条 违约责任
7.1 乙方欠费、外传账号、试图获取源码或违反第二条的,甲方有权**暂停或立即终止服务**,并要求:
1. 停止违约行为;
2. 支付欠费及违约金人民币 ________ 元(或按实际损失);
3. 赔偿甲方维权合理费用。
7.2 甲方无正当理由逾期未完成首次部署超过 ______ 日,或恶意长期停服且无合理解释的,乙方有权解除合同并要求退还已付未消费部分(部署费处理按 3.4 约定)。
---
## 第八条 合同解除与终止
8.1 协商一致可书面解除。
8.2 一方严重违约,守约方书面通知后 ______ 日内仍未改正的,守约方可解除。
8.3 终止后:乙方停止使用;甲方关闭访问权限;双方按第四条、第五条履行数据与保密义务。
---
## 第九条 争议解决
因本合同引起的争议,双方协商解决;协商不成的,提交甲方住所地有管辖权的人民法院诉讼解决(或:提交 ________ 仲裁委员会仲裁)。
---
## 第十条 其他
10.1 未尽事宜可签订补充协议。
10.2 本合同一式贰份,甲乙双方各执壹份,具有同等法律效力。
10.3 附件(如有):□《著作权声明》副本 □《服务说明与报价说明》 □ 服务清单 / 报价单 □ 域名与实例列表 □ 其他:________
---
## 签署栏
**甲方(服务提供方 / 著作权人):**
签名:________________  日期:______ 年 ________
**乙方(客户):**
签名 / 盖章:________________  日期:______ 年 ________
---
## 填写提示(签署前可删本段)
1. **标准商业路径**:专属服务器费 + 域名费 + 部署费(首单)+ 程序使用费(按月/年)— 四项建议在报价单里写清,合同 3.1 表格与报价一致。
2. **一用户一服务器**:新客户开新机器;不要把多名客户塞进同一台 VPS。
3. **源码**:默认一律不交付;若客户坚持私有化,应另签高价「源码许可/买断」合同,与本托管模板分开。
4. **自用**:著作权人本人使用无需签本合同,见《著作权声明》。
5. **不要**在仓库添加开源 `LICENSE`(MIT 等),与「保留所有权利 + 托管授权」冲突。
+59 -4
View File
@@ -61,6 +61,11 @@
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) {
@@ -235,9 +240,57 @@
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 || path.indexOf("/api/options/cross-transfer") >= 0) {
setSettingsSubTabInUrl("options_transfer");
return "options_transfer";
}
return "";
}
async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin",
@@ -400,14 +453,15 @@
}
}
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())
.catch(() => reloadCurrentTab());
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
}
function patchApplyListWindow() {
@@ -466,14 +520,15 @@
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())
.catch(() => reloadCurrentTab());
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
},
true
);
+177
View File
@@ -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);
+17 -8
View File
@@ -31,6 +31,8 @@
function applyDisplayToNav(display) {
const map = {
dashboard: "show_nav_dashboard",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
@@ -44,14 +46,19 @@
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
const key = map[tab];
if (!key) return;
const show = navPrefShow(display, key);
a.classList.toggle("nav-hidden", !show);
a.style.display = show ? "" : "none";
});
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;
}
@@ -59,6 +66,8 @@
const d = DISPLAY();
const map = {
dashboard: "show_nav_dashboard",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
+438
View File
@@ -5526,3 +5526,441 @@ html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover
color: #ff8b8b;
}
/* —— 实例手机壳:底栏四件套(仅 ≤720px + body.inst-phone) —— */
.inst-mobile-tabbar,
.inst-mobile-more,
.instance-phone-only {
display: none;
}
@media (max-width: 720px) {
:root {
--inst-m-tabbar-h: 56px;
--inst-m-page-pad: calc(var(--inst-m-tabbar-h) + max(16px, env(safe-area-inset-bottom)) + 12px);
}
/* 仅手机壳:不碰 >720 桌面/平板。overflow 放 container,避免裁切 fixed 下单弹窗 */
body.inst-phone {
padding-bottom: var(--inst-m-page-pad) !important;
box-sizing: border-box;
}
body.inst-phone .embed-top-nav.top-nav {
display: none !important;
}
body.inst-phone .header {
margin-bottom: 4px;
}
body.inst-phone .header h1 {
display: none !important;
}
body.inst-phone .container {
padding-bottom: 8px !important;
max-width: 100% !important;
min-width: 0 !important;
overflow-x: hidden !important;
}
body.inst-phone #embed-page-root,
body.inst-phone .embed-tab-pane,
body.inst-phone .embed-tab-pane.is-active-pane {
max-width: 100%;
min-width: 0;
overflow-x: hidden;
}
body.inst-phone .card {
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
body.inst-phone .instance-header-toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
body.inst-phone .instance-header-toolbar-end {
width: auto;
margin-left: auto;
}
body.inst-phone .instance-phone-only {
display: flex;
}
body.inst-phone .instance-header-phone-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
width: 100%;
}
html[data-theme="light"] body.inst-phone .instance-header-phone-strip {
border-top-color: rgba(0, 0, 0, 0.08);
}
body.inst-phone .inst-phone-chip {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 6px;
min-height: 32px;
min-width: 0;
padding: 6px 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
color: var(--inst-text);
}
html[data-theme="light"] body.inst-phone .inst-phone-chip {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.08);
}
body.inst-phone .inst-phone-chip em {
font-style: normal;
color: var(--inst-muted);
font-size: 11px;
flex: 0 0 auto;
}
body.inst-phone .inst-phone-chip b {
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
body.inst-phone .inst-phone-chip--pnl b {
color: var(--inst-nav-idle);
}
/* 实盘/关键位等表单:窄屏拉满,避免挤成一行裁切 */
body.inst-phone #add-order-form.form-row,
body.inst-phone form.form-row {
align-items: stretch;
}
body.inst-phone #add-order-form.form-row > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone #add-order-form.form-row > select,
body.inst-phone #add-order-form #sltp-mode,
body.inst-phone form.form-row > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone form.form-row > select {
flex: 1 1 100% !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box;
}
body.inst-phone #add-order-form .order-entry-model-row,
body.inst-phone #add-order-form .order-time-close-wrap,
body.inst-phone #add-order-form > label,
body.inst-phone #add-order-form > button {
flex: 1 1 100%;
max-width: 100%;
}
body.inst-phone #add-order-form .order-entry-model-row select {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
}
body.inst-phone .order-plan-preview {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
/* 期权:防止宽表撑破页面;表内横向滑看「操作」 */
body.inst-phone .options-page-wrap,
body.inst-phone .options-dual-grid,
body.inst-phone .options-order-card,
body.inst-phone .options-pos-card-wrap {
max-width: 100%;
min-width: 0;
}
body.inst-phone .options-dual-grid {
grid-template-columns: minmax(0, 1fr) !important;
}
body.inst-phone .options-chain-toolbar.form-row {
flex-wrap: wrap;
gap: 6px;
}
body.inst-phone .options-chain-toolbar .btn-secondary,
body.inst-phone .options-chain-toolbar select,
body.inst-phone .options-chain-toolbar .opt-chain-view-group,
body.inst-phone .options-chain-toolbar .opt-type-btn-group {
flex: 0 1 auto;
max-width: 100%;
}
body.inst-phone .options-strike-table-wrap,
body.inst-phone .options-strike-table-wrap--t,
body.inst-phone .options-history-table-wrap,
body.inst-phone .table-wrap {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto !important;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
touch-action: pan-x pan-y;
}
body.inst-phone .options-strike-table {
width: max-content;
min-width: 100%;
}
body.inst-phone .options-strike-table th,
body.inst-phone .options-strike-table td {
white-space: nowrap;
}
/* 列表视图:隐藏合约 / 买一 / 到期平衡 / 距平衡(保留行权价·类型·卖一·操作) */
body.inst-phone #opt-strike-head-list th:nth-child(3),
body.inst-phone #opt-strike-head-list th:nth-child(5),
body.inst-phone #opt-strike-head-list th:nth-child(6),
body.inst-phone #opt-strike-head-list th:nth-child(7),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(3),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(5),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(6),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(7) {
display: none !important;
}
/* 选择后下单弹窗:盖过底栏,可滚动完整显示 */
body.inst-phone .opt-order-backdrop {
z-index: 2400;
align-items: flex-end;
justify-content: center;
padding: 0;
padding-bottom: env(safe-area-inset-bottom);
background: rgba(0, 0, 0, 0.72);
}
body.inst-phone .opt-order-backdrop:not([hidden]) {
display: flex !important;
}
body.inst-phone .opt-order-dialog {
width: 100%;
max-width: 100%;
max-height: min(88vh, 720px);
margin: 0;
border-radius: 16px 16px 0 0;
padding: 14px 14px calc(14px + env(safe-area-inset-bottom));
overflow: auto;
-webkit-overflow-scrolling: touch;
}
body.inst-phone .opt-order-dialog .options-order-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
body.inst-phone .options-estimate-row .opt-est-main {
flex-wrap: wrap;
}
body.inst-phone .opt-size-mode-bar {
flex-wrap: wrap;
width: 100%;
}
body.inst-phone .inst-mobile-tabbar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
height: calc(var(--inst-m-tabbar-h) + env(safe-area-inset-bottom));
padding: 0 max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom)
max(8px, env(safe-area-inset-left));
align-items: stretch;
justify-content: space-around;
gap: 2px;
background: color-mix(in srgb, #12161f 92%, transparent);
border-top: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-sizing: border-box;
}
html[data-theme="light"] body.inst-phone .inst-mobile-tabbar {
background: color-mix(in srgb, #f4f7fb 94%, transparent);
border-top-color: rgba(0, 0, 0, 0.1);
}
body.inst-phone .inst-mobile-tabbar .inst-m-tab.nav-hidden {
display: none !important;
}
body.inst-phone .inst-m-tab {
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 6px 2px;
padding: 0 4px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--inst-muted);
font: inherit;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
body.inst-phone .inst-m-tab:hover,
body.inst-phone .inst-m-tab:focus-visible {
color: var(--inst-text);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
body.inst-phone .inst-m-tab.active {
color: var(--inst-nav-idle);
background: rgba(143, 200, 255, 0.12);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--inst-nav-idle) 35%, transparent);
}
body.inst-phone.inst-mobile-more-open .inst-mobile-more {
display: block;
}
body.inst-phone .inst-mobile-more {
position: fixed;
inset: 0;
z-index: 90;
}
body.inst-phone .inst-mobile-more-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
body.inst-phone .inst-mobile-more-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: min(78vh, 560px);
overflow: auto;
padding: 10px 16px calc(16px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
background: #12161f;
border: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: none;
box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35);
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-sheet {
background: #f4f7fb;
border-color: rgba(0, 0, 0, 0.1);
}
body.inst-phone .inst-mobile-more-handle {
width: 36px;
height: 4px;
margin: 2px auto 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-handle {
background: rgba(0, 0, 0, 0.15);
}
body.inst-phone .inst-mobile-more-title {
margin: 0 0 4px;
font-size: 1rem;
color: var(--inst-text);
}
body.inst-phone .inst-mobile-more-hint {
margin: 0 0 14px;
font-size: 11px;
color: var(--inst-muted);
}
body.inst-phone .inst-mobile-more-nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
body.inst-phone .inst-mobile-more-nav a {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--inst-text);
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-nav a {
border-color: rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.03);
}
body.inst-phone .inst-mobile-more-nav a.nav-hidden {
display: none !important;
}
body.inst-phone .inst-mobile-more-nav a.active {
border-color: color-mix(in srgb, var(--inst-nav-idle) 45%, transparent);
background: rgba(143, 200, 255, 0.12);
color: var(--inst-nav-idle);
}
body.inst-phone .inst-mobile-more-close {
width: 100%;
margin-top: 14px;
min-height: 44px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: var(--inst-text);
font: inherit;
font-size: 14px;
cursor: pointer;
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-close {
border-color: rgba(0, 0, 0, 0.12);
}
}
+7
View File
@@ -280,8 +280,15 @@
const mode = currentSizeMode();
const sheetsEl = document.getElementById("opt-sheets-amount");
const ethEl = document.getElementById("opt-eth-amount");
const hint = document.getElementById("opt-budget-full-hint");
const capEl = document.getElementById("opt-budget-full-cap");
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
if (hint) hint.style.display = mode === "budget_full" ? "" : "none";
if (capEl && root && root.dataset.tradeBudget) {
const n = Number(root.dataset.tradeBudget);
if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2);
}
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
const radio = chip.querySelector('input[name="opt-size-mode"]');
chip.classList.toggle("is-selected", !!(radio && radio.checked));
+17 -10
View File
@@ -147,9 +147,10 @@
opts = opts || {};
const hub = !!opts.hub;
const readOnly = !!opts.readOnly;
const net = netPnlFromPos(p);
const roi = netRoiFromPos(p, net);
const uplCls = pnlCls(net, hub);
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) : "";
@@ -166,6 +167,12 @@
'<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 + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
@@ -189,15 +196,12 @@
'<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>" +
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</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, p.premium_paid, hub)) + "</span></div>" +
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" +
"</div>" +
(function () {
const hint = closeGateHint(closePreview);
@@ -217,19 +221,22 @@
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 (Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
}
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const profitSpan = hidePnl
? ""
: '<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) + " USDC") + "</span>" +
'<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>" +
profitSpan +
'<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
"</span></div>"
+19
View File
@@ -58,6 +58,7 @@ HOT_RELOAD_EXACT = frozenset({
"RISK_COOLING_HOURS_MANUAL",
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE",
"KEY_AUTO_ORDER_ENABLED",
"TRADE_DIRECTION_RESTRICT_ENABLED",
@@ -69,7 +70,10 @@ HOT_RELOAD_EXACT = frozenset({
"MONITOR_POLL_SECONDS",
"AUTO_TRANSFER_ENABLED",
"AUTO_TRANSFER_AMOUNT",
"AUTO_TRANSFER_FROM",
"AUTO_TRANSFER_TO",
"AUTO_TRANSFER_BJ_HOUR",
"TRANSFER_CCY",
"FORCE_CLOSE_ENABLED",
"FORCE_CLOSE_BJ_HOUR",
"BTC_LEVERAGE",
@@ -125,6 +129,17 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("long_only", "仅做多"),
("short_only", "仅做空"),
),
"AUTO_TRANSFER_FROM": (
("funding", "funding 资金账户"),
("swap", "swap 交易账户"),
("spot", "spot 现货"),
),
"AUTO_TRANSFER_TO": (
("swap", "swap 交易账户"),
("funding", "funding 资金账户"),
("spot", "spot 现货"),
),
"TRANSFER_CCY": (("USDT", "USDT"),),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
@@ -135,6 +150,7 @@ _SELECT_ALIASES: dict[str, dict[str, str]] = {
"OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"TRANSFER_CCY": {"usdt": "USDT"},
}
@@ -158,10 +174,13 @@ def normalize_select_value(key: str, value: Optional[str]) -> str:
if low in aliases:
return aliases[low]
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
allowed_by_lower = {v.lower(): v for v in allowed}
if low in allowed:
return low
if raw in allowed:
return raw
if low in allowed_by_lower:
return allowed_by_lower[low]
return raw
+11 -4
View File
@@ -94,6 +94,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
@@ -102,10 +103,10 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
"fields": [
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"),
("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"),
("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
("TRANSFER_CCY", "划转币种", "默认 USDT"),
("TRANSFER_CCY", "划转币种", ""),
],
},
{
@@ -197,7 +198,11 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_COOLING_HOURS_MANUAL": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
"AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
@@ -212,7 +217,9 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key in file_values:
return file_values[key]
file_val = str(file_values.get(key) or "").strip()
if file_val:
return file_val
runtime = os.getenv(key)
if runtime is not None and str(runtime).strip() != "":
return str(runtime).strip()
+2 -1
View File
@@ -22,6 +22,7 @@ def execute_transfer_usdt(
) -> tuple[bool, str, Any]:
if amount <= 0:
return False, "划转金额必须大于0", None
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
ok_live, reason = ensure_live_ready()
if not ok_live:
return False, reason, None
@@ -31,7 +32,7 @@ def execute_transfer_usdt(
except Exception:
pass
try:
resp = exchange.transfer(transfer_ccy, float(amount), from_account, to_account)
resp = exchange.transfer(ccy, float(amount), from_account, to_account)
return True, "划转成功", resp
except Exception as e:
msg = str(e)
+833
View File
@@ -0,0 +1,833 @@
"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径.
仅只读行情;不触及下单链路.
"""
from __future__ import annotations
import csv
import io
import statistics
import time
from datetime import date, datetime, timedelta
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
import httpx
APP_TZ = ZoneInfo("Asia/Shanghai")
END_HOUR = 16
EXCHANGE = "okx"
TIMEFRAME = "1H"
SYMBOLS: dict[str, dict[str, str]] = {
"eth": {
"label": "ETH",
"index_inst": "ETH-USD",
"swap_inst": "ETH-USDT-SWAP",
},
"btc": {
"label": "BTC",
"index_inst": "BTC-USD",
"swap_inst": "BTC-USDT-SWAP",
},
}
PERIOD_DAYS: dict[str, int] = {
"1m": 30,
"2m": 60,
"3m": 90,
"6m": 180,
"1y": 365,
}
OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles"
OKX_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles"
OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles"
OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-candles"
def normalize_symbol(raw: str) -> str:
s = (raw or "").strip().lower()
if s in ("eth", "ethereum"):
return "eth"
if s in ("btc", "bitcoin"):
return "btc"
raise ValueError("symbol 仅支持 eth / btc")
def resolve_sample_days(period: str, custom_days: Any = None) -> int:
p = (period or "2m").strip().lower()
if p == "custom":
try:
n = int(custom_days)
except (TypeError, ValueError):
raise ValueError("自定义天数无效") from None
return max(7, min(400, n))
if p not in PERIOD_DAYS:
raise ValueError("周期无效")
return PERIOD_DAYS[p]
def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]:
"""返回 [start, end) 的本地时刻;end 为结算日 16:00."""
if not (0 <= int(start_hour) <= 23):
raise ValueError("起点须为 0-23 整点")
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
sh = int(start_hour)
if sh >= END_HOUR:
prev = settlement - timedelta(days=1)
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
else:
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
return start, end
def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]:
"""最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00)."""
now = now or datetime.now(APP_TZ)
if now.tzinfo is None:
now = now.replace(tzinfo=APP_TZ)
else:
now = now.astimezone(APP_TZ)
today = now.date()
today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
latest = today if now >= today_end else today - timedelta(days=1)
return [latest - timedelta(days=i) for i in range(int(sample_days))]
def _safe_float(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
"""open_time_ms -> {o,h,l,c}."""
m: dict[int, dict[str, float]] = {}
for b in bars or []:
if not isinstance(b, dict):
continue
ts = b.get("ts")
if ts is None:
ts = b.get("open_time_ms")
try:
ts_i = int(ts)
except (TypeError, ValueError):
continue
o = _safe_float(b.get("o") if "o" in b else b.get("open"))
h = _safe_float(b.get("h") if "h" in b else b.get("high"))
l = _safe_float(b.get("l") if "l" in b else b.get("low"))
c = _safe_float(b.get("c") if "c" in b else b.get("close"))
if None in (o, h, l, c):
continue
m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)}
return m
def compute_day_row(
settlement: date,
start_hour: int,
bar_map: dict[int, dict[str, float]],
) -> Optional[dict[str, Any]]:
start, end = window_bounds_for_settlement(settlement, start_hour)
start_ms = int(start.timestamp() * 1000)
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
if start_ms not in bar_map or last_bar_ms not in bar_map:
return None
opens = bar_map[start_ms]["o"]
close = bar_map[last_bar_ms]["c"]
hi = bar_map[start_ms]["h"]
lo = bar_map[start_ms]["l"]
t = start_ms
while t <= last_bar_ms:
b = bar_map.get(t)
if b:
hi = max(hi, b["h"])
lo = min(lo, b["l"])
t += 3600 * 1000
up = hi - opens
down = opens - lo
amp = hi - lo
change = close - opens
wd = settlement.weekday() # Mon=0 … Sun=6
is_we = wd >= 5
return {
"settlement_day": settlement.isoformat(),
"window_start": start.strftime("%Y-%m-%d %H:%M"),
"window_end": end.strftime("%Y-%m-%d %H:%M"),
"weekday": wd,
"weekday_label": "" if wd == 5 else ("" if wd == 6 else ""),
"is_weekend": is_we,
"open": round(opens, 4),
"high": round(hi, 4),
"low": round(lo, 4),
"close": round(close, 4),
"up_points": round(up, 4),
"down_points": round(down, 4),
"amplitude": round(amp, 4),
"change": round(change, 4),
}
def normalize_straddle_premium(raw: Any) -> Optional[float]:
"""双边权利金(点数).空/≤0 表示不做跨式对照."""
if raw is None or raw == "":
return None
try:
v = float(raw)
except (TypeError, ValueError):
raise ValueError("双边权利金须为数字") from None
if v <= 0:
return None
return v
def normalize_take_profit(raw: Any) -> Optional[float]:
"""止盈点.空/≤0 表示不止盈,有效波动用 |涨跌|."""
if raw is None or raw == "":
return None
try:
v = float(raw)
except (TypeError, ValueError):
raise ValueError("止盈点须为数字") from None
if v <= 0:
return None
return v
def normalize_weekend_filter(raw: Any) -> str:
"""all | exclude | only;默认全部."""
s = (str(raw) if raw is not None else "all").strip().lower()
if s in ("", "all", "全部"):
return "all"
if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"):
return "exclude"
if s in ("only", "weekend_only", "only_weekend", "仅周末"):
return "only"
raise ValueError("周末筛选须为 all / exclude / only")
def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]:
mode = normalize_weekend_filter(weekend_filter)
if mode == "all":
return list(rows or [])
out: list[dict[str, Any]] = []
for r in rows or []:
is_we = bool(r.get("is_weekend"))
if "is_weekend" not in r and r.get("settlement_day"):
try:
is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5
except ValueError:
is_we = False
if mode == "exclude" and is_we:
continue
if mode == "only" and not is_we:
continue
out.append(r)
return out
def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float:
"""触达止盈(≥)用止盈点,否则用 |涨跌|."""
abs_chg = abs(float(row.get("change") or 0))
if take_profit is None:
return abs_chg
tp = float(take_profit)
up = float(row.get("up_points") or 0)
down = float(row.get("down_points") or 0)
if up >= tp or down >= tp:
return tp
return abs_chg
def enrich_rows_pnl(
rows: list[dict[str, Any]],
*,
straddle_premium: Optional[float] = None,
take_profit: Optional[float] = None,
) -> list[dict[str, Any]]:
"""为日表附加有效波动 / 是否触达止盈 / 收益(有权利金时)."""
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
out: list[dict[str, Any]] = []
for r in rows or []:
item = dict(r)
if "is_weekend" not in item and item.get("settlement_day"):
try:
wd = date.fromisoformat(str(item["settlement_day"])).weekday()
item["weekday"] = wd
item["weekday_label"] = "" if wd == 5 else ("" if wd == 6 else "")
item["is_weekend"] = wd >= 5
except ValueError:
item.setdefault("weekday_label", "")
item.setdefault("is_weekend", False)
move = effective_move_points(item, tp)
hit = False
if tp is not None:
hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp
item["effective_move"] = round(move, 4)
item["take_profit_hit"] = hit
item["profit"] = round(move - prem, 4) if prem is not None else None
out.append(item)
return out
def straddle_long_stats(
rows: list[dict[str, Any]],
premium: float,
*,
take_profit: Any = None,
) -> dict[str, Any]:
"""买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|)."""
prem = float(premium)
if prem <= 0:
raise ValueError("双边权利金须 > 0")
tp = normalize_take_profit(take_profit)
enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp)
if not enriched:
return {
"side": "long_straddle",
"premium": prem,
"take_profit": tp,
"sample_count": 0,
"up_exceed_days": 0,
"up_exceed_ratio": None,
"down_exceed_days": 0,
"down_exceed_ratio": None,
"abs_change_exceed_days": 0,
"abs_change_exceed_ratio": None,
"tp_hit_days": 0,
"tp_hit_ratio": None,
"pnl_total": None,
"pnl_avg": None,
"win_days": 0,
"win_ratio": None,
"pnl_max": None,
"pnl_min": None,
}
n = len(enriched)
up_ex = sum(1 for r in enriched if float(r["up_points"]) > prem)
down_ex = sum(1 for r in enriched if float(r["down_points"]) > prem)
abs_ex = sum(1 for r in enriched if abs(float(r["change"])) > prem)
tp_hits = sum(1 for r in enriched if r.get("take_profit_hit"))
pnls = [float(r["profit"]) for r in enriched if r.get("profit") is not None]
win = sum(1 for p in pnls if p > 0)
return {
"side": "long_straddle",
"premium": round(prem, 4),
"take_profit": round(tp, 4) if tp is not None else None,
"sample_count": n,
"up_exceed_days": up_ex,
"up_exceed_ratio": round(up_ex / n, 4),
"down_exceed_days": down_ex,
"down_exceed_ratio": round(down_ex / n, 4),
"abs_change_exceed_days": abs_ex,
"abs_change_exceed_ratio": round(abs_ex / n, 4),
"tp_hit_days": tp_hits,
"tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None,
"pnl_total": round(sum(pnls), 4),
"pnl_avg": round(statistics.fmean(pnls), 4),
"win_days": win,
"win_ratio": round(win / n, 4),
"pnl_max": round(max(pnls), 4),
"pnl_min": round(min(pnls), 4),
}
def summarize_rows(
rows: list[dict[str, Any]],
*,
straddle_premium: Any = None,
take_profit: Any = None,
) -> dict[str, Any]:
if not rows:
out = {
"sample_count": 0,
"max_amplitude": None,
"max_amplitude_day": None,
"avg_amplitude": None,
"median_amplitude": None,
"max_up_points": None,
"avg_up_points": None,
"max_down_points": None,
"avg_down_points": None,
"up_day_ratio": None,
"down_day_ratio": None,
"straddle": None,
}
prem = normalize_straddle_premium(straddle_premium)
if prem is not None:
out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit)
return out
amps = [float(r["amplitude"]) for r in rows]
ups = [float(r["up_points"]) for r in rows]
downs = [float(r["down_points"]) for r in rows]
max_amp = max(amps)
max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp)
up_days = sum(1 for r in rows if float(r["change"]) > 0)
down_days = sum(1 for r in rows if float(r["change"]) < 0)
n = len(rows)
out: dict[str, Any] = {
"sample_count": n,
"max_amplitude": round(max_amp, 4),
"max_amplitude_day": max_amp_day,
"avg_amplitude": round(statistics.fmean(amps), 4),
"median_amplitude": round(statistics.median(amps), 4),
"max_up_points": round(max(ups), 4),
"avg_up_points": round(statistics.fmean(ups), 4),
"max_down_points": round(max(downs), 4),
"avg_down_points": round(statistics.fmean(downs), 4),
"up_day_ratio": round(up_days / n, 4),
"down_day_ratio": round(down_days / n, 4),
"straddle": None,
}
prem = normalize_straddle_premium(straddle_premium)
if prem is not None:
out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit)
return out
def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]:
if not row or len(row) < 5:
return None
try:
ts = int(row[0])
o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4])
except (TypeError, ValueError, IndexError):
return None
return {"ts": ts, "o": o, "h": h, "l": l, "c": c}
def _okx_get_json(
client: httpx.Client,
url: str,
params: dict[str, str],
*,
retries: int = 8,
) -> dict[str, Any]:
"""GET OKX 公共行情;遇 429 指数退避重试."""
last_err: Optional[BaseException] = None
for attempt in range(max(1, int(retries))):
try:
r = client.get(url, params=params)
if r.status_code == 429:
wait = min(12.0, 0.7 * (2**attempt))
time.sleep(wait)
last_err = httpx.HTTPStatusError(
f"429 Too Many Requests for url '{r.url}'",
request=r.request,
response=r,
)
continue
r.raise_for_status()
body = r.json()
if not isinstance(body, dict):
raise RuntimeError("OKX 返回非对象 JSON")
return body
except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else None
if status == 429 and attempt + 1 < retries:
wait = min(12.0, 0.7 * (2**attempt))
time.sleep(wait)
last_err = exc
continue
raise
except httpx.TransportError as exc:
if attempt + 1 < retries:
time.sleep(min(8.0, 0.5 * (2**attempt)))
last_err = exc
continue
raise
if last_err is not None:
raise last_err
raise RuntimeError("OKX 请求失败")
def fetch_okx_candles(
*,
url: str,
inst_id: str,
since_ms: int,
until_ms: int,
bar: str = "1H",
client: Optional[httpx.Client] = None,
timeout: float = 30.0,
history_url: Optional[str] = None,
max_pages: int = 200,
page_pause_sec: float = 0.12,
history_page_pause_sec: float = 0.22,
) -> list[dict[str, Any]]:
"""拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).
OKX 近期接口约仅 1440 ;更早需 history_* 端点续拉.
分页带间隔,429 自动退避重试.
"""
own = client is None
client = client or httpx.Client(
timeout=timeout,
trust_env=False,
headers={"User-Agent": "crypto_monitor-amp-stats/1.0"},
)
try:
out: dict[int, dict[str, Any]] = {}
after: Optional[str] = None
active_url = url
switched_history = False
for page_i in range(max(20, int(max_pages))):
if page_i > 0:
pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec
if pause > 0:
time.sleep(pause)
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
if after:
params["after"] = after
body = _okx_get_json(client, active_url, params)
if str(body.get("code") or "") not in ("0", "0.0", ""):
raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}")
data = body.get("data") or []
if not data:
# 近期接口到头 → 切历史端点再试
if history_url and not switched_history and after is not None:
active_url = history_url
switched_history = True
time.sleep(max(history_page_pause_sec, 0.35))
continue
break
oldest_ts = None
for row in data:
parsed = _parse_okx_candle_row(row)
if not parsed:
continue
ts = int(parsed["ts"])
oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts)
if ts < since_ms - 3600 * 1000:
continue
if ts > until_ms + 3600 * 1000:
continue
out[ts] = parsed
if oldest_ts is None:
break
if oldest_ts <= since_ms:
break
# 无新进度时避免死循环
if after is not None and str(oldest_ts) == after:
if history_url and not switched_history:
active_url = history_url
switched_history = True
time.sleep(max(history_page_pause_sec, 0.35))
continue
break
after = str(oldest_ts)
# 近期接口返回变少且仍未覆盖 since → 切历史
if (
history_url
and not switched_history
and len(data) < 100
and oldest_ts > since_ms
):
active_url = history_url
switched_history = True
time.sleep(max(history_page_pause_sec, 0.35))
return [out[k] for k in sorted(out.keys())]
finally:
if own:
client.close()
def fetch_symbol_bars(
symbol: str,
*,
since_ms: int,
until_ms: int,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
) -> tuple[list[dict[str, Any]], str, str]:
"""返回 (bars, price_source_label, inst_id)."""
key = normalize_symbol(symbol)
meta = SYMBOLS[key]
if fetch_fn:
bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms)
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
index_err: Optional[BaseException] = None
try:
bars = fetch_okx_candles(
url=OKX_INDEX_CANDLES,
history_url=OKX_HISTORY_INDEX_CANDLES,
inst_id=meta["index_inst"],
since_ms=since_ms,
until_ms=until_ms,
)
if bars:
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
except Exception as exc:
index_err = exc
# 指数侧已触发限频时先冷却,再降级永续,避免连环 429
time.sleep(1.2)
try:
bars = fetch_okx_candles(
url=OKX_SWAP_CANDLES,
history_url=OKX_HISTORY_SWAP_CANDLES,
inst_id=meta["swap_inst"],
since_ms=since_ms,
until_ms=until_ms,
)
except Exception as exc:
detail = f"index={index_err}; swap={exc}" if index_err else str(exc)
raise RuntimeError(f"OKX K线拉取失败({detail})") from exc
if not bars:
detail = f"index={index_err}" if index_err else "empty"
raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})")
return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"]
def compute_amp_stats(
*,
symbol: str = "eth",
start_hour: int = 16,
period: str = "2m",
custom_days: Any = None,
straddle_premium: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all",
now: Optional[datetime] = None,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
) -> dict[str, Any]:
key = normalize_symbol(symbol)
sh = int(start_hour)
if sh < 0 or sh > 23:
raise ValueError("起点须为 0-23 整点")
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter)
sample_days = resolve_sample_days(period, custom_days)
settlements = list_settlement_dates(sample_days=sample_days, now=now)
if not settlements:
raise RuntimeError("无可用结算日")
# 最远窗起点
oldest = settlements[-1]
newest = settlements[0]
start0, _ = window_bounds_for_settlement(oldest, sh)
_, end1 = window_bounds_for_settlement(newest, sh)
since_ms = int(start0.timestamp() * 1000)
until_ms = int(end1.timestamp() * 1000)
bars, price_source, inst_id = fetch_symbol_bars(
key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn
)
bar_map = bars_to_map(bars)
rows_all: list[dict[str, Any]] = []
missing: list[str] = []
for d in settlements:
row = compute_day_row(d, sh, bar_map)
if row is None:
missing.append(d.isoformat())
continue
rows_all.append(row)
return build_amp_result(
rows_all=rows_all,
symbol_key=key,
start_hour=sh,
period=period,
sample_days=sample_days,
straddle_premium=prem,
take_profit=tp,
weekend_filter=we_mode,
price_source=price_source,
inst_id=inst_id,
missing=missing,
)
def build_amp_result(
*,
rows_all: list[dict[str, Any]],
symbol_key: str,
start_hour: int,
period: str,
sample_days: int,
straddle_premium: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all",
price_source: str = "",
inst_id: str = "",
missing: Optional[list[str]] = None,
) -> dict[str, Any]:
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter)
filtered = filter_weekend_rows(rows_all, we_mode)
rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp)
summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp)
if period == "custom" or str(period).startswith("custom:"):
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
else:
period_label = str(period)
miss = missing or []
return {
"ok": True,
"exchange": EXCHANGE,
"symbol": symbol_key,
"symbol_label": SYMBOLS[symbol_key]["label"],
"start_hour": start_hour,
"end_hour": END_HOUR,
"period": period_label,
"sample_days_requested": sample_days,
"straddle_premium": prem,
"take_profit": tp,
"weekend_filter": we_mode,
"timeframe": TIMEFRAME,
"price_source": price_source,
"inst_id": inst_id,
"timezone": "Asia/Shanghai",
"rows_all": rows_all,
"rows": rows,
"summary": summary,
"missing_days": miss[:30],
"missing_count": len(miss),
}
def reframe_amp_stats(
*,
rows_all: list[dict[str, Any]],
symbol: str = "eth",
start_hour: int = 16,
period: str = "2m",
sample_days: int = 60,
straddle_premium: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all",
price_source: str = "",
inst_id: str = "",
missing: Optional[list[str]] = None,
) -> dict[str, Any]:
"""已有日表上改周末/权利金/止盈,不拉 K 线."""
key = normalize_symbol(symbol)
return build_amp_result(
rows_all=list(rows_all or []),
symbol_key=key,
start_hour=int(start_hour),
period=period,
sample_days=int(sample_days or 60),
straddle_premium=straddle_premium,
take_profit=take_profit,
weekend_filter=weekend_filter,
price_source=price_source,
inst_id=inst_id,
missing=missing,
)
def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
page = max(1, int(page or 1))
page_size = max(5, min(100, int(page_size or 20)))
total = len(rows)
start = (page - 1) * page_size
chunk = rows[start : start + page_size]
return {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": max(1, (total + page_size - 1) // page_size) if total else 1,
"rows": chunk,
}
def build_export_csv(payload: dict[str, Any]) -> str:
buf = io.StringIO()
# Excel 友好 BOM
buf.write("\ufeff")
w = csv.writer(buf)
s = payload.get("summary") or {}
w.writerow(["【统计摘要】"])
w.writerow(["交易所", payload.get("exchange")])
w.writerow(["标的", payload.get("symbol_label")])
w.writerow(["价源", payload.get("price_source")])
w.writerow(["起点整点", f"{payload.get('start_hour')}:00"])
w.writerow(["终点", f"{payload.get('end_hour')}:00"])
w.writerow(["周期", payload.get("period")])
w.writerow(["周末筛选", payload.get("weekend_filter")])
w.writerow(["样本数", s.get("sample_count")])
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")])
w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")])
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
st = s.get("straddle") or {}
if st:
w.writerow([])
w.writerow(["【买跨对照·双边权利金】", st.get("premium"), "止盈点", st.get("take_profit")])
w.writerow(["开→高超过权利金", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")])
w.writerow(["开→低超过权利金", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")])
w.writerow(["|涨跌|超过权利金", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")])
if st.get("take_profit") is not None:
w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_ratio")])
w.writerow(
[
"买跨点数盈亏合计",
st.get("pnl_total"),
"日均",
st.get("pnl_avg"),
"赚钱天数",
st.get("win_days"),
"胜率",
st.get("win_ratio"),
]
)
w.writerow(["单日最大赚", st.get("pnl_max"), "单日最大亏", st.get("pnl_min")])
w.writerow([])
w.writerow(["【日表明细】"])
w.writerow(
[
"结算日",
"星期",
"周末",
"窗起点",
"窗终点",
"开盘",
"最高",
"最低",
"收盘",
"开→高",
"开→低",
"振幅",
"涨跌值",
"有效波动",
"触达止盈",
"收益",
]
)
for r in payload.get("rows") or []:
w.writerow(
[
r.get("settlement_day"),
r.get("weekday_label") or "",
"" if r.get("is_weekend") else "",
r.get("window_start"),
r.get("window_end"),
r.get("open"),
r.get("high"),
r.get("low"),
r.get("close"),
r.get("up_points"),
r.get("down_points"),
r.get("amplitude"),
r.get("change"),
r.get("effective_move"),
"" if r.get("take_profit_hit") else "",
r.get("profit"),
]
)
return buf.getvalue()
def export_filename(payload: dict[str, Any]) -> str:
sym = (payload.get("symbol") or "eth").lower()
sh = int(payload.get("start_hour") or 16)
period = str(payload.get("period") or "2m").replace(":", "")
day = datetime.now(APP_TZ).strftime("%Y%m%d")
return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"
+67
View File
@@ -71,6 +71,7 @@ def install_instance_theme_static(app) -> None:
"strategy_roll.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; charset=utf-8",
"instance_mobile_nav.js": "application/javascript; charset=utf-8",
"instance_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
@@ -672,6 +673,72 @@ def register_hub_routes(app):
}
)
@app.route("/api/hub/options/review/archive")
@_hub_auth_required
def api_hub_options_review_archive():
"""中控期权档案:近 N 天已平仓复盘记录(默认排除对冲腿)."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import current_app
from lib.options.options_review_lib import (
compute_review_stats,
ensure_local_review_synced,
list_review_trades,
)
c = _ctx()
get_db = c.get("get_db")
if not get_db:
return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500
try:
days = int(request.args.get("days") or "365")
except ValueError:
days = 365
days = max(1, min(days, 3650))
try:
limit = int(request.args.get("limit") or "2000")
except ValueError:
limit = 2000
limit = max(1, min(limit, 5000))
include_hedge_legs = str(request.args.get("include_hedge_legs") or "").strip() in (
"1",
"true",
"yes",
)
tz = ZoneInfo("Asia/Shanghai")
closed_from = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d")
cfg = (current_app.extensions or {}).get("options_cfg") or {}
ex = cfg.get("exchange_options")
conn = get_db()
try:
ensure_local_review_synced(conn, ex=ex, backfill_exchange_pnl=bool(ex))
trades = list_review_trades(
conn,
include_hedge_legs=include_hedge_legs,
closed_from=closed_from,
limit=limit,
offset=0,
)
stats = compute_review_stats(
conn,
include_hedge_legs=include_hedge_legs,
closed_from=closed_from,
)
finally:
conn.close()
return jsonify(
{
"ok": True,
"days": days,
"limit": limit,
"product": "options",
"trades": trades,
"stats": stats,
}
)
@app.route("/api/hub/trades/today")
@_hub_auth_required
def api_hub_trades_today():
+400
View File
@@ -0,0 +1,400 @@
"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数)."""
from __future__ import annotations
import math
from typing import Any, Optional
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def default_contract_size(base: str) -> float:
"""OKX 线性永续常用面值(币/张);与计算器缺省一致."""
b = (base or "ETH").strip().upper()
return 0.01
def default_ct_mult(base: str) -> float:
return 0.01
def floor_sheets(n: float, step: float = 1.0) -> float:
if n is None or not math.isfinite(n) or n <= 0:
return 0.0
s = float(step) if step and step > 0 else 1.0
return math.floor(n / s + 1e-12) * s
def option_unit_cost(*, ask: float, ct_mult: float) -> float:
return float(ask) * float(ct_mult or 0.01)
def option_intrinsic_value(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
) -> float:
o = (opt_type or "").strip().upper()
k = float(strike)
s = float(spot)
if o == "C":
intrinsic = max(0.0, s - k)
elif o == "P":
intrinsic = max(0.0, k - s)
else:
intrinsic = 0.0
return intrinsic * float(sheets) * float(ct_mult or 0.01)
def option_pnl_at_spot(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
return option_intrinsic_value(
opt_type=opt_type,
strike=strike,
spot=spot,
sheets=sheets,
ct_mult=ct_mult,
) - float(premium_paid)
def perp_pnl(
*,
direction: str,
entry: float,
exit_px: float,
contracts: float,
contract_size: float,
) -> float:
coins = float(contracts) * float(contract_size or 0.01)
d = (direction or "long").strip().lower()
if d == "short":
return (float(entry) - float(exit_px)) * coins
return (float(exit_px) - float(entry)) * coins
def _validate_common(inp: dict[str, Any]) -> Optional[str]:
base = str(inp.get("base") or "ETH").strip().upper()
if base not in ("ETH", "BTC"):
return "标的仅支持 ETH / BTC"
direction = str(inp.get("direction") or "long").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long / short"
s0 = _f(inp.get("entry"))
sl = _f(inp.get("sl"))
tp = _f(inp.get("tp"))
risk = _f(inp.get("risk_u"))
if s0 is None or s0 <= 0:
return "请填写有效入场价"
if sl is None or sl <= 0:
return "请填写有效止损价"
if tp is None or tp <= 0:
return "请填写有效止盈价"
if risk is None or risk <= 0:
return "请填写有效风险额 R"
if direction == "long" and not (sl < s0 < tp):
return "做多须满足 止损 < 入场 < 止盈"
if direction == "short" and not (tp < s0 < sl):
return "做空须满足 止盈 < 入场 < 止损"
return None
def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]:
direction = str(inp.get("direction") or "long").strip().lower()
s0 = float(inp["entry"])
sl = float(inp["sl"])
tp = float(inp["tp"])
risk = float(inp["risk_u"])
per_sheet_sl = abs(s0 - sl) * contract_size
sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0
actual_sl_loss = abs(perp_pnl(
direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size
))
tp_pnl = perp_pnl(
direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size
)
# 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利
path_a = round(tp_pnl, 4)
path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4)
path_c_realized = path_b
path_c_missed = path_a
return {
"kind": "perp",
"sheets": sheets,
"contract_size": contract_size,
"per_sheet_sl_u": round(per_sheet_sl, 6),
"risk_used_u": round(actual_sl_loss, 4),
"path_a_tp": path_a,
"path_b_sl": path_b,
"path_c_realized": path_c_realized,
"path_c_missed": path_c_missed,
"path_c_note": "本单已止损;踏空未拿到原止盈空间",
"worst_u": path_b,
}
def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
direction = str(inp.get("direction") or "long").strip().lower()
risk = float(inp["risk_u"])
tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"])
sl = float(inp["sl"])
opt = inp.get("option") if isinstance(inp.get("option"), dict) else {}
default_type = "C" if direction == "long" else "P"
opt_type = str(opt.get("opt_type") or default_type).strip().upper()
if opt_type not in ("C", "P"):
opt_type = default_type
strike = _f(opt.get("strike"))
ask = _f(opt.get("ask"))
if strike is None or strike <= 0:
return {"ok": False, "msg": "请填写单期权行权价"}
if ask is None or ask <= 0:
return {"ok": False, "msg": "请填写单期权卖一价"}
unit = option_unit_cost(ask=ask, ct_mult=ct_mult)
sheets = floor_sheets(risk / unit) if unit > 0 else 0.0
premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0
# 若张数为 0
path_a = option_pnl_at_spot(
opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
)
path_b_at_sl = option_pnl_at_spot(
opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
)
path_b_worst = -premium
# 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈
path_c = path_a
return {
"ok": True,
"kind": "option",
"opt_type": opt_type,
"strike": strike,
"ask": ask,
"ct_mult": ct_mult,
"sheets": sheets,
"unit_cost_u": round(unit, 6),
"premium_u": round(premium, 4),
"path_a_tp": round(path_a, 4),
"path_b_sl": round(path_b_at_sl, 4),
"path_b_worst": round(path_b_worst, 4),
"path_c_hold_to_tp": round(path_c, 4),
"path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)",
"worst_u": round(path_b_worst, 4),
}
def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
direction = str(inp.get("direction") or "long").strip().lower()
risk = float(inp["risk_u"])
tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"])
sl = float(inp["sl"])
hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {}
main_default = "C" if direction == "long" else "P"
side_default = "P" if direction == "long" else "C"
main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {}
side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {}
main_type = str(main.get("opt_type") or main_default).strip().upper()
side_type = str(side.get("opt_type") or side_default).strip().upper()
if main_type not in ("C", "P"):
main_type = main_default
if side_type not in ("C", "P"):
side_type = side_default
main_k = _f(main.get("strike"))
main_ask = _f(main.get("ask"))
side_k = _f(side.get("strike"))
side_ask = _f(side.get("ask"))
if None in (main_k, main_ask, side_k, side_ask) or min(
main_k or 0, main_ask or 0, side_k or 0, side_ask or 0
) <= 0:
return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"}
main_budget = 0.7 * risk
side_budget = 0.3 * risk
main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult)
side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult)
main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0
side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0
main_prem = main_unit * main_sheets
side_prem = side_unit * side_sheets
premium = main_prem + side_prem
def combo_at(spot: float) -> float:
a = option_pnl_at_spot(
opt_type=main_type,
strike=float(main_k),
spot=spot,
sheets=main_sheets,
ct_mult=ct_mult,
premium_paid=main_prem,
)
b = option_pnl_at_spot(
opt_type=side_type,
strike=float(side_k),
spot=spot,
sheets=side_sheets,
ct_mult=ct_mult,
premium_paid=side_prem,
)
return a + b
path_a = combo_at(tp)
path_b_at_sl = combo_at(sl)
path_b_worst = -premium
path_c = path_a
return {
"ok": True,
"kind": "hedge",
"ratio": "7:3",
"ct_mult": ct_mult,
"main": {
"opt_type": main_type,
"strike": main_k,
"ask": main_ask,
"sheets": main_sheets,
"premium_u": round(main_prem, 4),
"budget_u": round(main_budget, 4),
},
"side": {
"opt_type": side_type,
"strike": side_k,
"ask": side_ask,
"sheets": side_sheets,
"premium_u": round(side_prem, 4),
"budget_u": round(side_budget, 4),
},
"premium_u": round(premium, 4),
"path_a_tp": round(path_a, 4),
"path_b_sl": round(path_b_at_sl, 4),
"path_b_worst": round(path_b_worst, 4),
"path_c_hold_to_tp": round(path_c, 4),
"path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)",
"worst_u": round(path_b_worst, 4),
}
def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]:
"""可解释规则推荐."""
candidates: list[tuple[str, float, dict[str, Any]]] = []
if perp and perp.get("sheets", 0) > 0:
candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp))
if opt and opt.get("ok") and opt.get("sheets", 0) > 0:
candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt))
if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0:
candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge))
if not candidates:
return {
"choice": "",
"reason": "输入不足,无法推荐",
"bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"],
}
best_name, best_a, _ = max(candidates, key=lambda x: x[1])
perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0
opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0
hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0
# 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A
perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0
opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None
hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None
anti_whipsaw = False
if perp_miss > 0 and (
(opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0)
):
anti_whipsaw = True
# 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约
others_max = max(opt_a, hedge_a, 0.0)
choice = best_name
if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99:
choice = "合约"
if anti_whipsaw:
reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约"
else:
reason = "同风险下合约干净止盈赔付最高"
elif anti_whipsaw and (opt_a > 0 or hedge_a > 0):
# 抗踏空优先期权类;期期与单腿接近时推期期
if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85):
choice = "期期对冲"
reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护"
else:
choice = "单期权"
reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约"
else:
reason = f"同风险下「{best_name}」干净止盈赔付最高"
bullets = [
f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)",
(
"止损与踏空:合约打止损即结束并可能踏空;"
"期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标"
if anti_whipsaw
else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈"
),
f"选用建议:{reason}",
]
return {"choice": choice, "reason": reason, "bullets": bullets}
def run_compare(inp: dict[str, Any]) -> dict[str, Any]:
err = _validate_common(inp)
if err:
return {"ok": False, "msg": err}
base = str(inp.get("base") or "ETH").strip().upper()
risk = float(inp["risk_u"])
cs = _f(inp.get("contract_size")) or default_contract_size(base)
ct = _f(inp.get("ct_mult")) or default_ct_mult(base)
perp = _calc_perp(inp, contract_size=float(cs))
opt = _calc_single_option(inp, ct_mult=float(ct))
hedge = _calc_hedge(inp, ct_mult=float(ct))
rec = recommend(
perp,
opt if opt.get("ok") else {"ok": False},
hedge if hedge.get("ok") else {"ok": False},
risk,
)
warnings: list[str] = []
if perp.get("sheets", 0) <= 0:
warnings.append("合约张数为 0:止损距过大或 R 过小")
if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0:
warnings.append("单期权张数为 0:卖一过高或 R 过小")
if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0:
warnings.append("期期对冲未开出张数:卖一过高或 R 过小")
if isinstance(opt, dict) and not opt.get("ok"):
warnings.append(str(opt.get("msg") or "单期权输入不完整"))
if isinstance(hedge, dict) and not hedge.get("ok"):
warnings.append(str(hedge.get("msg") or "期期对冲输入不完整"))
return {
"ok": True,
"base": base,
"direction": str(inp.get("direction") or "long").strip().lower(),
"entry": float(inp["entry"]),
"sl": float(inp["sl"]),
"tp": float(inp["tp"]),
"risk_u": risk,
"contract_size": float(cs),
"ct_mult": float(ct),
"perp": perp,
"option": opt,
"hedge": hedge,
"recommend": rec,
"warnings": warnings,
"notes": [
"期权止盈按标的到价的内在价值近似,非盘口卖出价",
"到期小盈/小亏未纳入主表与推荐",
"仅本地测算,不下单",
],
}
+599
View File
@@ -0,0 +1,599 @@
"""中控期权档案:同步 OKX options_review_trades 到 hub_symbol_archive.db."""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
from lib.hub.hub_symbol_archive_lib import (
TRADING_DAY_RESET_HOUR,
_connect,
default_db_path,
init_db as init_perp_archive_db,
ms_to_trading_day,
parse_wall_clock_ms,
resolve_period_bounds,
trading_day_bounds_ms,
)
def _now_ms() -> int:
return int(time.time() * 1000)
def init_options_archive_db(db_path: Path | None = None) -> None:
"""确保期权缓存表存在(与永续共用同一 SQLite)."""
init_perp_archive_db(db_path)
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS archive_options_trade_cache (
exchange_key TEXT NOT NULL,
history_key TEXT NOT NULL,
source_type TEXT,
underlying TEXT,
opened_at TEXT,
closed_at TEXT,
opened_at_ms INTEGER,
closed_at_ms INTEGER,
hold_seconds INTEGER,
realized_pnl_total REAL,
status_raw TEXT,
pos_id TEXT,
inst_id TEXT,
opt_type TEXT,
strike REAL,
exp_time TEXT,
sheets INTEGER,
open_avg REAL,
close_avg REAL,
premium_paid REAL,
realized_pnl REAL,
hedge_plan_id INTEGER,
plan_close_reason TEXT,
realized_pnl_perp REAL,
realized_pnl_options REAL,
premium_total REAL,
direction TEXT,
tp REAL,
sl REAL,
target_price REAL,
target_price_up REAL,
target_price_down REAL,
legs_json TEXT,
linked_hedge_plan_id INTEGER,
excluded_as_hedge_leg INTEGER DEFAULT 0,
strategy_tag TEXT,
result_tag TEXT,
reviewed INTEGER DEFAULT 0,
source_label TEXT,
payload_json TEXT NOT NULL,
synced_at INTEGER NOT NULL,
PRIMARY KEY (exchange_key, history_key)
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_archive_options_closed
ON archive_options_trade_cache (exchange_key, closed_at_ms)
"""
)
finally:
conn.close()
def purge_stale_options_trades_cache(
exchange_key: str,
active_history_keys: list[str],
*,
db_path: Path | None = None,
) -> int:
init_options_archive_db(db_path)
ex_k = (exchange_key or "").strip().lower()
if not ex_k:
return 0
active = {str(k).strip() for k in (active_history_keys or []) if str(k).strip()}
conn = _connect(db_path)
try:
rows = conn.execute(
"SELECT history_key FROM archive_options_trade_cache WHERE exchange_key=?",
(ex_k,),
).fetchall()
stale = [r["history_key"] for r in rows if r["history_key"] not in active]
removed = 0
for hk in stale:
cur = conn.execute(
"DELETE FROM archive_options_trade_cache WHERE exchange_key=? AND history_key=?",
(ex_k, hk),
)
removed += int(cur.rowcount or 0)
return removed
finally:
conn.close()
def _optional_float(raw: Any) -> float | None:
if raw in (None, ""):
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _optional_int(raw: Any) -> int | None:
if raw in (None, ""):
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def upsert_options_trades_cache(
exchange_key: str,
trades: list[dict[str, Any]],
*,
db_path: Path | None = None,
prune_missing: bool = True,
) -> dict[str, int]:
init_options_archive_db(db_path)
ex_k = (exchange_key or "").strip().lower()
if not ex_k:
return {"upserted": 0, "removed": 0}
now = _now_ms()
n = 0
active_keys: list[str] = []
conn = _connect(db_path)
try:
for t in trades or []:
if not isinstance(t, dict):
continue
hk = str(t.get("history_key") or "").strip()
if not hk:
continue
if int(t.get("excluded_as_hedge_leg") or 0):
continue
active_keys.append(hk)
opened_at = t.get("opened_at")
closed_at = t.get("closed_at")
opened_ms = t.get("opened_at_ms") or parse_wall_clock_ms(opened_at)
closed_ms = t.get("closed_at_ms") or parse_wall_clock_ms(closed_at)
entry = t.get("entry") if isinstance(t.get("entry"), dict) else {}
strategy_tag = t.get("strategy_tag") or (entry or {}).get("strategy_tag")
result_tag = t.get("result_tag") or (entry or {}).get("result_tag")
reviewed = 1 if t.get("reviewed") or entry else 0
row = dict(t)
row["exchange_key"] = ex_k
payload = json.dumps(row, ensure_ascii=False, default=str)
conn.execute(
"""
INSERT INTO archive_options_trade_cache (
exchange_key, history_key, source_type, underlying,
opened_at, closed_at, opened_at_ms, closed_at_ms, hold_seconds,
realized_pnl_total, status_raw,
pos_id, inst_id, opt_type, strike, exp_time, sheets,
open_avg, close_avg, premium_paid, realized_pnl,
hedge_plan_id, plan_close_reason, realized_pnl_perp, realized_pnl_options,
premium_total, direction, tp, sl, target_price, target_price_up, target_price_down,
legs_json, linked_hedge_plan_id, excluded_as_hedge_leg,
strategy_tag, result_tag, reviewed, source_label,
payload_json, synced_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(exchange_key, history_key) DO UPDATE SET
source_type=excluded.source_type,
underlying=excluded.underlying,
opened_at=excluded.opened_at,
closed_at=excluded.closed_at,
opened_at_ms=excluded.opened_at_ms,
closed_at_ms=excluded.closed_at_ms,
hold_seconds=excluded.hold_seconds,
realized_pnl_total=excluded.realized_pnl_total,
status_raw=excluded.status_raw,
pos_id=excluded.pos_id,
inst_id=excluded.inst_id,
opt_type=excluded.opt_type,
strike=excluded.strike,
exp_time=excluded.exp_time,
sheets=excluded.sheets,
open_avg=excluded.open_avg,
close_avg=excluded.close_avg,
premium_paid=excluded.premium_paid,
realized_pnl=excluded.realized_pnl,
hedge_plan_id=excluded.hedge_plan_id,
plan_close_reason=excluded.plan_close_reason,
realized_pnl_perp=excluded.realized_pnl_perp,
realized_pnl_options=excluded.realized_pnl_options,
premium_total=excluded.premium_total,
direction=excluded.direction,
tp=excluded.tp,
sl=excluded.sl,
target_price=excluded.target_price,
target_price_up=excluded.target_price_up,
target_price_down=excluded.target_price_down,
legs_json=excluded.legs_json,
linked_hedge_plan_id=excluded.linked_hedge_plan_id,
excluded_as_hedge_leg=excluded.excluded_as_hedge_leg,
strategy_tag=excluded.strategy_tag,
result_tag=excluded.result_tag,
reviewed=excluded.reviewed,
source_label=excluded.source_label,
payload_json=excluded.payload_json,
synced_at=excluded.synced_at
""",
(
ex_k,
hk,
t.get("source_type"),
t.get("underlying"),
opened_at,
closed_at,
int(opened_ms) if opened_ms else None,
int(closed_ms) if closed_ms else None,
_optional_int(t.get("hold_seconds")),
float(t.get("realized_pnl_total") or t.get("realized_pnl") or 0),
t.get("status_raw"),
t.get("pos_id"),
t.get("inst_id"),
t.get("opt_type"),
_optional_float(t.get("strike")),
t.get("exp_time"),
_optional_int(t.get("sheets")),
_optional_float(t.get("open_avg")),
_optional_float(t.get("close_avg")),
_optional_float(t.get("premium_paid")),
_optional_float(t.get("realized_pnl")),
_optional_int(t.get("hedge_plan_id")),
t.get("plan_close_reason"),
_optional_float(t.get("realized_pnl_perp")),
_optional_float(t.get("realized_pnl_options")),
_optional_float(t.get("premium_total")),
t.get("direction"),
_optional_float(t.get("tp")),
_optional_float(t.get("sl")),
_optional_float(t.get("target_price")),
_optional_float(t.get("target_price_up")),
_optional_float(t.get("target_price_down")),
t.get("legs_json")
if isinstance(t.get("legs_json"), str)
else (json.dumps(t.get("legs"), ensure_ascii=False) if t.get("legs") else None),
_optional_int(t.get("linked_hedge_plan_id")),
int(t.get("excluded_as_hedge_leg") or 0),
strategy_tag,
result_tag,
reviewed,
t.get("source_label"),
payload,
now,
),
)
n += 1
finally:
conn.close()
removed = 0
if prune_missing:
removed = purge_stale_options_trades_cache(ex_k, active_keys, db_path=db_path)
return {"upserted": n, "removed": removed}
def _options_row_to_dict(row: Any) -> dict[str, Any]:
out: dict[str, Any] = dict(row)
payload = {}
raw = out.get("payload_json")
if raw:
try:
payload = json.loads(raw) if isinstance(raw, str) else {}
except (TypeError, ValueError, json.JSONDecodeError):
payload = {}
if isinstance(payload, dict):
for k, v in payload.items():
if k not in out or out.get(k) in (None, ""):
out[k] = v
pnl = float(out.get("realized_pnl_total") or out.get("realized_pnl") or 0)
out["realized_pnl_total"] = pnl
out["pnl_amount"] = pnl # 复用永续统计/日历字段名
hold_sec = out.get("hold_seconds")
if hold_sec is not None:
try:
out["hold_minutes"] = round(float(hold_sec) / 60.0, 2)
except (TypeError, ValueError):
pass
if not out.get("opened_at_ms") and out.get("opened_at"):
ms = parse_wall_clock_ms(out.get("opened_at"))
if ms:
out["opened_at_ms"] = int(ms)
if not out.get("closed_at_ms") and out.get("closed_at"):
ms = parse_wall_clock_ms(out.get("closed_at"))
if ms:
out["closed_at_ms"] = int(ms)
out["trade_id"] = out.get("history_key")
out["id"] = out.get("history_key")
out["symbol"] = out.get("inst_id") or out.get("underlying") or ""
return out
def _empty_options_stats() -> dict[str, Any]:
return {
"open_count": 0,
"sick_count": 0,
"sick_pct": 0.0,
"pnl_total": 0.0,
"pnl_ex_sick": 0.0,
"win_count": 0,
"loss_count": 0,
"avg_win": 0.0,
"avg_loss": 0.0,
"max_win": 0.0,
"max_loss": 0.0,
"win_rate": 0.0,
"profit_loss_ratio": 0.0,
"turnover_total": 0.0,
"commission_total": 0.0,
"premium_total": 0.0,
"by_exchange": {},
"by_source_type": {},
}
def _compute_options_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
st = _empty_options_stats()
wins: list[float] = []
losses: list[float] = []
by_ex: dict[str, dict[str, Any]] = {}
by_src: dict[str, dict[str, Any]] = {}
def bucket() -> dict[str, Any]:
return {
"open_count": 0,
"pnl_total": 0.0,
"win_count": 0,
"loss_count": 0,
"premium_total": 0.0,
}
for td in trade_rows:
pnl = float(td.get("pnl_amount") or td.get("realized_pnl_total") or 0)
ex = str(td.get("exchange_key") or "okx")
src = str(td.get("source_type") or td.get("source_label") or "?")
prem = float(td.get("premium_total") or td.get("premium_paid") or 0)
st["open_count"] += 1
st["pnl_total"] += pnl
st["premium_total"] += prem
if pnl > 0.0001:
st["win_count"] += 1
wins.append(pnl)
elif pnl < -0.0001:
st["loss_count"] += 1
losses.append(pnl)
if ex not in by_ex:
by_ex[ex] = bucket()
by_ex[ex]["open_count"] += 1
by_ex[ex]["pnl_total"] += pnl
by_ex[ex]["premium_total"] += prem
if pnl > 0.0001:
by_ex[ex]["win_count"] += 1
elif pnl < -0.0001:
by_ex[ex]["loss_count"] += 1
if src not in by_src:
by_src[src] = bucket()
by_src[src]["open_count"] += 1
by_src[src]["pnl_total"] += pnl
total = int(st["open_count"] or 0)
st["pnl_ex_sick"] = round(float(st["pnl_total"]), 4)
st["pnl_total"] = round(float(st["pnl_total"]), 4)
st["premium_total"] = round(float(st["premium_total"]), 4)
st["avg_win"] = round(sum(wins) / len(wins), 4) if wins else 0.0
st["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else 0.0
st["max_win"] = round(max(wins), 4) if wins else 0.0
st["max_loss"] = round(min(losses), 4) if losses else 0.0
st["win_rate"] = round(st["win_count"] / total * 100, 1) if total else 0.0
if wins and losses and abs(st["avg_loss"]) > 1e-9:
st["profit_loss_ratio"] = round(abs(st["avg_win"] / st["avg_loss"]), 2)
for ex, b in by_ex.items():
b["pnl_total"] = round(float(b["pnl_total"]), 4)
b["premium_total"] = round(float(b["premium_total"]), 4)
b["sick_count"] = 0
b["sick_pct"] = 0.0
b["pnl_ex_sick"] = b["pnl_total"]
b["avg_win"] = 0.0
b["avg_loss"] = 0.0
b["max_win"] = 0.0
b["max_loss"] = 0.0
b["win_rate"] = (
round(b["win_count"] / b["open_count"] * 100, 1) if b["open_count"] else 0.0
)
b["profit_loss_ratio"] = 0.0
b["turnover_total"] = 0.0
b["commission_total"] = 0.0
for src, b in by_src.items():
b["pnl_total"] = round(float(b["pnl_total"]), 4)
st["by_exchange"] = by_ex
st["by_source_type"] = by_src
return st
def list_daily_options_trades(
trading_day: str = "",
*,
period: str = "",
date_from: str = "",
date_to: str = "",
exchange_key: str = "",
filter_profit: bool = False,
filter_loss: bool = False,
search: str = "",
source_type: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
init_options_archive_db(db_path)
p = (period or "today").strip().lower() or "today"
start_ms, end_ms, df, dt, period_label = resolve_period_bounds(
period=p,
trading_day=trading_day,
date_from=date_from,
date_to=date_to,
)
ex_filter = (exchange_key or "").strip().lower()
src_filter = (source_type or "").strip().lower()
conn = _connect(db_path)
try:
params: list[Any] = [start_ms, end_ms]
where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
where += " AND COALESCE(excluded_as_hedge_leg,0)=0"
if ex_filter:
where += " AND exchange_key=?"
params.append(ex_filter)
if src_filter:
where += " AND LOWER(COALESCE(source_type,''))=?"
params.append(src_filter)
rows = conn.execute(
f"""
SELECT * FROM archive_options_trade_cache
WHERE {where}
ORDER BY closed_at_ms DESC, history_key DESC
""",
params,
).fetchall()
trades: list[dict[str, Any]] = []
q = (search or "").strip().lower()
for r in rows:
td = _options_row_to_dict(r)
pnl = float(td.get("pnl_amount") or 0)
if filter_profit and pnl <= 0.0001:
continue
if filter_loss and pnl >= -0.0001:
continue
if q:
blob = " ".join(
str(td.get(k) or "")
for k in (
"underlying",
"inst_id",
"exchange_key",
"source_type",
"source_label",
"opt_type",
"strategy_tag",
"result_tag",
"direction",
)
).lower()
if q not in blob:
continue
trades.append(td)
return {
"period": p,
"period_label": period_label,
"trading_day": dt,
"date_from": df,
"date_to": dt,
"product": "options",
"trades": trades,
"stats": _compute_options_period_stats(trades),
}
finally:
conn.close()
def list_archive_options_calendar(
year: int,
month: int,
*,
exchange_key: str = "",
db_path: Path | None = None,
reset_hour: int = TRADING_DAY_RESET_HOUR,
) -> dict[str, Any]:
init_options_archive_db(db_path)
y = int(year)
m = int(month)
if m < 1 or m > 12:
raise ValueError("month 无效")
from datetime import datetime, timedelta
first = f"{y:04d}-{m:02d}-01"
if m == 12:
next_first = datetime(y + 1, 1, 1)
else:
next_first = datetime(y, m + 1, 1)
last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour)
_, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour)
ex_filter = (exchange_key or "").strip().lower()
conn = _connect(db_path)
try:
params: list[Any] = [start_ms, end_ms]
where = (
"closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
" AND COALESCE(excluded_as_hedge_leg,0)=0"
)
if ex_filter:
where += " AND exchange_key=?"
params.append(ex_filter)
rows = conn.execute(
f"SELECT * FROM archive_options_trade_cache WHERE {where}",
params,
).fetchall()
days: dict[str, dict[str, Any]] = {}
for r in rows:
td = _options_row_to_dict(r)
closed_ms = td.get("closed_at_ms") or parse_wall_clock_ms(td.get("closed_at"))
if not closed_ms:
continue
day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour)
if not day or day < first or day > last:
continue
bucket = days.setdefault(
day,
{
"trading_day": day,
"open_count": 0,
"sick_count": 0,
"pnl_total": 0.0,
"turnover_total": 0.0,
"commission_total": 0.0,
"has_sick": False,
},
)
bucket["open_count"] += 1
bucket["pnl_total"] += float(td.get("pnl_amount") or 0)
for d in days.values():
d["pnl_total"] = round(float(d["pnl_total"]), 4)
month_pnl = sum(float(d["pnl_total"]) for d in days.values())
month_count = sum(int(d["open_count"]) for d in days.values())
return {
"year": y,
"month": m,
"date_from": first,
"date_to": last,
"product": "options",
"days": days,
"month_pnl_total": round(month_pnl, 4),
"month_open_count": month_count,
}
finally:
conn.close()
def sync_options_exchange_archive(
exchange_key: str,
trades: list[dict[str, Any]],
*,
db_path: Path | None = None,
) -> dict[str, Any]:
"""仅缓存期权交易,不做 K 线."""
r = upsert_options_trades_cache(
exchange_key, trades, db_path=db_path, prune_missing=True
)
return {
"ok": True,
"exchange_key": (exchange_key or "").strip().lower(),
"product": "options",
"trades_upserted": r.get("upserted", 0),
"trades_removed": r.get("removed", 0),
"trade_count": len(trades or []),
}
+341
View File
@@ -0,0 +1,341 @@
"""中控永期对冲计算器:永续 1 币 + 按目标盈利反推期权仓位/波动点数(纯函数)."""
from __future__ import annotations
from typing import Any, Optional, Tuple
from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt, taker_fee_rate
DEFAULT_CT_MULT = 0.01
PERP_COINS = 1.0
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _parse_base_common(
*,
base: str,
spot: Any,
capital_usdt: Any,
target_profit_u: Any,
perp_leverage: Any,
option_leverage: Any,
ct_mult: Any,
) -> Tuple[Optional[dict[str, float]], Optional[str]]:
b = (base or "ETH").strip().upper()
if b not in ("ETH", "BTC"):
return None, "币种仅支持 BTC / ETH"
s = _f(spot)
capital = _f(capital_usdt)
target = _f(target_profit_u)
p_lev = _f(perp_leverage)
o_lev = _f(option_leverage)
ct = _f(ct_mult)
if s is None or capital is None or target is None or p_lev is None or o_lev is None:
return None, "参数格式错误"
if ct is None or ct <= 0:
ct = DEFAULT_CT_MULT
if s <= 0 or capital <= 0 or p_lev <= 0 or o_lev <= 0:
return None, "现价、资金、杠杆须大于 0"
if target < 0:
return None, "目标盈利不能为负"
prem_per_coin = s / o_lev
if prem_per_coin <= 0:
return None, "单币权利金无效"
margin = (s * PERP_COINS) / p_lev
return {
"base_ok": 1.0,
"spot": s,
"capital": capital,
"target": target,
"p_lev": p_lev,
"o_lev": o_lev,
"ct": ct,
"prem_per_coin": prem_per_coin,
"margin": margin,
"fee_rate": taker_fee_rate(),
}, None
def _move_for_perp_correct(*, spot: float, target: float, premium: float, fee_rate: float) -> float:
"""净利 = move premium fee(move) = target → 解 move.
fee = (2*spot + move) * fee_rate
move*(1-fee_rate) = target + premium + 2*spot*fee_rate
"""
denom = 1.0 - float(fee_rate)
if denom <= 0:
return 0.0
return (float(target) + float(premium) + 2.0 * float(spot) * float(fee_rate)) / denom
def calc_perp_options_hedge(
*,
base: str = "ETH",
spot: float,
capital_usdt: float,
target_profit_u: float,
move_mode: str = "points",
move_value: float,
perp_leverage: float,
option_leverage: float,
ct_mult: float = DEFAULT_CT_MULT,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""由波动反推期权开仓币数/张数(calc_mode=size)."""
common, err = _parse_base_common(
base=base,
spot=spot,
capital_usdt=capital_usdt,
target_profit_u=target_profit_u,
perp_leverage=perp_leverage,
option_leverage=option_leverage,
ct_mult=ct_mult,
)
if err or not common:
return None, err
s = common["spot"]
capital = common["capital"]
target = common["target"]
p_lev = common["p_lev"]
o_lev = common["o_lev"]
ct = common["ct"]
prem_per_coin = common["prem_per_coin"]
margin = common["margin"]
fee_rate = common["fee_rate"]
b = (base or "ETH").strip().upper()
move = _f(move_value)
mode = (move_mode or "points").strip().lower()
if mode not in ("points", "pct", "percent", "rate"):
return None, "波动模式须为 points 或 pct"
if mode in ("percent", "rate"):
mode = "pct"
if move is None:
return None, "参数格式错误"
if move <= 0:
return None, "现价、资金、波动、杠杆须大于 0"
if mode == "pct":
move_points = s * (move / 100.0)
else:
move_points = move
if move_points <= 0:
return None, "波动对应价格变动须大于 0"
exit_px = s + move_points
perp_gross = move_points * PERP_COINS
fee = estimate_roundtrip_fee_usdt(s, exit_px, qty=PERP_COINS, contract_size=1.0)
premium_budget = perp_gross - target - fee
if premium_budget <= 0:
return None, "波动收益不足以覆盖目标盈利+手续费,无法开期权"
opt_coins = premium_budget / prem_per_coin
opt_sheets = opt_coins / ct
premium_total = opt_coins * prem_per_coin
case_a_net = perp_gross - premium_total - fee
opt_intrinsic = opt_coins * move_points
opt_net = opt_intrinsic - premium_total
perp_loss = -perp_gross
portfolio_net = opt_net + perp_loss
return {
"calc_mode": "size",
"base": b,
"spot": round(s, 8),
"capital_usdt": round(capital, 8),
"target_profit_u": round(target, 8),
"move_mode": mode,
"move_value": round(move, 8),
"move_points": round(move_points, 8),
"exit_price": round(exit_px, 8),
"perp_coins": PERP_COINS,
"perp_leverage": round(p_lev, 8),
"option_leverage": round(o_lev, 8),
"ct_mult": ct,
"prem_per_coin": round(prem_per_coin, 8),
"perp_gross_u": round(perp_gross, 8),
"perp_fee_u": round(fee, 8),
"fee_rate": fee_rate,
"premium_budget_u": round(premium_budget, 8),
"opt_coins": round(opt_coins, 8),
"opt_sheets": round(opt_sheets, 8),
"premium_total_u": round(premium_total, 8),
"perp_margin_u": round(margin, 8),
"capital_ok": bool(capital >= margin),
"case_a": {
"label": "永续方向对",
"perp_pnl_u": round(perp_gross, 8),
"premium_u": round(premium_total, 8),
"fee_u": round(fee, 8),
"net_u": round(case_a_net, 8),
},
"case_b": {
"label": "期权方向对",
"opt_intrinsic_u": round(opt_intrinsic, 8),
"premium_u": round(premium_total, 8),
"opt_net_u": round(opt_net, 8),
"perp_pnl_u": round(perp_loss, 8),
"portfolio_net_u": round(portfolio_net, 8),
},
}, None
def calc_perp_options_points(
*,
base: str = "ETH",
spot: float,
capital_usdt: float,
target_profit_u: float,
perp_leverage: float,
option_leverage: float,
ratio_perp: float = 1.0,
ratio_opt: float = 2.0,
ct_mult: float = DEFAULT_CT_MULT,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""按永续:期权比例 + 目标盈利,反推两套情景所需波动点数.
永续币数固定为 ratio 归一后的 1 币侧(perp_coins = PERP_COINS).
期权币数 = PERP_COINS * (ratio_opt / ratio_perp), 1:2 2 .
A 永续方向对: move premium fee(move) = 目标盈利
B 期权方向对:
- 期权净利达目标: opt_coins*move premium = 目标
- 组合净利达目标: move*(opt_coins perp_coins) premium = 目标
"""
common, err = _parse_base_common(
base=base,
spot=spot,
capital_usdt=capital_usdt,
target_profit_u=target_profit_u,
perp_leverage=perp_leverage,
option_leverage=option_leverage,
ct_mult=ct_mult,
)
if err or not common:
return None, err
rp = _f(ratio_perp)
ro = _f(ratio_opt)
if rp is None or ro is None or rp <= 0 or ro <= 0:
return None, "永续:期权比例须大于 0"
s = common["spot"]
capital = common["capital"]
target = common["target"]
p_lev = common["p_lev"]
o_lev = common["o_lev"]
ct = common["ct"]
prem_per_coin = common["prem_per_coin"]
margin = common["margin"]
fee_rate = common["fee_rate"]
b = (base or "ETH").strip().upper()
opt_coins = PERP_COINS * (ro / rp)
premium_total = opt_coins * prem_per_coin
opt_sheets = opt_coins / ct
move_a = _move_for_perp_correct(spot=s, target=target, premium=premium_total, fee_rate=fee_rate)
if move_a <= 0:
return None, "无法解出永续方向对所需点数"
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=PERP_COINS, contract_size=1.0)
net_a = move_a * PERP_COINS - premium_total - fee_a
# 期权净利 = 目标
move_b_opt = (target + premium_total) / opt_coins
opt_net_at_b_opt = opt_coins * move_b_opt - premium_total
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * PERP_COINS
# 组合净利 = 目标
edge = opt_coins - PERP_COINS
if edge <= 0:
move_b_port = None
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
else:
move_b_port = (target + premium_total) / edge
port_err = None
if move_b_port is not None:
opt_net_at_b_port = opt_coins * move_b_port - premium_total
portfolio_at_b_port = opt_net_at_b_port - move_b_port * PERP_COINS
else:
opt_net_at_b_port = None
portfolio_at_b_port = None
return {
"calc_mode": "points",
"base": b,
"spot": round(s, 8),
"capital_usdt": round(capital, 8),
"target_profit_u": round(target, 8),
"ratio_perp": round(rp, 8),
"ratio_opt": round(ro, 8),
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
"perp_coins": PERP_COINS,
"opt_coins": round(opt_coins, 8),
"opt_sheets": round(opt_sheets, 8),
"perp_leverage": round(p_lev, 8),
"option_leverage": round(o_lev, 8),
"ct_mult": ct,
"prem_per_coin": round(prem_per_coin, 8),
"premium_total_u": round(premium_total, 8),
"fee_rate": fee_rate,
"perp_margin_u": round(margin, 8),
"capital_ok": bool(capital >= margin),
"case_a": {
"label": "永续方向对",
"move_points": round(move_a, 8),
"move_pct": round(move_a / s * 100.0, 8),
"perp_pnl_u": round(move_a * PERP_COINS, 8),
"premium_u": round(premium_total, 8),
"fee_u": round(fee_a, 8),
"net_u": round(net_a, 8),
},
"case_b": {
"label": "期权方向对",
"move_points_opt_net": round(move_b_opt, 8),
"move_pct_opt_net": round(move_b_opt / s * 100.0, 8),
"opt_net_u": round(opt_net_at_b_opt, 8),
"portfolio_net_at_opt_target_u": round(portfolio_at_b_opt, 8),
"move_points_portfolio": None if move_b_port is None else round(move_b_port, 8),
"move_pct_portfolio": None
if move_b_port is None
else round(move_b_port / s * 100.0, 8),
"opt_net_at_portfolio_target_u": None
if opt_net_at_b_port is None
else round(opt_net_at_b_port, 8),
"portfolio_net_u": None if portfolio_at_b_port is None else round(portfolio_at_b_port, 8),
"portfolio_error": port_err,
"premium_u": round(premium_total, 8),
},
}, None
def _fmt_ratio(v: float) -> str:
if abs(v - round(v)) < 1e-9:
return str(int(round(v)))
s = f"{v:.4f}".rstrip("0").rstrip(".")
return s
def calc_perp_options(
*,
calc_mode: str = "size",
**kwargs: Any,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""统一入口:size=由波动推仓位;points=由比例推点数."""
mode = (calc_mode or "size").strip().lower()
if mode in ("points", "ratio", "move"):
return calc_perp_options_points(**kwargs)
# size mode: ignore ratio kwargs if present
kwargs.pop("ratio_perp", None)
kwargs.pop("ratio_opt", None)
return calc_perp_options_hedge(**kwargs)
+51 -6
View File
@@ -10,9 +10,32 @@ from typing import Any
from lib.paths import REPO_ROOT
STRATEGY_EXCHANGES: tuple[str, ...] = ("binance", "okx", "gate")
STRATEGY_EXCHANGES: tuple[str, ...] = (
"playbook_v2",
"playbook",
"behavior",
"binance",
"okx",
"gate",
)
STRATEGY_META: dict[str, dict[str, str]] = {
"playbook_v2": {
"label": "执行手册v2",
"title": "交易执行手册 v2(期权 / 合约 · 无对冲)",
"md_rel": "docs/交易执行手册-v2-期权与合约.md",
},
"playbook": {
"label": "执行手册v1",
"title": "交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)",
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
"md_rel": "docs/交易执行手册-期权与Gate.md",
},
"behavior": {
"label": "行为准则",
"title": "交易行为准则(开单三检)",
"md_rel": "docs/交易行为准则-开单三检.md",
},
"binance": {
"label": "币安",
"title": "币安·山寨多头趋势",
@@ -43,6 +66,9 @@ def _md_path(exchange_key: str) -> Path:
meta = STRATEGY_META.get((exchange_key or "").strip().lower())
if not meta:
raise KeyError(exchange_key)
md_rel = (meta.get("md_rel") or "").strip()
if md_rel:
return REPO_ROOT / md_rel
return _strategy_dir() / meta["md_file"]
@@ -210,11 +236,30 @@ def load_strategy_payload(exchange_key: str) -> dict[str, Any]:
}
def strategy_meta_payload() -> dict[str, Any]:
tabs = [
{"key": k, "label": STRATEGY_META[k]["label"], "title": STRATEGY_META[k]["title"]}
for k in STRATEGY_EXCHANGES
]
_STRATEGY_TAB_DISPLAY_PREF: dict[str, str] = {
"playbook_v2": "show_strategy_playbook_v2",
"playbook": "show_strategy_playbook",
"behavior": "show_strategy_behavior",
"binance": "show_strategy_binance",
"okx": "show_strategy_okx",
"gate": "show_strategy_gate",
}
def strategy_meta_payload(display: dict[str, Any] | None = None) -> dict[str, Any]:
prefs = display if isinstance(display, dict) else {}
tabs = []
for k in STRATEGY_EXCHANGES:
pref_key = _STRATEGY_TAB_DISPLAY_PREF.get(k)
if pref_key and prefs.get(pref_key) is False:
continue
tabs.append(
{
"key": k,
"label": STRATEGY_META[k]["label"],
"title": STRATEGY_META[k]["title"],
}
)
return {"ok": True, "exchanges": tabs}
@@ -9,6 +9,8 @@ DISPLAY_RUNTIME_PREFIX = "display."
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False,
"show_nav_key_monitor": True,
"show_nav_trade": True,
"show_nav_strategy": True,
"show_nav_strategy_records": True,
"show_nav_records": True,
@@ -28,6 +30,8 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
DISPLAY_LABELS: dict[str, str] = {
"show_nav_dashboard": "数据看板",
"show_nav_key_monitor": "关键位监控",
"show_nav_trade": "实盘下单",
"show_nav_strategy": "策略交易",
"show_nav_strategy_records": "策略交易记录",
"show_nav_records": "交易记录与复盘",
@@ -47,6 +51,8 @@ DISPLAY_LABELS: dict[str, str] = {
NAV_TAB_ALLOWED: dict[str, str] = {
"dashboard": "show_nav_dashboard",
"key_monitor": "show_nav_key_monitor",
"trade": "show_nav_trade",
"strategy": "show_nav_strategy",
"strategy_records": "show_nav_strategy_records",
"records": "show_nav_records",
@@ -110,6 +116,8 @@ def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
def display_meta_for_ui() -> list[dict[str, Any]]:
nav_keys = [
"show_nav_dashboard",
"show_nav_key_monitor",
"show_nav_trade",
"show_nav_strategy",
"show_nav_strategy_records",
"show_nav_records",
+16
View File
@@ -84,6 +84,11 @@ def embed_shell_enabled() -> bool:
return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on")
_SETTINGS_SUB_TABS = frozenset(
{"nav", "password", "transfer", "export", "options_swap", "options_transfer"}
)
def redirect_to_embed_shell_if_enabled(page: str):
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
if not embed_shell_enabled():
@@ -93,6 +98,12 @@ def redirect_to_embed_shell_if_enabled(page: str):
if (request.path or "").rstrip("/") == "/embed":
return None
q = {k: v for k, v in request.args.items()}
# embed 的 tab=页面名;系统设置内页签用 settings_tab,避免 /settings?tab=transfer 被覆盖成 tab=settings
if (page or "").strip() == "settings":
sub = (q.get("settings_tab") or "").strip()
legacy = (q.get("tab") or "").strip()
if not sub and legacy in _SETTINGS_SUB_TABS:
q["settings_tab"] = legacy
q["tab"] = page
q["embed"] = "1"
return redirect("/embed?" + urlencode(q))
@@ -115,6 +126,11 @@ def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str:
tab = path_to_embed_tab(split.path)
q = dict(parse_qsl(split.query, keep_blank_values=True))
if tab:
if tab == "settings":
sub = (q.get("settings_tab") or "").strip()
legacy = (q.get("tab") or "").strip()
if not sub and legacy in _SETTINGS_SUB_TABS:
q["settings_tab"] = legacy
q["tab"] = tab
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
+10
View File
@@ -8,6 +8,7 @@ from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled
from lib.trade.account_risk_lib import (
cooling_hours_manual,
cooling_hours_manual_journal,
daily_loss_limit,
manual_close_daily_limit,
max_active_positions_from_env,
mood_issues_daily_freeze_enabled,
@@ -113,6 +114,15 @@ def build_instance_settings_view(
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
_row("日手动平仓上限", f"{manual_close_daily_limit()}", "超限当日冻结"),
_row(
"日亏损次数上限",
(
f"{daily_loss_limit()}"
if daily_loss_limit() > 0
else "未启用"
),
"平仓亏损达限后当日冻结开仓;0=不启用" if daily_loss_limit() > 0 else "RISK_DAILY_LOSS_LIMIT=0",
),
_row(
"复盘情绪日冻结",
_on_off(mood_issues_daily_freeze_enabled()),
@@ -1,7 +1,7 @@
{# 系统设置 · 导航显示开关(SSR 预渲染,保存仍走 API) #}
<div class="settings-tab-inner" id="display-prefs-card">
<h2>导航显示</h2>
<p class="settings-env-hint">以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.关键位监控,实盘下单,系统设置为固定项.</p>
<p class="settings-env-hint">以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效.系统设置为固定项.</p>
<div id="display-prefs-form" class="display-prefs-form" data-prefs-ssr="1">
{% if display_meta %}
{% for group in display_meta %}
+54 -5
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
<link rel="stylesheet" href="/static/instance_theme.css?v=108">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title>
@@ -31,8 +31,8 @@
</div>
<nav class="top-nav embed-top-nav" aria-label="实例导航">
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %}
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
{% endif %}
@@ -93,6 +93,54 @@
</div>
</div>
<!-- 手机端主导航(≤720px);桌面不显示 -->
<nav id="inst-mobile-tabbar" class="inst-mobile-tabbar" aria-label="手机主导航">
<a href="/trade" class="inst-m-tab{% if initial_tab == 'trade' %} active{% endif %}" data-embed-tab="trade"{% if not display.show_nav_trade %} style="display:none"{% endif %}>下单</a>
<a href="/key_monitor" class="inst-m-tab{% if initial_tab == 'key_monitor' %} active{% endif %}" data-embed-tab="key_monitor"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位</a>
{% if options_nav_visible and display.show_nav_options %}
<a href="/options" class="inst-m-tab{% if initial_tab == 'options' %} active{% endif %}" data-embed-tab="options">期权</a>
{% endif %}
<button type="button" class="inst-m-tab" data-embed-tab="more" id="inst-m-tab-more" aria-haspopup="dialog" aria-expanded="false">更多</button>
</nav>
<div id="inst-mobile-more" class="inst-mobile-more" aria-hidden="true">
<div class="inst-mobile-more-backdrop" id="inst-mobile-more-backdrop"></div>
<div class="inst-mobile-more-sheet" role="dialog" aria-modal="true" aria-labelledby="inst-mobile-more-title">
<div class="inst-mobile-more-handle" aria-hidden="true"></div>
<h2 id="inst-mobile-more-title" class="inst-mobile-more-title">更多</h2>
<p class="inst-mobile-more-hint">次要页面 · 完整界面请用电脑</p>
<nav class="inst-mobile-more-nav" aria-label="更多页面">
<a href="/dashboard" data-embed-tab="dashboard"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
{% if display.show_nav_records %}
<a href="/records" data-embed-tab="records">交易记录</a>
{% endif %}
{% if not intraday_discipline and display.show_nav_strategy %}
<a href="/strategy" data-embed-tab="strategy">策略交易</a>
{% endif %}
{% if not intraday_discipline and display.show_nav_strategy_records %}
<a href="/strategy/records" data-embed-tab="strategy_records">策略记录</a>
{% endif %}
{% if display.show_nav_stats %}
<a href="/stats" data-embed-tab="stats">统计分析</a>
{% endif %}
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" data-embed-tab="options_review">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" data-embed-tab="hedge_plan">对冲计划</a>
{% endif %}
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config">env配置</a>
{% endif %}
<a href="/settings" data-embed-tab="settings">系统设置</a>
</nav>
<button type="button" class="inst-mobile-more-close" id="inst-mobile-more-close">关闭</button>
</div>
</div>
<script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
@@ -118,8 +166,9 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=15"></script>
<script src="/static/instance_settings_prefs.js?v=16"></script>
<script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=27"></script>
<script src="/static/instance_embed.js?v=29"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script>
</body>
</html>
+2 -2
View File
@@ -118,8 +118,8 @@
</div>
<div class="top-nav">
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %}
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
{% endif %}
@@ -1,7 +1,7 @@
{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #}
<div class="instance-header-panel card">
<div class="instance-header-toolbar">
<div class="instance-header-toolbar-filter">
<div class="instance-header-toolbar-filter instance-desktop-only">
<span class="list-window-label" title="列表按 UTC 时间筛选,默认本月">UTC {{ list_window.label }}</span>
<label class="list-window-preset">预设
<select id="win-preset-select" onchange="toggleListWindowCustom()">
@@ -37,4 +37,18 @@
<div class="instance-header-stats-wrap instance-desktop-only">
{% include 'instance_header_stats.html' %}
</div>
<div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要">
<span class="inst-phone-chip">
<em>交易</em>
<b data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</b>
</span>
<span class="inst-phone-chip">
<em>资金</em>
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
</span>
<span class="inst-phone-chip">
<em>总资</em>
<b data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</b>
</span>
</div>
</div>
+25 -2
View File
@@ -6,9 +6,21 @@
</div>
{% if settings_tabs %}
<div class="env-config-body card settings-config-body">
{% set _sub = (request.args.get('settings_tab') or '').strip() %}
{% set _legacy_tab = (request.args.get('tab') or '').strip() %}
{% set ns = namespace(active_idx=0, active_key='') %}
{% for tab in settings_tabs %}
{% if _sub and tab.key == _sub %}
{% set ns.active_idx = loop.index0 %}
{% set ns.active_key = tab.key %}
{% elif (not _sub) and _legacy_tab and tab.key == _legacy_tab %}
{% set ns.active_idx = loop.index0 %}
{% set ns.active_key = tab.key %}
{% endif %}
{% endfor %}
<div class="env-config-body card settings-config-body" data-settings-active-tab="{{ ns.active_key }}">
{% for tab in settings_tabs %}
<input type="radio" name="settings-section" id="settings-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
<input type="radio" name="settings-section" id="settings-sec-{{ loop.index0 }}" class="env-tab-radio" data-settings-tab="{{ tab.key }}"{% if loop.index0 == ns.active_idx %} checked{% endif %}>
{% endfor %}
<div class="env-config-tabs" role="tablist" aria-label="系统设置分类">
{% for tab in settings_tabs %}
@@ -52,3 +64,14 @@
{% include 'options_settings_panel.html' %}
{% endif %}
</div>
<script>
(function () {
try {
var q = new URLSearchParams(window.location.search || "");
var key = (q.get("settings_tab") || "").trim();
if (!key) return;
var radio = document.querySelector('input.env-tab-radio[data-settings-tab="' + key + '"]');
if (radio) radio.checked = true;
} catch (e) {}
})();
</script>
+8
View File
@@ -95,6 +95,14 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status)
"""
)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
):
try:
conn.execute(ddl)
except Exception:
pass
init_options_review_tables(conn)
+20
View File
@@ -237,6 +237,7 @@ def sync_open_options_trades(
*,
live_inst_ids: set[str],
fetch_history_fn: Callable[[str], list[dict[str, Any]]],
notify_cfg: dict[str, Any] | None = None,
) -> int:
"""
交易所已无持仓时,将本地 open 记录同步为 closed.
@@ -319,6 +320,24 @@ def sync_open_options_trades(
),
)
updated += 1
if notify_cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
reason = "到期结算" if close_reason == "expired" else "交易所平仓"
notify_options_close(
notify_cfg,
conn,
inst_id=inst_id,
reason=reason,
trade_id=int(row["id"]),
premium_paid=paid,
premium_received=prem_recv,
realized_pnl=realized_pnl,
close_quote=close_quote,
)
except Exception:
pass
return updated
@@ -414,6 +433,7 @@ def options_monitor_loop(
close_fn=target_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label},
)
if sync_trades_fn is not None:
sync_trades_fn(conn)
+330
View File
@@ -0,0 +1,330 @@
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记)."""
from __future__ import annotations
import sqlite3
from typing import Any, Callable, Optional
def _fmt(v: Any, d: int = 4) -> str:
try:
if v is None or v == "":
return ""
return f"{float(v):.{d}f}"
except (TypeError, ValueError):
return str(v)
def _opt_type_label(opt_type: Any) -> str:
t = str(opt_type or "").strip().upper()
if t in ("C", "CALL"):
return "Call"
if t in ("P", "PUT"):
return "Put"
return t or ""
def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
):
try:
conn.execute(ddl)
except Exception:
pass
def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
return False
try:
send(content)
return True
except Exception:
return False
def build_options_open_message(
*,
account_label: str,
inst_id: str,
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
open_quote: Any = None,
target_index: Any = None,
signal_note: str = "",
trade_id: Any = None,
) -> str:
lines = [
"【OKX期权·开仓】",
f"账户:{account_label or 'OKX期权'}",
]
if trade_id is not None:
lines.append(f"本地单号:#{trade_id}")
lines.extend(
[
f"合约:{inst_id}",
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"张数:{sheets if sheets is not None else ''}",
f"开仓报价:{_fmt(open_quote)} USDC",
f"权利金:{_fmt(premium_paid)} USDC",
]
)
if target_index is not None and str(target_index).strip() != "":
try:
lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError):
lines.append(f"目标指数:{target_index}")
if signal_note:
lines.append(f"备注:{signal_note[:200]}")
return "\n".join(lines)
def build_options_close_message(
*,
account_label: str,
inst_id: str,
reason: str = "",
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
premium_received: Any = None,
realized_pnl: Any = None,
close_quote: Any = None,
target_index: Any = None,
trigger_idx: Any = None,
trade_id: Any = None,
) -> str:
lines = [
"【OKX期权·平仓】",
f"账户:{account_label or 'OKX期权'}",
]
if trade_id is not None:
lines.append(f"本地单号:#{trade_id}")
lines.extend(
[
f"合约:{inst_id}",
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"原因:{(reason or '平仓').strip()}",
f"张数:{sheets if sheets is not None else ''}",
f"平仓报价:{_fmt(close_quote)} USDC",
f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC",
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
]
)
if target_index is not None and str(target_index).strip() != "":
try:
lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError):
lines.append(f"目标指数:{target_index}")
if trigger_idx is not None and str(trigger_idx).strip() != "":
try:
lines.append(f"触发指数:{float(trigger_idx):g}")
except (TypeError, ValueError):
lines.append(f"触发指数:{trigger_idx}")
return "\n".join(lines)
def notify_options_open(
cfg: dict[str, Any],
conn: sqlite3.Connection | None,
*,
trade_id: int | None,
inst_id: str,
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
open_quote: Any = None,
target_index: Any = None,
signal_note: str = "",
) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None
if conn is not None and trade_id is not None:
row = conn.execute(
"SELECT wechat_open_sent FROM options_trades WHERE id=?",
(int(trade_id),),
).fetchone()
if row and int(row["wechat_open_sent"] or 0):
return False
msg = build_options_open_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id,
underlying=underlying,
opt_type=opt_type,
sheets=sheets,
premium_paid=premium_paid,
open_quote=open_quote,
target_index=target_index,
signal_note=signal_note,
trade_id=trade_id,
)
ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None:
conn.execute(
"UPDATE options_trades SET wechat_open_sent=1 WHERE id=?",
(int(trade_id),),
)
try:
conn.commit()
except Exception:
pass
return ok
def _load_trade_row(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None:
row = conn.execute("SELECT * FROM options_trades WHERE id=?", (int(trade_id),)).fetchone()
return dict(row) if row else None
def notify_options_close(
cfg: dict[str, Any],
conn: sqlite3.Connection | None,
*,
inst_id: str,
reason: str = "平仓",
trade_id: int | None = None,
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
premium_received: Any = None,
realized_pnl: Any = None,
close_quote: Any = None,
target_index: Any = None,
trigger_idx: Any = None,
force: bool = False,
) -> bool:
"""平仓必发.默认按 trade_id / 同合约未标记行幂等."""
if conn is not None:
ensure_options_notify_columns(conn)
rows: list[dict[str, Any]] = []
if conn is not None and trade_id is not None:
r = _load_trade_row(conn, int(trade_id))
if r:
rows = [r]
elif conn is not None and inst_id:
q = conn.execute(
"""
SELECT * FROM options_trades
WHERE inst_id=? AND status='closed'
AND COALESCE(wechat_close_sent,0)=0
ORDER BY id DESC
LIMIT 20
""",
(inst_id,),
).fetchall()
rows = [dict(x) for x in q]
if not rows and force:
q2 = conn.execute(
"""
SELECT * FROM options_trades
WHERE inst_id=? AND status='closed'
ORDER BY id DESC LIMIT 1
""",
(inst_id,),
).fetchone()
if q2:
rows = [dict(q2)]
if rows:
# 同次平仓可能多腿:合并一条推送,逐条标记
total_paid = sum(float(r.get("premium_paid") or 0) for r in rows)
total_recv = sum(float(r.get("premium_received") or 0) for r in rows if r.get("premium_received") is not None)
pnls = [float(r["realized_pnl"]) for r in rows if r.get("realized_pnl") is not None]
total_pnl = sum(pnls) if pnls else None
if total_pnl is None and (premium_received is not None or realized_pnl is not None):
total_pnl = realized_pnl
total_recv = premium_received if premium_received is not None else total_recv
total_paid = premium_paid if premium_paid is not None else total_paid
head = rows[0]
pending = [r for r in rows if not int(r.get("wechat_close_sent") or 0)]
if not pending and not force:
return False
msg = build_options_close_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id or str(head.get("inst_id") or ""),
reason=reason,
underlying=underlying or str(head.get("underlying") or ""),
opt_type=opt_type or head.get("opt_type"),
sheets=sheets if sheets is not None else sum(int(r.get("sheets") or 0) for r in rows),
premium_paid=total_paid,
premium_received=total_recv if rows else premium_received,
realized_pnl=total_pnl,
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
target_index=target_index,
trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None,
)
ok = notify_options_send(cfg, msg)
if ok and conn is not None:
for r in pending or rows:
conn.execute(
"UPDATE options_trades SET wechat_close_sent=1 WHERE id=?",
(int(r["id"]),),
)
try:
conn.commit()
except Exception:
pass
return ok
# 无库行时仍发一条(尽量不丢提醒)
msg = build_options_close_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id,
reason=reason,
underlying=underlying,
opt_type=opt_type,
sheets=sheets,
premium_paid=premium_paid,
premium_received=premium_received,
realized_pnl=realized_pnl,
close_quote=close_quote,
target_index=target_index,
trigger_idx=trigger_idx,
trade_id=trade_id,
)
return notify_options_send(cfg, msg)
def notify_options_close_trade_ids(
cfg: dict[str, Any],
conn: sqlite3.Connection,
trade_ids: list[int],
*,
reason: str,
) -> bool:
ids = [int(x) for x in trade_ids if x is not None]
if not ids:
return False
ensure_options_notify_columns(conn)
placeholders = ",".join("?" for _ in ids)
rows = conn.execute(
f"""
SELECT * FROM options_trades
WHERE id IN ({placeholders}) AND COALESCE(wechat_close_sent,0)=0
""",
ids,
).fetchall()
if not rows:
return False
first = dict(rows[0])
return notify_options_close(
cfg,
conn,
inst_id=str(first.get("inst_id") or ""),
reason=reason,
trade_id=int(first["id"]) if len(rows) == 1 else None,
underlying=str(first.get("underlying") or ""),
opt_type=first.get("opt_type"),
sheets=sum(int(r["sheets"] or 0) for r in rows),
premium_paid=sum(float(r["premium_paid"] or 0) for r in rows),
premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None),
realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None),
close_quote=first.get("close_quote"),
)
+5
View File
@@ -259,6 +259,11 @@ def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float:
return round(int(sheets) * float(ct_mult), 8)
def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> float:
"""按可用余额打满:余额大于预算用预算,否则用余额."""
return min(float(trading_usdc), float(trade_budget_usdc))
def calc_order_size(
*,
quote_per_unit: float,
+43 -2
View File
@@ -163,13 +163,18 @@ def _require_options_ex(cfg: dict[str, Any]):
def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
"""交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量)."""
"""打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer."""
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
from lib.options.options_pricing_lib import resolve_budget_full_usdc
raw = fetch_options_trading_usdc(ex)
if raw is None or float(raw) <= 0:
return None, "交易账户 USDC 可用余额不足"
return float(raw), ""
trading = float(raw)
cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0))
if cap <= 0:
return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)"
return resolve_budget_full_usdc(trading, float(cap)), ""
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
@@ -640,11 +645,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]()
trade_id = None
target_mon = None
open_underlying = ""
open_opt_type = None
try:
init_options_tables(conn)
meta = q.get("meta") or {}
u = str(meta.get("uly") or inst_id).split("-")[0]
opt_type = meta.get("optType")
open_underlying = u
open_opt_type = opt_type
cur = conn.execute(
"""
INSERT INTO options_trades
@@ -683,9 +692,30 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
finally:
conn.close()
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
from lib.options.options_notify_lib import notify_options_open
invalidate_option_positions_cache()
_sync_options_trades(cfg, force=True)
try:
conn_n = cfg["get_db"]()
try:
notify_options_open(
cfg,
conn_n,
trade_id=trade_id,
inst_id=inst_id,
underlying=open_underlying,
opt_type=open_opt_type,
sheets=sheets,
premium_paid=sizing.get("total_premium"),
open_quote=float(ask) if ask is not None else None,
target_index=target_index,
signal_note=signal_note,
)
finally:
conn_n.close()
except Exception:
pass
return jsonify(
{
"ok": True,
@@ -938,11 +968,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if result.get("fully_closed"):
try:
from lib.options.options_target_lib import cancel_target_monitor
from lib.options.options_notify_lib import notify_options_close
conn2 = cfg["get_db"]()
try:
cancel_target_monitor(conn2, inst_id=inst_id)
conn2.commit()
notify_options_close(
cfg,
conn2,
inst_id=inst_id,
reason="手动平仓",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
)
finally:
conn2.close()
except Exception:
@@ -1253,6 +1293,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
conn,
live_inst_ids=live_ids,
fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
notify_cfg=cfg,
)
def _target_close(inst_id: str) -> dict[str, Any]:
+26
View File
@@ -292,6 +292,7 @@ def close_option_by_bid_depth(
def _notify_target_close(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
@@ -299,7 +300,28 @@ def _notify_target_close(
target: float,
idx: float,
result: dict[str, Any],
conn: Any = None,
) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason="目标位平仓",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target,
trigger_idx=idx,
)
return
except Exception:
pass
if not send_wechat:
return
try:
@@ -313,6 +335,7 @@ def _notify_target_close(
f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or ''}",
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
@@ -339,6 +362,7 @@ def run_options_target_closes(
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None,
) -> int:
"""
扫描 active 目标委托;指数到位后限价平仓.
@@ -433,11 +457,13 @@ def run_options_target_closes(
_commit_monitor(conn)
triggered += 1
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
return triggered
+6 -2
View File
@@ -1,6 +1,7 @@
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
@@ -18,7 +19,7 @@
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;<strong>T 型</strong>默认 ATM ±5 档,可展开全部。</li>
<li>「按可用余额打满」可用额度 = min(交易 USDC × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「预算缓冲比例」改</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)</li>
<li>平仓仅买一限价,详见说明文档。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
@@ -133,6 +134,9 @@
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
</p>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
@@ -316,4 +320,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=49"></script>
<script src="/static/options_panel.js?v=50"></script>
+69 -2
View File
@@ -86,6 +86,14 @@ def manual_close_daily_limit() -> int:
return 2
def daily_loss_limit() -> int:
"""日亏损次数上限:达限当日冻结开仓;0=不因亏损次数冻结."""
try:
return max(0, int(os.getenv("RISK_DAILY_LOSS_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def max_active_positions_from_env(default: int = 1) -> int:
try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default))))
@@ -116,6 +124,7 @@ def ensure_account_risk_schema(conn) -> None:
id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
daily_loss_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
@@ -124,10 +133,18 @@ def ensure_account_risk_schema(conn) -> None:
updated_at TEXT
)"""
)
cols = {
str(r[1])
for r in conn.execute("PRAGMA table_info(account_risk_state)").fetchall()
}
if "daily_loss_count" not in cols:
conn.execute(
"ALTER TABLE account_risk_state ADD COLUMN daily_loss_count INTEGER DEFAULT 0"
)
row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone()
if not row:
conn.execute(
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_loss_count, daily_frozen) VALUES (1, '', 0, 0, 0)"
)
@@ -268,6 +285,7 @@ def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) ->
"""UPDATE account_risk_state SET
trading_day=?,
manual_close_count=0,
daily_loss_count=0,
daily_frozen=0,
cooloff_until_ms=?,
cooloff_hours=?,
@@ -600,6 +618,43 @@ def on_manual_close(
)
def on_closed_trade_pnl(
conn,
*,
pnl_amount: Any,
trading_day: str,
now: Optional[datetime] = None,
) -> None:
"""
已平仓交易记盈亏后调用:亏损笔数达 RISK_DAILY_LOSS_LIMIT 则当日冻结开仓.
上限为 0 时不启用本规则.
"""
if not risk_control_enabled():
return
limit = daily_loss_limit()
if limit <= 0:
return
try:
pnl = float(pnl_amount)
except (TypeError, ValueError):
return
if pnl >= 0:
return
row = _sync_trading_day(conn, trading_day, now=now)
if int(_row_get(row, "daily_frozen") or 0) == 1:
return
count = int(_row_get(row, "daily_loss_count") or 0) + 1
conn.execute(
"""UPDATE account_risk_state SET
daily_loss_count=?,
updated_at=?
WHERE id=1""",
(count, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
)
if count >= limit:
_set_daily_frozen(conn, trading_day=trading_day, now=now)
def on_journal_saved(
conn,
*,
@@ -762,6 +817,7 @@ def compute_account_risk_status(
"cooloff_until_ms": None,
"cooloff_until": None,
"manual_close_count": 0,
"daily_loss_count": 0,
"daily_frozen": False,
}
row = _sync_trading_day(conn, trading_day, now=now)
@@ -784,12 +840,21 @@ def compute_account_risk_status(
row = _load_state(conn)
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
daily_loss_count = int(_row_get(row, "daily_loss_count") or 0)
loss_limit = daily_loss_limit()
status = STATUS_NORMAL
reason = ""
if daily_frozen:
status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
parts = []
if loss_limit > 0 and daily_loss_count >= loss_limit:
parts.append(f"日亏损 {daily_loss_count}/{loss_limit}")
if manual_close_count >= manual_close_daily_limit():
parts.append(f"手动平仓 {manual_close_count}")
if not parts:
parts.append("手动平仓/日亏损达限或复盘情绪标签")
reason = "账户今日已冻结(" + "".join(parts) + ")"
elif cooloff_until_ms is not None:
remaining_ms = cooloff_until_ms - now_ms
hours = _cooloff_hours_value(row)
@@ -818,6 +883,8 @@ def compute_account_risk_status(
if fmt_local_ms and cooloff_until_ms
else None,
"manual_close_count": manual_close_count,
"daily_loss_count": daily_loss_count,
"daily_loss_limit": loss_limit,
"daily_frozen": daily_frozen,
"pending_journal_trade_id": pending,
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
+1 -1
View File
@@ -6,7 +6,7 @@
| 功能 | 说明 |
|------|------|
| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
| **交易教练** | 口语化陪聊;注入三户监控快照(**含 OKX 期权持仓明细**)、执行手册短摘要与今日总结摘要(后台自动生成,不在页面展示) |
| **普通聊天** | 不绑交易数据,适合闲聊,答疑 |
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
| **会话历史** | 右侧列表:切换,删除;消息一键复制 |
+222
View File
@@ -0,0 +1,222 @@
"""中控振幅统计 API."""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from amp_stats_store import delete_history, get_history, list_history, save_history
from lib.hub.amp_stats_lib import (
build_export_csv,
compute_amp_stats,
export_filename,
normalize_straddle_premium,
normalize_take_profit,
normalize_weekend_filter,
reframe_amp_stats,
rows_page,
)
class ComputeBody(BaseModel):
symbol: str = "eth"
start_hour: int = 16
period: str = "2m"
custom_days: Optional[int] = None
straddle_premium: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all"
page: int = 1
page_size: int = 20
class SaveBody(BaseModel):
result: dict[str, Any] = Field(default_factory=dict)
class ReframeBody(BaseModel):
"""已有日表上改周末/权利金/止盈(不拉 K 线)."""
rows_all: list[dict[str, Any]] = Field(default_factory=list)
symbol: str = "eth"
start_hour: int = 16
period: str = "2m"
sample_days: int = 60
straddle_premium: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all"
price_source: str = ""
inst_id: str = ""
page: int = 1
page_size: int = 20
def create_amp_stats_router() -> APIRouter:
router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"])
@router.get("/meta")
def api_meta():
return {
"ok": True,
"exchange": "okx",
"symbols": [
{"key": "eth", "label": "ETH"},
{"key": "btc", "label": "BTC"},
],
"end_hour": 16,
"start_hours": list(range(24)),
"periods": [
{"key": "1m", "label": "1个月"},
{"key": "2m", "label": "2个月"},
{"key": "3m", "label": "3个月"},
{"key": "6m", "label": "半年"},
{"key": "1y", "label": "1年"},
{"key": "custom", "label": "自定义"},
],
"weekend_filters": [
{"key": "all", "label": "全部"},
{"key": "exclude", "label": "排除周末"},
{"key": "only", "label": "仅周末"},
],
"default_period": "2m",
"default_weekend_filter": "all",
"timeframe": "1H",
"metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)",
"straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金",
}
@router.post("/compute")
def api_compute(body: ComputeBody):
try:
result = compute_amp_stats(
symbol=body.symbol,
start_hour=body.start_hour,
period=body.period,
custom_days=body.custom_days,
straddle_premium=body.straddle_premium,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size)
return {
"ok": True,
"result": result,
"page": page,
}
@router.post("/reframe")
def api_reframe(body: ReframeBody):
rows_all = body.rows_all or []
if not rows_all:
raise HTTPException(status_code=400, detail="无日表可重算")
try:
result = reframe_amp_stats(
rows_all=rows_all,
symbol=body.symbol,
start_hour=body.start_hour,
period=body.period,
sample_days=body.sample_days,
straddle_premium=body.straddle_premium,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter,
price_source=body.price_source,
inst_id=body.inst_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size)
return {"ok": True, "result": result, "page": page}
@router.get("/history")
def api_history(symbol: str = "", limit: int = 50):
return {"ok": True, "items": list_history(symbol=symbol, limit=limit)}
@router.post("/history")
def api_history_save(body: SaveBody):
payload = body.result if isinstance(body.result, dict) else {}
if not payload.get("rows") and not payload.get("rows_all") and not payload.get("summary"):
raise HTTPException(status_code=400, detail="无可保存的结果")
item = save_history(payload)
return {"ok": True, "item": item}
@router.get("/history/{item_id}")
def api_history_detail(item_id: str):
item = get_history(item_id)
if not item:
raise HTTPException(status_code=404, detail="历史不存在")
return {"ok": True, "item": item}
@router.delete("/history/{item_id}")
def api_history_delete(item_id: str):
if not delete_history(item_id):
raise HTTPException(status_code=404, detail="历史不存在")
return {"ok": True}
@router.get("/export")
def api_export(
history_id: str = Query(default=""),
symbol: str = Query(default="eth"),
start_hour: int = Query(default=16),
period: str = Query(default="2m"),
custom_days: Optional[int] = Query(default=None),
straddle_premium: Optional[float] = Query(default=None),
take_profit: Optional[float] = Query(default=None),
weekend_filter: str = Query(default="all"),
):
if (history_id or "").strip():
item = get_history(history_id.strip())
if not item:
raise HTTPException(status_code=404, detail="历史不存在")
rows_all = item.get("rows_all") or item.get("rows") or []
try:
payload = reframe_amp_stats(
rows_all=rows_all,
symbol=item.get("symbol") or symbol,
start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour),
period=str(item.get("period") or period),
sample_days=int(item.get("sample_days_requested") or 60),
straddle_premium=straddle_premium
if straddle_premium is not None
else item.get("straddle_premium"),
take_profit=take_profit if take_profit is not None else item.get("take_profit"),
weekend_filter=weekend_filter or item.get("weekend_filter") or "all",
price_source=str(item.get("price_source") or ""),
inst_id=str(item.get("inst_id") or ""),
missing=item.get("missing_days") or [],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
else:
try:
# validate enums early
normalize_weekend_filter(weekend_filter)
normalize_straddle_premium(straddle_premium)
normalize_take_profit(take_profit)
payload = compute_amp_stats(
symbol=symbol,
start_hour=start_hour,
period=period,
custom_days=custom_days,
straddle_premium=straddle_premium,
take_profit=take_profit,
weekend_filter=weekend_filter,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
csv_text = build_export_csv(payload)
name = export_filename(payload)
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
return router
+122
View File
@@ -0,0 +1,122 @@
"""振幅统计历史作业存储(中控 JSON)."""
from __future__ import annotations
import json
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
_LOCK = threading.Lock()
_STORE_NAME = "amp_stats_history.json"
_MAX_ITEMS = 80
def _store_path() -> Path:
return Path(__file__).resolve().parent / _STORE_NAME
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _load() -> dict[str, Any]:
path = _store_path()
if not path.is_file():
return {"items": []}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"items": []}
if not isinstance(data, dict):
return {"items": []}
items = data.get("items")
if not isinstance(items, list):
items = []
return {"items": items}
def _save(data: dict[str, Any]) -> None:
path = _store_path()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def list_history(*, symbol: str = "", limit: int = 50) -> list[dict[str, Any]]:
with _LOCK:
items = list(_load().get("items") or [])
sym = (symbol or "").strip().lower()
if sym:
items = [x for x in items if str(x.get("symbol") or "").lower() == sym]
limit = max(1, min(200, int(limit or 50)))
out = []
for it in items[:limit]:
out.append(
{
"id": it.get("id"),
"created_at": it.get("created_at"),
"symbol": it.get("symbol"),
"symbol_label": it.get("symbol_label"),
"start_hour": it.get("start_hour"),
"end_hour": it.get("end_hour"),
"period": it.get("period"),
"price_source": it.get("price_source"),
"sample_count": (it.get("summary") or {}).get("sample_count"),
"max_amplitude": (it.get("summary") or {}).get("max_amplitude"),
"max_amplitude_day": (it.get("summary") or {}).get("max_amplitude_day"),
}
)
return out
def get_history(item_id: str) -> Optional[dict[str, Any]]:
iid = (item_id or "").strip()
if not iid:
return None
with _LOCK:
for it in _load().get("items") or []:
if str(it.get("id")) == iid:
return dict(it)
return None
def save_history(payload: dict[str, Any]) -> dict[str, Any]:
item = {
"id": uuid.uuid4().hex[:12],
"created_at": _now_iso(),
"exchange": payload.get("exchange"),
"symbol": payload.get("symbol"),
"symbol_label": payload.get("symbol_label"),
"start_hour": payload.get("start_hour"),
"end_hour": payload.get("end_hour"),
"period": payload.get("period"),
"timeframe": payload.get("timeframe"),
"price_source": payload.get("price_source"),
"inst_id": payload.get("inst_id"),
"timezone": payload.get("timezone"),
"summary": payload.get("summary") or {},
"rows": payload.get("rows") or [],
"missing_count": payload.get("missing_count") or 0,
}
with _LOCK:
data = _load()
items = list(data.get("items") or [])
items.insert(0, item)
data["items"] = items[:_MAX_ITEMS]
_save(data)
return item
def delete_history(item_id: str) -> bool:
iid = (item_id or "").strip()
if not iid:
return False
with _LOCK:
data = _load()
items = list(data.get("items") or [])
new_items = [x for x in items if str(x.get("id")) != iid]
if len(new_items) == len(items):
return False
data["items"] = new_items
_save(data)
return True
+2 -1
View File
@@ -7,7 +7,8 @@
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **策略说明** | 执行手册 + 行为准则(开单三检) + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **振幅统计** | OKX ETH/BTC 时段点数振幅档案(只读,固定 16:00 收窗) |
| **使用说明** | 本页:中控与实例怎么用 |
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
+250 -20
View File
@@ -79,6 +79,12 @@ from lib.hub.hub_symbol_archive_lib import (
update_review_quote,
upsert_trade_overlay,
)
from lib.hub.hub_options_archive_lib import (
init_options_archive_db,
list_archive_options_calendar,
list_daily_options_trades,
sync_options_exchange_archive,
)
from lib.hub.hub_entry_plan_lib import (
compute_entry_plan_stats,
create_entry_plan,
@@ -355,9 +361,11 @@ def _schedule_board_refresh() -> None:
async def _run_archive_sync_once() -> dict:
global _last_archive_sync
init_archive_db()
init_options_archive_db()
settings = load_settings()
targets = enabled_exchanges(settings)
results: list[dict] = []
options_results: list[dict] = []
for ex in targets:
ex_key = str(ex.get("key") or "").strip().lower()
if not ex_key:
@@ -390,34 +398,71 @@ async def _run_archive_sync_once() -> dict:
"msg": msg,
}
)
else:
trades = trades_resp.get("trades") or []
for t in trades:
if isinstance(t, dict):
t["exchange_key"] = ex_key
def remote_fetch(**kwargs):
return _fetch_instance_ohlcv_sync(
ex,
symbol=kwargs.get("symbol") or "",
timeframe=kwargs.get("timeframe") or "5m",
since_ms=kwargs.get("since_ms"),
limit=int(kwargs.get("limit") or 500),
)
r = await asyncio.to_thread(
sync_exchange_symbol_archives,
ex_key,
trades,
remote_fetch,
)
r["name"] = ex.get("name")
r["trade_count"] = len(trades)
results.append(r)
caps = [str(x).lower() for x in (ex.get("capabilities") or [])]
if "options" not in caps:
continue
trades = trades_resp.get("trades") or []
for t in trades:
opt_resp = await asyncio.to_thread(
_fetch_instance_options_review_archive_sync,
ex,
days=ARCHIVE_TRADE_DAYS,
limit=ARCHIVE_TRADE_LIMIT,
)
if not opt_resp.get("ok"):
options_results.append(
{
"exchange_key": ex_key,
"name": ex.get("name"),
"ok": False,
"status": opt_resp.get("status"),
"msg": opt_resp.get("msg")
or opt_resp.get("error")
or opt_resp.get("detail")
or "拉取期权复盘失败",
"product": "options",
}
)
continue
opt_trades = opt_resp.get("trades") or []
for t in opt_trades:
if isinstance(t, dict):
t["exchange_key"] = ex_key
def remote_fetch(**kwargs):
return _fetch_instance_ohlcv_sync(
ex,
symbol=kwargs.get("symbol") or "",
timeframe=kwargs.get("timeframe") or "5m",
since_ms=kwargs.get("since_ms"),
limit=int(kwargs.get("limit") or 500),
)
r = await asyncio.to_thread(
sync_exchange_symbol_archives,
orow = await asyncio.to_thread(
sync_options_exchange_archive,
ex_key,
trades,
remote_fetch,
opt_trades,
)
r["name"] = ex.get("name")
r["trade_count"] = len(trades)
results.append(r)
orow["name"] = ex.get("name")
options_results.append(orow)
out = {
"ok": True,
"exchanges": len(targets),
"results": results,
"options_results": options_results,
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
}
_last_archive_sync = out
@@ -991,6 +1036,7 @@ def root_redirect():
@app.get("/monitor")
@app.get("/plan")
@app.get("/calculator")
@app.get("/compare")
@app.get("/market")
@app.get("/archive")
@app.get("/quotes")
@@ -998,6 +1044,7 @@ def root_redirect():
@app.get("/funds")
@app.get("/ai")
@app.get("/strategy")
@app.get("/amp-stats")
@app.get("/help")
@app.get("/logs")
@app.get("/settings")
@@ -1012,8 +1059,10 @@ def _all_exchanges_for_ai() -> list:
from hub_ai.routes import create_hub_ai_router
from hub_dashboard import build_dashboard_payload, default_trading_day
from amp_stats_routes import create_amp_stats_router
app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai))
app.include_router(create_amp_stats_router())
async def _run_dashboard_aggregate() -> dict:
@@ -1110,9 +1159,21 @@ class SettingsDisplayBody(BaseModel):
show_nav_quotes: bool = True
show_nav_ai: bool = True
show_nav_calculator: bool = True
show_nav_compare: bool = True
show_nav_strategy: bool = True
show_nav_amp_stats: bool = True
show_nav_help: bool = True
show_nav_logs: bool = True
show_monitor_binance: bool = True
show_monitor_okx_perp: bool = True
show_monitor_okx_options: bool = True
show_monitor_gate: bool = True
show_strategy_playbook_v2: bool = True
show_strategy_playbook: bool = True
show_strategy_behavior: bool = True
show_strategy_binance: bool = True
show_strategy_okx: bool = True
show_strategy_gate: bool = True
class SupervisorSettingsBody(BaseModel):
@@ -1208,6 +1269,42 @@ class RollCalculatorBody(BaseModel):
base: str = "ETH"
class PerpOptionsCalculatorBody(BaseModel):
calc_mode: str = "size"
base: str = "ETH"
spot: float = Field(gt=0)
capital_usdt: float = Field(gt=0)
target_profit_u: float = Field(ge=0)
move_mode: str = "points"
move_value: float | None = None
perp_leverage: float = Field(gt=0)
option_leverage: float = Field(gt=0)
ct_mult: float = Field(default=0.01, gt=0)
ratio_perp: float = Field(default=1.0, gt=0)
ratio_opt: float = Field(default=2.0, gt=0)
class CompareOptionLegBody(BaseModel):
opt_type: str = "C"
strike: float | None = None
ask: float | None = None
class CompareBody(BaseModel):
base: str = "ETH"
direction: str = "long"
entry: float = Field(gt=0)
sl: float = Field(gt=0)
tp: float = Field(gt=0)
risk_u: float = Field(gt=0)
tp_opt: float | None = None
tp_hedge: float | None = None
contract_size: float | None = None
ct_mult: float | None = None
option: CompareOptionLegBody | None = None
hedge: dict | None = None
@app.get("/api/calculator/exchanges")
def api_calculator_exchanges():
from lib.hub.hub_calculator_market_lib import list_calculator_exchanges
@@ -1268,6 +1365,64 @@ def api_calculator_roll(body: RollCalculatorBody):
return {"ok": True, "data": data}
@app.post("/api/calculator/perp-options")
def api_calculator_perp_options(body: PerpOptionsCalculatorBody):
from lib.hub.hub_perp_options_calc_lib import calc_perp_options
mode = (body.calc_mode or "size").strip().lower()
if mode in ("points", "ratio", "move"):
data, err = calc_perp_options(
calc_mode="points",
base=body.base,
spot=body.spot,
capital_usdt=body.capital_usdt,
target_profit_u=body.target_profit_u,
perp_leverage=body.perp_leverage,
option_leverage=body.option_leverage,
ct_mult=body.ct_mult,
ratio_perp=body.ratio_perp,
ratio_opt=body.ratio_opt,
)
else:
if body.move_value is None or body.move_value <= 0:
return JSONResponse({"ok": False, "msg": "请填写波动数值"}, status_code=400)
data, err = calc_perp_options(
calc_mode="size",
base=body.base,
spot=body.spot,
capital_usdt=body.capital_usdt,
target_profit_u=body.target_profit_u,
move_mode=body.move_mode,
move_value=body.move_value,
perp_leverage=body.perp_leverage,
option_leverage=body.option_leverage,
ct_mult=body.ct_mult,
)
if err:
return JSONResponse({"ok": False, "msg": err}, status_code=400)
return {"ok": True, "data": data}
@app.post("/api/compare/calc")
def api_compare_calc(body: CompareBody):
from lib.hub.hub_compare_lib import run_compare
payload = body.model_dump()
hedge = payload.get("hedge") if isinstance(payload.get("hedge"), dict) else {}
# normalize hedge legs from nested dicts
if hedge:
payload["hedge"] = {
"main": hedge.get("main") if isinstance(hedge.get("main"), dict) else {},
"side": hedge.get("side") if isinstance(hedge.get("side"), dict) else {},
}
if payload.get("option") is None:
payload["option"] = {}
data = run_compare(payload)
if not data.get("ok"):
return JSONResponse(data, status_code=400)
return data
def _find_exchange_by_key(exchange_key: str) -> dict | None:
key = (exchange_key or "").strip().lower()
if not key:
@@ -1308,6 +1463,34 @@ def _fetch_instance_trades_archive_sync(
return {"ok": False, "msg": str(e)}
def _fetch_instance_options_review_archive_sync(
ex: dict,
*,
days: int = 365,
limit: int = 2000,
) -> dict:
base = (ex.get("flask_url") or "").rstrip("/")
if not base:
return {"ok": False, "msg": "未配置 flask_url"}
params = {"days": str(int(days)), "limit": str(int(limit))}
url = f"{base}/api/hub/options/review/archive?{urlencode(params)}"
try:
with httpx.Client(timeout=max(HUB_FLASK_TIMEOUT, 120.0)) as client:
r = client.get(url, headers=_hub_headers())
if r.status_code >= 400:
parsed = _parse_http_json_body(r)
parsed.setdefault("ok", False)
parsed.setdefault("status", r.status_code)
return parsed
data = r.json() if r.content else {}
if isinstance(data, dict):
data.setdefault("ok", True)
return data
return {"ok": False, "msg": "无效 JSON"}
except Exception as e:
return {"ok": False, "msg": str(e)}
def _fetch_instance_ohlcv_sync(
ex: dict,
*,
@@ -3088,6 +3271,52 @@ def api_archive_calendar(
return {"ok": True, **payload}
@app.get("/api/archive/options/daily-trades")
def api_archive_options_daily_trades(
period: str = "",
trading_day: str = "",
date_from: str = "",
date_to: str = "",
exchange_key: str = "",
filter_profit: str = "",
filter_loss: str = "",
search: str = "",
source_type: str = "",
):
init_options_archive_db()
payload = list_daily_options_trades(
trading_day=trading_day,
period=period or "today",
date_from=date_from,
date_to=date_to,
exchange_key=exchange_key,
filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"),
filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"),
search=search,
source_type=source_type,
)
return {"ok": True, **payload}
@app.get("/api/archive/options/calendar")
def api_archive_options_calendar(
year: int = 0,
month: int = 0,
exchange_key: str = "",
):
init_options_archive_db()
if year <= 0 or month <= 0:
td = today_trading_day()
parts = td.split("-")
year = int(parts[0])
month = int(parts[1])
try:
payload = list_archive_options_calendar(year, month, exchange_key=exchange_key)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, **payload}
@app.get("/api/archive/quotes")
def api_archive_quotes():
init_archive_db()
@@ -3301,7 +3530,8 @@ async def api_archive_sync():
@app.get("/api/strategy/meta")
def api_strategy_meta():
return strategy_meta_payload()
display = (load_settings() or {}).get("display") or {}
return strategy_meta_payload(display)
@app.get("/api/help/meta")
+6
View File
@@ -25,6 +25,7 @@ from hub_ai.context import (
format_chat_context_for_chat,
format_chat_position_overview,
)
from hub_ai.playbook_brief import format_playbook_brief_for_chat
from hub_ai.prompts import (
CHAT_GENERAL_SYSTEM,
CHAT_SYSTEM,
@@ -217,6 +218,10 @@ def send_chat_message(
ctx = build_chat_context(exchanges, trading_day=day)
day = ctx["trading_day"]
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count)
# 首轮带完整手册摘要;续聊缩短,避免挤占对话上下文
playbook = format_playbook_brief_for_chat(
max_chars=1200 if prior_count <= 0 else 700
)
user_prompt = build_chat_user_prompt(
context_text=brief_ctx,
trading_day=day,
@@ -225,6 +230,7 @@ def send_chat_message(
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
playbook_brief=playbook,
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
+115 -9
View File
@@ -86,7 +86,81 @@ def _filter_open_positions(positions: list) -> list[dict]:
def _account_open_position_count(ac: dict) -> int:
return len(_filter_open_positions(ac.get("positions") or []))
perp = len(_filter_open_positions(ac.get("positions") or []))
opt = int(ac.get("options_open_position_count") or 0)
if opt <= 0:
opt = len(_iter_options_position_dicts(ac))
return perp + opt
def _iter_options_position_dicts(ac: dict) -> list[dict]:
snap = ac.get("options_snapshot")
if not isinstance(snap, dict):
return []
if snap.get("ok") is False or snap.get("enabled") is False:
return []
out: list[dict] = []
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
inst = str(p.get("inst_id") or p.get("instId") or "").strip()
if not inst:
continue
out.append(p)
return out
def _format_options_position_detail_line(p: dict) -> str:
inst = p.get("inst_id") or p.get("instId") or "?"
opt_type = (p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
src = _options_source_label(p)
sheets = p.get("pos")
if sheets is None:
sheets = p.get("sheets")
if sheets is None:
sheets = p.get("contracts")
if sheets is None:
sheets = "?"
parts = [f"期权 {inst} {label}", f"来源{src}", f"张数{sheets}"]
paid = _safe_float(p.get("premium_paid"))
if paid is not None:
parts.append(f"权利金{paid:g}U")
net: Optional[float] = None
try:
from lib.options.options_positions_lib import net_pnl_from_display_row
net = net_pnl_from_display_row(p)
except Exception:
net = None
if net is None:
net = _safe_float(p.get("net_pnl"))
if net is None:
net = _safe_float(p.get("upl"))
if net is not None:
parts.append(f"净盈亏{net:.4f}U")
tgt = _options_target_monitor_text(p)
if tgt and tgt not in ("", "-", ""):
parts.append(f"目标{tgt}")
return " - " + " ".join(parts)
def _append_options_position_lines(lines: list[str], ac: dict, *, limit: int = 6, indent: str = " - ") -> None:
rows = _iter_options_position_dicts(ac)
if not rows:
return
if indent.startswith(" "):
# chat slim: already under account bullet
for p in rows[:limit]:
lines.append(f" · {_format_options_position_detail_line(p).lstrip(' - ')}")
if len(rows) > limit:
lines.append(f" · …共{len(rows)}笔期权持仓")
return
lines.append("期权持仓明细(交易所实盘,含目标位若已挂):")
for p in rows[:limit]:
lines.append(_format_options_position_detail_line(p))
if len(rows) > limit:
lines.append(f" - …共{len(rows)}笔期权持仓")
def _monitor_counts(ac: dict) -> dict[str, int]:
@@ -788,7 +862,9 @@ def format_context_text(payload: dict) -> str:
lines.append(
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
f"实盘持仓 {totals.get('open_position_count', 0)} | "
f"实盘持仓 {totals.get('open_position_count', 0)}"
f"(永续{totals.get('perpetual_open_position_count', totals.get('open_position_count', 0))}/"
f"期权{totals.get('options_open_position_count', 0)}) | "
f"浮盈亏 {totals.get('float_pnl_u')}U | "
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}"
@@ -855,6 +931,7 @@ def format_context_text(payload: dict) -> str:
if not isinstance(p, dict):
continue
lines.append(_format_position_detail_line(p, hub_mon))
_append_options_position_lines(lines, ac, limit=8)
lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
)
@@ -885,7 +962,9 @@ def format_summary_context_text(payload: dict) -> str:
lines.append(
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
f"实盘持仓 {totals.get('open_position_count', 0)} | "
f"实盘持仓 {totals.get('open_position_count', 0)}"
f"(永续{totals.get('perpetual_open_position_count', totals.get('open_position_count', 0))}/"
f"期权{totals.get('options_open_position_count', 0)}) | "
f"浮盈亏 {totals.get('float_pnl_u')}U | "
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}"
@@ -943,6 +1022,7 @@ def format_summary_context_text(payload: dict) -> str:
if not isinstance(p, dict):
continue
lines.append(_format_position_detail_line(p, hub_mon))
_append_options_position_lines(lines, ac, limit=8)
lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
)
@@ -1289,21 +1369,30 @@ def collect_closed_trades_snapshot(
def format_chat_position_overview(payload: dict) -> str:
totals = payload.get("totals") or {}
total_open = int(totals.get("open_position_count") or 0)
opt_total = int(totals.get("options_open_position_count") or 0)
perp_total = int(
totals.get("perpetual_open_position_count")
if totals.get("perpetual_open_position_count") is not None
else max(0, total_open - opt_total)
)
if total_open <= 0:
head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓).浮盈亏 0U 表示无持仓,不是「有仓但不动」."
else:
head = (
f"【实盘持仓总览】监控户合计 {total_open},"
f"【实盘持仓总览】监控户合计 {total_open}"
f"(永续{perp_total}/期权{opt_total}),"
f"浮盈亏合计 {totals.get('float_pnl_u')}U."
)
lines = [
head,
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.",
"【区分】只有带「持仓明细/交易所实盘/期权持仓」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.",
]
for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控":
continue
n = int(ac.get("open_position_count") or _account_open_position_count(ac))
opt_n = int(ac.get("options_open_position_count") or len(_iter_options_position_dicts(ac)))
perp_n = len(_filter_open_positions(ac.get("positions") or []))
mc = _monitor_counts(ac)
mon_parts = []
if mc["trends"]:
@@ -1319,8 +1408,11 @@ def format_chat_position_overview(payload: dict) -> str:
lines.append(f"- {ac.get('name')}:空仓{mon_txt}")
else:
lines.append(
f"- {ac.get('name')}:{n} 浮盈亏{ac.get('float_pnl_u')}U{mon_txt}"
f"- {ac.get('name')}:{n}(永续{perp_n}/期权{opt_n}) "
f"浮盈亏{ac.get('float_pnl_u')}U{mon_txt}"
)
for p in _iter_options_position_dicts(ac)[:4]:
lines.append(f" · {_format_options_position_detail_line(p).lstrip(' - ')}")
return "\n".join(lines)
@@ -1328,11 +1420,19 @@ def format_chat_context_slim(payload: dict) -> str:
"""聊天专用:不含 180 日资金曲线与昨日平仓明细,避免挤占对话上下文."""
totals = payload.get("totals") or {}
day = totals.get("trading_day")
opt_total = int(totals.get("options_open_position_count") or 0)
perp_total = int(
totals.get("perpetual_open_position_count")
if totals.get("perpetual_open_position_count") is not None
else max(0, int(totals.get("open_position_count") or 0) - opt_total)
)
lines = [
f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
f"实盘持仓 {totals.get('open_position_count', 0)} | 浮盈亏 {totals.get('float_pnl_u')}U",
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓.持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致).",
f"实盘持仓 {totals.get('open_position_count', 0)}"
f"(永续{perp_total}/期权{opt_total}) | 浮盈亏 {totals.get('float_pnl_u')}U",
"【说明】持仓=交易所实盘(含期权);趋势/关键位/监控单=本地计划,不等于已开仓."
"永续行「止损/止盈」=条件单或监控计划价;期权行含合约/来源/权利金/净盈亏/目标位.",
]
for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控":
@@ -1340,7 +1440,12 @@ def format_chat_context_slim(payload: dict) -> str:
continue
st = ac.get("trade_stats") or {}
open_n = int(ac.get("open_position_count") or _account_open_position_count(ac))
pos_txt = "空仓" if open_n <= 0 else f"{open_n}仓 浮盈亏{ac.get('float_pnl_u')}U"
opt_n = int(ac.get("options_open_position_count") or len(_iter_options_position_dicts(ac)))
perp_n = len(_filter_open_positions(ac.get("positions") or []))
if open_n <= 0:
pos_txt = "空仓"
else:
pos_txt = f"{open_n}仓(永续{perp_n}/期权{opt_n}) 浮盈亏{ac.get('float_pnl_u')}U"
mc = _monitor_counts(ac)
mon = []
if mc["trends"]:
@@ -1369,6 +1474,7 @@ def format_chat_context_slim(payload: dict) -> str:
if not isinstance(p, dict):
continue
lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}")
_append_options_position_lines(lines, ac, limit=6, indent=" · ")
return "\n".join(lines)
@@ -0,0 +1,31 @@
"""交易教练用的执行手册短摘要(现行 v2:无对冲)."""
from __future__ import annotations
from pathlib import Path
from lib.paths import REPO_ROOT
# 控制 token:保持简短;手册大改时同步修订本摘要.
_PLAYBOOK_BRIEF = """【用户策略执行手册·摘要】(来源:docs/交易执行手册-v2-期权与合约.md + docs/交易行为准则-开单三检.md)
开单防火墙(强制):信号判断流程确认情绪自检三检不过不开成败先看三检是否跑完
主链条(强制):1H方向(含N字)空间(支撑/阻力,至少约2%)结构(约8h+/48×15m:收敛/两段回调/箱体/假突破等)定损盈(结构突破=外沿;假突破=针尖)选工具(只剩期权或合约)
丢掉对冲:不做期期对冲/偏置对冲;对冲易产生有保护就能多做的幻觉日更不是目标,过检才开
一句话:1H定方向量空间等够结构按模型定损盈只在期权与合约里选;期权不手平;Gate一位置两次,错完收工;珍惜机会
分工:OKX期权与Gate合约;其它账户暂不做同一时段尽量只让一边说话
入场:主链条过关一天期期权方向单(空间够可优先虚值);极明确位置Gate合约;独立假破优先只做合约或空仓明确不做横盘双买对冲
仓位(总资约800U):单笔期权约10U且一次一仓;Gate保证金约50U×约10x,止损约5U,单笔最亏约10U;日最坏约20U
期权纪律:开仓后中间不手动平仓,只认规则止盈或到期;默认一天期,尽量北京时间16:00后开次日到期
Gate纪律:只做很明确位置;同一位置最多两次(结构突破/假突破);两次都错当日收工
教练用法:对照上述纪律讨论执行与心态;开单前优先提醒主链条与三检;勿另造策略或鼓励对冲/期权手平/超仓/每天默认开期权"""
def playbook_md_path() -> Path:
return REPO_ROOT / "docs" / "交易执行手册-v2-期权与合约.md"
def format_playbook_brief_for_chat(max_chars: int = 1200) -> str:
"""返回注入交易教练上下文的短摘要."""
text = _PLAYBOOK_BRIEF.strip()
if len(text) <= max_chars:
return text
return text[: max(200, max_chars - 1)].rstrip() + ""
+11 -1
View File
@@ -44,10 +44,12 @@ CHAT_SYSTEM = """
- 不要第1点第2点你应该;不要作为你的教练我必须.
- 不预测涨跌,不保证收益,不替用户做决定.
- 只能依据提供的监控与交易数据说话;看不到的就说我这边看不到,你可以去 xx 实例页确认.
- **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动.
- **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘 / 期权持仓才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动.
- **期权持仓**:快照中期权 行与永续同样是实盘;须分开提及.期权净盈亏/目标位以快照为准.
- **监控单 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓.
- 用户口述与快照冲突时,以快照为准并口语说明我这边看到是空仓/有N仓.
- 若附带今日总结摘要,那是较早生成的缓存,**实盘持仓以当前多账户快照里的实盘持仓总览为准**,摘要里若提到持仓可能已过时.
- 若附带用户策略执行手册·摘要,须按该纪律理解账户分工与离场规则(如期权通常不手平Gate 一位置两次等);勿另造策略或鼓励违反摘要纪律.
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清.
- **优先接住用户现在说对话核心摘要**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字.
- **接续对话**:对话核心摘要时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行.
@@ -143,12 +145,20 @@ def build_chat_user_prompt(
history_lines: str = "",
user_message: str,
attachment_note: str = "",
playbook_brief: str = "",
) -> str:
parts = [f"【交易日】{trading_day}"]
if rolling_summary.strip():
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()])
if playbook_brief.strip():
parts.extend(
[
"【用户策略执行手册·摘要(须知悉分工与纪律)】",
playbook_brief.strip(),
]
)
parts.extend([
"【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】",
context_text.strip() or "(无监控数据)",
+14
View File
@@ -29,9 +29,23 @@ DEFAULT_DISPLAY = {
"show_nav_quotes": True,
"show_nav_ai": True,
"show_nav_calculator": True,
"show_nav_compare": True,
"show_nav_strategy": True,
"show_nav_amp_stats": True,
"show_nav_help": True,
"show_nav_logs": True,
# 监控区卡片(仅隐藏界面,不关闭账户)
"show_monitor_binance": True,
"show_monitor_okx_perp": True,
"show_monitor_okx_options": True,
"show_monitor_gate": True,
# 策略说明页签
"show_strategy_playbook_v2": True,
"show_strategy_playbook": True,
"show_strategy_behavior": True,
"show_strategy_binance": True,
"show_strategy_okx": True,
"show_strategy_gate": True,
}
DEFAULT_EXCHANGES = [
+451
View File
@@ -0,0 +1,451 @@
/**
* 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选.
*/
(function () {
const page = document.getElementById("page-amp-stats");
if (!page) return;
let lastResult = null;
let pageNo = 1;
let bound = false;
let reframeTimer = null;
const el = (id) => document.getElementById(id);
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && (data.detail || data.msg)) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pct(ratio) {
if (ratio == null || ratio === "") return "—";
const n = Number(ratio);
if (!Number.isFinite(n)) return "—";
return (n * 100).toFixed(1) + "%";
}
function readPremium() {
const raw = (el("amp-straddle-premium")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function readTakeProfit() {
const raw = (el("amp-take-profit")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function readWeekend() {
return el("amp-weekend-filter")?.value || "all";
}
function setStatus(msg) {
const s = el("amp-status");
if (s) s.textContent = msg || "";
}
function setView(view) {
const isHist = view === "history";
el("amp-panel-stats")?.classList.toggle("hidden", isHist);
el("amp-panel-history")?.classList.toggle("hidden", !isHist);
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
const on = btn.getAttribute("data-view") === view;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
if (isHist) void loadHistory();
}
function syncCustomDays() {
const period = el("amp-period")?.value || "2m";
const wrap = el("amp-custom-wrap");
if (wrap) wrap.classList.toggle("hidden", period !== "custom");
}
function fillMetaControls() {
const hourSel = el("amp-start-hour");
if (hourSel && !hourSel.options.length) {
for (let h = 0; h < 24; h++) {
const opt = document.createElement("option");
opt.value = String(h);
opt.textContent = String(h).padStart(2, "0") + ":00";
if (h === 16) opt.selected = true;
hourSel.appendChild(opt);
}
}
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "is-pos" : "is-neg";
}
function renderSummary(summary, result) {
const box = el("amp-summary");
if (!box) return;
const s = summary || {};
if (!s.sample_count) {
box.innerHTML = '<p class="amp-empty">暂无汇总</p>';
renderStraddle(null);
return;
}
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">样本</span><span class="amp-sum-v">${esc(s.sample_count)}</span></div>` +
`<div><span class="amp-sum-k">最大振幅</span><span class="amp-sum-v">${esc(s.max_amplitude)} <small>(${esc(s.max_amplitude_day)})</small></span></div>` +
`<div><span class="amp-sum-k">振幅均值</span><span class="amp-sum-v">${esc(s.avg_amplitude)}</span></div>` +
`<div><span class="amp-sum-k">振幅中位</span><span class="amp-sum-v">${esc(s.median_amplitude)}</span></div>` +
`<div><span class="amp-sum-k">开→高最大/均</span><span class="amp-sum-v">${esc(s.max_up_points)} / ${esc(s.avg_up_points)}</span></div>` +
`<div><span class="amp-sum-k">开→低最大/均</span><span class="amp-sum-v">${esc(s.max_down_points)} / ${esc(s.avg_down_points)}</span></div>` +
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
`</div>`;
renderStraddle(s.straddle);
}
function renderStraddle(st) {
const box = el("amp-straddle");
if (!box) return;
if (!st) {
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>';
return;
}
const verdict =
st.pnl_total == null
? "—"
: Number(st.pnl_total) > 0
? "样本合计盈利"
: Number(st.pnl_total) < 0
? "样本合计亏损"
: "样本合计持平";
const tpLine =
st.take_profit != null
? `<div><span class="amp-sum-k">止盈点 / 触达</span><span class="amp-sum-v">${esc(st.take_profit)} · ${esc(st.tp_hit_days)} 天 · ${esc(pct(st.tp_hit_ratio))}</span></div>`
: `<div><span class="amp-sum-k">止盈点</span><span class="amp-sum-v">未设(按|涨跌|)</span></div>`;
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">双边权利金</span><span class="amp-sum-v">${esc(st.premium)}</span></div>` +
tpLine +
`<div><span class="amp-sum-k">开→高超过权利金</span><span class="amp-sum-v">${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}</span></div>` +
`<div><span class="amp-sum-k">开→低超过权利金</span><span class="amp-sum-v">${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}</span></div>` +
`<div><span class="amp-sum-k">|涨跌|超过权利金</span><span class="amp-sum-v">${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}</span></div>` +
`<div><span class="amp-sum-k">买跨盈亏合计</span><span class="amp-sum-v ${pnlClass(st.pnl_total)}">${esc(st.pnl_total)} <small>(${esc(verdict)})</small></span></div>` +
`<div><span class="amp-sum-k">日均盈亏</span><span class="amp-sum-v ${pnlClass(st.pnl_avg)}">${esc(st.pnl_avg)}</span></div>` +
`<div><span class="amp-sum-k">赚钱天数/胜率</span><span class="amp-sum-v">${esc(st.win_days)} · ${esc(pct(st.win_ratio))}</span></div>` +
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(st.pnl_max)} / ${esc(st.pnl_min)}</span></div>` +
`</div>`;
}
function dayLabel(r) {
const day = esc(r.settlement_day);
if (r.is_weekend && r.weekday_label) {
return `${day}<span class="amp-wd-tag">${esc(r.weekday_label)}</span>`;
}
return day;
}
function renderTable(pagePayload) {
const body = el("amp-table-body");
const pager = el("amp-pager");
if (!body) return;
const rows = (pagePayload && pagePayload.rows) || [];
if (!rows.length) {
body.innerHTML = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
} else {
body.innerHTML = rows
.map((r) => {
const profit =
r.profit == null || r.profit === ""
? "—"
: `<span class="amp-pnl ${pnlClass(r.profit)}">${esc(r.profit)}</span>`;
const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
return (
`<tr${trClass}>` +
`<td>${dayLabel(r)}</td>` +
`<td>${esc(r.window_start)}</td>` +
`<td>${esc(r.open)}</td>` +
`<td>${esc(r.high)}</td>` +
`<td>${esc(r.low)}</td>` +
`<td>${esc(r.close)}</td>` +
`<td>${esc(r.up_points)}</td>` +
`<td>${esc(r.down_points)}</td>` +
`<td><strong>${esc(r.amplitude)}</strong></td>` +
`<td>${esc(r.change)}</td>` +
`<td>${profit}</td>` +
`</tr>`
);
})
.join("");
}
if (pager && pagePayload) {
pager.innerHTML =
`<button type="button" class="ghost" id="amp-page-prev" ${pagePayload.page <= 1 ? "disabled" : ""}>上一页</button>` +
`<span class="amp-pager-meta">第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天</span>` +
`<button type="button" class="ghost" id="amp-page-next" ${pagePayload.page >= pagePayload.total_pages ? "disabled" : ""}>下一页</button>`;
el("amp-page-prev")?.addEventListener("click", () => {
if (pageNo > 1) {
pageNo -= 1;
void reframe(false);
}
});
el("amp-page-next")?.addEventListener("click", () => {
if (pagePayload.page < pagePayload.total_pages) {
pageNo += 1;
void reframe(false);
}
});
}
}
function rowsAllFromLast() {
if (!lastResult) return [];
if (Array.isArray(lastResult.rows_all) && lastResult.rows_all.length) return lastResult.rows_all;
return lastResult.rows || [];
}
async function reframe(resetPage) {
if (!lastResult) {
renderStraddle(null);
return;
}
if (resetPage) pageNo = 1;
const rowsAll = rowsAllFromLast();
if (!rowsAll.length) return;
try {
const data = await apiFetch("/api/amp-stats/reframe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rows_all: rowsAll,
symbol: lastResult.symbol || el("amp-symbol")?.value || "eth",
start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16),
period: lastResult.period || el("amp-period")?.value || "2m",
sample_days: lastResult.sample_days_requested || 60,
straddle_premium: readPremium(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(),
price_source: lastResult.price_source || "",
inst_id: lastResult.inst_id || "",
page: pageNo,
page_size: 20,
}),
});
const prevAll = rowsAll;
lastResult = data.result || lastResult;
if (!lastResult.rows_all || !lastResult.rows_all.length) lastResult.rows_all = prevAll;
renderSummary(lastResult.summary, lastResult);
renderTable(data.page);
setStatus(`完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`);
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function scheduleReframe() {
if (!lastResult) return;
if (reframeTimer) clearTimeout(reframeTimer);
reframeTimer = setTimeout(() => void reframe(true), 280);
}
async function compute(resetPage) {
if (resetPage) pageNo = 1;
const symbol = el("amp-symbol")?.value || "eth";
const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60);
setStatus("计算中…(长周期会分页拉 OKX,遇限频会自动重试,请稍候)");
try {
const data = await apiFetch("/api/amp-stats/compute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol,
start_hour: startHour,
period,
custom_days: period === "custom" ? customDays : null,
straddle_premium: readPremium(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(),
page: pageNo,
page_size: 20,
}),
});
lastResult = data.result || null;
renderSummary(lastResult && lastResult.summary, lastResult);
renderTable(data.page);
const miss = (lastResult && lastResult.missing_count) || 0;
setStatus(
miss
? `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0} · 缺 ${miss}`
: `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`
);
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
async function saveHistory() {
if (!lastResult) {
setStatus("请先计算");
return;
}
try {
await apiFetch("/api/amp-stats/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result: lastResult }),
});
setStatus("已保存到历史");
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function downloadCurrent() {
if (!lastResult) {
setStatus("请先计算");
return;
}
const symbol = el("amp-symbol")?.value || "eth";
const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60);
const prem = readPremium();
const tp = readTakeProfit();
const q = new URLSearchParams({
symbol,
start_hour: String(startHour),
period,
weekend_filter: readWeekend(),
});
if (period === "custom") q.set("custom_days", String(customDays));
if (prem != null) q.set("straddle_premium", String(prem));
if (tp != null) q.set("take_profit", String(tp));
window.location.href = "/api/amp-stats/export?" + q.toString();
}
async function loadHistory() {
const box = el("amp-history-list");
if (!box) return;
box.innerHTML = '<p class="amp-empty">加载中…</p>';
try {
const data = await apiFetch("/api/amp-stats/history?limit=50");
const items = data.items || [];
if (!items.length) {
box.innerHTML = '<p class="amp-empty">暂无历史</p>';
return;
}
box.innerHTML = items
.map(
(it) =>
`<div class="amp-hist-card" data-id="${esc(it.id)}">` +
`<div class="amp-hist-main">` +
`<strong>${esc(it.symbol_label || it.symbol)}</strong> · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` +
`<div class="amp-hist-sub">${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})</div>` +
`</div>` +
`<div class="amp-hist-actions">` +
`<button type="button" class="ghost amp-hist-view">查看</button>` +
`<button type="button" class="ghost amp-hist-dl">下载</button>` +
`<button type="button" class="danger amp-hist-del">删除</button>` +
`</div></div>`
)
.join("");
box.querySelectorAll(".amp-hist-card").forEach((card) => {
const id = card.getAttribute("data-id");
card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id));
card.querySelector(".amp-hist-dl")?.addEventListener("click", () => {
const prem = readPremium();
const tp = readTakeProfit();
let url =
"/api/amp-stats/export?history_id=" +
encodeURIComponent(id) +
"&weekend_filter=" +
encodeURIComponent(readWeekend());
if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem));
if (tp != null) url += "&take_profit=" + encodeURIComponent(String(tp));
window.location.href = url;
});
card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
if (!confirm("删除该历史记录?")) return;
await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id), { method: "DELETE" });
void loadHistory();
});
});
} catch (e) {
box.innerHTML = `<p class="amp-empty">${esc(String(e && e.message ? e.message : e))}</p>`;
}
}
async function openHistory(id) {
try {
const data = await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id));
lastResult = data.item || null;
setView("stats");
if (lastResult) {
if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth";
if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16);
if (lastResult.straddle_premium != null && el("amp-straddle-premium")) {
el("amp-straddle-premium").value = String(lastResult.straddle_premium);
}
if (lastResult.take_profit != null && el("amp-take-profit")) {
el("amp-take-profit").value = String(lastResult.take_profit);
}
if (lastResult.weekend_filter && el("amp-weekend-filter")) {
el("amp-weekend-filter").value = lastResult.weekend_filter;
}
pageNo = 1;
setStatus("已载入历史 " + id);
await reframe(true);
}
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function bind() {
if (bound) return;
bound = true;
fillMetaControls();
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.getAttribute("data-view")));
});
el("amp-period")?.addEventListener("change", syncCustomDays);
el("amp-btn-compute")?.addEventListener("click", () => void compute(true));
el("amp-btn-save")?.addEventListener("click", () => void saveHistory());
el("amp-btn-download")?.addEventListener("click", downloadCurrent);
el("amp-straddle-premium")?.addEventListener("input", scheduleReframe);
el("amp-take-profit")?.addEventListener("input", scheduleReframe);
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
syncCustomDays();
}
window.hubAmpStatsPage = {
init() {
bind();
setView("stats");
setStatus("");
renderStraddle(null);
},
};
})();
+317 -2
View File
@@ -2357,6 +2357,12 @@ html[data-theme="light"] .hub-pos-card .pos-tp-profit {
margin-bottom: 12px;
}
.stat-row-options .stat-label {
color: var(--accent);
text-transform: none;
letter-spacing: 0;
}
.stat-box {
background: var(--inset-surface);
border: 1px solid var(--border-soft);
@@ -3495,6 +3501,18 @@ button.btn-sm {
margin-top: 8px;
}
.settings-display-subtitle {
margin: 16px 0 6px;
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.settings-display-subtitle + .settings-display-hint {
margin-top: 0;
margin-bottom: 8px;
}
.settings-display-hint {
margin: 8px 0 0;
font-size: 0.78rem;
@@ -4301,6 +4319,70 @@ body.login-page {
gap: 6px;
}
/* 手机:隐藏「操作·刷新/紧急全平」,桌面不变 */
body.hub-phone #monitor-ops-fold {
display: none !important;
}
/* 手机收起态:今日统计固定两行(左标题/交易日,右总浮盈亏) */
body.hub-phone .monitor-stats-card.is-collapsed {
padding: 0;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
column-gap: 10px;
row-gap: 2px;
align-items: center;
padding: 10px 12px;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-head-main {
display: contents;
}
body.hub-phone .monitor-stats-card.is-collapsed .card-title-row {
grid-column: 1;
grid-row: 1;
flex-wrap: nowrap;
min-width: 0;
}
body.hub-phone .monitor-stats-card.is-collapsed .card-title {
font-size: 13px;
margin: 0;
}
body.hub-phone .monitor-stats-card.is-collapsed .card-sub {
grid-column: 1;
grid-row: 2;
margin: 0;
font-size: 10px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-float-summary {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
padding: 0;
text-align: right;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-float-summary .monitor-stat-label {
margin-bottom: 0;
font-size: 10px;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-float-value {
font-size: 16px;
}
body.hub-phone .monitor-stat-cell {
padding: 8px 6px;
}
@@ -4626,7 +4708,11 @@ body.login-page {
}
body.hub-phone .calc-layout[data-calc-tab="trend"] [data-calc-pane="roll"],
body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"] {
body.hub-phone .calc-layout[data-calc-tab="trend"] [data-calc-pane="po"],
body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"],
body.hub-phone .calc-layout[data-calc-tab="roll"] [data-calc-pane="po"],
body.hub-phone .calc-layout[data-calc-tab="po"] [data-calc-pane="trend"],
body.hub-phone .calc-layout[data-calc-tab="po"] [data-calc-pane="roll"] {
display: none;
}
@@ -7975,6 +8061,34 @@ body.funds-fullscreen-open {
gap: 12px;
align-items: stretch;
}
.archive-product-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 10px;
}
.archive-product-tab {
border: 1px solid var(--border-soft);
background: transparent;
color: inherit;
padding: 8px 18px;
border-radius: 999px;
cursor: pointer;
font-family: var(--font);
font-size: 0.88rem;
font-weight: 600;
}
.archive-product-tab.is-active {
background: rgba(16, 185, 129, 0.2);
border-color: rgba(16, 185, 129, 0.55);
color: var(--text);
}
body.archive-product-options .archive-toolbar-desktop[data-archive-perp-only],
body.archive-product-options #archive-btn-chart-toggle,
body.archive-product-options #archive-filter-sick,
body.archive-product-options #archive-tab-viz {
display: none !important;
}
.archive-content-tabs {
display: flex;
flex-wrap: wrap;
@@ -9666,10 +9780,33 @@ body:not(.hub-phone) #page-calculator .calc-layout {
}
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="trend"] [data-calc-pane="roll"],
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"] {
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="trend"] [data-calc-pane="po"],
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="trend"],
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-calc-pane="po"],
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="po"] [data-calc-pane="trend"],
body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="po"] [data-calc-pane="roll"] {
display: none;
}
#page-calculator .calc-po-cases {
display: grid;
gap: 12px;
margin-top: 12px;
}
#page-calculator .calc-po-case {
padding: 10px 12px;
border: 1px solid var(--border-soft, rgba(255, 255, 255, 0.08));
border-radius: 10px;
background: color-mix(in srgb, var(--panel, #12161f) 88%, transparent);
}
#page-calculator .calc-po-case h4 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
body:not(.hub-phone) #page-calculator .calc-card {
padding: 0;
background: transparent;
@@ -10875,3 +11012,181 @@ html[data-theme="light"] .hub-logs-card-hint {
min-height: 360px;
}
}
/* —— 振幅统计 —— */
.amp-view-tabs { display: flex; gap: 8px; margin: 0 0 12px; }
.amp-view-tab {
min-height: 34px; padding: 6px 14px; border: 1px solid var(--border-soft);
border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer;
}
.amp-view-tab.is-active { color: var(--text); border-color: var(--accent); background: rgba(0, 212, 255, 0.08); }
.amp-panel { padding: 14px 16px 18px; }
.amp-form {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px 12px; align-items: end; margin-bottom: 8px;
}
.amp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.amp-field select, .amp-field input {
min-height: 34px; padding: 6px 8px; border-radius: 8px;
border: 1px solid var(--border-soft); background: var(--panel-solid); color: var(--text);
}
.amp-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.amp-hint { font-size: 12px; color: var(--muted); margin: 4px 0 12px; }
.amp-status { margin: 0 0 8px; }
.amp-block-title { font-size: 14px; margin: 14px 0 8px; }
.amp-sum-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px;
}
.amp-sum-grid > div {
border: 1px solid var(--border-soft); border-radius: 8px; padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.amp-sum-k { font-size: 11px; color: var(--muted); }
.amp-sum-v { font-size: 14px; font-weight: 600; color: var(--text); }
.amp-sum-v.is-pos { color: var(--green); }
.amp-sum-v.is-neg { color: var(--red); }
.amp-pnl.is-pos { color: var(--green); font-weight: 600; }
.amp-pnl.is-neg { color: var(--red); font-weight: 600; }
.amp-straddle { margin-bottom: 4px; }
.amp-table tr.amp-row-weekend td { background: rgba(255, 180, 60, 0.08); }
.amp-wd-tag {
display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 4px;
font-size: 11px; font-weight: 600; color: #f0c14b;
border: 1px solid rgba(240, 193, 75, 0.45);
}
.amp-table-wrap { overflow-x: auto; }
.amp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.amp-table th, .amp-table td {
border-bottom: 1px solid var(--border-soft); padding: 7px 8px; text-align: right; white-space: nowrap;
}
.amp-table th:first-child, .amp-table td:first-child,
.amp-table th:nth-child(2), .amp-table td:nth-child(2) { text-align: left; }
.amp-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.amp-pager-meta { font-size: 12px; color: var(--muted); }
.amp-empty { color: var(--muted); text-align: center; padding: 16px; }
.amp-history-list { display: flex; flex-direction: column; gap: 10px; }
.amp-hist-card {
display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px;
}
.amp-hist-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
.amp-hist-actions { display: flex; gap: 6px; align-items: center; }
@media (max-width: 720px) {
.amp-form { grid-template-columns: 1fr 1fr; }
.amp-actions { grid-column: 1 / -1; }
}
/* --- strategy compare --- */
#page-compare .toolbar {
padding: 12px 16px;
margin-bottom: 14px;
}
.cmp-form { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
.cmp-form .card,
.cmp-common-card,
.cmp-sum-card,
.cmp-rec-card {
padding: 18px 20px;
}
.cmp-common-card h2,
.cmp-form .card h2 {
margin: 0 0 14px;
font-size: 15px;
}
.cmp-subhead {
margin: 16px 0 10px;
font-size: 13px;
color: var(--muted);
font-weight: 600;
}
.cmp-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 16px;
}
.cmp-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.cmp-field input,
.cmp-field select {
background: var(--inset-surface);
border: 1px solid var(--border-soft);
border-radius: 8px;
color: var(--text);
padding: 9px 12px;
font-size: 13px;
}
.cmp-input-cols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.cmp-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.cmp-sum-card h3 { margin: 0 0 12px; font-size: 14px; }
.cmp-sum-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
margin: 6px 0;
color: var(--muted);
}
.cmp-sum-row strong { color: var(--text); font-weight: 600; }
.cmp-muted { color: var(--muted); font-size: 12px; margin: 0; }
.cmp-table-wrap { margin-bottom: 16px; }
.cmp-table-scroll { overflow-x: auto; }
.cmp-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.cmp-table th,
.cmp-table td {
border-bottom: 1px solid var(--border-soft);
padding: 14px 16px;
vertical-align: top;
text-align: left;
}
.cmp-table th:first-child,
.cmp-table td:first-child { width: 22%; }
.cmp-table tr:last-child td { border-bottom: none; }
.cmp-cell-note {
margin-top: 6px;
font-size: 11px;
color: var(--muted);
line-height: 1.4;
}
.cmp-pnl-pos { color: var(--green); font-weight: 600; }
.cmp-pnl-neg { color: var(--red); font-weight: 600; }
.cmp-rec-head { font-size: 16px; margin-bottom: 8px; }
.cmp-rec-reason { margin: 0 0 10px; color: var(--muted); font-size: 13px; }
.cmp-rec-list { margin: 0; padding-left: 20px; font-size: 13px; line-height: 1.55; }
.cmp-warn { margin-top: 12px; font-size: 12px; color: var(--warn, #e6a23c); }
.cmp-foot-note { margin: 12px 0 0; font-size: 11px; color: var(--muted); }
@media (max-width: 900px) {
.cmp-form .card,
.cmp-common-card,
.cmp-sum-card,
.cmp-rec-card {
padding: 16px;
}
.cmp-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cmp-input-cols,
.cmp-summary { grid-template-columns: 1fr; }
.cmp-table th,
.cmp-table td { padding: 12px 14px; }
}
+228 -101
View File
@@ -9,10 +9,42 @@
return !!d[key];
}
window.hubDisplayPref = displayPref;
function showAccountPnlPref() {
return displayPref("show_account_pnl", true);
}
window.hubShowAccountPnlPref = showAccountPnlPref;
function showMonitorBinancePref() {
return displayPref("show_monitor_binance", true);
}
function showMonitorOkxPerpPref() {
return displayPref("show_monitor_okx_perp", true);
}
function showMonitorOkxOptionsPref() {
return displayPref("show_monitor_okx_options", true);
}
function showMonitorGatePref() {
return displayPref("show_monitor_gate", true);
}
function monitorExchangeKeyVisible(key) {
const k = String(key || "").toLowerCase();
if (k === "binance") return showMonitorBinancePref();
if (k === "gate") return showMonitorGatePref();
if (k === "okx") return showMonitorOkxPerpPref() || showMonitorOkxOptionsPref();
return true;
}
function filterVisibleMonitorRows(rows) {
return (rows || []).filter((r) => monitorExchangeKeyVisible(r && r.key));
}
function showNavFundsPref() {
return displayPref("show_nav_funds", true);
}
@@ -41,10 +73,18 @@
return displayPref("show_nav_calculator", true);
}
function showNavComparePref() {
return displayPref("show_nav_compare", true);
}
function showNavStrategyPref() {
return displayPref("show_nav_strategy", true);
}
function showNavAmpStatsPref() {
return displayPref("show_nav_amp_stats", true);
}
function showNavHelpPref() {
return displayPref("show_nav_help", true);
}
@@ -63,7 +103,9 @@
["nav-quotes", "m-nav-quotes", d.show_nav_quotes === false],
["nav-ai", "m-tab-ai", d.show_nav_ai === false],
["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false],
["nav-compare", "m-nav-compare", d.show_nav_compare === false],
["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false],
["nav-amp-stats", "m-nav-amp-stats", d.show_nav_amp_stats === false],
["nav-help", "m-nav-help", d.show_nav_help === false],
["nav-logs", "m-nav-logs", d.show_nav_logs === false],
];
@@ -135,7 +177,9 @@
if (page === "quotes") return showNavQuotesPref();
if (page === "ai") return showNavAiPref();
if (page === "calculator") return showNavCalculatorPref();
if (page === "compare") return showNavComparePref();
if (page === "strategy") return showNavStrategyPref();
if (page === "amp-stats") return showNavAmpStatsPref();
if (page === "help") return showNavHelpPref();
if (page === "logs") return showNavLogsPref();
return true;
@@ -143,28 +187,33 @@
function syncDisplayPrefsUI(data) {
const d = (data && data.display) || {};
const pnlCb = document.getElementById("pref-show-account-pnl");
const fundsCb = document.getElementById("pref-show-nav-funds");
const dashCb = document.getElementById("pref-show-nav-dashboard");
const planCb = document.getElementById("pref-show-nav-plan");
const archiveCb = document.getElementById("pref-show-nav-archive");
const quotesCb = document.getElementById("pref-show-nav-quotes");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const strategyCb = document.getElementById("pref-show-nav-strategy");
const helpCb = document.getElementById("pref-show-nav-help");
const logsCb = document.getElementById("pref-show-nav-logs");
if (pnlCb) pnlCb.checked = d.show_account_pnl !== false;
if (fundsCb) fundsCb.checked = d.show_nav_funds !== false;
if (dashCb) dashCb.checked = d.show_nav_dashboard !== false;
if (planCb) planCb.checked = d.show_nav_plan !== false;
if (archiveCb) archiveCb.checked = d.show_nav_archive !== false;
if (quotesCb) quotesCb.checked = d.show_nav_quotes !== false;
if (aiCb) aiCb.checked = d.show_nav_ai !== false;
if (calcCb) calcCb.checked = d.show_nav_calculator !== false;
if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false;
if (helpCb) helpCb.checked = d.show_nav_help !== false;
if (logsCb) logsCb.checked = d.show_nav_logs !== false;
const setChk = (id, key) => {
const el = document.getElementById(id);
if (el) el.checked = d[key] !== false;
};
setChk("pref-show-account-pnl", "show_account_pnl");
setChk("pref-show-nav-funds", "show_nav_funds");
setChk("pref-show-nav-dashboard", "show_nav_dashboard");
setChk("pref-show-nav-plan", "show_nav_plan");
setChk("pref-show-nav-archive", "show_nav_archive");
setChk("pref-show-nav-quotes", "show_nav_quotes");
setChk("pref-show-nav-ai", "show_nav_ai");
setChk("pref-show-nav-calculator", "show_nav_calculator");
setChk("pref-show-nav-compare", "show_nav_compare");
setChk("pref-show-nav-strategy", "show_nav_strategy");
setChk("pref-show-nav-amp-stats", "show_nav_amp_stats");
setChk("pref-show-nav-help", "show_nav_help");
setChk("pref-show-nav-logs", "show_nav_logs");
setChk("pref-show-monitor-binance", "show_monitor_binance");
setChk("pref-show-monitor-okx-perp", "show_monitor_okx_perp");
setChk("pref-show-monitor-okx-options", "show_monitor_okx_options");
setChk("pref-show-monitor-gate", "show_monitor_gate");
setChk("pref-show-strategy-playbook-v2", "show_strategy_playbook_v2");
setChk("pref-show-strategy-playbook", "show_strategy_playbook");
setChk("pref-show-strategy-behavior", "show_strategy_behavior");
setChk("pref-show-strategy-binance", "show_strategy_binance");
setChk("pref-show-strategy-okx", "show_strategy_okx");
setChk("pref-show-strategy-gate", "show_strategy_gate");
syncNavVisibility(data);
}
@@ -1277,7 +1326,9 @@
if (p.includes("funds")) return "funds";
if (p.includes("plan")) return "plan";
if (p.includes("calculator")) return "calculator";
if (p.includes("compare")) return "compare";
if (p.includes("help")) return "help";
if (p.includes("amp-stats")) return "amp-stats";
if (p.includes("strategy")) return "strategy";
if (p.includes("logs")) return "logs";
if (p.includes("market")) return "market";
@@ -1293,8 +1344,10 @@
if (page === "funds") return "page-funds";
if (page === "plan") return "page-plan";
if (page === "calculator") return "page-calculator";
if (page === "compare") return "page-compare";
if (page === "help") return "page-help";
if (page === "strategy") return "page-strategy";
if (page === "amp-stats") return "page-amp-stats";
if (page === "logs") return "page-logs";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
@@ -1324,11 +1377,13 @@
document.body.classList.toggle("hub-page-monitor", page === "monitor");
document.body.classList.toggle("hub-page-market", page === "market");
document.body.classList.toggle("hub-page-calculator", page === "calculator");
document.body.classList.toggle("hub-page-compare", page === "compare");
document.body.classList.toggle("hub-page-settings", page === "settings");
document.body.classList.toggle("hub-page-archive", page === "archive");
document.body.classList.toggle("hub-page-quotes", page === "quotes");
document.body.classList.toggle("hub-page-plan", page === "plan");
document.body.classList.toggle("hub-page-strategy", page === "strategy");
document.body.classList.toggle("hub-page-amp-stats", page === "amp-stats");
document.body.classList.toggle("hub-page-logs", page === "logs");
document.body.classList.toggle("hub-page-help", page === "help");
syncHubPhoneShellClass();
@@ -1363,6 +1418,11 @@
if (page === "calculator" && window.hubCalculatorPage) {
window.hubCalculatorPage.init();
}
if (page === "compare" && window.hubComparePage) {
window.hubComparePage.init();
} else if (window.hubComparePage && window.hubComparePage.destroy) {
window.hubComparePage.destroy();
}
if (page === "funds" && window.hubFundsPage) {
window.hubFundsPage.init();
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
@@ -1373,6 +1433,9 @@
} else if (window.hubStrategyPage && window.hubStrategyPage.destroy) {
window.hubStrategyPage.destroy();
}
if (page === "amp-stats" && window.hubAmpStatsPage) {
window.hubAmpStatsPage.init();
}
if (page === "help" && window.hubHelpPage) {
window.hubHelpPage.init();
} else if (window.hubHelpPage && window.hubHelpPage.destroy) {
@@ -1537,7 +1600,7 @@
if (upd) upd.textContent = txt;
if (updSum) updSum.textContent = txt;
}
updateMonitorAlertSummary(rows || []);
updateMonitorAlertSummary(filterVisibleMonitorRows(rows || []));
void refreshMacroRiskBanner(rows || []);
renderMonitorGrid(rows || []);
}
@@ -2048,19 +2111,20 @@
if (lastMonitorRows.length && nowMobile !== wasMobile) {
wasMobile = nowMobile;
renderMonitorGrid(lastMonitorRows);
updateMonitorAlertSummary(lastMonitorRows);
updateMonitorAlertSummary(filterVisibleMonitorRows(lastMonitorRows));
syncHubMobileTabActive(currentPage());
return;
}
wasMobile = nowMobile;
const box = document.getElementById("monitor-grid");
if (box && lastMonitorRows.length) {
const split = monitorOptionsSplitActive(lastMonitorRows);
syncMonitorGridColumns(box, lastMonitorRows.length + (lastMonitorTotals ? 1 : 0), {
const visible = filterVisibleMonitorRows(lastMonitorRows);
const split = monitorOptionsSplitActive(visible);
syncMonitorGridColumns(box, visible.length + (lastMonitorTotals ? 1 : 0), {
statsFirst: !!lastMonitorTotals && !split,
optionsSplit: split,
});
updateMonitorAlertSummary(lastMonitorRows);
updateMonitorAlertSummary(visible);
}
syncHubMobileTabActive(currentPage());
}, 120);
@@ -2412,10 +2476,20 @@
lossN > 0 && Number.isFinite(Number(t.loss_pnl_u))
? `<span class="${pnlCls(t.loss_pnl_u)}">${esc(pnlSigned(t.loss_pnl_u, 2))}U</span>`
: "—";
const showFloat = showAccountPnlPref();
const floatMain = esc(pnlSigned(floatVal, 2)) + "U";
const floatCls = Math.abs(floatVal) > 1e-9 ? pnlCls(floatVal) : "";
const foldLabel = collapsed ? "展开明细" : "收起";
return `<div class="card card-online monitor-stats-card${collapsed ? " is-collapsed" : ""}" data-monitor-stats="1">
const floatSummary = showFloat
? `<div class="monitor-stats-float-summary">
<div class="monitor-stat-label">总浮盈亏</div>
<div class="monitor-stat-value monitor-stats-float-value ${floatCls}">${floatMain}</div>
</div>`
: "";
const floatCell = showFloat ? cell("总浮盈亏", floatMain, "", floatCls) : "";
return `<div class="card card-online monitor-stats-card${collapsed ? " is-collapsed" : ""}${
showFloat ? "" : " hide-float-pnl"
}" data-monitor-stats="1">
<div class="card-head monitor-stats-head">
<div class="monitor-stats-head-main">
<div class="card-title-row">
@@ -2424,10 +2498,7 @@
</div>
<div class="card-sub">交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日</div>
</div>
<div class="monitor-stats-float-summary">
<div class="monitor-stat-label">总浮盈亏</div>
<div class="monitor-stat-value monitor-stats-float-value ${floatCls}">${floatMain}</div>
</div>
${floatSummary}
</div>
<div class="card-body monitor-stats-detail">
<div class="monitor-stats-grid">
@@ -2436,7 +2507,7 @@
${cell("持有仓位", String(Number(t.open_position_count) || 0), "", "")}
${cell("盈利", String(winN), winSub, winN > 0 ? "pnl-pos" : "")}
${cell("亏损", String(lossN), lossSub, lossN > 0 ? "pnl-neg" : "")}
${cell("总浮盈亏", floatMain, "", floatCls)}
${floatCell}
</div>
</div>
</div>`;
@@ -2447,11 +2518,12 @@
const fs = document.getElementById("exchange-fullscreen");
const fsInner = document.getElementById("exchange-fullscreen-inner");
if (!box) return;
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
const visibleSource = filterVisibleMonitorRows(rows);
if (expandedExchangeId && !visibleSource.some((r) => String(r.id) === String(expandedExchangeId))) {
closeExchangeFullscreen();
}
const mobileTiles = isMobileLayout() && !expandedExchangeId;
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
const displayRows = mobileTiles ? sortRowsForMobileDashboard(visibleSource) : visibleSource;
const optionsSplit = monitorOptionsSplitActive(displayRows);
monitorGridOptionsSplit = optionsSplit;
const showStatsCard = !expandedExchangeId;
@@ -2464,20 +2536,33 @@
let cardsHtml = "";
if (optionsSplit) {
const okxRow = displayRows.find((r) => rowHasOptionsLayout(r));
const otherRows = displayRows.filter((r) => !rowHasOptionsLayout(r));
const ph =
'<div class="card card-monitor-split-side card-monitor-placeholder" aria-hidden="true"></div>';
/* 平铺 2×2 顺序:永续|币安 / 期权|Gate —— 同行左右同高,多仓时该行一起长高 */
const cells = [
okxRow ? renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true }) : ph,
otherRows[0] ? renderMonitorCard(otherRows[0], { splitSide: true }) : ph,
okxRow ? renderMonitorCard(okxRow, { okxPart: "options", splitSide: true }) : ph,
otherRows[1] ? renderMonitorCard(otherRows[1], { splitSide: true }) : ph,
];
for (let i = 2; i < otherRows.length; i++) {
cells.push(renderMonitorCard(otherRows[i], { splitSide: true }));
const otherByKey = {};
displayRows
.filter((r) => !rowHasOptionsLayout(r))
.forEach((r) => {
otherByKey[String(r.key || "").toLowerCase()] = r;
});
/* 平铺顺序尽量保持:永续|币安 / 期权|Gate;隐藏项不占位 */
const cells = [];
if (okxRow && showMonitorOkxPerpPref()) {
cells.push(renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true }));
}
cardsHtml = `<div class="monitor-split-body monitor-split-2x2">${cells.join("")}</div>`;
if (otherByKey.binance && showMonitorBinancePref()) {
cells.push(renderMonitorCard(otherByKey.binance, { splitSide: true }));
}
if (okxRow && showMonitorOkxOptionsPref()) {
cells.push(renderMonitorCard(okxRow, { okxPart: "options", splitSide: true }));
}
if (otherByKey.gate && showMonitorGatePref()) {
cells.push(renderMonitorCard(otherByKey.gate, { splitSide: true }));
}
Object.keys(otherByKey).forEach((k) => {
if (k === "binance" || k === "gate") return;
cells.push(renderMonitorCard(otherByKey[k], { splitSide: true }));
});
cardsHtml = cells.length
? `<div class="monitor-split-body monitor-split-2x2">${cells.join("")}</div>`
: "";
} else {
cardsHtml =
displayRows
@@ -2506,7 +2591,7 @@
}
if (expandedExchangeId && fs && fsInner) {
const row = rows.find((r) => String(r.id) === String(expandedExchangeId));
const row = visibleSource.find((r) => String(r.id) === String(expandedExchangeId));
if (row) {
try {
fsInner.innerHTML = renderFullscreenExchange(row);
@@ -3741,22 +3826,39 @@
}
function optionsBalanceFields(opt) {
const bal = (opt && opt.balances) || opt || {};
if (!opt || typeof opt !== "object") {
return { funding: null, trading: null, upl: null };
}
const bal =
opt.balances && typeof opt.balances === "object" ? opt.balances : {};
const pick = (a, b) => (a != null && a !== "" ? a : b);
return {
funding: sumUsdtEquiv(bal.funding_usdt, bal.funding_usdc),
trading: sumUsdtEquiv(bal.trading_usdt, bal.trading_usdc),
upl: opt && opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
? Number(opt.upl_total_usdc)
: null,
funding: sumUsdtEquiv(
pick(bal.funding_usdt, opt.funding_usdt),
pick(bal.funding_usdc, opt.funding_usdc)
),
trading: sumUsdtEquiv(
pick(bal.trading_usdt, opt.trading_usdt),
pick(bal.trading_usdc, opt.trading_usdc)
),
upl:
opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
? Number(opt.upl_total_usdc)
: null,
};
}
function renderStatRow(funding, trading, upnl) {
function renderStatRow(funding, trading, upnl, kind) {
if (!showAccountPnlPref()) return "";
return `<div class="stat-row">
<div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">交易账户</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
const isOpt = kind === "options";
const fundLabel = isOpt ? "期权资金账户" : "资金账户";
const tradeLabel = isOpt ? "期权交易账户" : "交易账户";
const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计";
const rowCls = isOpt ? "stat-row stat-row-options" : "stat-row";
return `<div class="${rowCls}">
<div class="stat-box"><div class="stat-label">${fundLabel}</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">${tradeLabel}</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">${pnlLabel}</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
</div>`;
}
@@ -3764,6 +3866,11 @@
return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl);
}
function renderOptionsAccountStatRow(opt) {
const bal = optionsBalanceFields(opt);
return renderStatRow(bal.funding, bal.trading, bal.upl, "options");
}
function shortOptionsInst(instId) {
const s = String(instId || "");
if (s.length <= 22) return s;
@@ -3799,9 +3906,10 @@
function renderOptionsPositionsTable(pos, targets) {
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
const showPnl = showAccountPnlPref();
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
html +=
"<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th><th>净盈亏</th><th>收益率</th>";
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>";
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
html += "</tr></thead><tbody>";
pos.forEach((p) => {
const optType =
@@ -3825,10 +3933,12 @@
<td>${esc(optType)}</td>
<td>${esc(p.pos)}</td>
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
${renderOptionsTargetCell(target)}
<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>
</tr>`;
${renderOptionsTargetCell(target)}`;
if (showPnl) {
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
}
html += "</tr>";
});
html += "</tbody></table></div>";
return html;
@@ -3836,7 +3946,8 @@
function monitorOptionsSplitActive(rows) {
if (isMobileLayout() || expandedExchangeId) return false;
return (rows || []).some((r) => rowHasOptionsLayout(r));
if (!(rows || []).some((r) => rowHasOptionsLayout(r))) return false;
return showMonitorOkxPerpPref() || showMonitorOkxOptionsPref();
}
function renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday) {
@@ -3856,8 +3967,9 @@
}
const cls = hubPosListCountClass(pos.length);
let html = `<div class="hub-pos-list hub-opt-pos-list ${cls}" data-pos-count="${pos.length}">`;
const hidePnl = !showAccountPnlPref();
pos.forEach((p) => {
html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true });
html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true, hidePnl });
});
html += "</div>";
return html;
@@ -3869,18 +3981,17 @@
const opt = row.options || {};
let html = "";
if (opt.enabled === false) {
html += renderStatRow(null, null, null);
html += renderOptionsAccountStatRow(opt);
html += '<div class="section-title hub-options-title">期权持仓</div>';
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
} else if (opt.ok === false) {
html += renderStatRow(null, null, null);
html += renderOptionsAccountStatRow(opt);
html += '<div class="section-title hub-options-title">期权持仓</div>';
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
} else {
const pos = Array.isArray(opt.positions) ? opt.positions : [];
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
const bal = optionsBalanceFields(opt);
html += renderStatRow(bal.funding, bal.trading, bal.upl);
html += renderOptionsAccountStatRow(opt);
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
html +=
layout === "cards"
@@ -4312,17 +4423,22 @@
opt.position_count != null ? opt.position_count : (opt.positions || []).length
);
const n = Number.isFinite(optCount) ? optCount : 0;
const bal = typeof optionsBalanceFields === "function" ? optionsBalanceFields(opt) : {};
const optUpl = bal && bal.upl != null ? bal.upl : null;
optLine = n > 0 ? `期权 ${n}` : "期权 空仓";
if (optUpl != null && Number.isFinite(Number(optUpl))) {
optLine += ` · 浮盈 ${fmt(optUpl, 2)}U`;
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
if (openCount === 0) {
const bal = optionsBalanceFields(opt);
const optUpl = bal.upl != null ? bal.upl : null;
const parts = [n > 0 ? `期权 ${n}` : "期权 空仓"];
if (showAccountPnlPref()) {
if (bal.funding != null) parts.push(`资金 ${fmt(bal.funding, 2)}U`);
if (bal.trading != null) parts.push(`交易 ${fmt(bal.trading, 2)}U`);
if (optUpl != null && Number.isFinite(Number(optUpl))) {
parts.push(`浮盈 ${fmt(optUpl, 2)}U`);
}
if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) {
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
pnlShow = optUpl;
pnlSuffix = "期权";
}
}
optLine = parts.join(" · ");
}
}
const hm = row.hub_monitor || {};
@@ -5043,17 +5159,10 @@
function collectSettingsFromUI() {
const rows = [...document.querySelectorAll("#settings-list .settings-card")];
const pnlCb = document.getElementById("pref-show-account-pnl");
const fundsCb = document.getElementById("pref-show-nav-funds");
const dashCb = document.getElementById("pref-show-nav-dashboard");
const planCb = document.getElementById("pref-show-nav-plan");
const archiveCb = document.getElementById("pref-show-nav-archive");
const quotesCb = document.getElementById("pref-show-nav-quotes");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const strategyCb = document.getElementById("pref-show-nav-strategy");
const helpCb = document.getElementById("pref-show-nav-help");
const logsCb = document.getElementById("pref-show-nav-logs");
const chk = (id) => {
const el = document.getElementById(id);
return el ? !!el.checked : true;
};
const supEnabled = document.getElementById("supervisor-enabled");
const supProg = document.getElementById("supervisor-wechat-program");
const supWebhook = document.getElementById("supervisor-wechat-webhook");
@@ -5066,17 +5175,29 @@
return {
version: 1,
display: {
show_account_pnl: pnlCb ? !!pnlCb.checked : true,
show_nav_funds: fundsCb ? !!fundsCb.checked : true,
show_nav_dashboard: dashCb ? !!dashCb.checked : true,
show_nav_plan: planCb ? !!planCb.checked : true,
show_nav_archive: archiveCb ? !!archiveCb.checked : true,
show_nav_quotes: quotesCb ? !!quotesCb.checked : true,
show_nav_ai: aiCb ? !!aiCb.checked : true,
show_nav_calculator: calcCb ? !!calcCb.checked : true,
show_nav_strategy: strategyCb ? !!strategyCb.checked : true,
show_nav_help: helpCb ? !!helpCb.checked : true,
show_nav_logs: logsCb ? !!logsCb.checked : true,
show_account_pnl: chk("pref-show-account-pnl"),
show_nav_funds: chk("pref-show-nav-funds"),
show_nav_dashboard: chk("pref-show-nav-dashboard"),
show_nav_plan: chk("pref-show-nav-plan"),
show_nav_archive: chk("pref-show-nav-archive"),
show_nav_quotes: chk("pref-show-nav-quotes"),
show_nav_ai: chk("pref-show-nav-ai"),
show_nav_calculator: chk("pref-show-nav-calculator"),
show_nav_compare: chk("pref-show-nav-compare"),
show_nav_strategy: chk("pref-show-nav-strategy"),
show_nav_amp_stats: chk("pref-show-nav-amp-stats"),
show_nav_help: chk("pref-show-nav-help"),
show_nav_logs: chk("pref-show-nav-logs"),
show_monitor_binance: chk("pref-show-monitor-binance"),
show_monitor_okx_perp: chk("pref-show-monitor-okx-perp"),
show_monitor_okx_options: chk("pref-show-monitor-okx-options"),
show_monitor_gate: chk("pref-show-monitor-gate"),
show_strategy_playbook_v2: chk("pref-show-strategy-playbook-v2"),
show_strategy_playbook: chk("pref-show-strategy-playbook"),
show_strategy_behavior: chk("pref-show-strategy-behavior"),
show_strategy_binance: chk("pref-show-strategy-binance"),
show_strategy_okx: chk("pref-show-strategy-okx"),
show_strategy_gate: chk("pref-show-strategy-gate"),
},
supervisor: {
enabled: supEnabled ? !!supEnabled.checked : true,
@@ -5134,6 +5255,12 @@
loadSettingsMetaLine();
}
if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows);
if (window.hubDashboardPage && window.hubDashboardPage.refresh) {
window.hubDashboardPage.refresh();
}
if (window.hubStrategyPage && typeof window.hubStrategyPage.reloadMeta === "function") {
window.hubStrategyPage.reloadMeta();
}
if (!pageNavAllowed(currentPage())) {
history.replaceState({}, "", "/monitor");
setActiveNav();
+142 -5
View File
@@ -35,6 +35,7 @@
const elQuoteContent = document.getElementById("archive-quote-content");
const elQuoteSubmit = document.getElementById("archive-quote-submit");
const elContentTabs = document.getElementById("archive-content-tabs");
const elProductTabs = document.getElementById("archive-product-tabs");
const elPanelViz = document.getElementById("archive-panel-viz");
const elPanelCalendar = document.getElementById("archive-panel-calendar");
const elPanelTrades = document.getElementById("archive-panel-trades");
@@ -76,6 +77,7 @@
let selectedQuoteId = null;
let editingQuoteId = null;
let archiveContentTab = "trades";
let archiveProduct = "perp";
let quoteDayTrades = [];
let quoteDayTradesDay = "";
let quoteDayTradesReq = 0;
@@ -416,6 +418,44 @@
syncPeriodUI();
}
function isOptionsProduct() {
return archiveProduct === "options";
}
function syncProductUI() {
document.body.classList.toggle("archive-product-options", isOptionsProduct());
if (elProductTabs) {
elProductTabs.querySelectorAll(".archive-product-tab").forEach(function (btn) {
const on = btn.getAttribute("data-archive-product") === archiveProduct;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
}
if (isOptionsProduct()) {
setChartOpen(false);
if (archiveContentTab === "viz") setArchiveContentTab("trades");
}
}
function setArchiveProduct(product) {
const next = product === "options" ? "options" : "perp";
if (next === archiveProduct) return;
archiveProduct = next;
selected = null;
selectedTradeKey = null;
syncProductUI();
void loadDailyTrades();
void loadCalendar();
}
function dailyTradesApiPath() {
return isOptionsProduct() ? "/api/archive/options/daily-trades" : "/api/archive/daily-trades";
}
function calendarApiPath() {
return isOptionsProduct() ? "/api/archive/options/calendar" : "/api/archive/calendar";
}
function queryDailyParams() {
const q = new URLSearchParams();
q.set("period", periodMode);
@@ -430,7 +470,7 @@
if (ex) q.set("exchange_key", ex);
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1");
if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
if (!isOptionsProduct() && elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim());
return q.toString();
}
@@ -554,7 +594,7 @@
return q;
},
fetchFn: async function (q) {
const r = await apiFetch("/api/archive/calendar?" + q.toString());
const r = await apiFetch(calendarApiPath() + "?" + q.toString());
return r.json();
},
parseResponse: function (data) {
@@ -1089,7 +1129,7 @@
elQuoteDayTradesBody.innerHTML = '<p class="archive-empty">加载当日已平仓…</p>';
if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day;
try {
const r = await apiFetch("/api/archive/daily-trades?" + q.toString());
const r = await apiFetch(dailyTradesApiPath() + "?" + q.toString());
const j = await r.json();
if (req !== quoteDayTradesReq) return;
if (!r.ok) {
@@ -1827,6 +1867,80 @@
return;
}
const pageRows = pagedDailyTrades();
if (isOptionsProduct()) {
elTrades.innerHTML =
'<table class="archive-trades-table"><thead><tr>' +
"<th>交易所</th><th>标的</th><th>合约/来源</th><th>开仓时间</th><th>平仓时间</th><th>持仓</th>" +
"<th>类型</th><th>策略</th><th>盈亏</th><th>权利金</th><th>复盘</th>" +
"</tr></thead><tbody>" +
pageRows
.map(function (t) {
const rowKey = tradeRowKey(t);
const active = rowKey && rowKey === selectedTradeKey ? " is-active" : "";
const holdMin =
t.hold_minutes != null
? t.hold_minutes
: t.hold_seconds != null
? Number(t.hold_seconds) / 60
: null;
const optLabel =
t.source_label ||
t.source_type ||
(t.opt_type === "C" || t.opt_type === "CALL"
? "Call"
: t.opt_type === "P" || t.opt_type === "PUT"
? "Put"
: "—");
const pnl = t.pnl_amount != null ? t.pnl_amount : t.realized_pnl_total;
return (
'<tr class="archive-trade-row' +
active +
'" data-key="' +
esc(rowKey) +
'">' +
"<td>" +
esc(tradeRowExchange(t)) +
"</td>" +
'<td class="archive-symbol">' +
esc(t.underlying || "—") +
"</td>" +
"<td>" +
esc(t.inst_id || t.source_label || "—") +
"</td>" +
'<td class="archive-dt">' +
fmtDt(t.opened_at) +
"</td>" +
'<td class="archive-dt">' +
fmtDt(t.closed_at) +
"</td>" +
'<td class="archive-hold">' +
fmtDurationMinutes(holdMin) +
"</td>" +
"<td>" +
esc(optLabel) +
"</td>" +
"<td>" +
esc(t.strategy_tag || "—") +
"</td>" +
'<td class="' +
pnlClass(pnl) +
'">' +
fmtPnl(pnl) +
"</td>" +
"<td>" +
fmtVolStat(t.premium_total != null ? t.premium_total : t.premium_paid) +
"</td>" +
"<td>" +
(t.reviewed ? "已复盘" : "—") +
"</td>" +
"</tr>"
);
})
.join("") +
"</tbody></table>";
updateTradesPager();
return;
}
elTrades.innerHTML =
'<table class="archive-trades-table"><thead><tr>' +
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
@@ -2051,7 +2165,7 @@
async function loadDailyTrades() {
setStatus("加载交易记录…");
const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams());
const r = await apiFetch(dailyTradesApiPath() + "?" + queryDailyParams());
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "加载失败");
@@ -2080,7 +2194,8 @@
void loadCalendar();
if (archiveContentTab === "quotes") void loadQuoteDayTrades();
setStatus(
(periodLabel || tradingDay || "当日") +
(isOptionsProduct() ? "期权 · " : "永续 · ") +
(periodLabel || tradingDay || "当日") +
" · 列表 " +
dailyTrades.length +
" 笔 · " +
@@ -2105,6 +2220,7 @@
function formatSyncSummary(j) {
const results = j.results || [];
const optResults = j.options_results || [];
const okN = results.filter(function (x) {
return x.ok !== false;
}).length;
@@ -2118,6 +2234,19 @@
parts.push(line);
}
});
optResults.forEach(function (row) {
const label = (row.exchange_key || row.name || "?") + "期权";
if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误"));
else {
let line =
label +
" " +
(row.trade_count != null ? row.trade_count : row.trades_upserted || 0) +
" 笔";
if (row.trades_removed > 0) line += " 清" + row.trades_removed;
parts.push(line);
}
});
return parts.join(" · ");
}
@@ -2217,6 +2346,13 @@
setArchiveContentTab(btn.getAttribute("data-archive-tab") || "trades");
});
}
if (elProductTabs) {
elProductTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".archive-product-tab");
if (!btn) return;
setArchiveProduct(btn.getAttribute("data-archive-product") || "perp");
});
}
if (elTfTabs) {
elTfTabs.addEventListener("click", function (ev) {
const btn = ev.target.closest(".archive-tf-btn");
@@ -2249,6 +2385,7 @@
syncPeriodUI();
syncTradesLayout();
bindEvents();
syncProductUI();
setArchiveContentTab("trades");
inited = true;
}
+275 -1
View File
@@ -572,8 +572,270 @@
}
}
function renderPerpOptionsResult(data) {
const box = $("calc-po-result");
if (!box) return;
if ((data.calc_mode || "size") === "points") {
renderPerpOptionsPointsResult(data);
return;
}
const a = data.case_a || {};
const b = data.case_b || {};
const capitalHint = data.capital_ok
? "资金充足(参考)"
: "保证金高于交易资金(仅提示)";
box.classList.remove("hidden");
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>标的</span><strong>" +
esc(data.base || "—") +
" · 永续 " +
fmt(data.perp_coins, 2) +
" 币</strong></div>" +
"<div><span>单币权利金</span><strong>" +
fmt(data.prem_per_coin, 2) +
"U</strong></div>" +
"<div><span>永续毛收益</span><strong class=\"" +
pnlClass(data.perp_gross_u) +
'">' +
fmtU(data.perp_gross_u) +
"</strong></div>" +
"<div><span>永续手续费</span><strong>" +
fmt(data.perp_fee_u, 2) +
"U</strong></div>" +
"<div><span>权利金预算</span><strong>" +
fmt(data.premium_budget_u, 2) +
"U</strong></div>" +
"<div><span>期权开仓</span><strong>" +
fmt(data.opt_coins, 2) +
" 币 / " +
fmt(data.opt_sheets, 2) +
" 张</strong></div>" +
"<div><span>永续保证金</span><strong>" +
fmt(data.perp_margin_u, 2) +
"U</strong></div>" +
"<div><span>开仓参考</span><strong>" +
esc(capitalHint) +
"</strong></div>" +
"</div>" +
'<div class="calc-po-cases">' +
'<section class="calc-po-case">' +
"<h4>情景 A · 永续方向对</h4>" +
'<div class="calc-summary">' +
"<div><span>永续盈亏</span><strong class=\"" +
pnlClass(a.perp_pnl_u) +
'">' +
fmtU(a.perp_pnl_u) +
"</strong></div>" +
"<div><span>权利金(全亏)</span><strong>" +
fmt(a.premium_u, 2) +
"U</strong></div>" +
"<div><span>手续费</span><strong>" +
fmt(a.fee_u, 2) +
"U</strong></div>" +
"<div><span>净利</span><strong class=\"" +
pnlClass(a.net_u) +
'">' +
fmtU(a.net_u) +
"</strong></div>" +
"</div></section>" +
'<section class="calc-po-case">' +
"<h4>情景 B · 期权方向对</h4>" +
'<div class="calc-summary">' +
"<div><span>期权内在</span><strong>" +
fmtU(b.opt_intrinsic_u) +
"</strong></div>" +
"<div><span>权利金</span><strong>" +
fmt(b.premium_u, 2) +
"U</strong></div>" +
"<div><span>期权净利</span><strong class=\"" +
pnlClass(b.opt_net_u) +
'">' +
fmtU(b.opt_net_u) +
"</strong></div>" +
"<div><span>永续盈亏</span><strong class=\"" +
pnlClass(b.perp_pnl_u) +
'">' +
fmtU(b.perp_pnl_u) +
"</strong></div>" +
"<div><span>组合净利</span><strong class=\"" +
pnlClass(b.portfolio_net_u) +
'">' +
fmtU(b.portfolio_net_u) +
"</strong></div>" +
"</div></section></div>";
}
function renderPerpOptionsPointsResult(data) {
const box = $("calc-po-result");
if (!box) return;
const a = data.case_a || {};
const b = data.case_b || {};
const capitalHint = data.capital_ok
? "资金充足(参考)"
: "保证金高于交易资金(仅提示)";
let caseB =
'<section class="calc-po-case">' +
"<h4>情景 B · 期权方向对(以组合净利为准)</h4>" +
'<div class="calc-summary">';
if (b.move_points_portfolio != null) {
caseB +=
"<div><span>所需波动点数</span><strong>" +
fmt(b.move_points_portfolio, 2) +
" · " +
fmt(b.move_pct_portfolio, 2) +
"%</strong></div>" +
"<div><span>组合净利</span><strong class=\"" +
pnlClass(b.portfolio_net_u) +
'">' +
fmtU(b.portfolio_net_u) +
"</strong></div>" +
"<div><span>其中期权净利</span><strong class=\"" +
pnlClass(b.opt_net_at_portfolio_target_u) +
'">' +
fmtU(b.opt_net_at_portfolio_target_u) +
"</strong></div>" +
"<div><span>其中永续盈亏</span><strong class=\"" +
pnlClass(
b.portfolio_net_u != null && b.opt_net_at_portfolio_target_u != null
? Number(b.portfolio_net_u) - Number(b.opt_net_at_portfolio_target_u)
: null
) +
'">' +
fmtU(
b.portfolio_net_u != null && b.opt_net_at_portfolio_target_u != null
? Number(b.portfolio_net_u) - Number(b.opt_net_at_portfolio_target_u)
: null
) +
"</strong></div>";
} else if (b.portfolio_error) {
caseB +=
'<div class="calc-field-span2"><span>组合达目标</span><strong class="calc-market-err">' +
esc(b.portfolio_error) +
"</strong></div>";
}
caseB += "</div></section>";
box.classList.remove("hidden");
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>标的</span><strong>" +
esc(data.base || "—") +
" · 比例 " +
esc(data.ratio_label || "—") +
"</strong></div>" +
"<div><span>仓位</span><strong>永续 " +
fmt(data.perp_coins, 2) +
" 币 / 期权 " +
fmt(data.opt_coins, 2) +
" 币(" +
fmt(data.opt_sheets, 2) +
" 张)</strong></div>" +
"<div><span>单币权利金</span><strong>" +
fmt(data.prem_per_coin, 2) +
"U</strong></div>" +
"<div><span>权利金总额</span><strong>" +
fmt(data.premium_total_u, 2) +
"U</strong></div>" +
"<div><span>目标盈利</span><strong>" +
fmt(data.target_profit_u, 2) +
"U</strong></div>" +
"<div><span>永续保证金</span><strong>" +
fmt(data.perp_margin_u, 2) +
"U</strong></div>" +
"<div><span>开仓参考</span><strong>" +
esc(capitalHint) +
"</strong></div>" +
"</div>" +
'<div class="calc-po-cases">' +
'<section class="calc-po-case">' +
"<h4>情景 A · 永续方向对</h4>" +
'<div class="calc-summary">' +
"<div><span>所需波动点数</span><strong>" +
fmt(a.move_points, 2) +
" · " +
fmt(a.move_pct, 2) +
"%</strong></div>" +
"<div><span>永续盈亏</span><strong class=\"" +
pnlClass(a.perp_pnl_u) +
'">' +
fmtU(a.perp_pnl_u) +
"</strong></div>" +
"<div><span>权利金(全亏)</span><strong>" +
fmt(a.premium_u, 2) +
"U</strong></div>" +
"<div><span>手续费</span><strong>" +
fmt(a.fee_u, 2) +
"U</strong></div>" +
"<div><span>净利</span><strong class=\"" +
pnlClass(a.net_u) +
'">' +
fmtU(a.net_u) +
"</strong></div>" +
"</div></section>" +
caseB +
"</div>";
}
async function submitPerpOptions(e) {
e.preventDefault();
const calcMode = ($("calc-po-calc-mode") && $("calc-po-calc-mode").value) || "size";
const body = {
calc_mode: calcMode,
base: ($("calc-po-base") && $("calc-po-base").value) || "ETH",
spot: num("calc-po-spot"),
capital_usdt: num("calc-po-capital"),
target_profit_u: num("calc-po-target"),
move_mode: ($("calc-po-move-mode") && $("calc-po-move-mode").value) || "points",
move_value: num("calc-po-move"),
perp_leverage: num("calc-po-perp-lev"),
option_leverage: num("calc-po-opt-lev"),
ct_mult: num("calc-po-ct-mult") || 0.01,
ratio_perp: num("calc-po-ratio-perp") || 1,
ratio_opt: num("calc-po-ratio-opt") || 2,
};
try {
const r = await fetch("/api/calculator/perp-options", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) {
showErr("calc-po-result", j.msg || "计算失败");
return;
}
renderPerpOptionsResult(j.data);
} catch (err) {
showErr("calc-po-result", String(err));
}
}
function syncPoMoveLabel() {
const mode = ($("calc-po-move-mode") && $("calc-po-move-mode").value) || "points";
const lab = $("calc-po-move-label");
if (lab) lab.textContent = mode === "pct" ? "波动率 %" : "波动点数";
}
function syncPoCalcMode() {
const mode = ($("calc-po-calc-mode") && $("calc-po-calc-mode").value) || "size";
const points = mode === "points";
page.querySelectorAll(".calc-po-size-only").forEach(function (el) {
el.classList.toggle("hidden", points);
});
page.querySelectorAll(".calc-po-points-only").forEach(function (el) {
el.classList.toggle("hidden", !points);
});
const moveInput = $("calc-po-move");
if (moveInput) {
if (points) moveInput.removeAttribute("required");
else moveInput.setAttribute("required", "required");
}
}
function applyCalcTab(tab) {
const t = tab === "roll" ? "roll" : "trend";
const t = tab === "roll" || tab === "po" ? tab : "trend";
const layout = page.querySelector(".calc-layout");
if (layout) layout.setAttribute("data-calc-tab", t);
page.querySelectorAll(".calc-m-tab").forEach(function (btn) {
@@ -609,13 +871,25 @@
await loadCalculatorExchanges();
const trendForm = $("calc-trend-form");
const rollForm = $("calc-roll-form");
const poForm = $("calc-po-form");
const dirSel = $("calc-trend-direction");
const poMode = $("calc-po-move-mode");
const poCalcMode = $("calc-po-calc-mode");
if (trendForm) trendForm.addEventListener("submit", submitTrend);
if (rollForm) rollForm.addEventListener("submit", submitRoll);
if (poForm) poForm.addEventListener("submit", submitPerpOptions);
if (dirSel) {
dirSel.addEventListener("change", syncTrendAddLabel);
syncTrendAddLabel();
}
if (poMode) {
poMode.addEventListener("change", syncPoMoveLabel);
syncPoMoveLabel();
}
if (poCalcMode) {
poCalcMode.addEventListener("change", syncPoCalcMode);
syncPoCalcMode();
}
bindRollLegsUI();
bindMarket("calc-trend");
bindMarket("calc-roll");
+296
View File
@@ -0,0 +1,296 @@
/**
* 中控策略对比:同风险额 R 合约 / 单期权 / 期期7:3
*/
(function () {
const page = document.getElementById("page-compare");
if (!page) return;
let inited = false;
let calcTimer = null;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function num(id) {
const el = $(id);
if (!el) return null;
const n = Number(el.value);
return Number.isFinite(n) ? n : null;
}
function text(id) {
const el = $(id);
return el ? String(el.value || "").trim() : "";
}
function fmtU(v) {
if (v == null || !Number.isFinite(Number(v))) return "—";
const n = Number(v);
const abs = Math.abs(n).toFixed(2);
if (Math.abs(n) < 1e-9) return "0.00U";
return (n > 0 ? "+" : "-") + abs + "U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
return n > 0 ? "cmp-pnl-pos" : "cmp-pnl-neg";
}
function setStatus(msg, isErr) {
const el = $("cmp-status");
if (!el) return;
el.textContent = msg || "";
el.className = "toolbar-meta" + (isErr ? " err" : "");
}
function syncDirectionDefaults() {
const dir = text("cmp-direction") || "long";
const isLong = dir === "long";
const optType = $("cmp-opt-type");
const mainType = $("cmp-hedge-main-type");
const sideType = $("cmp-hedge-side-type");
if (optType && !optType.dataset.touched) optType.value = isLong ? "C" : "P";
if (mainType && !mainType.dataset.touched) mainType.value = isLong ? "C" : "P";
if (sideType && !sideType.dataset.touched) sideType.value = isLong ? "P" : "C";
}
function collectPayload() {
const tp = num("cmp-tp");
return {
base: text("cmp-base") || "ETH",
direction: text("cmp-direction") || "long",
entry: num("cmp-entry"),
sl: num("cmp-sl"),
tp: tp,
risk_u: num("cmp-risk"),
tp_opt: num("cmp-tp-opt") != null ? num("cmp-tp-opt") : tp,
tp_hedge: num("cmp-tp-hedge") != null ? num("cmp-tp-hedge") : tp,
option: {
opt_type: text("cmp-opt-type") || "C",
strike: num("cmp-opt-strike"),
ask: num("cmp-opt-ask"),
},
hedge: {
main: {
opt_type: text("cmp-hedge-main-type") || "C",
strike: num("cmp-hedge-main-strike"),
ask: num("cmp-hedge-main-ask"),
},
side: {
opt_type: text("cmp-hedge-side-type") || "P",
strike: num("cmp-hedge-side-strike"),
ask: num("cmp-hedge-side-ask"),
},
},
};
}
function renderSummaryCards(data) {
const box = $("cmp-summary");
if (!box) return;
const perp = data.perp || {};
const opt = data.option || {};
const hedge = data.hedge || {};
const cards = [];
cards.push(`<article class="cmp-sum-card card">
<h3>单独合约</h3>
<div class="cmp-sum-row"><span>张数</span><strong>${esc(perp.sheets)}</strong></div>
<div class="cmp-sum-row"><span>止损占用</span><strong>${fmtU(perp.risk_used_u)}</strong></div>
<div class="cmp-sum-row"><span>面值</span><strong>${esc(perp.contract_size)} /</strong></div>
</article>`);
if (opt.ok) {
cards.push(`<article class="cmp-sum-card card">
<h3>单独期权 · ${esc(opt.opt_type)} ${esc(opt.strike)}</h3>
<div class="cmp-sum-row"><span>张数</span><strong>${esc(opt.sheets)}</strong></div>
<div class="cmp-sum-row"><span>权利金</span><strong>${fmtU(opt.premium_u)}</strong></div>
<div class="cmp-sum-row"><span>单张成本</span><strong>${fmtU(opt.unit_cost_u)}</strong></div>
</article>`);
} else {
cards.push(`<article class="cmp-sum-card card">
<h3>单独期权</h3>
<p class="cmp-muted">${esc(opt.msg || "输入不完整")}</p>
</article>`);
}
if (hedge.ok) {
const m = hedge.main || {};
const s = hedge.side || {};
cards.push(`<article class="cmp-sum-card card">
<h3>期期对冲 7:3</h3>
<div class="cmp-sum-row"><span>主腿 ${esc(m.opt_type)} ${esc(m.strike)}</span><strong>${esc(m.sheets)} · ${fmtU(m.premium_u)}</strong></div>
<div class="cmp-sum-row"><span>次腿 ${esc(s.opt_type)} ${esc(s.strike)}</span><strong>${esc(s.sheets)} · ${fmtU(s.premium_u)}</strong></div>
<div class="cmp-sum-row"><span>总权利金</span><strong>${fmtU(hedge.premium_u)}</strong></div>
</article>`);
} else {
cards.push(`<article class="cmp-sum-card card">
<h3>期期对冲</h3>
<p class="cmp-muted">${esc(hedge.msg || "输入不完整")}</p>
</article>`);
}
box.innerHTML = cards.join("");
}
function cell(v, note) {
const main = `<span class="${pnlClass(v)}">${fmtU(v)}</span>`;
if (!note) return main;
return `${main}<div class="cmp-cell-note">${esc(note)}</div>`;
}
function renderTable(data) {
const box = $("cmp-table-wrap");
if (!box) return;
const perp = data.perp || {};
const opt = data.option && data.option.ok ? data.option : null;
const hedge = data.hedge && data.hedge.ok ? data.hedge : null;
const dash = "—";
box.innerHTML = `<div class="cmp-table-scroll"><table class="cmp-table">
<thead>
<tr>
<th>路径</th>
<th>单独合约</th>
<th>单独期权</th>
<th>期期对冲</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>A 干净止盈</strong><div class="cmp-cell-note"></div></td>
<td>${cell(perp.path_a_tp)}</td>
<td>${opt ? cell(opt.path_a_tp) : dash}</td>
<td>${hedge ? cell(hedge.path_a_tp) : dash}</td>
</tr>
<tr>
<td><strong>B 打止损</strong><div class="cmp-cell-note">;</div></td>
<td>${cell(perp.path_b_sl)}</td>
<td>${
opt
? cell(opt.path_b_sl, "最坏到期亏满权利金 " + fmtU(opt.path_b_worst))
: dash
}</td>
<td>${
hedge
? cell(hedge.path_b_sl, "最坏双腿归零 " + fmtU(hedge.path_b_worst))
: dash
}</td>
</tr>
<tr>
<td><strong>C 先止损再去止盈</strong><div class="cmp-cell-note"></div></td>
<td>${cell(
perp.path_c_realized,
"踏空未拿到 " + fmtU(perp.path_c_missed)
)}</td>
<td>${opt ? cell(opt.path_c_hold_to_tp, opt.path_c_note || "") : dash}</td>
<td>${hedge ? cell(hedge.path_c_hold_to_tp, hedge.path_c_note || "") : dash}</td>
</tr>
</tbody>
</table></div>`;
}
function renderRecommend(data) {
const box = $("cmp-recommend");
if (!box) return;
const rec = data.recommend || {};
const bullets = Array.isArray(rec.bullets) ? rec.bullets : [];
const warns = Array.isArray(data.warnings) ? data.warnings : [];
box.innerHTML = `<div class="cmp-rec-card card">
<div class="cmp-rec-head">推荐:<strong>${esc(rec.choice || "—")}</strong></div>
<p class="cmp-rec-reason">${esc(rec.reason || "")}</p>
<ul class="cmp-rec-list">${bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>
${
warns.length
? `<div class="cmp-warn">${warns.map((w) => esc(w)).join(" · ")}</div>`
: ""
}
<p class="cmp-foot-note">${(data.notes || []).map(esc).join(" · ")}</p>
</div>`;
}
async function runCalc() {
const payload = collectPayload();
if (
payload.entry == null ||
payload.sl == null ||
payload.tp == null ||
payload.risk_u == null
) {
setStatus("请填写入场 / 止损 / 止盈 / 风险额", true);
return;
}
setStatus("计算中…");
try {
const r = await fetch("/api/compare/calc", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!data.ok) {
setStatus(data.msg || "计算失败", true);
return;
}
renderSummaryCards(data);
renderTable(data);
renderRecommend(data);
setStatus("已更新");
} catch (e) {
setStatus(String(e.message || e), true);
}
}
function scheduleCalc() {
if (calcTimer) clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
void runCalc();
}, 280);
}
function bind() {
const form = $("cmp-form");
if (!form || form.dataset.bound === "1") return;
form.dataset.bound = "1";
form.addEventListener("submit", (ev) => {
ev.preventDefault();
void runCalc();
});
form.querySelectorAll("input, select").forEach((el) => {
el.addEventListener("change", () => {
if (el.id === "cmp-direction") syncDirectionDefaults();
if (
el.id === "cmp-opt-type" ||
el.id === "cmp-hedge-main-type" ||
el.id === "cmp-hedge-side-type"
) {
el.dataset.touched = "1";
}
scheduleCalc();
});
el.addEventListener("input", scheduleCalc);
});
const btn = $("cmp-btn-run");
if (btn) btn.addEventListener("click", () => void runCalc());
}
window.hubComparePage = {
init() {
if (!inited) {
bind();
syncDirectionDefaults();
inited = true;
}
scheduleCalc();
},
destroy() {
/* keep form state */
},
};
})();
+41 -16
View File
@@ -54,8 +54,16 @@
elStatus.className = "dash-status" + (isErr ? " err" : "");
}
function showAccountPnlPref() {
if (typeof window.hubShowAccountPnlPref === "function") {
return !!window.hubShowAccountPnlPref();
}
return true;
}
function renderKpi(totals) {
if (!elKpi || !totals) return;
const showPnl = showAccountPnlPref();
const closed = Number(totals.total_pnl_u);
const floating = Number(totals.float_pnl_u);
const funding = totals.total_funding_usdt;
@@ -68,16 +76,20 @@
totals.perpetual_open_position_count != null
? Number(totals.perpetual_open_position_count) || 0
: Math.max(0, totalPos - optPos);
const items = [
kpiItem("交易日", esc(totals.trading_day || "—")),
kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"),
const items = [kpiItem("交易日", esc(totals.trading_day || "—"))];
if (showPnl) {
items.push(kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"));
}
items.push(
kpiItem("总持仓数量", `${totalPos}`),
kpiItem("期权持仓", `${optPos}`),
kpiItem("永续持仓", `${perpPos}`),
kpiItem("平仓数量", `${totals.closed_count || 0}`),
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)),
kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)),
];
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed))
);
if (showPnl) {
items.push(kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)));
}
elKpi.innerHTML = `<div class="dash-kpi-summary">${items.join("")}</div>`;
}
@@ -197,6 +209,7 @@
function renderUnifiedPerpTable(rows) {
if (!rows.length) return "";
const showPnl = showAccountPnlPref();
const body = rows
.map(({ ac, ln }) => {
const source = String((ln && ln.source) || "—");
@@ -213,7 +226,7 @@
<td>${contracts}</td>
<td>${slTpCell(ln, "sl")}</td>
<td>${slTpCell(ln, "tp")}</td>
<td>${floatPnlCell(ln)}</td>
${showPnl ? `<td>${floatPnlCell(ln)}</td>` : ""}
</tr>`;
})
.join("");
@@ -222,7 +235,9 @@
<div class="dash-table-wrap">
<table class="dash-table dash-pos-table">
<thead><tr>
<th>交易所</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th>
<th>交易所</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th>${
showPnl ? "<th>浮盈</th>" : ""
}
</tr></thead>
<tbody>${body}</tbody>
</table>
@@ -291,7 +306,7 @@
return null;
}
function renderOptionsLegRow(ac, p) {
function renderOptionsLegRow(ac, p, showPnl) {
const optType =
(p.opt_type || "").toUpperCase() === "C"
? "Call"
@@ -303,21 +318,25 @@
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
const net = optionsNetPnl(p);
const roi = optionsRoiPct(p);
return `<tr>
let html = `<tr>
<td>${exchangeLinkCell(ac)}</td>
<td>${sourceTypeCell(source)}</td>
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
<td>${esc(optType)}</td>
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td>
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>
</tr>`;
<td><span class="${targetCls}">${esc(target)}</span></td>`;
if (showPnl) {
html += `<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>`;
}
html += "</tr>";
return html;
}
function renderUnifiedOptionsTable(rows) {
if (!rows.length) return "";
const showPnl = showAccountPnlPref();
// 同所同计划相邻,Call 在前 Put 在后;不额外画分组框
const sorted = rows.slice().sort((a, b) => {
const ka = optionsGroupKey(a.ac, a.p);
@@ -330,14 +349,16 @@
if (tb === "C") return 1;
return ta.localeCompare(tb);
});
const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p)).join("");
const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p, showPnl)).join("");
return `<div class="dash-pos-block dash-options-block">
<div class="dash-ac-section-label">期权持仓</div>
<div class="dash-table-wrap dash-options-table-wrap">
<table class="dash-table dash-options-table">
<thead><tr>
<th>交易所</th><th></th><th></th><th>Call/Put</th><th></th><th></th><th></th><th></th><th></th>
<th>交易所</th><th></th><th></th><th>Call/Put</th><th></th><th></th><th></th>${
showPnl ? "<th>净盈亏</th><th>收益率</th>" : ""
}
</tr></thead>
<tbody>${body}</tbody>
</table>
@@ -539,5 +560,9 @@
inited = false;
stopLive();
},
refresh() {
if (!inited) return;
void fetchDashboardSnapshot({ silent: true, force: true });
},
};
})();
+368 -10
View File
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260720-calc-equal-height" />
<link rel="stylesheet" href="/assets/app.css?v=20260728-po-calc" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
@@ -53,9 +53,11 @@
<a href="/plan" id="nav-plan">开仓计划</a>
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/strategy" id="nav-strategy">策略说明</a>
<a href="/amp-stats" id="nav-amp-stats">振幅统计</a>
<a href="/help" id="nav-help">使用说明</a>
<a href="/market" id="nav-market">行情区</a>
<a href="/calculator" id="nav-calculator">计算器</a>
<a href="/compare" id="nav-compare">策略对比</a>
<a href="/archive" id="nav-archive">内照明心</a>
<a href="/quotes" id="nav-quotes">语录</a>
<a href="/dashboard" id="nav-dashboard">数据看板</a>
@@ -449,7 +451,11 @@
<div id="page-archive" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">IN</span> 内照明心</h1>
<p class="page-desc">交易记录 · 交易日历 · 图表概览 · 复盘语录</p>
<p class="page-desc">永续 / 期权交易记录 · 交易日历 · 图表概览 · 复盘语录</p>
</div>
<div class="archive-product-tabs" id="archive-product-tabs" role="tablist" aria-label="品种">
<button type="button" class="archive-product-tab is-active" role="tab" aria-selected="true" data-archive-product="perp">永续</button>
<button type="button" class="archive-product-tab" role="tab" aria-selected="false" data-archive-product="options">期权</button>
</div>
<div class="archive-toolbar toolbar">
<label class="chk-label archive-toolbar-desktop"><input type="checkbox" id="archive-filter-profit" /> 盈利单</label>
@@ -812,6 +818,9 @@
<button type="button" class="calc-m-tab" data-calc-tab="roll" role="tab" aria-selected="false">
<span class="calc-tab-label-mobile">滚仓</span><span class="calc-tab-label-desktop">滚仓计算器</span>
</button>
<button type="button" class="calc-m-tab" data-calc-tab="po" role="tab" aria-selected="false">
<span class="calc-tab-label-mobile">永期对冲</span><span class="calc-tab-label-desktop">永期对冲计算器</span>
</button>
</div>
<div class="calc-layout" data-calc-tab="trend">
<section class="calc-card card" data-calc-pane="trend">
@@ -952,15 +961,212 @@
</aside>
</div>
</section>
<section class="calc-card card" data-calc-pane="po">
<div class="calc-pane-split">
<div class="calc-input-panel">
<h2>永期对冲计算器</h2>
<p class="calc-hint">永续固定 1 币;单币权利金 = 现价 / 期权杠杆;权利金按全亏;只扣永续开平手续费(各 0.05%).「推仓位」由波动反推期权数量;「推点数」按永续:期权比例反推达目标盈利所需波动.</p>
<form id="calc-po-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>测算模式</span>
<select id="calc-po-calc-mode">
<option value="size" selected>由波动推期权仓位</option>
<option value="points">由比例推波动点数</option>
</select>
</label>
<label class="calc-field">
<span>币种</span>
<select id="calc-po-base">
<option value="ETH" selected>ETH</option>
<option value="BTC">BTC</option>
</select>
</label>
<label class="calc-field">
<span>现价</span>
<input id="calc-po-spot" type="number" min="0" step="any" value="1800" required />
</label>
<label class="calc-field">
<span>交易资金 (U·参考)</span>
<input id="calc-po-capital" type="number" min="0.01" step="any" value="3000" required />
</label>
<label class="calc-field">
<span>目标盈利 (U)</span>
<input id="calc-po-target" type="number" min="0" step="any" value="15" required />
</label>
<label class="calc-field calc-po-size-only">
<span>波动模式</span>
<select id="calc-po-move-mode">
<option value="points" selected>波动点数</option>
<option value="pct">波动率 %</option>
</select>
</label>
<label class="calc-field calc-po-size-only">
<span id="calc-po-move-label">波动点数</span>
<input id="calc-po-move" type="number" min="0" step="any" value="50" />
</label>
<label class="calc-field calc-po-points-only hidden">
<span>永续比例</span>
<input id="calc-po-ratio-perp" type="number" min="0.01" step="any" value="1" />
</label>
<label class="calc-field calc-po-points-only hidden">
<span>期权比例</span>
<input id="calc-po-ratio-opt" type="number" min="0.01" step="any" value="2" />
</label>
<label class="calc-field">
<span>永续杠杆</span>
<input id="calc-po-perp-lev" type="number" min="0.01" step="any" value="10" required />
</label>
<label class="calc-field">
<span>期权杠杆</span>
<input id="calc-po-opt-lev" type="number" min="0.01" step="any" value="100" required />
</label>
<label class="calc-field">
<span>合约乘数 ct_mult</span>
<input id="calc-po-ct-mult" type="number" min="0.0001" step="any" value="0.01" required />
</label>
</div>
<div class="calc-actions">
<button type="submit" class="primary">计算</button>
</div>
</form>
</div>
<aside class="calc-result-panel" aria-label="永期对冲结果推算">
<h3 class="calc-result-title">结果推算</h3>
<p class="calc-result-placeholder">填写左侧参数后点击「计算」</p>
<div id="calc-po-result" class="calc-result hidden"></div>
</aside>
</div>
</section>
</div>
</div>
</div>
<div id="page-compare" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">CMP</span> 策略对比</h1>
<p class="page-desc">同风险额下对比 · 单独合约 / 单独期权 / 期期对冲(7:3) · 看止盈谁强、谁更易踏空</p>
</div>
<div class="toolbar">
<button type="button" id="cmp-btn-run" class="primary">计算对比</button>
<span id="cmp-status" class="toolbar-meta"></span>
</div>
<form id="cmp-form" class="cmp-form">
<section class="card cmp-common-card">
<h2>公共参数</h2>
<div class="cmp-form-grid">
<label class="cmp-field">
<span>标的</span>
<select id="cmp-base">
<option value="ETH" selected>ETH</option>
<option value="BTC">BTC</option>
</select>
</label>
<label class="cmp-field">
<span>方向</span>
<select id="cmp-direction">
<option value="long" selected>做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="cmp-field">
<span>入场价</span>
<input id="cmp-entry" type="number" min="0" step="any" value="3500" required />
</label>
<label class="cmp-field">
<span>统一风险 R (U)</span>
<input id="cmp-risk" type="number" min="0.01" step="any" value="10" required />
</label>
<label class="cmp-field">
<span>统一止损价</span>
<input id="cmp-sl" type="number" min="0" step="any" value="3400" required />
</label>
<label class="cmp-field">
<span>止盈价</span>
<input id="cmp-tp" type="number" min="0" step="any" value="3700" required />
</label>
</div>
</section>
<div class="cmp-input-cols">
<section class="card">
<h2>单独期权</h2>
<div class="cmp-form-grid">
<label class="cmp-field">
<span>类型</span>
<select id="cmp-opt-type">
<option value="C" selected>Call</option>
<option value="P">Put</option>
</select>
</label>
<label class="cmp-field">
<span>行权价</span>
<input id="cmp-opt-strike" type="number" min="0" step="any" value="3600" />
</label>
<label class="cmp-field">
<span>卖一价(每币)</span>
<input id="cmp-opt-ask" type="number" min="0" step="any" value="50" />
</label>
<label class="cmp-field">
<span>期权目标价(默认同止盈)</span>
<input id="cmp-tp-opt" type="number" min="0" step="any" placeholder="空=用止盈价" />
</label>
</div>
</section>
<section class="card">
<h2>期期对冲 · 主腿 70%</h2>
<div class="cmp-form-grid">
<label class="cmp-field">
<span>类型</span>
<select id="cmp-hedge-main-type">
<option value="C" selected>Call</option>
<option value="P">Put</option>
</select>
</label>
<label class="cmp-field">
<span>行权价</span>
<input id="cmp-hedge-main-strike" type="number" min="0" step="any" value="3600" />
</label>
<label class="cmp-field">
<span>卖一价</span>
<input id="cmp-hedge-main-ask" type="number" min="0" step="any" value="50" />
</label>
</div>
<h2 class="cmp-subhead">次腿 30%</h2>
<div class="cmp-form-grid">
<label class="cmp-field">
<span>类型</span>
<select id="cmp-hedge-side-type">
<option value="C">Call</option>
<option value="P" selected>Put</option>
</select>
</label>
<label class="cmp-field">
<span>行权价</span>
<input id="cmp-hedge-side-strike" type="number" min="0" step="any" value="3400" />
</label>
<label class="cmp-field">
<span>卖一价</span>
<input id="cmp-hedge-side-ask" type="number" min="0" step="any" value="30" />
</label>
<label class="cmp-field">
<span>对冲目标价(默认同止盈)</span>
<input id="cmp-tp-hedge" type="number" min="0" step="any" placeholder="空=用止盈价" />
</label>
</div>
</section>
</div>
</form>
<div id="cmp-summary" class="cmp-summary"></div>
<div id="cmp-table-wrap" class="cmp-table-wrap"></div>
<div id="cmp-recommend" class="cmp-recommend"></div>
</div>
<div id="page-strategy" class="page hidden">
<div class="page-head strategy-page-head">
<div>
<h1><span class="head-tag">STR</span> 策略说明</h1>
<p class="page-desc">策略正文(带目录) · 执行清单(打印对照) · 三所切换</p>
<p class="page-desc">执行手册v2(无对冲)· 行为准则 · 三所策略正文 · 执行清单</p>
</div>
<div class="strategy-page-actions no-print">
<button type="button" id="strategy-btn-download" class="ghost">下载 HTML</button>
@@ -1003,6 +1209,102 @@
</div>
</div>
<div id="page-amp-stats" class="page hidden">
<div class="page-head">
<div>
<h1><span class="head-tag">AMP</span> 振幅统计</h1>
<p class="page-desc">OKX 指数 · 整点起点 → 固定 16:00 · 点数振幅档案(只读)</p>
</div>
</div>
<div class="amp-view-tabs" role="tablist" aria-label="振幅视图">
<button type="button" class="amp-view-tab is-active" data-view="stats" role="tab" aria-selected="true">统计</button>
<button type="button" class="amp-view-tab" data-view="history" role="tab" aria-selected="false">历史</button>
</div>
<section id="amp-panel-stats" class="card amp-panel">
<div class="amp-form">
<label class="amp-field">
<span>标的</span>
<select id="amp-symbol">
<option value="eth" selected>ETH</option>
<option value="btc">BTC</option>
</select>
</label>
<label class="amp-field">
<span>数据源</span>
<input type="text" value="OKX" disabled />
</label>
<label class="amp-field">
<span>起点整点</span>
<select id="amp-start-hour"></select>
</label>
<label class="amp-field">
<span>终点</span>
<input type="text" value="16:00" disabled />
</label>
<label class="amp-field">
<span>周期</span>
<select id="amp-period">
<option value="1m">1个月</option>
<option value="2m" selected>2个月</option>
<option value="3m">3个月</option>
<option value="6m">半年</option>
<option value="1y">1年</option>
<option value="custom">自定义</option>
</select>
</label>
<label class="amp-field hidden" id="amp-custom-wrap">
<span>自定义天数</span>
<input id="amp-custom-days" type="number" min="7" max="400" value="60" />
</label>
<label class="amp-field">
<span>周末</span>
<select id="amp-weekend-filter">
<option value="all" selected>全部</option>
<option value="exclude">排除周末</option>
<option value="only">仅周末</option>
</select>
</label>
<label class="amp-field">
<span>买跨·双边权利金(点)</span>
<input id="amp-straddle-premium" type="number" min="0" step="any" placeholder="如 30" />
</label>
<label class="amp-field">
<span>止盈点(点)</span>
<input id="amp-take-profit" type="number" min="0" step="any" placeholder="空=按涨跌" />
</label>
<div class="amp-actions">
<button type="button" id="amp-btn-compute" class="primary">计算</button>
<button type="button" id="amp-btn-save" class="ghost">保存到历史</button>
<button type="button" id="amp-btn-download" class="ghost">下载 CSV</button>
</div>
</div>
<p id="amp-status" class="toolbar-meta amp-status"></p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.</p>
<h3 class="amp-block-title">汇总</h3>
<div id="amp-summary" class="amp-summary"></div>
<h3 class="amp-block-title">买跨对照</h3>
<div id="amp-straddle" class="amp-summary amp-straddle"></div>
<h3 class="amp-block-title">日表明细</h3>
<div class="amp-table-wrap">
<table class="amp-table">
<thead>
<tr>
<th>结算日</th><th>窗起点</th><th></th><th></th><th></th><th></th>
<th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th><th>收益</th>
</tr>
</thead>
<tbody id="amp-table-body">
<tr><td colspan="11" class="amp-empty">点击「计算」加载</td></tr>
</tbody>
</table>
</div>
<div id="amp-pager" class="amp-pager"></div>
</section>
<section id="amp-panel-history" class="card amp-panel hidden">
<div id="amp-history-list" class="amp-history-list"></div>
</section>
</div>
<div id="page-help" class="page hidden">
<div class="page-head">
<div>
@@ -1115,7 +1417,7 @@
</div>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-account-pnl" checked />
监控区显示资金账户,交易账户与浮动盈亏
监控区/数据看板显示资金账户交易账户与浮动盈亏(关闭可隐藏期权盈亏与总浮盈)
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-funds" checked />
@@ -1145,10 +1447,18 @@
<input type="checkbox" id="pref-show-nav-calculator" checked />
顶栏显示「计算器」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-compare" checked />
顶栏显示「策略对比」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-strategy" checked />
顶栏显示「策略说明」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-amp-stats" checked />
顶栏显示「振幅统计」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-help" checked />
顶栏显示「使用说明」
@@ -1157,6 +1467,50 @@
<input type="checkbox" id="pref-show-nav-logs" checked />
顶栏显示「系统日志」
</label>
<p class="settings-display-subtitle">监控区卡片</p>
<p class="settings-display-hint">仅隐藏监控区界面卡片,不关闭账户与后台拉取.例:只做 OKX 期权与 Gate 时可关掉币安与 OKX 永续.</p>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-monitor-binance" checked />
监控区显示「币安」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-monitor-okx-perp" checked />
监控区显示「OKX 永续」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-monitor-okx-options" checked />
监控区显示「OKX 期权」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-monitor-gate" checked />
监控区显示「Gate」
</label>
<p class="settings-display-subtitle">策略说明页签</p>
<p class="settings-display-hint">关闭后该页签从策略说明中消失;顶栏「策略说明」入口仍由上方导航开关控制.</p>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-strategy-playbook-v2" checked />
策略说明显示「执行手册v2」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-strategy-playbook" checked />
策略说明显示「执行手册v1」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-strategy-behavior" checked />
策略说明显示「行为准则」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-strategy-binance" checked />
策略说明显示「币安」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-strategy-okx" checked />
策略说明显示「OKX」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-strategy-gate" checked />
策略说明显示「Gate」
</label>
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效.关闭导航后对应页面将不可从顶栏进入,直接访问 URL 会跳回监控区.</p>
</section>
@@ -1343,6 +1697,8 @@
<a href="/quotes" id="m-nav-quotes">语录</a>
<a href="/dashboard" id="m-nav-dashboard">数据看板</a>
<a href="/strategy" id="m-nav-strategy">策略说明</a>
<a href="/amp-stats" id="m-nav-amp-stats">振幅统计</a>
<a href="/compare" id="m-nav-compare">策略对比</a>
<a href="/help" id="m-nav-help">使用说明</a>
<a href="/logs" id="m-nav-logs">系统日志</a>
<a href="/settings" id="m-nav-settings">系统设置</a>
@@ -1399,20 +1755,22 @@
<script src="/assets/chart_draw.js?v=20260720-option-day-1600"></script>
<script src="/assets/chart.js?v=20260720-option-day-1600"></script>
<script src="/assets/plan.js?v=20260720-autofill"></script>
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
<script src="/assets/calculator.js?v=20260728-po-2dp"></script>
<script src="/assets/compare.js?v=20260723-compare"></script>
<script src="/assets/trade_stats_calendar.js?v=3"></script>
<script src="/assets/archive.js?v=20260717-archive-cal-chart"></script>
<script src="/assets/archive.js?v=20260724-opt-archive"></script>
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260720-dash-sl-tp"></script>
<script src="/assets/strategy.js?v=8"></script>
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
<script src="/assets/strategy.js?v=11"></script>
<script src="/assets/amp_stats.js?v=5"></script>
<script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.js?v=1"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=2"></script>
<script src="/assets/options_position_cards.js?v=3"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260720-dash-back"></script>
<script src="/assets/app.js?v=20260724-display-hide"></script>
</body>
</html>
+27 -8
View File
@@ -21,7 +21,7 @@
const btnPrintChecklistInline = document.getElementById("strategy-btn-print-checklist-inline");
const btnDownload = document.getElementById("strategy-btn-download");
let activeKey = "binance";
let activeKey = "playbook_v2";
let activeView = "doc";
let tabsMeta = [];
let cache = {};
@@ -74,6 +74,14 @@
function renderExchangeTabs() {
if (!tabsEl) return;
if (!tabsMeta.length) {
tabsEl.innerHTML = "";
if (statusEl) statusEl.textContent = "当前无可显示的策略页签(可在系统设置·显示与导航中开启)";
if (docBody) docBody.innerHTML = "";
if (docToc) docToc.innerHTML = "";
if (checklistBody) checklistBody.innerHTML = "";
return;
}
tabsEl.innerHTML = tabsMeta
.map(
(t) =>
@@ -128,13 +136,14 @@
function sectionTag(title) {
const t = String(title || "");
if (/账户|定位/.test(t)) return "账户";
if (/开仓类型|开仓|入场|反转|顺势|波段|假破|结构/.test(t)) return "入场";
if (/总原则|原则/.test(t)) return "原则";
if (/账户|定位|分工/.test(t)) return "账户";
if (/开仓类型|开仓|入场|反转|顺势|波段|假破|结构|对冲|方向单/.test(t)) return "入场";
if (/周期/.test(t)) return "周期";
if (/方向/.test(t)) return "方向";
if (/纪律|出场|笔数|节奏/.test(t)) return "纪律";
if (/持仓|离场|强平/.test(t)) return "离场";
if (/资金|杠杆|计仓/.test(t)) return "仓位";
if (/纪律|出场|笔数|节奏|止损|次数/.test(t)) return "纪律";
if (/持仓|离场|强平|到期/.test(t)) return "离场";
if (/资金|杠杆|计仓|仓位|预算/.test(t)) return "仓位";
if (/系统|字段|对接/.test(t)) return "系统";
if (/修订|记录/.test(t)) return "版本";
if (/边界|关系|行情状态/.test(t)) return "边界";
@@ -273,6 +282,15 @@
renderExchangeTabs();
}
async function reloadMeta() {
try {
await loadMeta();
if (tabsMeta.length) await loadExchange(activeKey);
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
}
}
async function printSection(mode) {
const part = mode === "checklist" ? "checklist" : "doc";
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
@@ -333,7 +351,8 @@
setView(activeView);
try {
await loadMeta();
await loadExchange(activeKey);
if (tabsMeta.length) await loadExchange(activeKey);
else if (statusEl) statusEl.textContent = "当前无可显示的策略页签(可在系统设置·显示与导航中开启)";
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
}
@@ -346,5 +365,5 @@
}
}
window.hubStrategyPage = { init, destroy };
window.hubStrategyPage = { init, destroy, reloadMeta };
})();
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env python3
"""Generate business-style XMind (Zen/2020+) from playbook + behavior rules."""
from __future__ import annotations
import json
import uuid
import zipfile
from pathlib import Path
from typing import Any, Optional
OUT = Path(__file__).resolve().parents[1] / "docs" / "交易执行手册与行为准则.xmind"
# 商务配色:深蓝主调 + 灰蓝辅色 + 强调色
C_ROOT = "#0F2942"
C_L1 = "#1B4F72"
C_L2 = "#2E86AB"
C_PASS = "#1E8449"
C_FAIL = "#922B21"
C_WARN = "#B9770E"
C_MUTED = "#566573"
C_TEXT = "#FFFFFF"
C_TEXT_DARK = "#1C2833"
def tid() -> str:
return uuid.uuid4().hex[:26]
def style(
*,
fill: Optional[str] = None,
color: Optional[str] = None,
font_size: str = "12pt",
bold: bool = False,
shape: str = "org.xmind.topicShape.roundedRect",
line: Optional[str] = None,
) -> dict[str, Any]:
props: dict[str, str] = {
"shape-class": shape,
"fo:font-family": "Microsoft YaHei",
"fo:font-size": font_size,
"fo:font-weight": "bold" if bold else "normal",
"border-line-width": "0pt",
"line-width": "1.5pt",
"line-class": "org.xmind.branchConnection.roundedelbow",
}
if fill:
props["svg:fill"] = fill
if color:
props["fo:color"] = color
if line:
props["line-color"] = line
return {"id": tid(), "properties": props}
def topic(
title: str,
children: list | None = None,
*,
markers: list[str] | None = None,
labels: list[str] | None = None,
notes: str | None = None,
fill: Optional[str] = None,
color: Optional[str] = None,
font_size: str = "12pt",
bold: bool = False,
line: Optional[str] = None,
) -> dict:
node: dict[str, Any] = {
"id": tid(),
"class": "topic",
"title": title,
"style": style(
fill=fill, color=color, font_size=font_size, bold=bold, line=line
),
}
if markers:
node["markers"] = [{"markerId": m} for m in markers]
if labels:
node["labels"] = labels
if notes:
node["notes"] = {"plain": {"content": notes}}
if children:
node["children"] = {"attached": children}
return node
def t1(title: str, children: list, markers: list[str], label: str) -> dict:
return topic(
title,
children,
markers=markers,
labels=[label],
fill=C_L1,
color=C_TEXT,
font_size="16pt",
bold=True,
line=C_L1,
)
def t2(title: str, children: list | None = None, markers: list[str] | None = None) -> dict:
return topic(
title,
children,
markers=markers or ["flag-dark-blue"],
fill=C_L2,
color=C_TEXT,
font_size="13pt",
bold=True,
line=C_L2,
)
def leaf(
title: str,
*,
markers: list[str] | None = None,
fill: Optional[str] = None,
color: Optional[str] = C_TEXT_DARK,
) -> dict:
return topic(
title,
markers=markers or ["symbol-right"],
fill=fill or "#EBF5FB",
color=color,
font_size="11pt",
line="#AED6F1",
)
def ok(title: str) -> dict:
return leaf(title, markers=["other-yes", "symbol-right"], fill="#E8F8F5", color=C_PASS)
def no(title: str) -> dict:
return leaf(title, markers=["other-no", "flag-gray"], fill="#FDEDEC", color=C_FAIL)
def warn(title: str) -> dict:
return leaf(title, markers=["symbol-info"], fill="#FEF9E7", color=C_WARN)
def build_content() -> list:
root = topic(
"交易执行体系\n手册 v2 · 开单三检",
[
t1(
"① 设计理念",
[
t2(
"核心主张",
[
leaf("少而精,珍惜机会,样本干净", markers=["star-dark-blue"]),
leaf("不保证收益;过程可控,结果随缘", markers=["symbol-info"]),
leaf("过滤比频率重要;日更不是目标", markers=["symbol-info"]),
leaf("看不懂不做;不为开单找理由", markers=["symbol-info"]),
warn("丢掉对冲:无「有保护就能多做」幻觉"),
],
markers=["other-lightbulb"],
),
t2(
"工具边界",
[
leaf("OKX 期权:方向单(虚值等)", markers=["flag-blue"]),
leaf("Gate 合约:结构清楚时的波段", markers=["flag-dark-blue"]),
leaf("同一时段尽量只让一边说话", markers=["symbol-equality"]),
no("不做期期对冲 / 偏置壳"),
],
markers=["symbol-info"],
),
t2(
"文档分工",
[
leaf("行为准则:能不能动手(防火墙)", markers=["other-lock"]),
leaf("执行手册:怎么做单(玩法/仓位/离场)", markers=["other-note"]),
],
markers=["other-businesscard"],
),
],
markers=["priority-1", "other-lightbulb"],
label="理念",
),
t1(
"② 资金要求",
[
t2(
"总盘约 800U",
[
leaf("单笔约 1.25% 量级", markers=["symbol-info"]),
leaf("全错一天约 2.5% 量级——防守优先", markers=["symbol-info"]),
],
markers=["other-businesscard"],
),
t2(
"单笔期权",
[
leaf("约 10U 权利金预算", markers=["priority-1"]),
leaf("一次只持有一个期权仓位", markers=["symbol-info"]),
leaf("打满 = min(余额, 单笔预算)", markers=["symbol-equality"]),
],
markers=["flag-blue"],
),
t2(
"Gate 合约",
[
leaf("日内保证金约 50U · 约 10 倍", markers=["symbol-info"]),
leaf("止损一般约 5U", markers=["symbol-info"]),
leaf("单笔最大亏损不超过约 10U", markers=["flag-gray"]),
leaf("有单才用保证金,无单为 0", markers=["task-done"]),
],
markers=["flag-dark-blue"],
),
t2(
"日损失心理框",
[
warn("都错:合计大约 ≤20U"),
ok("都对:期望可到 40U+(理想,非每日目标)"),
no("不为「好像有保护」放大仓位"),
warn("尽量少同向双开;双开按合计最坏约 20U"),
],
markers=["symbol-info"],
),
],
markers=["priority-2", "other-businesscard"],
label="资金",
),
t1(
"③ 操盘思路",
[
t2(
"行为准则 · 开单三检",
[
topic(
"一句话防火墙",
[
leaf("信号够不够清晰?", markers=["symbol-question"]),
leaf("流程有没有跑通?", markers=["symbol-question"]),
leaf("情绪是不是在证明自己?", markers=["symbol-question"]),
no("三检不过 → 不开"),
],
markers=["other-lock", "priority-1"],
fill="#154360",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["防火墙"],
),
topic(
"总循环",
[
leaf("信号判断 → 流程确认 → 情绪自检", markers=["arrow-right"]),
leaf("全部通过 → 开仓", markers=["other-yes"]),
leaf("等待系统结果(止盈/止损/到期)", markers=["other-clock"]),
leaf("复盘整环 → 等待下一信号", markers=["arrow-refresh"]),
no("任一步否决 → 空仓离开"),
],
markers=["arrow-right"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
),
topic(
"开单前三秒停顿",
[
leaf("核心信号是什么?", markers=["symbol-info"]),
leaf("安全流程跑通了吗?", markers=["task-start"]),
leaf("冷静执行,还是怕踏空/回本/证明自己?", markers=["symbol-info"]),
],
markers=["other-clock"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
),
topic(
"检1 · 信号判断",
[
ok("一句话说清唯一核心确认"),
ok("点位/结构本身已够清楚"),
no("说不清、靠宏观故事自圆"),
no("「好像有戏」但确认模糊"),
leaf("对照:1H→空间→结构→定损盈→工具", markers=["arrow-right"]),
],
markers=["priority-1", "symbol-info"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["Signal"],
),
topic(
"检2 · 流程确认",
[
ok("资金与当日额度符合"),
ok("单笔/组合敞口在预算内"),
no("资金或次数已触限"),
no("单笔或日最坏超限 → 暂停"),
no("「先开了再说」跳步"),
],
markers=["priority-2", "task-start"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["Process"],
),
topic(
"检3 · 情绪自检",
[
ok("符合系统 + 账户没问题 → 开"),
ok("可接受空仓,旁观者视角"),
no("怕踏空"),
no("上回亏了要回本"),
no("必须证明我是对的"),
warn("红灯亮了,信号再好看也不开"),
],
markers=["priority-3", "smiley-smile"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["Emotion"],
),
topic(
"复盘只记什么",
[
leaf("信号:是否做了?核心写了什么?", markers=["other-note"]),
leaf("流程:资金/敞口是否过关?有无跳步?", markers=["other-note"]),
leaf("情绪:当时是哪一类心态?", markers=["other-note"]),
warn("结果不推翻「三检是否完成」评分"),
],
markers=["other-note"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
),
],
markers=["other-lock", "flag-purple"],
),
t2(
"开仓逻辑",
[
topic(
"主链条(强制)",
[
leaf("1H 方向:明显 N 字;跟 1H 波段", markers=["priority-1"]),
leaf("空间:空看支撑、多看阻力;≥约 2%", markers=["priority-2"]),
leaf("结构:15m/5m;量级约 8h+(约 48×15m", markers=["priority-3"]),
leaf("定损盈:外沿/针尖;RR 须接受", markers=["priority-4"]),
leaf("选工具:期权 或 合约(不对冲)", markers=["priority-5"]),
no("任一步不过 → 空仓等待"),
],
markers=["arrow-right", "symbol-info"],
fill="#154360",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["主链"],
),
topic(
"结构形态参考",
[
leaf("收敛", markers=["flag-blue"]),
leaf("两段式回调", markers=["flag-dark-blue"]),
leaf("箱体", markers=["flag-gray"]),
leaf("假突破", markers=["flag-orange"]),
],
markers=["symbol-image"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
),
topic(
"期权入场",
[
ok("主链条全过;结构突破/假突破成立"),
leaf("一天期方向单;空间够优先虚值", markers=["star-blue"]),
leaf("默认先只开期权,不上合约", markers=["symbol-info"]),
leaf("尽量 16:00 后开次日到期", markers=["other-clock"]),
no("不做:期期对冲、偏置壳、为开而开"),
],
markers=["flag-blue", "symbol-plus"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["期权"],
),
topic(
"合约入场(Gate",
[
ok("主链条过关;位置极明确"),
leaf("想清进场:假突破 / 结构突破", markers=["symbol-info"]),
leaf("止损挂模型位(外沿/针尖)", markers=["symbol-info"]),
warn("独立假突破:只做合约或空仓"),
no("勿与「突破期权后再加仓」混仓"),
],
markers=["flag-dark-blue", "symbol-plus"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["合约"],
),
],
markers=["symbol-plus", "arrow-up-right"],
),
t2(
"平仓逻辑",
[
topic(
"期权离场",
[
ok("只认:系统/规则止盈"),
ok("只认:到期"),
no("开仓后中间不手动平仓"),
warn("紧急手平 → 标记非策略样本"),
],
markers=["flag-green", "symbol-minus"],
fill="#145A32",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["期权"],
),
topic(
"合约离场",
[
ok("结构止盈为准"),
ok("结构止损为准(约 5U 量级)"),
leaf("等待系统/挂单结果,不情绪手平", markers=["other-clock"]),
],
markers=["flag-dark-green", "symbol-minus"],
fill="#145A32",
color=C_TEXT,
font_size="12pt",
bold=True,
labels=["合约"],
),
topic(
"持仓期盯什么",
[
leaf("程序与纪律是否正常", markers=["task-done"]),
no("不是浮盈浮亏数字本身"),
leaf("无信号空档:空跑三检也是训练", markers=["other-lightbulb"]),
],
markers=["symbol-info"],
fill="#1A5276",
color=C_TEXT,
font_size="12pt",
bold=True,
),
],
markers=["symbol-minus", "flag-green"],
),
],
markers=["priority-3", "arrow-right"],
label="操盘",
),
t1(
"④ 纪律执行",
[
t2(
"Gate 日纪律",
[
leaf("只做很明确的位置", markers=["symbol-info"]),
leaf("同一位置最多两次机会(突破/假突破)", markers=["priority-2"]),
no("两次都错 → 当日不再做单"),
ok("离场以结构止盈/止损为准"),
],
markers=["flag-dark-blue", "task-done"],
),
t2(
"期权日纪律",
[
no("不手平;等规则止盈或到期"),
leaf("一次一仓;约 10U 权利金", markers=["symbol-info"]),
no("不对冲;不做每天默认开期权"),
leaf("损位跟模型:外沿/针尖", markers=["symbol-info"]),
],
markers=["flag-blue", "task-done"],
),
t2(
"开仓前自检清单",
[
leaf("今日只动期权/合约?未开对冲?", markers=["task-start"]),
leaf("1H 方向清楚(含 N 字)?", markers=["task-start"]),
leaf("空间足够?结构量级够?", markers=["task-start"]),
leaf("止损/止盈与 RR 定好?", markers=["task-start"]),
leaf("工具选期权还是合约?理由写清?", markers=["task-start"]),
leaf("合约:本位置第几次?今日两次用完?", markers=["task-start"]),
],
markers=["other-yes", "task-start"],
),
t2(
"一句话版本",
[
leaf("1H→空间→结构→定损盈→期权/合约", markers=["arrow-right"]),
leaf("不对冲;期权不手平", markers=["flag-gray"]),
leaf("一位置两次,错完收工", markers=["priority-2"]),
leaf("珍惜机会,日更不是目标", markers=["star-dark-blue"]),
],
markers=["star-dark-blue", "symbol-right"],
),
],
markers=["priority-4", "task-done"],
label="纪律",
),
],
# 中心主题保持干净:不加图标/标签/备注,避免绿人、黄便签等杂乱标识
markers=None,
labels=None,
fill=C_ROOT,
color=C_TEXT,
font_size="20pt",
bold=True,
line=C_ROOT,
)
root["structureClass"] = "org.xmind.ui.logic.right"
# XMind Zen 内置主题名;客户端可识别 business
sheet = {
"id": tid(),
"class": "sheet",
"title": "执行手册与行为准则 · 商务版",
"rootTopic": root,
"theme": {
"id": tid(),
"title": "business",
"centralTopic": {
"id": "centralTopic",
"properties": {
"svg:fill": C_ROOT,
"fo:color": C_TEXT,
"fo:font-family": "Microsoft YaHei",
"fo:font-size": "20pt",
"fo:font-weight": "bold",
"shape-class": "org.xmind.topicShape.roundedRect",
"line-color": C_L1,
"line-width": "2pt",
"line-class": "org.xmind.branchConnection.roundedelbow",
},
},
"mainTopic": {
"id": "mainTopic",
"properties": {
"svg:fill": C_L1,
"fo:color": C_TEXT,
"fo:font-family": "Microsoft YaHei",
"fo:font-size": "15pt",
"fo:font-weight": "bold",
"shape-class": "org.xmind.topicShape.roundedRect",
"line-color": C_L2,
"line-width": "1.5pt",
},
},
"subTopic": {
"id": "subTopic",
"properties": {
"svg:fill": C_L2,
"fo:color": C_TEXT,
"fo:font-family": "Microsoft YaHei",
"fo:font-size": "12pt",
"shape-class": "org.xmind.topicShape.roundedRect",
"line-color": "#85C1E9",
},
},
"floatingTopic": {
"id": "floatingTopic",
"properties": {
"svg:fill": C_MUTED,
"fo:color": C_TEXT,
"fo:font-family": "Microsoft YaHei",
},
},
"importantTopic": {
"id": "importantTopic",
"properties": {
"svg:fill": C_WARN,
"fo:color": C_TEXT,
},
},
"minorTopic": {
"id": "minorTopic",
"properties": {
"svg:fill": "#EBF5FB",
"fo:color": C_TEXT_DARK,
},
},
"expiredTopic": {
"id": "expiredTopic",
"properties": {
"svg:fill": "#D5D8DC",
"fo:color": C_MUTED,
},
},
"calloutTopic": {
"id": "calloutTopic",
"properties": {
"svg:fill": "#FEF9E7",
"fo:color": C_WARN,
},
},
"summaryTopic": {
"id": "summaryTopic",
"properties": {
"svg:fill": "#145A32",
"fo:color": C_TEXT,
},
},
"boundary": {
"id": "boundary",
"properties": {
"svg:fill": "#D6EAF8",
"fo:color": C_L1,
"line-color": C_L2,
},
},
"summary": {
"id": "summary",
"properties": {
"line-color": C_L1,
"line-width": "2pt",
},
},
"relationship": {
"id": "relationship",
"properties": {
"line-color": C_MUTED,
"line-pattern": "dash",
},
},
"map": {
"id": "map",
"properties": {
"svg:fill": "#F4F6F7",
"color-list": f"{C_L1} {C_L2} #2874A6 #1ABC9C #B9770E",
"line-tapered": "none",
},
},
},
}
return [sheet]
def main() -> None:
content = build_content()
metadata = {
"creator": {"name": "crypto_monitor", "version": "1.1"},
"activeSheetId": content[0]["id"],
}
manifest = {
"file-entries": {
"content.json": {},
"metadata.json": {},
"manifest.json": {},
}
}
OUT.parent.mkdir(parents=True, exist_ok=True)
if OUT.exists():
OUT.unlink()
with zipfile.ZipFile(OUT, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("content.json", json.dumps(content, ensure_ascii=False, indent=2))
zf.writestr("metadata.json", json.dumps(metadata, ensure_ascii=False, indent=2))
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
print(f"wrote {OUT}")
if __name__ == "__main__":
main()
+37
View File
@@ -20,6 +20,7 @@ from lib.trade.account_risk_lib import (
enrich_risk_status_countdown,
ensure_account_risk_schema,
max_active_positions_from_env,
on_closed_trade_pnl,
on_journal_saved,
on_manual_close,
on_user_initiated_close,
@@ -58,6 +59,7 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
os.environ["RISK_DAILY_LOSS_LIMIT"] = "2"
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
os.environ["APP_TIMEZONE"] = "Asia/Shanghai"
@@ -521,6 +523,41 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["MAX_ACTIVE_POSITIONS"] = "3"
self.assertEqual(max_active_positions_from_env(), 3)
def test_daily_loss_limit_freezes_on_second_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-1.5, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 1)
self.assertEqual(st["status"], STATUS_NORMAL)
on_closed_trade_pnl(conn, pnl_amount=-0.2, trading_day="2026-06-14", now=now)
st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st2["daily_loss_count"], 2)
self.assertEqual(st2["status"], STATUS_DAILY)
self.assertFalse(st2["can_trade"])
self.assertIn("日亏损", st2["reason"])
def test_daily_loss_limit_zero_disables(self):
os.environ["RISK_DAILY_LOSS_LIMIT"] = "0"
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["daily_loss_limit"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
def test_profitable_close_does_not_count_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=3.2, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=0, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
if __name__ == "__main__":
unittest.main()
+329
View File
@@ -0,0 +1,329 @@
"""振幅统计核心逻辑单元测试(不打交易所)."""
from __future__ import annotations
import unittest
from datetime import date, datetime
from zoneinfo import ZoneInfo
from lib.hub.amp_stats_lib import (
build_export_csv,
compute_amp_stats,
compute_day_row,
list_settlement_dates,
summarize_rows,
window_bounds_for_settlement,
)
import httpx
TZ = ZoneInfo("Asia/Shanghai")
def _bar(ts_ms: int, o: float, h: float, l: float, c: float) -> dict:
return {"ts": ts_ms, "o": o, "h": h, "l": l, "c": c}
class AmpStatsLibTests(unittest.TestCase):
def test_window_cross_day_22_to_16(self):
start, end = window_bounds_for_settlement(date(2026, 7, 22), 22)
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-21 22:00")
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
def test_window_same_day_8_to_16(self):
start, end = window_bounds_for_settlement(date(2026, 7, 22), 8)
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-22 08:00")
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
def test_settlement_excludes_incomplete_today(self):
now = datetime(2026, 7, 22, 10, 0, tzinfo=TZ)
days = list_settlement_dates(sample_days=3, now=now)
self.assertEqual(days[0].isoformat(), "2026-07-21")
self.assertEqual(len(days), 3)
def test_settlement_includes_today_after_1600(self):
now = datetime(2026, 7, 22, 16, 0, tzinfo=TZ)
days = list_settlement_dates(sample_days=1, now=now)
self.assertEqual(days[0].isoformat(), "2026-07-22")
def test_day_row_points(self):
# 22:00 D-1 → 16:00 D; O=2000 H=2500 L=1800 C=2100 → up500 down200 amp700
settlement = date(2026, 7, 22)
start, end = window_bounds_for_settlement(settlement, 22)
bar_map = {}
t = int(start.timestamp() * 1000)
last = int((end.replace(hour=15)).timestamp() * 1000)
# first bar
bar_map[t] = {"o": 2000.0, "h": 2100.0, "l": 1950.0, "c": 2050.0}
cur = t + 3600 * 1000
while cur < last:
bar_map[cur] = {"o": 2050.0, "h": 2200.0, "l": 1900.0, "c": 2100.0}
cur += 3600 * 1000
# peak and trough somewhere
mid = t + 5 * 3600 * 1000
bar_map[mid] = {"o": 2100.0, "h": 2500.0, "l": 1800.0, "c": 2000.0}
bar_map[last] = {"o": 2000.0, "h": 2150.0, "l": 1990.0, "c": 2100.0}
# fill any missing hours with flat
cur = t
while cur <= last:
if cur not in bar_map:
bar_map[cur] = {"o": 2000.0, "h": 2000.0, "l": 2000.0, "c": 2000.0}
cur += 3600 * 1000
row = compute_day_row(settlement, 22, bar_map)
self.assertIsNotNone(row)
self.assertEqual(row["open"], 2000.0)
self.assertEqual(row["high"], 2500.0)
self.assertEqual(row["low"], 1800.0)
self.assertEqual(row["up_points"], 500.0)
self.assertEqual(row["down_points"], 200.0)
self.assertEqual(row["amplitude"], 700.0)
self.assertEqual(row["change"], 100.0)
def test_summary_max_amplitude(self):
rows = [
{"amplitude": 100, "up_points": 40, "down_points": 60, "change": 10, "settlement_day": "2026-07-01"},
{"amplitude": 700, "up_points": 500, "down_points": 200, "change": -5, "settlement_day": "2026-07-02"},
{"amplitude": 200, "up_points": 50, "down_points": 150, "change": 20, "settlement_day": "2026-07-03"},
]
s = summarize_rows(rows)
self.assertEqual(s["max_amplitude"], 700)
self.assertEqual(s["max_amplitude_day"], "2026-07-02")
self.assertEqual(s["max_up_points"], 500)
self.assertEqual(s["max_down_points"], 200)
self.assertIsNone(s["straddle"])
def test_long_straddle_stats(self):
rows = [
# |chg|=40>30 win+10; up=40>30; down=10
{"up_points": 40, "down_points": 10, "change": 40, "amplitude": 50, "settlement_day": "2026-07-01"},
# |chg|=10 lose-20; up=5; down=35>30
{"up_points": 5, "down_points": 35, "change": -10, "amplitude": 40, "settlement_day": "2026-07-02"},
# |chg|=30 not >30 lose-30; boundary
{"up_points": 30, "down_points": 30, "change": 30, "amplitude": 60, "settlement_day": "2026-07-03"},
]
s = summarize_rows(rows, straddle_premium=30)
st = s["straddle"]
self.assertEqual(st["side"], "long_straddle")
self.assertEqual(st["premium"], 30)
self.assertEqual(st["up_exceed_days"], 1) # only 40
self.assertEqual(st["down_exceed_days"], 1) # only 35
self.assertEqual(st["abs_change_exceed_days"], 1) # only 40
self.assertAlmostEqual(st["pnl_total"], 40 - 30 + 10 - 30 + 30 - 30)
self.assertEqual(st["win_days"], 1)
self.assertEqual(st["win_ratio"], round(1 / 3, 4))
csv_text = build_export_csv(
{"exchange": "okx", "symbol_label": "ETH", "summary": s, "rows": rows, "start_hour": 22, "end_hour": 16}
)
self.assertIn("买跨对照", csv_text)
self.assertIn("买跨点数盈亏合计", csv_text)
def test_take_profit_and_weekend(self):
from lib.hub.amp_stats_lib import (
enrich_rows_pnl,
filter_weekend_rows,
reframe_amp_stats,
)
# Sat 2026-07-18, Sun 2026-07-19, Mon 2026-07-20
rows = [
{
"settlement_day": "2026-07-18",
"is_weekend": True,
"weekday_label": "",
"up_points": 100,
"down_points": 10,
"change": -5,
"amplitude": 110,
},
{
"settlement_day": "2026-07-19",
"is_weekend": True,
"weekday_label": "",
"up_points": 20,
"down_points": 15,
"change": 12,
"amplitude": 35,
},
{
"settlement_day": "2026-07-20",
"is_weekend": False,
"weekday_label": "",
"up_points": 50,
"down_points": 40,
"change": 8,
"amplitude": 90,
},
]
excl = filter_weekend_rows(rows, "exclude")
self.assertEqual(len(excl), 1)
self.assertEqual(excl[0]["settlement_day"], "2026-07-20")
only = filter_weekend_rows(rows, "only")
self.assertEqual(len(only), 2)
# TP=80: day1 hit → move 80; day2 no → |12|; day3 no → 8
enriched = enrich_rows_pnl(rows, straddle_premium=10, take_profit=80)
self.assertTrue(enriched[0]["take_profit_hit"])
self.assertEqual(enriched[0]["effective_move"], 80)
self.assertEqual(enriched[0]["profit"], 70)
self.assertFalse(enriched[1]["take_profit_hit"])
self.assertEqual(enriched[1]["effective_move"], 12)
self.assertEqual(enriched[1]["profit"], 2)
# TP empty → use |change|
no_tp = enrich_rows_pnl(rows[:1], straddle_premium=10, take_profit=None)
self.assertEqual(no_tp[0]["effective_move"], 5)
self.assertEqual(no_tp[0]["profit"], -5)
# TP boundary >= : up=80 counts as hit
edge = enrich_rows_pnl(
[{"up_points": 80, "down_points": 1, "change": 2, "settlement_day": "2026-07-20", "is_weekend": False}],
straddle_premium=10,
take_profit=80,
)
self.assertTrue(edge[0]["take_profit_hit"])
self.assertEqual(edge[0]["profit"], 70)
reframed = reframe_amp_stats(
rows_all=rows,
symbol="eth",
weekend_filter="exclude",
straddle_premium=10,
take_profit=80,
)
self.assertEqual(reframed["summary"]["sample_count"], 1)
# Mon: 未触达止盈 → |8|-10
self.assertEqual(reframed["rows"][0]["profit"], -2)
self.assertIn("收益", build_export_csv(reframed))
def test_fetch_switches_to_history_endpoint(self):
"""近期接口到头后应切 history 续拉."""
from lib.hub.amp_stats_lib import fetch_okx_candles
calls: list[str] = []
class FakeResp:
def __init__(self, data, url="https://x", status_code=200):
self._data = data
self.status_code = status_code
self.url = url
self.request = httpx.Request("GET", url)
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPStatusError(
"err", request=self.request, response=self
)
def json(self):
return {"code": "0", "data": self._data}
class FakeClient:
def get(self, url, params=None):
calls.append(url)
after = (params or {}).get("after")
# recent: only 2 pages then empty; history continues
if "history" not in url:
if after is None:
return FakeResp([["2000", "1", "2", "0.5", "1.5"], ["1900", "1", "2", "0.5", "1.5"]], url=url)
if after == "1900":
return FakeResp([], url=url) # recent exhausted
return FakeResp([], url=url)
# history
if after == "1900":
return FakeResp([["1800", "1", "2", "0.5", "1.5"], ["1000", "1", "2", "0.5", "1.5"]], url=url)
return FakeResp([], url=url)
def close(self):
return None
bars = fetch_okx_candles(
url="https://www.okx.com/api/v5/market/index-candles",
history_url="https://www.okx.com/api/v5/market/history-index-candles",
inst_id="ETH-USD",
since_ms=1000,
until_ms=3000,
client=FakeClient(),
max_pages=10,
page_pause_sec=0,
history_page_pause_sec=0,
)
self.assertTrue(any("history-index-candles" in u for u in calls))
self.assertGreaterEqual(len(bars), 3)
self.assertEqual(bars[0]["ts"], 1000)
def test_fetch_retries_on_429(self):
from lib.hub.amp_stats_lib import fetch_okx_candles
import httpx as _httpx
hits = {"n": 0}
class FakeResp:
def __init__(self, status_code, data=None):
self.status_code = status_code
self.url = "https://www.okx.com/api/v5/market/history-candles"
self.request = _httpx.Request("GET", self.url)
self._data = data or []
def raise_for_status(self):
if self.status_code >= 400:
raise _httpx.HTTPStatusError("429", request=self.request, response=self)
def json(self):
return {"code": "0", "data": self._data}
class FakeClient:
def get(self, url, params=None):
hits["n"] += 1
if hits["n"] < 3:
return FakeResp(429)
return FakeResp(200, [["1000", "1", "2", "0.5", "1.5"]])
def close(self):
return None
bars = fetch_okx_candles(
url="https://www.okx.com/api/v5/market/candles",
history_url=None,
inst_id="ETH-USDT-SWAP",
since_ms=1000,
until_ms=2000,
client=FakeClient(),
max_pages=3,
page_pause_sec=0,
history_page_pause_sec=0,
)
self.assertGreaterEqual(hits["n"], 3)
self.assertEqual(len(bars), 1)
def test_compute_with_mock_fetch(self):
now = datetime(2026, 7, 22, 18, 0, tzinfo=TZ)
def fetch_fn(*, inst_id, since_ms, until_ms):
bars = []
t = since_ms - (since_ms % (3600 * 1000))
while t <= until_ms:
# synthetic: open 2000, one spike day
o = 2000.0
h = 2500.0 if t == since_ms + 5 * 3600 * 1000 else 2050.0
l = 1800.0 if t == since_ms + 5 * 3600 * 1000 else 1950.0
c = 2020.0
bars.append(_bar(t, o, h, l, c))
t += 3600 * 1000
return bars
result = compute_amp_stats(
symbol="eth",
start_hour=16,
period="custom",
custom_days=7,
now=now,
fetch_fn=fetch_fn,
)
self.assertTrue(result["ok"])
self.assertEqual(result["exchange"], "okx")
self.assertGreaterEqual(result["summary"]["sample_count"], 1)
csv_text = build_export_csv(result)
self.assertIn("最大振幅", csv_text)
self.assertIn("日表明细", csv_text)
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
"""hub_ai:期权持仓进教练上下文 + 执行手册摘要."""
from __future__ import annotations
import unittest
from hub_ai.context import (
format_chat_context_for_chat,
format_chat_context_slim,
format_chat_position_overview,
)
from hub_ai.playbook_brief import format_playbook_brief_for_chat
from hub_ai.prompts import build_chat_user_prompt
def _sample_payload():
return {
"totals": {
"trading_day": "2026-07-22",
"total_pnl_u": 0,
"closed_count": 0,
"win_count": 0,
"loss_count": 0,
"float_pnl_u": 1.25,
"open_position_count": 1,
"options_open_position_count": 1,
"perpetual_open_position_count": 0,
},
"accounts": [
{
"name": "OKX_趋势",
"key": "okx",
"status": "已监控",
"open_position_count": 1,
"options_open_position_count": 1,
"float_pnl_u": 1.25,
"funding_usdt": 100,
"trading_usdt": 50,
"trade_stats": {"total_pnl_u": 0, "closed_count": 0, "win_count": 0, "loss_count": 0},
"positions": [],
"trades": [],
"monitor_lines": {},
"options_snapshot": {
"ok": True,
"enabled": True,
"positions": [
{
"inst_id": "ETH-USD-260723-3500-C",
"opt_type": "C",
"pos": 1,
"premium_paid": 8.5,
"source_label": "纯期权",
"source": "option",
"upl": 1.25,
"target_monitor_text": "目标 3600",
}
],
},
}
],
}
class HubAiOptionsPlaybookTests(unittest.TestCase):
def test_chat_slim_includes_options_line(self):
text = format_chat_context_slim(_sample_payload())
self.assertIn("期权 ETH-USD-260723-3500-C Call", text)
self.assertIn("永续0/期权1", text)
self.assertIn("权利金8.5U", text)
def test_overview_lists_options(self):
text = format_chat_position_overview(_sample_payload())
self.assertIn("期权1", text)
self.assertIn("ETH-USD-260723-3500-C", text)
def test_chat_bundle_keeps_options(self):
text = format_chat_context_for_chat(_sample_payload(), max_chars=8000)
self.assertIn("ETH-USD-260723-3500-C", text)
self.assertIn("期权", text)
def test_playbook_brief_injected(self):
brief = format_playbook_brief_for_chat()
self.assertIn("执行手册", brief)
self.assertIn("OKX", brief)
self.assertIn("不手动平仓", brief)
prompt = build_chat_user_prompt(
context_text="快照",
trading_day="2026-07-22",
summary_excerpt="",
user_message="今天怎么样",
playbook_brief=brief,
)
self.assertIn("用户策略执行手册", prompt)
self.assertIn("不手动平仓", prompt)
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
"""策略对比仓位与情景测算."""
from __future__ import annotations
from lib.hub.hub_compare_lib import run_compare
def test_long_eth_realistic_asks():
out = run_compare(
{
"base": "ETH",
"direction": "long",
"entry": 3500,
"sl": 3400,
"tp": 3700,
"risk_u": 10,
"option": {"opt_type": "C", "strike": 3600, "ask": 50},
"hedge": {
"main": {"opt_type": "C", "strike": 3600, "ask": 50},
"side": {"opt_type": "P", "strike": 3400, "ask": 30},
},
}
)
assert out["ok"] is True
perp = out["perp"]
# 每张止损 = 100 * 0.01 = 1U → 10 张
assert perp["sheets"] == 10
assert abs(perp["path_b_sl"] + 10) < 1e-6
assert perp["path_a_tp"] > 0
assert perp["path_c_realized"] == perp["path_b_sl"]
assert perp["path_c_missed"] == perp["path_a_tp"]
opt = out["option"]
assert opt["ok"] is True
# unit = 50 * 0.01 = 0.5U → 20 张, premium = 10
assert opt["sheets"] == 20
assert abs(opt["premium_u"] - 10) < 1e-6
assert abs(opt["path_b_worst"] + 10) < 1e-6
# at TP 3700, call 3600 intrinsic = 100 * 20 * 0.01 = 20, pnl = 20-10 = 10
assert abs(opt["path_a_tp"] - 10) < 1e-6
assert abs(opt["path_c_hold_to_tp"] - opt["path_a_tp"]) < 1e-6
hedge = out["hedge"]
assert hedge["ok"] is True
# main budget 7, unit 0.5 → 14 sheets; side budget 3, unit 0.3 → 10 sheets
assert hedge["main"]["sheets"] == 14
assert hedge["side"]["sheets"] == 10
assert out["recommend"]["choice"] in ("合约", "单期权", "期期对冲")
def test_short_validation():
bad = run_compare(
{
"base": "ETH",
"direction": "short",
"entry": 3500,
"sl": 3400,
"tp": 3300,
"risk_u": 10,
}
)
assert bad["ok"] is False
def test_recommend_has_bullets():
out = run_compare(
{
"base": "ETH",
"direction": "long",
"entry": 3500,
"sl": 3490,
"tp": 3520,
"risk_u": 10,
"option": {"opt_type": "C", "strike": 3500, "ask": 20},
"hedge": {
"main": {"opt_type": "C", "strike": 3500, "ask": 20},
"side": {"opt_type": "P", "strike": 3480, "ask": 15},
},
}
)
assert out["ok"] is True
assert out["recommend"]["choice"]
assert len(out["recommend"]["bullets"]) == 3
+69
View File
@@ -0,0 +1,69 @@
"""期权档案缓存 upsert / 列表 / 日历."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from lib.hub.hub_options_archive_lib import (
init_options_archive_db,
list_archive_options_calendar,
list_daily_options_trades,
upsert_options_trades_cache,
)
class TestHubOptionsArchive(unittest.TestCase):
def setUp(self) -> None:
self._td = tempfile.TemporaryDirectory()
self.db = Path(self._td.name) / "hub_symbol_archive.db"
init_options_archive_db(self.db)
def tearDown(self) -> None:
self._td.cleanup()
def test_upsert_and_list_daily(self) -> None:
trades = [
{
"history_key": "local_opt:1",
"source_type": "option_spot",
"source_label": "纯期权",
"underlying": "ETH",
"inst_id": "ETH-USD-250725-3200-C",
"opt_type": "C",
"opened_at": "2026-07-20 10:00:00",
"closed_at": "2026-07-20 16:00:00",
"hold_seconds": 21600,
"realized_pnl_total": 12.5,
"premium_paid": 8.0,
"reviewed": True,
"strategy_tag": "假突破",
},
{
"history_key": "local_opt:2",
"source_type": "option_spot",
"underlying": "ETH",
"opened_at": "2026-07-19 10:00:00",
"closed_at": "2026-07-19 12:00:00",
"realized_pnl_total": -3.0,
"excluded_as_hedge_leg": 1,
},
]
r = upsert_options_trades_cache("okx", trades, db_path=self.db)
self.assertEqual(r["upserted"], 1)
payload = list_daily_options_trades(
"2026-07-20",
period="today",
db_path=self.db,
)
self.assertEqual(len(payload["trades"]), 1)
self.assertEqual(payload["trades"][0]["history_key"], "local_opt:1")
self.assertAlmostEqual(payload["stats"]["pnl_total"], 12.5)
cal = list_archive_options_calendar(2026, 7, db_path=self.db)
self.assertIn("2026-07-20", cal["days"])
self.assertEqual(cal["days"]["2026-07-20"]["open_count"], 1)
if __name__ == "__main__":
unittest.main()
+136
View File
@@ -0,0 +1,136 @@
"""hub_perp_options_calc_lib 永期对冲测算."""
import unittest
from lib.hub.hub_perp_options_calc_lib import (
calc_perp_options,
calc_perp_options_hedge,
calc_perp_options_points,
)
class HubPerpOptionsCalcTests(unittest.TestCase):
def test_example_1800_50_15_100(self):
data, err = calc_perp_options_hedge(
base="ETH",
spot=1800,
capital_usdt=3000,
target_profit_u=15,
move_mode="points",
move_value=50,
perp_leverage=10,
option_leverage=100,
ct_mult=0.01,
)
self.assertIsNone(err)
assert data is not None
self.assertEqual(data["prem_per_coin"], 18.0)
self.assertEqual(data["perp_gross_u"], 50.0)
# fee = (1800+1850)*0.0005 = 1.825
self.assertAlmostEqual(data["perp_fee_u"], 1.825, places=6)
self.assertAlmostEqual(data["premium_budget_u"], 33.175, places=6)
self.assertAlmostEqual(data["opt_coins"], 33.175 / 18.0, places=6)
self.assertAlmostEqual(data["opt_sheets"], data["opt_coins"] / 0.01, places=4)
self.assertAlmostEqual(data["case_a"]["net_u"], 15.0, places=6)
# B: opt_net = coins*50 - coins*18 = coins*32; portfolio = opt_net - 50
coins = data["opt_coins"]
self.assertAlmostEqual(data["case_b"]["opt_net_u"], coins * 32.0, places=6)
self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], coins * 32.0 - 50.0, places=6)
self.assertAlmostEqual(data["perp_margin_u"], 180.0, places=6)
self.assertTrue(data["capital_ok"])
def test_pct_mode(self):
data, err = calc_perp_options_hedge(
base="BTC",
spot=100000,
capital_usdt=5000,
target_profit_u=100,
move_mode="pct",
move_value=1,
perp_leverage=5,
option_leverage=50,
ct_mult=0.01,
)
self.assertIsNone(err)
assert data is not None
self.assertAlmostEqual(data["move_points"], 1000.0, places=6)
self.assertAlmostEqual(data["perp_gross_u"], 1000.0, places=6)
self.assertAlmostEqual(data["prem_per_coin"], 2000.0, places=6)
def test_budget_too_small(self):
data, err = calc_perp_options_hedge(
base="ETH",
spot=1800,
capital_usdt=3000,
target_profit_u=60,
move_mode="points",
move_value=50,
perp_leverage=10,
option_leverage=100,
)
self.assertIsNone(data)
self.assertIn("无法开期权", err or "")
def test_bad_base(self):
data, err = calc_perp_options_hedge(
base="SOL",
spot=100,
capital_usdt=1000,
target_profit_u=10,
move_mode="points",
move_value=5,
perp_leverage=5,
option_leverage=20,
)
self.assertIsNone(data)
self.assertIsNotNone(err)
def test_points_ratio_1_to_2(self):
# spot=1800, optLev=100 → prem/coin=18; ratio 1:2 → opt=2, premium=36
# A: move = (15+36+2*1800*0.0005)/(1-0.0005) = (51+1.8)/0.9995
data, err = calc_perp_options_points(
base="ETH",
spot=1800,
capital_usdt=3000,
target_profit_u=15,
perp_leverage=10,
option_leverage=100,
ratio_perp=1,
ratio_opt=2,
ct_mult=0.01,
)
self.assertIsNone(err)
assert data is not None
self.assertEqual(data["calc_mode"], "points")
self.assertEqual(data["opt_coins"], 2.0)
self.assertEqual(data["premium_total_u"], 36.0)
expect_a = (15 + 36 + 2 * 1800 * 0.0005) / 0.9995
self.assertAlmostEqual(data["case_a"]["move_points"], expect_a, places=6)
self.assertAlmostEqual(data["case_a"]["net_u"], 15.0, places=5)
# 期权净利=目标: (15+36)/2 = 25.5
self.assertAlmostEqual(data["case_b"]["move_points_opt_net"], 25.5, places=6)
self.assertAlmostEqual(data["case_b"]["opt_net_u"], 15.0, places=6)
# 组合净利=目标: (15+36)/(2-1) = 51
self.assertAlmostEqual(data["case_b"]["move_points_portfolio"], 51.0, places=6)
self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], 15.0, places=6)
def test_points_ratio_1_to_1_no_portfolio(self):
data, err = calc_perp_options(
calc_mode="points",
base="ETH",
spot=1800,
capital_usdt=3000,
target_profit_u=15,
perp_leverage=10,
option_leverage=100,
ratio_perp=1,
ratio_opt=1,
)
self.assertIsNone(err)
assert data is not None
self.assertIsNone(data["case_b"]["move_points_portfolio"])
self.assertIsNotNone(data["case_b"]["portfolio_error"])
if __name__ == "__main__":
unittest.main()
+11 -2
View File
@@ -12,10 +12,19 @@ from lib.hub.hub_strategy_lib import (
class TestHubStrategyLib(unittest.TestCase):
def test_meta_has_three_exchanges(self):
def test_meta_has_playbook_and_exchanges(self):
meta = strategy_meta_payload()
keys = [x["key"] for x in meta["exchanges"]]
self.assertEqual(keys, ["binance", "okx", "gate"])
self.assertEqual(keys, ["playbook", "binance", "okx", "gate"])
def test_load_playbook_payload(self):
p = load_strategy_payload("playbook")
self.assertTrue(p["ok"])
self.assertEqual(p["label"], "执行手册")
self.assertIn("交易执行手册", p["md_source"])
self.assertIn("strategy_html", p)
self.assertIn("<h2", p["strategy_html"].lower())
self.assertIn("总原则", p["strategy_html"])
def test_load_binance_payload(self):
p = load_strategy_payload("binance")
@@ -21,6 +21,19 @@ class TestInstanceDisplayPrefs(unittest.TestCase):
prefs = normalize_display_prefs({"show_nav_stats": False})
self.assertFalse(tab_allowed("stats", prefs))
self.assertTrue(tab_allowed("trade", prefs))
self.assertTrue(tab_allowed("key_monitor", prefs))
def test_key_monitor_and_trade_nav_can_hide(self):
prefs = normalize_display_prefs(
{"show_nav_key_monitor": False, "show_nav_trade": False}
)
self.assertFalse(tab_allowed("key_monitor", prefs))
self.assertFalse(tab_allowed("trade", prefs))
on = normalize_display_prefs({})
self.assertTrue(on["show_nav_key_monitor"])
self.assertTrue(on["show_nav_trade"])
self.assertTrue(tab_allowed("key_monitor", on))
self.assertTrue(tab_allowed("trade", on))
def test_dashboard_nav_default_off(self):
prefs = normalize_display_prefs({})
+16
View File
@@ -0,0 +1,16 @@
"""按可用余额打满:min(余额, 单笔预算)."""
from __future__ import annotations
from lib.options.options_pricing_lib import resolve_budget_full_usdc
def test_balance_above_budget_uses_budget():
assert resolve_budget_full_usdc(100.0, 10.0) == 10.0
def test_balance_below_budget_uses_balance():
assert resolve_budget_full_usdc(5.0, 10.0) == 5.0
def test_balance_equals_budget():
assert resolve_budget_full_usdc(10.0, 10.0) == 10.0
+48
View File
@@ -0,0 +1,48 @@
"""期权开平仓微信文案."""
from __future__ import annotations
import unittest
from lib.options.options_notify_lib import (
build_options_close_message,
build_options_open_message,
)
class TestOptionsNotify(unittest.TestCase):
def test_open_close_messages(self) -> None:
open_msg = build_options_open_message(
account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C",
underlying="ETH",
opt_type="C",
sheets=2,
premium_paid=8.5,
open_quote=0.01,
target_index=3400,
signal_note="假突破",
trade_id=12,
)
self.assertIn("【OKX期权·开仓】", open_msg)
self.assertIn("ETH-USD-250725-3200-C", open_msg)
self.assertIn("目标指数:3400", open_msg)
close_msg = build_options_close_message(
account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C",
reason="手动平仓",
underlying="ETH",
opt_type="C",
sheets=2,
premium_paid=8.5,
premium_received=12.0,
realized_pnl=3.5,
)
self.assertIn("【OKX期权·平仓】", close_msg)
self.assertIn("手动平仓", close_msg)
self.assertIn("3.5000", close_msg)
if __name__ == "__main__":
unittest.main()