44 Commits

Author SHA1 Message Date
dekun 644dcdf092 Rename points-mode label to absolute coin counts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:57:50 +08:00
dekun d89aff3ad6 Use absolute coin counts in perp-options points mode.
Treat 2:4 as 2 perp + 4 option coins instead of normalizing to 1:2, and disable embed page caching so hub iframe picks up trade UI updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:54:16 +08:00
dekun 9f3395de2f Add sideways max-loss to perpetual-options calculator.
Show premium wipeout plus flat round-trip perp fees as case C for both size and points modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:38:20 +08:00
dekun 28a329cb63 Restructure live order form into labeled rows for clearer hierarchy.
Group policy selects, SL/RR fields, options, and submit so the unlabeled RR input and scrambled checkbox/price row no longer fight for space.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:24:17 +08:00
dekun 1c2c012dd7 Align snapshot/20260728-2 hash with tag target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:41:53 +08:00
dekun 05864d72c2 Fix snapshot tag commit hash in docs table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:40:44 +08:00
dekun 722c511543 Document snapshot/20260728-2 after amp-stats move-points and two-day amp.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:40:43 +08:00
dekun 26bc19f047 Add two-day amplitude window to amp-stats.
For each settlement day, also compute H-L over start minus one day through 16:00 (e.g. 25 16:00 to 27 16:00).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:05:38 +08:00
dekun c81ba147cc Replace amp-stats straddle/perp overlays with move-points amplitude ratio.
Input points now drives amplitude hit share; table keeps both-side moves and amp达标.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:56:48 +08:00
dekun 90be23e845 Fix amp-stats perp PnL to exit at daily profit target.
Hit A/B via open-to-high/low; day PnL equals target when touched, otherwise settle at close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:41:29 +08:00
dekun 2ce67da8e8 Use daily open as perp-hedge entry and toggle buy-straddle vs perp overlays.
Amp-stats now prices premium from each day's open, and the form switches mutually between straddle and perpetual-options对照.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:31:17 +08:00
dekun d049c5d317 Add perpetual-options hedge overlay to amp-stats with hit rates and daily PnL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:20:42 +08:00
dekun 845884fc67 Document perpetual-options hedge calculator and snapshot/20260728.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:08:25 +08:00
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
44 changed files with 4387 additions and 1172 deletions
+3
View File
@@ -3,5 +3,8 @@
deploy/** text eol=lf deploy/** text eol=lf
# 文档统一 LF,避免 Windows 编辑后产生 CRLF 脏 diff # 文档统一 LF,避免 Windows 编辑后产生 CRLF 脏 diff
docs/** text eol=lf 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 模板统一 LF,避免 Linux PM2 source 报 $'\r': command not found
**/.env.example text eol=lf **/.env.example text eol=lf
+3 -3
View File
@@ -411,7 +411,7 @@ _APP_STARTED_AT = time.time()
_RECONCILE_FLAT_STREAK = {} _RECONCILE_FLAT_STREAK = {}
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) 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")) UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
@@ -9870,7 +9870,7 @@ def manual_transfer():
amount = float(request.form.get("amount", "0")) amount = float(request.form.get("amount", "0"))
except Exception: except Exception:
flash("划转金额格式错误") flash("划转金额格式错误")
return redirect("/settings") return redirect("/settings?settings_tab=transfer")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
@@ -9885,7 +9885,7 @@ def manual_transfer():
flash(f"手动划转成功:{amount}U {from_account}->{to_account}") flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else: else:
flash(f"手动划转失败:{msg}") flash(f"手动划转失败:{msg}")
return redirect("/settings") return redirect("/settings?settings_tab=transfer")
def _journal_ai_chart_builder(row): def _journal_ai_chart_builder(row):
+3 -3
View File
@@ -404,7 +404,7 @@ KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
_APP_STARTED_AT = time.time() _APP_STARTED_AT = time.time()
_RECONCILE_FLAT_STREAK = {} _RECONCILE_FLAT_STREAK = {}
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) 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")) UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
@@ -9727,7 +9727,7 @@ def manual_transfer():
amount = float(request.form.get("amount", "0")) amount = float(request.form.get("amount", "0"))
except Exception: except Exception:
flash("划转金额格式错误") flash("划转金额格式错误")
return redirect("/settings") return redirect("/settings?settings_tab=transfer")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
@@ -9742,7 +9742,7 @@ def manual_transfer():
flash(f"手动划转成功:{amount}U {from_account}->{to_account}") flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else: else:
flash(f"手动划转失败:{msg}") flash(f"手动划转失败:{msg}")
return redirect("/settings") return redirect("/settings?settings_tab=transfer")
def _journal_ai_chart_builder(row): def _journal_ai_chart_builder(row):
+3 -3
View File
@@ -384,7 +384,7 @@ BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(
_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {} _BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {}
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) 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") 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_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")))) EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
@@ -9455,7 +9455,7 @@ def manual_transfer():
amount = float(request.form.get("amount", "0")) amount = float(request.form.get("amount", "0"))
except Exception: except Exception:
flash("划转金额格式错误") flash("划转金额格式错误")
return redirect("/settings") return redirect("/settings?settings_tab=transfer")
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
@@ -9477,7 +9477,7 @@ def manual_transfer():
flash(f"手动划转成功:{amount}U {from_account}->{to_account}") flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
else: else:
flash(f"手动划转失败:{msg}") flash(f"手动划转失败:{msg}")
return redirect("/settings") return redirect("/settings?settings_tab=transfer")
def _journal_ai_chart_builder(row): def _journal_ai_chart_builder(row):
Binary file not shown.
+7 -1
View File
@@ -6,6 +6,12 @@
| 标签 | 指向提交 | 说明 | | 标签 | 指向提交 | 说明 |
|------|----------|------| |------|----------|------|
| `snapshot/20260728-2` | `05864d7` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 |
| `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 |
| `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-2` | `9e0591c` | 2026-07-23:策略对比页(合约/单期权/期期7:3)、监控与看板隐藏浮盈偏好、对比页卡片内边距等 |
| `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 | | `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 |
| `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 | | `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 |
@@ -28,7 +34,7 @@
git tag -l 'snapshot/*' git tag -l 'snapshot/*'
# 检出快照(只读查看,勿在此分支直接开发) # 检出快照(只读查看,勿在此分支直接开发)
git checkout snapshot/20260723-2 git checkout snapshot/20260728-2
# 回到主线 # 回到主线
git checkout main git checkout main
+25 -23
View File
@@ -3,7 +3,8 @@
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。 中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。 > 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
> **不改下单链路**;不算 IV / 权利金 > **不改下单链路**;不算 IV。
> 买跨 / 永期对冲测算请用中控 **策略计算器**,本页不再做对照盈亏。
--- ---
@@ -21,8 +22,9 @@
2. 选择 **标的** ETH / BTC;数据源固定 **OKX** 2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
3. **起点整点**0023);终点固定 **16:00** 3. **起点整点**0023);终点固定 **16:00**
4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月) 4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
5. **计算** → 下方看汇总 + 分页日表 5. 可选填 **波动点数**(如 `50`)→ 看振幅达标占比
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细 6. **计算** → 下方看汇总 + 振幅占比 + 分页日表
7. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
**跨天例子** **跨天例子**
@@ -42,14 +44,15 @@
| 字段 | 算法 | | 字段 | 算法 |
|------|------| |------|------|
| 开→高 | `H O` | | 开→高 | `H O`(一边波动) |
| 开→低 | `O L` | | 开→低 | `O L`(另一边波动) |
| **振幅** | `H L`= 开→高 + 开→低) | | **振幅** | `H L`= 开→高 + 开→低),窗为起点整点 → 当日 16:00 |
| 涨跌值 | `C O` | | **两日振幅** | 同上口径,但起点再往前推 1 天;例起点 16:00、结算 27 日 → **25日16:00 → 27日16:00** |
| 涨跌值 | `C O`(单日窗) |
例:O=2000H=2500L=1800 → 开→高 500,开→低 200,振幅 **700** 例:O=2000H=2500L=1800 → 开→高 500,开→低 200,振幅 **700**
汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。 汇总必含:最大振幅(及日期)、两日振幅最大/均值/中位、开→高/开→低的最大与均值等。
K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。 K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。
近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles` 近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`
@@ -57,22 +60,20 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
--- ---
## 买跨对照(赌波动) ## 波动点数 → 振幅占比
表单可填 **双边权利金(点)**,例`30`;旁边可填 **止盈点**(可空) 表单可填 **波动点数**`50`)。填写后下方 **振幅占比** 块显示
| 汇总项 | 口径 | | 汇总项 | 口径 |
|--------|------| |--------|------|
| 开→高超过权利金 | `HO > 权利金` 的天数与占比 | | 振幅≥点数 | 单日窗 `HL ≥ 点数` 的天数与**占比**(主指标) |
| 开→低超过权利金 | `OL > 权利金` 的天数与占比 | | 两日振幅≥点数 | 两日窗振幅 ≥ 点数 的天数与占比 |
| \|涨跌\|超过权利金 | `\|CO\| > 权利金` 天数与占比 | | 开→高≥点数 | `HO ≥ 点数` 天数与占比 |
| 有效波动 | 若设止盈且 `开→高≥止盈``开→低≥止盈` → 用止盈点;否则用 `\|CO\|` | | 开→低≥点数 | `OL ≥ 点数` 天数与占比 |
| 买跨收益 | `有效波动 权利金`(日表「收益」列同口径) | | \|涨跌\|≥点数 | `\|CO\| ≥ 点数` 天数与占比 |
- 方向:**买跨** 日表保留 **开→高 / 开→低**、**振幅**、**两日振幅**(悬停可见两日窗起止),并标 **振幅达标**
- 权利金越过:严格 **`>`**;止盈触达:**`≥`** 改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
### 周末 ### 周末
@@ -97,7 +98,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| `manual_trading_hub/amp_stats_routes.py` | API | | `manual_trading_hub/amp_stats_routes.py` | API |
| `manual_trading_hub/amp_stats_store.py` | 历史 JSON | | `manual_trading_hub/amp_stats_store.py` | 历史 JSON |
| `manual_trading_hub/static/amp_stats.js` | 前端 | | `manual_trading_hub/static/amp_stats.js` | 前端 |
| `tests/test_amp_stats_lib.py` | 单元测试 | | `tests/test_amp_stats_lib.py` | 单 |
--- ---
@@ -106,6 +107,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-07-23 | 首版上线说明 | | 2026-07-23 | 首版上线说明 |
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 | | 2026-07-23 | 买跨对照、周末筛选、止盈点 |
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 | | 2026-07-28 | 永期对冲对照(后已移除) |
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 | | 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 |
| 2026-07-28 | 增加两日振幅(例 25日16:00→27日16:00) |
+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
意向合作请说明:交易经验与是否全职、大致账户规模(可不精确)、希望开通的交易所、是否接受系统纪律限制。
**谢绝:** 仅询源码价格、要求多人共用一台服务器、明确表示不接受任何交易限制的需求。
---
*文档版本:与仓库同步维护;报价为参考,最终以双方确认的报价单与合同为准。*
+195
View File
@@ -0,0 +1,195 @@
# 永期对冲计算器
中控 **策略计算器** 第 3 个 tab:永期对冲。用于离线测算「永续 1 币 + 买方期权」在目标盈利口径下的期权仓位,或按永续:期权比例反推达目标所需波动点数。
入口:中控 → 策略计算器 → **永期对冲**
API`POST /api/calculator/perp-options`
逻辑库:`lib/hub/hub_perp_options_calc_lib.py`
单测:`tests/test_hub_perp_options_calc_lib.py`
与实例页「对冲计划」不同:本页**不实盘下单、不拉期权链**,价格与杠杆均为手填。
---
## 共同假设
| 项 | 口径 |
|----|------|
| 品种 | BTC / ETH |
| 永续仓位 | 固定 **1 币** |
| 单币权利金 | `现价 / 期权杠杆`(例:1800÷100=18U |
| 权利金 | **按全亏**计入;忽略时间价值 / Theta |
| 永续手续费 | 开+平各 `0.05%``PERP_TAKER_FEE_RATE`,默认 0.0005 |
| 期权手续费 | **不算** |
| 交易资金 | 仅参考:与 `现价/永续杠杆` 比保证金是否够开 |
| `ct_mult` | 默认 0.01;张数 = 期权币数 / ct_mult |
| 展示 | 金额与点数统一 **小数点后两位** |
---
## 模式一:由波动推期权仓位(`calc_mode=size`
已知波动(点数或波动率%)、目标盈利、期权杠杆 → 反推期权开多少币/张。
### 公式
```text
单币权利金 = 现价 / 期权杠杆
永续毛收益 = 波动点数 × 1
(波动率模式:现价 × 波动率% × 1)
平仓价 ≈ 现价 + 波动点数(永续方向对按上涨测算)
永续手续费 = (开仓名义 + 平仓名义) × 0.05%
权利金预算 = 永续毛收益 − 目标盈利 − 永续手续费
期权币数 = 权利金预算 / 单币权利金
期权张数 = 期权币数 / ct_mult
```
若权利金预算 ≤ 0:提示「波动收益不足以覆盖目标盈利+手续费,无法开期权」。
### 情景
**A · 永续方向对(期权全亏)**
```text
净利 = 永续毛收益 − 权利金总额 − 永续手续费
(设计上 ≈ 目标盈利)
```
**B · 期权方向对(永续 1 币反向亏同等波动)**
```text
期权内在 = 期权币数 × 波动点数
期权净利 = 期权内在 − 权利金总额
永续亏损 = −永续毛收益
组合净利 = 期权净利 + 永续亏损
```
**C · 横盘(最大亏损)**
波动≈0、期权到期无内在价值:
```text
永续盈亏 ≈ 0
永续开平手续费 = 2 × 现价 × 1 × 0.05% (同价开平)
最大亏损 = 权利金总额 + 永续开平手续费
组合净利 = −最大亏损
```
忽略资金费 / Theta 过程中的中间态;口径与「权利金按全亏」一致。
### 手测示例
现价 1800、波动 50 点、目标盈利 15、期权杠杆 100、永续杠杆 10:
| 量 | 约值 |
|----|------|
| 单币权利金 | 18U |
| 永续手续费 | 1.83U |
| 权利金预算 | 33.18U |
| 期权币数 / 张数 | ≈1.84 币 / ≈184 张 |
| A 净利 | ≈15U |
| B 期权净利 / 组合 | ≈59U / ≈9U |
---
## 模式二:由币数推波动点数(`calc_mode=points`
已知永续币数 / 期权币数(如 **1:2****2:4**)、目标盈利、期权杠杆 → 反推两套情景要涨/跌多少点才能达到目标。
**按绝对币数**,不再把输入归一到「永续 1 币」。填 2 与 4 → 永续 2 币 + 期权 4 币(权利金、保证金、手续费均按 2 倍于 1:2 放大;达同一目标盈利所需点数会变小)。
### 仓位
```text
永续币数 = 输入的永续币数
期权币数 = 输入的期权币数
权利金总额 = 期权币数 × (现价 / 期权杠杆)
永续保证金 = 现价 × 永续币数 / 永续杠杆
```
### 情景 A · 永续方向对
净利 = 目标盈利:
```text
qty×move 权利金 fee(move,qty) = 目标
fee = (2×现价 + move) × qty × 0.05%
move = (目标 + 权利金 + 2×现价×qty×0.05%) / (qty × (1 0.05%))
```
### 情景 B · 期权方向对(以组合净利为准)
组合净利 = 目标盈利:
```text
组合 = 期权币数×move − 权利金 − 永续币数×move
= move×(期权币数 − 永续币数) − 权利金
move = (目标 + 权利金) / (期权币数 − 永续币数)
```
要求期权币数 > 永续币数;若相等,组合恒为 −权利金,无法解出正目标。
结果区展示:所需波动点数(及折合%)、组合净利、其中期权净利、其中永续盈亏。
### 手测示例
现价 1800、目标 15、期权杠杆 100、币数 1:2 → 权利金总额 36U:
| 情景 | 所需点数(约) |
|------|----------------|
| A 永续方向对(净利=15 | ≈52.83 |
| B 组合净利=15 | 51.00 |
| C 横盘最大亏损 | 37.80(权利金 36 + 同价开平费 1.8) |
币数 **2:4**(权利金 72U、保证金 360U):
| 情景 | 约值 |
|------|------|
| 仓位 | 永续 2 币 / 期权 4 币(400 张) |
| A 所需点数 | ≈45.32 |
| B 组合达目标 | 43.50 |
| C 横盘最大亏损 | 75.60 |
---
## API 请求体(摘要)
```json
{
"calc_mode": "size | points",
"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,
"ratio_perp": 1,
"ratio_opt": 2
}
```
- `size` 模式必填 `move_value``points` 模式用 `ratio_perp` / `ratio_opt`,可不填波动。
---
## 相关文件
| 路径 | 作用 |
|------|------|
| `lib/hub/hub_perp_options_calc_lib.py` | 纯函数测算 |
| `manual_trading_hub/hub.py` | `POST /api/calculator/perp-options` |
| `manual_trading_hub/static/index.html` | 计算器 tab UI |
| `manual_trading_hub/static/calculator.js` | 提交与结果渲染 |
| `lib/trade/trade_fee_lib.py` | 永续双边手续费 |
## 不做
实盘开平仓、拉 OKX 期权链卖一、把本页结果自动写入对冲计划。
振幅统计页可对历史日表做同口径对照,见 [振幅统计说明.md](./振幅统计说明.md)「永期对冲对照」。
+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 等),与「保留所有权利 + 托管授权」冲突。
+60 -4
View File
@@ -61,6 +61,11 @@
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab); 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) { function pageNavAllowed(tab) {
@@ -235,12 +240,61 @@
const parts = []; const parts = [];
if (qs) parts.push(qs); if (qs) parts.push(qs);
parts.push("embed=1"); 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("&"); 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) { async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), { const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin", credentials: "same-origin",
cache: "no-store",
headers: { "X-Instance-Soft-Nav": "1" }, headers: { "X-Instance-Soft-Nav": "1" },
}); });
const ct = (r.headers.get("content-type") || "").toLowerCase(); const ct = (r.headers.get("content-type") || "").toLowerCase();
@@ -400,14 +454,15 @@
} }
} }
const fd = new FormData(form); const fd = new FormData(form);
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
return fetch(form.action, { return fetch(form.action, {
method: form.method || "POST", method: form.method || "POST",
body: fd, body: fd,
credentials: "same-origin", credentials: "same-origin",
redirect: "manual", redirect: "manual",
}) })
.then(() => reloadCurrentTab()) .then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab()); .catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
} }
function patchApplyListWindow() { function patchApplyListWindow() {
@@ -466,14 +521,15 @@
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return; if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
ev.preventDefault(); ev.preventDefault();
const fd = new FormData(form); const fd = new FormData(form);
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
fetch(form.action, { fetch(form.action, {
method: form.method || "POST", method: form.method || "POST",
body: fd, body: fd,
credentials: "same-origin", credentials: "same-origin",
redirect: "manual", redirect: "manual",
}) })
.then(() => reloadCurrentTab()) .then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab()); .catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
}, },
true 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 -1
View File
@@ -22,8 +22,24 @@
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff} .card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center} .form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem} .form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} /* 实盘下单监控:分层布局 */
.order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px}
.order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}
.order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]),
.order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
.order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem}
.order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em}
.order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box}
.order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto}
.order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px}
.order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none}
.order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef}
.order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px}
.order-monitor-form .om-row-action{padding-top:2px}
.order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem} .order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-preview-risk{color:#ff6b6b} .order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600} .order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f} .order-preview-profit{color:#4cd97f}
+10 -1
View File
@@ -31,6 +31,8 @@
function applyDisplayToNav(display) { function applyDisplayToNav(display) {
const map = { const map = {
dashboard: "show_nav_dashboard", dashboard: "show_nav_dashboard",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy", strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records", strategy_records: "show_nav_strategy_records",
records: "show_nav_records", records: "show_nav_records",
@@ -44,8 +46,13 @@
system_guide: "show_nav_system_guide", system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config", env_config: "show_nav_env_config",
}; };
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => { 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]; const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
if (tab === "more") return;
const key = map[tab]; const key = map[tab];
if (!key) return; if (!key) return;
const show = navPrefShow(display, key); const show = navPrefShow(display, key);
@@ -59,6 +66,8 @@
const d = DISPLAY(); const d = DISPLAY();
const map = { const map = {
dashboard: "show_nav_dashboard", dashboard: "show_nav_dashboard",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy", strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records", strategy_records: "show_nav_strategy_records",
records: "show_nav_records", records: "show_nav_records",
+477
View File
@@ -1959,6 +1959,15 @@ html[data-theme="light"] .order-plan-preview {
border-color: #b8c8d8 !important; border-color: #b8c8d8 !important;
} }
html[data-theme="light"] .order-monitor-form .om-field-lab {
color: #5a6a82;
}
html[data-theme="light"] .order-monitor-form .om-check,
html[data-theme="light"] .order-monitor-form .om-time-close {
color: #3a4a62;
}
html[data-theme="light"] .order-preview-rr { html[data-theme="light"] .order-preview-rr {
color: #4a6078 !important; color: #4a6078 !important;
} }
@@ -5526,3 +5535,471 @@ html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover
color: #ff8b8b; color: #ff8b8b;
} }
/* —— 实例手机壳:底栏四件套(仅 ≤720px + body.inst-phone) —— */
.inst-mobile-tabbar,
.inst-mobile-more,
.instance-phone-only {
display: none;
}
@media (max-width: 720px) {
:root {
--inst-m-tabbar-h: 56px;
--inst-m-page-pad: calc(var(--inst-m-tabbar-h) + max(16px, env(safe-area-inset-bottom)) + 12px);
}
/* 仅手机壳:不碰 >720 桌面/平板。overflow 放 container,避免裁切 fixed 下单弹窗 */
body.inst-phone {
padding-bottom: var(--inst-m-page-pad) !important;
box-sizing: border-box;
}
body.inst-phone .embed-top-nav.top-nav {
display: none !important;
}
body.inst-phone .header {
margin-bottom: 4px;
}
body.inst-phone .header h1 {
display: none !important;
}
body.inst-phone .container {
padding-bottom: 8px !important;
max-width: 100% !important;
min-width: 0 !important;
overflow-x: hidden !important;
}
body.inst-phone #embed-page-root,
body.inst-phone .embed-tab-pane,
body.inst-phone .embed-tab-pane.is-active-pane {
max-width: 100%;
min-width: 0;
overflow-x: hidden;
}
body.inst-phone .card {
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
body.inst-phone .instance-header-toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
body.inst-phone .instance-header-toolbar-end {
width: auto;
margin-left: auto;
}
body.inst-phone .instance-phone-only {
display: flex;
}
body.inst-phone .instance-header-phone-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
width: 100%;
}
html[data-theme="light"] body.inst-phone .instance-header-phone-strip {
border-top-color: rgba(0, 0, 0, 0.08);
}
body.inst-phone .inst-phone-chip {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 6px;
min-height: 32px;
min-width: 0;
padding: 6px 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
color: var(--inst-text);
}
html[data-theme="light"] body.inst-phone .inst-phone-chip {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.08);
}
body.inst-phone .inst-phone-chip em {
font-style: normal;
color: var(--inst-muted);
font-size: 11px;
flex: 0 0 auto;
}
body.inst-phone .inst-phone-chip b {
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
body.inst-phone .inst-phone-chip--pnl b {
color: var(--inst-nav-idle);
}
/* 实盘/关键位等表单:窄屏拉满,避免挤成一行裁切 */
body.inst-phone #add-order-form.form-row,
body.inst-phone form.form-row {
align-items: stretch;
}
body.inst-phone #add-order-form.form-row > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone #add-order-form.form-row > select,
body.inst-phone #add-order-form #sltp-mode,
body.inst-phone form.form-row > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone form.form-row > select {
flex: 1 1 100% !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box;
}
body.inst-phone #add-order-form .order-entry-model-row,
body.inst-phone #add-order-form .order-time-close-wrap,
body.inst-phone #add-order-form > label,
body.inst-phone #add-order-form > button {
flex: 1 1 100%;
max-width: 100%;
}
body.inst-phone #add-order-form .order-entry-model-row select {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
}
body.inst-phone .order-monitor-form .om-row {
align-items: stretch;
}
body.inst-phone .order-monitor-form .om-row-policy > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone .order-monitor-form .om-row-policy > select,
body.inst-phone .order-monitor-form #sltp-mode,
body.inst-phone .order-monitor-form .om-field,
body.inst-phone .order-monitor-form .om-live-meta,
body.inst-phone .order-monitor-form .om-check,
body.inst-phone .order-monitor-form .om-time-close,
body.inst-phone .order-monitor-form .order-entry-model-row,
body.inst-phone .order-monitor-form .om-submit {
flex: 1 1 100% !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0;
box-sizing: border-box;
margin-left: 0;
}
body.inst-phone .order-monitor-form .om-field input {
width: 100% !important;
max-width: 100% !important;
}
body.inst-phone .order-monitor-form .om-live-meta {
padding-bottom: 0;
}
body.inst-phone .order-plan-preview {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
/* 期权:防止宽表撑破页面;表内横向滑看「操作」 */
body.inst-phone .options-page-wrap,
body.inst-phone .options-dual-grid,
body.inst-phone .options-order-card,
body.inst-phone .options-pos-card-wrap {
max-width: 100%;
min-width: 0;
}
body.inst-phone .options-dual-grid {
grid-template-columns: minmax(0, 1fr) !important;
}
body.inst-phone .options-chain-toolbar.form-row {
flex-wrap: wrap;
gap: 6px;
}
body.inst-phone .options-chain-toolbar .btn-secondary,
body.inst-phone .options-chain-toolbar select,
body.inst-phone .options-chain-toolbar .opt-chain-view-group,
body.inst-phone .options-chain-toolbar .opt-type-btn-group {
flex: 0 1 auto;
max-width: 100%;
}
body.inst-phone .options-strike-table-wrap,
body.inst-phone .options-strike-table-wrap--t,
body.inst-phone .options-history-table-wrap,
body.inst-phone .table-wrap {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto !important;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
touch-action: pan-x pan-y;
}
body.inst-phone .options-strike-table {
width: max-content;
min-width: 100%;
}
body.inst-phone .options-strike-table th,
body.inst-phone .options-strike-table td {
white-space: nowrap;
}
/* 列表视图:隐藏合约 / 买一 / 到期平衡 / 距平衡(保留行权价·类型·卖一·操作) */
body.inst-phone #opt-strike-head-list th:nth-child(3),
body.inst-phone #opt-strike-head-list th:nth-child(5),
body.inst-phone #opt-strike-head-list th:nth-child(6),
body.inst-phone #opt-strike-head-list th:nth-child(7),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(3),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(5),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(6),
body.inst-phone .opt-strike-row:not(.opt-strike-row-t) > td:nth-child(7) {
display: none !important;
}
/* 选择后下单弹窗:盖过底栏,可滚动完整显示 */
body.inst-phone .opt-order-backdrop {
z-index: 2400;
align-items: flex-end;
justify-content: center;
padding: 0;
padding-bottom: env(safe-area-inset-bottom);
background: rgba(0, 0, 0, 0.72);
}
body.inst-phone .opt-order-backdrop:not([hidden]) {
display: flex !important;
}
body.inst-phone .opt-order-dialog {
width: 100%;
max-width: 100%;
max-height: min(88vh, 720px);
margin: 0;
border-radius: 16px 16px 0 0;
padding: 14px 14px calc(14px + env(safe-area-inset-bottom));
overflow: auto;
-webkit-overflow-scrolling: touch;
}
body.inst-phone .opt-order-dialog .options-order-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
body.inst-phone .options-estimate-row .opt-est-main {
flex-wrap: wrap;
}
body.inst-phone .opt-size-mode-bar {
flex-wrap: wrap;
width: 100%;
}
body.inst-phone .inst-mobile-tabbar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
height: calc(var(--inst-m-tabbar-h) + env(safe-area-inset-bottom));
padding: 0 max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom)
max(8px, env(safe-area-inset-left));
align-items: stretch;
justify-content: space-around;
gap: 2px;
background: color-mix(in srgb, #12161f 92%, transparent);
border-top: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-sizing: border-box;
}
html[data-theme="light"] body.inst-phone .inst-mobile-tabbar {
background: color-mix(in srgb, #f4f7fb 94%, transparent);
border-top-color: rgba(0, 0, 0, 0.1);
}
body.inst-phone .inst-mobile-tabbar .inst-m-tab.nav-hidden {
display: none !important;
}
body.inst-phone .inst-m-tab {
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 6px 2px;
padding: 0 4px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--inst-muted);
font: inherit;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
body.inst-phone .inst-m-tab:hover,
body.inst-phone .inst-m-tab:focus-visible {
color: var(--inst-text);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
body.inst-phone .inst-m-tab.active {
color: var(--inst-nav-idle);
background: rgba(143, 200, 255, 0.12);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--inst-nav-idle) 35%, transparent);
}
body.inst-phone.inst-mobile-more-open .inst-mobile-more {
display: block;
}
body.inst-phone .inst-mobile-more {
position: fixed;
inset: 0;
z-index: 90;
}
body.inst-phone .inst-mobile-more-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
body.inst-phone .inst-mobile-more-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: min(78vh, 560px);
overflow: auto;
padding: 10px 16px calc(16px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
background: #12161f;
border: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: none;
box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35);
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-sheet {
background: #f4f7fb;
border-color: rgba(0, 0, 0, 0.1);
}
body.inst-phone .inst-mobile-more-handle {
width: 36px;
height: 4px;
margin: 2px auto 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-handle {
background: rgba(0, 0, 0, 0.15);
}
body.inst-phone .inst-mobile-more-title {
margin: 0 0 4px;
font-size: 1rem;
color: var(--inst-text);
}
body.inst-phone .inst-mobile-more-hint {
margin: 0 0 14px;
font-size: 11px;
color: var(--inst-muted);
}
body.inst-phone .inst-mobile-more-nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
body.inst-phone .inst-mobile-more-nav a {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--inst-text);
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-nav a {
border-color: rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.03);
}
body.inst-phone .inst-mobile-more-nav a.nav-hidden {
display: none !important;
}
body.inst-phone .inst-mobile-more-nav a.active {
border-color: color-mix(in srgb, var(--inst-nav-idle) 45%, transparent);
background: rgba(143, 200, 255, 0.12);
color: var(--inst-nav-idle);
}
body.inst-phone .inst-mobile-more-close {
width: 100%;
margin-top: 14px;
min-height: 44px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: var(--inst-text);
font: inherit;
font-size: 14px;
cursor: pointer;
}
html[data-theme="light"] body.inst-phone .inst-mobile-more-close {
border-color: rgba(0, 0, 0, 0.12);
}
}
+7
View File
@@ -280,8 +280,15 @@
const mode = currentSizeMode(); const mode = currentSizeMode();
const sheetsEl = document.getElementById("opt-sheets-amount"); const sheetsEl = document.getElementById("opt-sheets-amount");
const ethEl = document.getElementById("opt-eth-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 (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "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) { document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
const radio = chip.querySelector('input[name="opt-size-mode"]'); const radio = chip.querySelector('input[name="opt-size-mode"]');
chip.classList.toggle("is-selected", !!(radio && radio.checked)); chip.classList.toggle("is-selected", !!(radio && radio.checked));
+18
View File
@@ -70,7 +70,10 @@ HOT_RELOAD_EXACT = frozenset({
"MONITOR_POLL_SECONDS", "MONITOR_POLL_SECONDS",
"AUTO_TRANSFER_ENABLED", "AUTO_TRANSFER_ENABLED",
"AUTO_TRANSFER_AMOUNT", "AUTO_TRANSFER_AMOUNT",
"AUTO_TRANSFER_FROM",
"AUTO_TRANSFER_TO",
"AUTO_TRANSFER_BJ_HOUR", "AUTO_TRANSFER_BJ_HOUR",
"TRANSFER_CCY",
"FORCE_CLOSE_ENABLED", "FORCE_CLOSE_ENABLED",
"FORCE_CLOSE_BJ_HOUR", "FORCE_CLOSE_BJ_HOUR",
"BTC_LEVERAGE", "BTC_LEVERAGE",
@@ -126,6 +129,17 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("long_only", "仅做多"), ("long_only", "仅做多"),
("short_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": ( "HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"), ("budget", "预算金额"),
("sheets", "张数"), ("sheets", "张数"),
@@ -136,6 +150,7 @@ _SELECT_ALIASES: dict[str, dict[str, str]] = {
"OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, "OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"BINANCE_MARGIN_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"}, "GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"TRANSFER_CCY": {"usdt": "USDT"},
} }
@@ -159,10 +174,13 @@ def normalize_select_value(key: str, value: Optional[str]) -> str:
if low in aliases: if low in aliases:
return aliases[low] return aliases[low]
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())} allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
allowed_by_lower = {v.lower(): v for v in allowed}
if low in allowed: if low in allowed:
return low return low
if raw in allowed: if raw in allowed:
return raw return raw
if low in allowed_by_lower:
return allowed_by_lower[low]
return raw return raw
+9 -4
View File
@@ -103,10 +103,10 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
"fields": [ "fields": [
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""), ("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"), ("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"), ("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"), ("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""), ("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
("TRANSFER_CCY", "划转币种", "默认 USDT"), ("TRANSFER_CCY", "划转币种", ""),
], ],
}, },
{ {
@@ -200,6 +200,9 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2", "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2", "RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true", "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_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
@@ -214,7 +217,9 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key in file_values: 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) runtime = os.getenv(key)
if runtime is not None and str(runtime).strip() != "": if runtime is not None and str(runtime).strip() != "":
return str(runtime).strip() return str(runtime).strip()
+2 -1
View File
@@ -22,6 +22,7 @@ def execute_transfer_usdt(
) -> tuple[bool, str, Any]: ) -> tuple[bool, str, Any]:
if amount <= 0: if amount <= 0:
return False, "划转金额必须大于0", None return False, "划转金额必须大于0", None
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
ok_live, reason = ensure_live_ready() ok_live, reason = ensure_live_ready()
if not ok_live: if not ok_live:
return False, reason, None return False, reason, None
@@ -31,7 +32,7 @@ def execute_transfer_usdt(
except Exception: except Exception:
pass pass
try: 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 return True, "划转成功", resp
except Exception as e: except Exception as e:
msg = str(e) msg = str(e)
+233 -178
View File
@@ -68,10 +68,20 @@ def resolve_sample_days(period: str, custom_days: Any = None) -> int:
return PERIOD_DAYS[p] return PERIOD_DAYS[p]
def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]: def window_bounds_for_settlement(
"""返回 [start, end) 的本地时刻;end 为结算日 16:00.""" settlement: date,
start_hour: int,
*,
span_days: int = 1,
) -> tuple[datetime, datetime]:
"""返回 [start, end) 的本地时刻;end 为结算日 16:00.
span_days=1: 与现口径相同( 26日16:0027日16:00)
span_days=2: 再往前推 1 ( 25日16:0027日16:00)
"""
if not (0 <= int(start_hour) <= 23): if not (0 <= int(start_hour) <= 23):
raise ValueError("起点须为 0-23 整点") raise ValueError("起点须为 0-23 整点")
span = max(1, int(span_days or 1))
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ) end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
sh = int(start_hour) sh = int(start_hour)
if sh >= END_HOUR: if sh >= END_HOUR:
@@ -79,6 +89,8 @@ def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[dat
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ) start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
else: else:
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ) start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
if span > 1:
start = start - timedelta(days=span - 1)
return start, end return start, end
@@ -127,12 +139,12 @@ def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
return m return m
def compute_day_row( def _ohlc_window_metrics(
settlement: date, start: datetime,
start_hour: int, end: datetime,
bar_map: dict[int, dict[str, float]], bar_map: dict[int, dict[str, float]],
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
start, end = window_bounds_for_settlement(settlement, start_hour) """在 [start, end) 上算开高低收与开→高/开→低/振幅/涨跌."""
start_ms = int(start.timestamp() * 1000) start_ms = int(start.timestamp() * 1000)
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close # 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000) last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
@@ -153,15 +165,9 @@ def compute_day_row(
down = opens - lo down = opens - lo
amp = hi - lo amp = hi - lo
change = close - opens change = close - opens
wd = settlement.weekday() # Mon=0 … Sun=6
is_we = wd >= 5
return { return {
"settlement_day": settlement.isoformat(),
"window_start": start.strftime("%Y-%m-%d %H:%M"), "window_start": start.strftime("%Y-%m-%d %H:%M"),
"window_end": end.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), "open": round(opens, 4),
"high": round(hi, 4), "high": round(hi, 4),
"low": round(lo, 4), "low": round(lo, 4),
@@ -173,27 +179,67 @@ def compute_day_row(
} }
def normalize_straddle_premium(raw: Any) -> Optional[float]: def compute_day_row(
"""双边权利金(点数).空/≤0 表示不做跨式对照.""" settlement: date,
start_hour: int,
bar_map: dict[int, dict[str, float]],
) -> Optional[dict[str, Any]]:
start, end = window_bounds_for_settlement(settlement, start_hour, span_days=1)
m1 = _ohlc_window_metrics(start, end, bar_map)
if m1 is None:
return None
start2, end2 = window_bounds_for_settlement(settlement, start_hour, span_days=2)
m2 = _ohlc_window_metrics(start2, end2, bar_map)
wd = settlement.weekday() # Mon=0 … Sun=6
is_we = wd >= 5
row: dict[str, Any] = {
"settlement_day": settlement.isoformat(),
"weekday": wd,
"weekday_label": "" if wd == 5 else ("" if wd == 6 else ""),
"is_weekend": is_we,
**m1,
}
if m2 is None:
row.update(
{
"window2_start": start2.strftime("%Y-%m-%d %H:%M"),
"window2_end": end2.strftime("%Y-%m-%d %H:%M"),
"open_2d": None,
"high_2d": None,
"low_2d": None,
"close_2d": None,
"up_points_2d": None,
"down_points_2d": None,
"amplitude_2d": None,
"change_2d": None,
}
)
else:
row.update(
{
"window2_start": m2["window_start"],
"window2_end": m2["window_end"],
"open_2d": m2["open"],
"high_2d": m2["high"],
"low_2d": m2["low"],
"close_2d": m2["close"],
"up_points_2d": m2["up_points"],
"down_points_2d": m2["down_points"],
"amplitude_2d": m2["amplitude"],
"change_2d": m2["change"],
}
)
return row
def normalize_move_points(raw: Any) -> Optional[float]:
"""对照波动点数.空/≤0 表示不做点数达标对照."""
if raw is None or raw == "": if raw is None or raw == "":
return None return None
try: try:
v = float(raw) v = float(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError("双边权利金须为数字") from None 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: if v <= 0:
return None return None
return v return v
@@ -231,32 +277,13 @@ def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all")
return out return out
def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float: def _ensure_weekend_flags(item: dict[str, Any]) -> None:
"""触达止盈(≥)用止盈点,否则用 |涨跌|.""" if "is_weekend" in item:
abs_chg = abs(float(row.get("change") or 0)) return
if take_profit is None: if not item.get("settlement_day"):
return abs_chg item.setdefault("weekday_label", "")
tp = float(take_profit) item.setdefault("is_weekend", False)
up = float(row.get("up_points") or 0) return
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: try:
wd = date.fromisoformat(str(item["settlement_day"])).weekday() wd = date.fromisoformat(str(item["settlement_day"])).weekday()
item["weekday"] = wd item["weekday"] = wd
@@ -265,85 +292,113 @@ def enrich_rows_pnl(
except ValueError: except ValueError:
item.setdefault("weekday_label", "") item.setdefault("weekday_label", "")
item.setdefault("is_weekend", False) item.setdefault("is_weekend", False)
move = effective_move_points(item, tp)
hit = False
if tp is not None: def enrich_rows(
hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp rows: list[dict[str, Any]],
item["effective_move"] = round(move, 4) *,
item["take_profit_hit"] = hit move_points: Any = None,
item["profit"] = round(move - prem, 4) if prem is not None else None ) -> list[dict[str, Any]]:
"""为日表附加周末标注,以及相对波动点数的两边达标."""
mp = normalize_move_points(move_points)
out: list[dict[str, Any]] = []
for r in rows or []:
item = dict(r)
_ensure_weekend_flags(item)
up = float(item.get("up_points") or 0)
down = float(item.get("down_points") or 0)
amp = float(item.get("amplitude") or 0)
hit_up = bool(mp is not None and up >= mp)
hit_down = bool(mp is not None and down >= mp)
amp_hit = bool(mp is not None and amp >= mp)
amp2 = item.get("amplitude_2d")
amp2_v = float(amp2) if amp2 is not None and amp2 != "" else None
amp_hit_2d = bool(mp is not None and amp2_v is not None and amp2_v >= mp)
item["move_points"] = mp
item["hit_up"] = hit_up
item["hit_down"] = hit_down
item["hit_either"] = hit_up or hit_down
item["hit_both"] = hit_up and hit_down
item["amp_hit"] = amp_hit
item["amp_hit_2d"] = amp_hit_2d
out.append(item) out.append(item)
return out return out
def straddle_long_stats( # 兼容旧调用名
rows: list[dict[str, Any]], def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]:
premium: float, return enrich_rows(rows, move_points=kwargs.get("move_points"))
*,
take_profit: Any = None,
) -> dict[str, Any]: def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]:
"""买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|).""" """波动点数达标汇总:开→高/开→低两边."""
prem = float(premium) mp = float(move_points)
if prem <= 0: if mp <= 0:
raise ValueError("双边权利金须 > 0") raise ValueError("波动点数须 > 0")
tp = normalize_take_profit(take_profit) work = enrich_rows(rows, move_points=mp)
enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp) n = len(work)
if not enriched: empty = {
return { "move_points": round(mp, 4),
"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, "sample_count": n,
"up_exceed_days": up_ex, "up_hit_days": 0,
"up_exceed_ratio": round(up_ex / n, 4), "up_hit_ratio": None,
"down_exceed_days": down_ex, "down_hit_days": 0,
"down_exceed_ratio": round(down_ex / n, 4), "down_hit_ratio": None,
"abs_change_exceed_days": abs_ex, "either_hit_days": 0,
"abs_change_exceed_ratio": round(abs_ex / n, 4), "either_hit_ratio": None,
"tp_hit_days": tp_hits, "both_hit_days": 0,
"tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None, "both_hit_ratio": None,
"pnl_total": round(sum(pnls), 4), "amp_hit_days": 0,
"pnl_avg": round(statistics.fmean(pnls), 4), "amp_hit_ratio": None,
"win_days": win, "amp_2d_hit_days": 0,
"win_ratio": round(win / n, 4), "amp_2d_hit_ratio": None,
"pnl_max": round(max(pnls), 4), "abs_change_hit_days": 0,
"pnl_min": round(min(pnls), 4), "abs_change_hit_ratio": None,
} }
if n <= 0:
return empty
up_hit = sum(1 for r in work if r.get("hit_up"))
down_hit = sum(1 for r in work if r.get("hit_down"))
either = sum(1 for r in work if r.get("hit_either"))
both = sum(1 for r in work if r.get("hit_both"))
amp_hit = sum(1 for r in work if r.get("amp_hit"))
amp2_rows = [r for r in work if r.get("amplitude_2d") is not None]
amp2_hit = sum(1 for r in work if r.get("amp_hit_2d"))
n2 = len(amp2_rows)
abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp)
empty.update(
{
"up_hit_days": up_hit,
"up_hit_ratio": round(up_hit / n, 4),
"down_hit_days": down_hit,
"down_hit_ratio": round(down_hit / n, 4),
"either_hit_days": either,
"either_hit_ratio": round(either / n, 4),
"both_hit_days": both,
"both_hit_ratio": round(both / n, 4),
"amp_hit_days": amp_hit,
"amp_hit_ratio": round(amp_hit / n, 4),
"amp_2d_hit_days": amp2_hit,
"amp_2d_hit_ratio": round(amp2_hit / n2, 4) if n2 else None,
"abs_change_hit_days": abs_hit,
"abs_change_hit_ratio": round(abs_hit / n, 4),
}
)
return empty
def summarize_rows( def summarize_rows(
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
*, *,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
mp = normalize_move_points(move_points)
empty_2d = {
"max_amplitude_2d": None,
"max_amplitude_2d_day": None,
"avg_amplitude_2d": None,
"median_amplitude_2d": None,
}
if not rows: if not rows:
out = { out = {
"sample_count": 0, "sample_count": 0,
@@ -357,11 +412,11 @@ def summarize_rows(
"avg_down_points": None, "avg_down_points": None,
"up_day_ratio": None, "up_day_ratio": None,
"down_day_ratio": None, "down_day_ratio": None,
"straddle": None, **empty_2d,
"move_points_stats": None,
} }
prem = normalize_straddle_premium(straddle_premium) if mp is not None:
if prem is not None: out["move_points_stats"] = move_points_stats([], mp)
out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit)
return out return out
amps = [float(r["amplitude"]) for r in rows] amps = [float(r["amplitude"]) for r in rows]
ups = [float(r["up_points"]) for r in rows] ups = [float(r["up_points"]) for r in rows]
@@ -371,6 +426,7 @@ def summarize_rows(
up_days = sum(1 for r in rows if float(r["change"]) > 0) 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) down_days = sum(1 for r in rows if float(r["change"]) < 0)
n = len(rows) n = len(rows)
amps2 = [float(r["amplitude_2d"]) for r in rows if r.get("amplitude_2d") is not None]
out: dict[str, Any] = { out: dict[str, Any] = {
"sample_count": n, "sample_count": n,
"max_amplitude": round(max_amp, 4), "max_amplitude": round(max_amp, 4),
@@ -383,11 +439,19 @@ def summarize_rows(
"avg_down_points": round(statistics.fmean(downs), 4), "avg_down_points": round(statistics.fmean(downs), 4),
"up_day_ratio": round(up_days / n, 4), "up_day_ratio": round(up_days / n, 4),
"down_day_ratio": round(down_days / n, 4), "down_day_ratio": round(down_days / n, 4),
"straddle": None, **empty_2d,
"move_points_stats": None,
} }
prem = normalize_straddle_premium(straddle_premium) if amps2:
if prem is not None: max_a2 = max(amps2)
out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit) out["max_amplitude_2d"] = round(max_a2, 4)
out["max_amplitude_2d_day"] = next(
r["settlement_day"] for r in rows if r.get("amplitude_2d") is not None and float(r["amplitude_2d"]) == max_a2
)
out["avg_amplitude_2d"] = round(statistics.fmean(amps2), 4)
out["median_amplitude_2d"] = round(statistics.median(amps2), 4)
if mp is not None:
out["move_points_stats"] = move_points_stats(rows, mp)
return out return out
@@ -591,8 +655,7 @@ def compute_amp_stats(
start_hour: int = 16, start_hour: int = 16,
period: str = "2m", period: str = "2m",
custom_days: Any = None, custom_days: Any = None,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all", weekend_filter: Any = "all",
now: Optional[datetime] = None, now: Optional[datetime] = None,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
@@ -601,18 +664,17 @@ def compute_amp_stats(
sh = int(start_hour) sh = int(start_hour)
if sh < 0 or sh > 23: if sh < 0 or sh > 23:
raise ValueError("起点须为 0-23 整点") raise ValueError("起点须为 0-23 整点")
prem = normalize_straddle_premium(straddle_premium) mp = normalize_move_points(move_points)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter) we_mode = normalize_weekend_filter(weekend_filter)
sample_days = resolve_sample_days(period, custom_days) sample_days = resolve_sample_days(period, custom_days)
settlements = list_settlement_dates(sample_days=sample_days, now=now) settlements = list_settlement_dates(sample_days=sample_days, now=now)
if not settlements: if not settlements:
raise RuntimeError("无可用结算日") raise RuntimeError("无可用结算日")
# 最远窗起点 # 最远窗起点(含两日振幅,多拉 1 天)
oldest = settlements[-1] oldest = settlements[-1]
newest = settlements[0] newest = settlements[0]
start0, _ = window_bounds_for_settlement(oldest, sh) start0, _ = window_bounds_for_settlement(oldest, sh, span_days=2)
_, end1 = window_bounds_for_settlement(newest, sh) _, end1 = window_bounds_for_settlement(newest, sh, span_days=1)
since_ms = int(start0.timestamp() * 1000) since_ms = int(start0.timestamp() * 1000)
until_ms = int(end1.timestamp() * 1000) until_ms = int(end1.timestamp() * 1000)
bars, price_source, inst_id = fetch_symbol_bars( bars, price_source, inst_id = fetch_symbol_bars(
@@ -633,8 +695,7 @@ def compute_amp_stats(
start_hour=sh, start_hour=sh,
period=period, period=period,
sample_days=sample_days, sample_days=sample_days,
straddle_premium=prem, move_points=mp,
take_profit=tp,
weekend_filter=we_mode, weekend_filter=we_mode,
price_source=price_source, price_source=price_source,
inst_id=inst_id, inst_id=inst_id,
@@ -649,19 +710,17 @@ def build_amp_result(
start_hour: int, start_hour: int,
period: str, period: str,
sample_days: int, sample_days: int,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all", weekend_filter: Any = "all",
price_source: str = "", price_source: str = "",
inst_id: str = "", inst_id: str = "",
missing: Optional[list[str]] = None, missing: Optional[list[str]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
prem = normalize_straddle_premium(straddle_premium) mp = normalize_move_points(move_points)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter) we_mode = normalize_weekend_filter(weekend_filter)
filtered = filter_weekend_rows(rows_all, we_mode) filtered = filter_weekend_rows(rows_all, we_mode)
rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp) rows = enrich_rows(filtered, move_points=mp)
summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp) summary = summarize_rows(rows, move_points=mp)
if period == "custom" or str(period).startswith("custom:"): if period == "custom" or str(period).startswith("custom:"):
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}" period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
else: else:
@@ -676,8 +735,7 @@ def build_amp_result(
"end_hour": END_HOUR, "end_hour": END_HOUR,
"period": period_label, "period": period_label,
"sample_days_requested": sample_days, "sample_days_requested": sample_days,
"straddle_premium": prem, "move_points": mp,
"take_profit": tp,
"weekend_filter": we_mode, "weekend_filter": we_mode,
"timeframe": TIMEFRAME, "timeframe": TIMEFRAME,
"price_source": price_source, "price_source": price_source,
@@ -698,14 +756,13 @@ def reframe_amp_stats(
start_hour: int = 16, start_hour: int = 16,
period: str = "2m", period: str = "2m",
sample_days: int = 60, sample_days: int = 60,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all", weekend_filter: Any = "all",
price_source: str = "", price_source: str = "",
inst_id: str = "", inst_id: str = "",
missing: Optional[list[str]] = None, missing: Optional[list[str]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""已有日表上改周末/权利金/止盈,不拉 K 线.""" """已有日表上改周末/波动点数,不拉 K 线."""
key = normalize_symbol(symbol) key = normalize_symbol(symbol)
return build_amp_result( return build_amp_result(
rows_all=list(rows_all or []), rows_all=list(rows_all or []),
@@ -713,8 +770,7 @@ def reframe_amp_stats(
start_hour=int(start_hour), start_hour=int(start_hour),
period=period, period=period,
sample_days=int(sample_days or 60), sample_days=int(sample_days or 60),
straddle_premium=straddle_premium, move_points=move_points,
take_profit=take_profit,
weekend_filter=weekend_filter, weekend_filter=weekend_filter,
price_source=price_source, price_source=price_source,
inst_id=inst_id, inst_id=inst_id,
@@ -754,31 +810,20 @@ def build_export_csv(payload: dict[str, Any]) -> str:
w.writerow(["样本数", s.get("sample_count")]) w.writerow(["样本数", s.get("sample_count")])
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")]) 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("avg_amplitude"), "中位数", s.get("median_amplitude")])
w.writerow(["两日最大振幅", s.get("max_amplitude_2d"), "日期", s.get("max_amplitude_2d_day")])
w.writerow(["两日振幅均值", s.get("avg_amplitude_2d"), "中位数", s.get("median_amplitude_2d")])
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")]) w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_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")]) w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
st = s.get("straddle") or {} mp = s.get("move_points_stats") or {}
if st: if mp:
w.writerow([]) w.writerow([])
w.writerow(["买跨对照·双边权利金】", st.get("premium"), "止盈点", st.get("take_profit")]) w.writerow(["波动点数·振幅占比】", mp.get("move_points")])
w.writerow(["开→高超过权利金", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")]) w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")])
w.writerow(["开→低超过权利金", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")]) w.writerow(["两日振幅≥点数天数", mp.get("amp_2d_hit_days"), "占比", mp.get("amp_2d_hit_ratio")])
w.writerow(["|涨跌|超过权利金", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")]) w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")])
if st.get("take_profit") is not None: w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")])
w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_ratio")]) w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_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(["【日表明细】"]) w.writerow(["【日表明细】"])
w.writerow( w.writerow(
@@ -796,9 +841,14 @@ def build_export_csv(payload: dict[str, Any]) -> str:
"开→低", "开→低",
"振幅", "振幅",
"涨跌值", "涨跌值",
"有效波动", "两日窗起点",
"触达止盈", "两日窗终点",
"收益", "两日振幅",
"两日开→高",
"两日开→低",
"对照点数",
"振幅达标",
"两日振幅达标",
] ]
) )
for r in payload.get("rows") or []: for r in payload.get("rows") or []:
@@ -817,9 +867,14 @@ def build_export_csv(payload: dict[str, Any]) -> str:
r.get("down_points"), r.get("down_points"),
r.get("amplitude"), r.get("amplitude"),
r.get("change"), r.get("change"),
r.get("effective_move"), r.get("window2_start"),
"" if r.get("take_profit_hit") else "", r.get("window2_end"),
r.get("profit"), r.get("amplitude_2d"),
r.get("up_points_2d"),
r.get("down_points_2d"),
r.get("move_points") if r.get("move_points") is not None else "",
"" if r.get("amp_hit") else ("" if r.get("move_points") is not None else ""),
"" if r.get("amp_hit_2d") else ("" if r.get("move_points") is not None and r.get("amplitude_2d") is not None else ""),
] ]
) )
return buf.getvalue() return buf.getvalue()
+1
View File
@@ -71,6 +71,7 @@ def install_instance_theme_static(app) -> None:
"strategy_roll.js": "application/javascript; charset=utf-8", "strategy_roll.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8", "instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; 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_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8", "instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8", "instance_settings_prefs.js": "application/javascript; charset=utf-8",
+387
View File
@@ -0,0 +1,387 @@
"""中控永期对冲计算器:永续 1 币 + 按目标盈利反推期权仓位/波动点数(纯函数)."""
from __future__ import annotations
from typing import Any, Optional, Tuple
from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt, taker_fee_rate
DEFAULT_CT_MULT = 0.01
PERP_COINS = 1.0
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _parse_base_common(
*,
base: str,
spot: Any,
capital_usdt: Any,
target_profit_u: Any,
perp_leverage: Any,
option_leverage: Any,
ct_mult: Any,
) -> Tuple[Optional[dict[str, float]], Optional[str]]:
b = (base or "ETH").strip().upper()
if b not in ("ETH", "BTC"):
return None, "币种仅支持 BTC / ETH"
s = _f(spot)
capital = _f(capital_usdt)
target = _f(target_profit_u)
p_lev = _f(perp_leverage)
o_lev = _f(option_leverage)
ct = _f(ct_mult)
if s is None or capital is None or target is None or p_lev is None or o_lev is None:
return None, "参数格式错误"
if ct is None or ct <= 0:
ct = DEFAULT_CT_MULT
if s <= 0 or capital <= 0 or p_lev <= 0 or o_lev <= 0:
return None, "现价、资金、杠杆须大于 0"
if target < 0:
return None, "目标盈利不能为负"
prem_per_coin = s / o_lev
if prem_per_coin <= 0:
return None, "单币权利金无效"
margin = (s * PERP_COINS) / p_lev
return {
"base_ok": 1.0,
"spot": s,
"capital": capital,
"target": target,
"p_lev": p_lev,
"o_lev": o_lev,
"ct": ct,
"prem_per_coin": prem_per_coin,
"margin": margin,
"fee_rate": taker_fee_rate(),
}, None
def _move_for_perp_correct(
*,
spot: float,
target: float,
premium: float,
fee_rate: float,
perp_coins: float = 1.0,
) -> float:
"""净利 = qty*move premium fee(move,qty) = target → 解 move.
fee = (2*spot + move) * qty * fee_rate
qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate
"""
qty = float(perp_coins)
if qty <= 0:
return 0.0
denom = qty * (1.0 - float(fee_rate))
if denom <= 0:
return 0.0
return (float(target) + float(premium) + 2.0 * float(spot) * qty * float(fee_rate)) / denom
def _case_sideways(
*,
spot: float,
premium_total: float,
perp_coins: float = 1.0,
) -> dict[str, Any]:
"""横盘/到期无方向:永续≈0,期权权利金全亏,另计永续开平同价手续费.
最大亏损(正数) = 权利金总额 + 开平手续费(exit=entry)
组合净利 = 最大亏损
"""
qty = float(perp_coins) if float(perp_coins) > 0 else PERP_COINS
fee_flat = estimate_roundtrip_fee_usdt(spot, spot, qty=qty, contract_size=1.0)
prem = float(premium_total)
max_loss = prem + float(fee_flat)
return {
"label": "横盘",
"perp_pnl_u": 0.0,
"premium_u": round(prem, 8),
"fee_u": round(float(fee_flat), 8),
"max_loss_u": round(max_loss, 8),
"net_u": round(-max_loss, 8),
}
def calc_perp_options_hedge(
*,
base: str = "ETH",
spot: float,
capital_usdt: float,
target_profit_u: float,
move_mode: str = "points",
move_value: float,
perp_leverage: float,
option_leverage: float,
ct_mult: float = DEFAULT_CT_MULT,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""由波动反推期权开仓币数/张数(calc_mode=size)."""
common, err = _parse_base_common(
base=base,
spot=spot,
capital_usdt=capital_usdt,
target_profit_u=target_profit_u,
perp_leverage=perp_leverage,
option_leverage=option_leverage,
ct_mult=ct_mult,
)
if err or not common:
return None, err
s = common["spot"]
capital = common["capital"]
target = common["target"]
p_lev = common["p_lev"]
o_lev = common["o_lev"]
ct = common["ct"]
prem_per_coin = common["prem_per_coin"]
margin = common["margin"]
fee_rate = common["fee_rate"]
b = (base or "ETH").strip().upper()
move = _f(move_value)
mode = (move_mode or "points").strip().lower()
if mode not in ("points", "pct", "percent", "rate"):
return None, "波动模式须为 points 或 pct"
if mode in ("percent", "rate"):
mode = "pct"
if move is None:
return None, "参数格式错误"
if move <= 0:
return None, "现价、资金、波动、杠杆须大于 0"
if mode == "pct":
move_points = s * (move / 100.0)
else:
move_points = move
if move_points <= 0:
return None, "波动对应价格变动须大于 0"
exit_px = s + move_points
perp_gross = move_points * PERP_COINS
fee = estimate_roundtrip_fee_usdt(s, exit_px, qty=PERP_COINS, contract_size=1.0)
premium_budget = perp_gross - target - fee
if premium_budget <= 0:
return None, "波动收益不足以覆盖目标盈利+手续费,无法开期权"
opt_coins = premium_budget / prem_per_coin
opt_sheets = opt_coins / ct
premium_total = opt_coins * prem_per_coin
case_a_net = perp_gross - premium_total - fee
opt_intrinsic = opt_coins * move_points
opt_net = opt_intrinsic - premium_total
perp_loss = -perp_gross
portfolio_net = opt_net + perp_loss
return {
"calc_mode": "size",
"base": b,
"spot": round(s, 8),
"capital_usdt": round(capital, 8),
"target_profit_u": round(target, 8),
"move_mode": mode,
"move_value": round(move, 8),
"move_points": round(move_points, 8),
"exit_price": round(exit_px, 8),
"perp_coins": PERP_COINS,
"perp_leverage": round(p_lev, 8),
"option_leverage": round(o_lev, 8),
"ct_mult": ct,
"prem_per_coin": round(prem_per_coin, 8),
"perp_gross_u": round(perp_gross, 8),
"perp_fee_u": round(fee, 8),
"fee_rate": fee_rate,
"premium_budget_u": round(premium_budget, 8),
"opt_coins": round(opt_coins, 8),
"opt_sheets": round(opt_sheets, 8),
"premium_total_u": round(premium_total, 8),
"perp_margin_u": round(margin, 8),
"capital_ok": bool(capital >= margin),
"case_a": {
"label": "永续方向对",
"perp_pnl_u": round(perp_gross, 8),
"premium_u": round(premium_total, 8),
"fee_u": round(fee, 8),
"net_u": round(case_a_net, 8),
},
"case_b": {
"label": "期权方向对",
"opt_intrinsic_u": round(opt_intrinsic, 8),
"premium_u": round(premium_total, 8),
"opt_net_u": round(opt_net, 8),
"perp_pnl_u": round(perp_loss, 8),
"portfolio_net_u": round(portfolio_net, 8),
},
"case_sideways": _case_sideways(spot=s, premium_total=premium_total),
}, None
def calc_perp_options_points(
*,
base: str = "ETH",
spot: float,
capital_usdt: float,
target_profit_u: float,
perp_leverage: float,
option_leverage: float,
ratio_perp: float = 1.0,
ratio_opt: float = 2.0,
ct_mult: float = DEFAULT_CT_MULT,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""按永续/期权币数 + 目标盈利,反推两套情景所需波动点数.
永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 ).
2:4 永续 2 + 期权 4 ;1:2 永续 1 + 期权 2 .
A 永续方向对: qty*move premium fee(move,qty) = 目标盈利
B 期权方向对:
- 期权净利达目标: opt_coins*move premium = 目标
- 组合净利达目标: move*(opt_coins perp_coins) premium = 目标
"""
common, err = _parse_base_common(
base=base,
spot=spot,
capital_usdt=capital_usdt,
target_profit_u=target_profit_u,
perp_leverage=perp_leverage,
option_leverage=option_leverage,
ct_mult=ct_mult,
)
if err or not common:
return None, err
rp = _f(ratio_perp)
ro = _f(ratio_opt)
if rp is None or ro is None or rp <= 0 or ro <= 0:
return None, "永续/期权币数须大于 0"
s = common["spot"]
capital = common["capital"]
target = common["target"]
p_lev = common["p_lev"]
o_lev = common["o_lev"]
ct = common["ct"]
prem_per_coin = common["prem_per_coin"]
fee_rate = common["fee_rate"]
b = (base or "ETH").strip().upper()
perp_coins = rp
opt_coins = ro
premium_total = opt_coins * prem_per_coin
opt_sheets = opt_coins / ct
margin = (s * perp_coins) / p_lev
move_a = _move_for_perp_correct(
spot=s,
target=target,
premium=premium_total,
fee_rate=fee_rate,
perp_coins=perp_coins,
)
if move_a <= 0:
return None, "无法解出永续方向对所需点数"
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=perp_coins, contract_size=1.0)
net_a = move_a * perp_coins - premium_total - fee_a
# 期权净利 = 目标
move_b_opt = (target + premium_total) / opt_coins
opt_net_at_b_opt = opt_coins * move_b_opt - premium_total
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins
# 组合净利 = 目标
edge = opt_coins - perp_coins
if edge <= 0:
move_b_port = None
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
else:
move_b_port = (target + premium_total) / edge
port_err = None
if move_b_port is not None:
opt_net_at_b_port = opt_coins * move_b_port - premium_total
portfolio_at_b_port = opt_net_at_b_port - move_b_port * perp_coins
else:
opt_net_at_b_port = None
portfolio_at_b_port = None
return {
"calc_mode": "points",
"base": b,
"spot": round(s, 8),
"capital_usdt": round(capital, 8),
"target_profit_u": round(target, 8),
"ratio_perp": round(rp, 8),
"ratio_opt": round(ro, 8),
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
"perp_coins": round(perp_coins, 8),
"opt_coins": round(opt_coins, 8),
"opt_sheets": round(opt_sheets, 8),
"perp_leverage": round(p_lev, 8),
"option_leverage": round(o_lev, 8),
"ct_mult": ct,
"prem_per_coin": round(prem_per_coin, 8),
"premium_total_u": round(premium_total, 8),
"fee_rate": fee_rate,
"perp_margin_u": round(margin, 8),
"capital_ok": bool(capital >= margin),
"case_a": {
"label": "永续方向对",
"move_points": round(move_a, 8),
"move_pct": round(move_a / s * 100.0, 8),
"perp_pnl_u": round(move_a * perp_coins, 8),
"premium_u": round(premium_total, 8),
"fee_u": round(fee_a, 8),
"net_u": round(net_a, 8),
},
"case_b": {
"label": "期权方向对",
"move_points_opt_net": round(move_b_opt, 8),
"move_pct_opt_net": round(move_b_opt / s * 100.0, 8),
"opt_net_u": round(opt_net_at_b_opt, 8),
"portfolio_net_at_opt_target_u": round(portfolio_at_b_opt, 8),
"move_points_portfolio": None if move_b_port is None else round(move_b_port, 8),
"move_pct_portfolio": None
if move_b_port is None
else round(move_b_port / s * 100.0, 8),
"opt_net_at_portfolio_target_u": None
if opt_net_at_b_port is None
else round(opt_net_at_b_port, 8),
"portfolio_net_u": None if portfolio_at_b_port is None else round(portfolio_at_b_port, 8),
"portfolio_error": port_err,
"premium_u": round(premium_total, 8),
},
"case_sideways": _case_sideways(
spot=s, premium_total=premium_total, perp_coins=perp_coins
),
}, None
def _fmt_ratio(v: float) -> str:
if abs(v - round(v)) < 1e-9:
return str(int(round(v)))
s = f"{v:.4f}".rstrip("0").rstrip(".")
return s
def calc_perp_options(
*,
calc_mode: str = "size",
**kwargs: Any,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""统一入口:size=由波动推仓位;points=由比例推点数."""
mode = (calc_mode or "size").strip().lower()
if mode in ("points", "ratio", "move"):
return calc_perp_options_points(**kwargs)
# size mode: ignore ratio kwargs if present
kwargs.pop("ratio_perp", None)
kwargs.pop("ratio_opt", None)
return calc_perp_options_hedge(**kwargs)
+25 -3
View File
@@ -7,7 +7,7 @@ import os
from typing import Callable from typing import Callable
from urllib.parse import parse_qsl, urlencode, urlsplit from urllib.parse import parse_qsl, urlencode, urlsplit
from flask import Flask, Response, jsonify, redirect, request, session from flask import Flask, Response, jsonify, make_response, redirect, request, session
from jinja2 import ChoiceLoader, FileSystemLoader from jinja2 import ChoiceLoader, FileSystemLoader
EMBED_TABS: tuple[str, ...] = ( EMBED_TABS: tuple[str, ...] = (
@@ -84,6 +84,11 @@ def embed_shell_enabled() -> bool:
return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on") 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): def redirect_to_embed_shell_if_enabled(page: str):
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换).""" """直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
if not embed_shell_enabled(): if not embed_shell_enabled():
@@ -93,6 +98,12 @@ def redirect_to_embed_shell_if_enabled(page: str):
if (request.path or "").rstrip("/") == "/embed": if (request.path or "").rstrip("/") == "/embed":
return None return None
q = {k: v for k, v in request.args.items()} 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["tab"] = page
q["embed"] = "1" q["embed"] = "1"
return redirect("/embed?" + urlencode(q)) 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) tab = path_to_embed_tab(split.path)
q = dict(parse_qsl(split.query, keep_blank_values=True)) q = dict(parse_qsl(split.query, keep_blank_values=True))
if tab: 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["tab"] = tab
q["embed"] = "1" q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower() ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
@@ -168,7 +184,10 @@ def register_embed_routes(
if tab not in EMBED_TABS: if tab not in EMBED_TABS:
tab = "trade" tab = "trade"
session["hub_embed_shell"] = True session["hub_embed_shell"] = True
return render_main_page_fn(tab, embed_mode="shell") resp = make_response(render_main_page_fn(tab, embed_mode="shell"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@login_required @login_required
@app.route("/api/embed/page/<tab>") @app.route("/api/embed/page/<tab>")
@@ -182,7 +201,10 @@ def register_embed_routes(
html = render_main_page_fn(tab, embed_mode="fragment") html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response): if isinstance(html, Response):
html = html.get_data(as_text=True) html = html.get_data(as_text=True)
return jsonify({"ok": True, "page": tab, "html": html}) resp = jsonify({"ok": True, "page": tab, "html": html})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
def pwa_app_name(exchange_key: str) -> str: def pwa_app_name(exchange_key: str) -> str:
+11 -5
View File
@@ -1279,6 +1279,12 @@ if(fullMarginEl){
} }
const sltpModeEl = document.getElementById("sltp-mode"); const sltpModeEl = document.getElementById("sltp-mode");
function setOmFieldVisible(inputEl, show){
if(!inputEl) return;
inputEl.style.display = show ? "" : "none";
const wrap = inputEl.closest(".om-field");
if(wrap) wrap.style.display = show ? "" : "none";
}
function toggleSltpMode(){ function toggleSltpMode(){
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
const slEl = document.getElementById("order-sl"); const slEl = document.getElementById("order-sl");
@@ -1289,14 +1295,14 @@ function toggleSltpMode(){
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
const pct = mode === "pct"; const pct = mode === "pct";
const fixed = mode === "fixed_rr"; const fixed = mode === "fixed_rr";
slEl.style.display = pct ? "none" : ""; setOmFieldVisible(slEl, !pct);
tpEl.style.display = (pct || fixed) ? "none" : ""; setOmFieldVisible(tpEl, !(pct || fixed));
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; setOmFieldVisible(fixedRrEl, fixed);
slEl.required = !pct; slEl.required = !pct;
tpEl.required = !pct && !fixed; tpEl.required = !pct && !fixed;
if(fixedRrEl) fixedRrEl.required = fixed; if(fixedRrEl) fixedRrEl.required = fixed;
slPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(slPctEl, pct);
tpPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(tpPctEl, pct);
slPctEl.required = pct; slPctEl.required = pct;
tpPctEl.required = pct; tpPctEl.required = pct;
refreshOrderTpPreview(); refreshOrderTpPreview();
@@ -97,50 +97,7 @@
{% endif %} {% endif %}
</div> </div>
{% include order_rule_tips_tpl %} {% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}"> {% include 'order_monitor_open_form.html' %}
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
{% if not intraday_discipline %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
{% include 'order_plan_preview_bar.html' %}
</div> </div>
<div class="card"> <div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2> <h2 style="margin-bottom:8px">实时持仓</h2>
+53 -4
View File
@@ -7,8 +7,8 @@
<script src="/static/autofill_guard.js?v=1"></script> <script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <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/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=11"> <link rel="stylesheet" href="/static/instance_page.css?v=12">
<link rel="stylesheet" href="/static/instance_theme.css?v=105"> <link rel="stylesheet" href="/static/instance_theme.css?v=109">
<script src="/static/account_risk_badge.js?v=4"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
@@ -93,6 +93,54 @@
</div> </div>
</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/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=4"></script> <script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></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> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </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_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=27"></script> <script src="/static/instance_embed.js?v=30"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script>
</body> </body>
</html> </html>
+14 -51
View File
@@ -17,8 +17,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=11"> <link rel="stylesheet" href="/static/instance_page.css?v=12">
<link rel="stylesheet" href="/static/instance_theme.css?v=105"> <link rel="stylesheet" href="/static/instance_theme.css?v=109">
</head> </head>
<body <body
@@ -174,50 +174,7 @@
{% endif %} {% endif %}
</div> </div>
{% include order_rule_tips_tpl %} {% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}"> {% include 'order_monitor_open_form.html' %}
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
{% if not intraday_discipline %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
{% include 'order_plan_preview_bar.html' %}
</div> </div>
<div class="card"> <div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2> <h2 style="margin-bottom:8px">实时持仓</h2>
@@ -1791,6 +1748,12 @@ if(fullMarginEl){
} }
const sltpModeEl = document.getElementById("sltp-mode"); const sltpModeEl = document.getElementById("sltp-mode");
function setOmFieldVisible(inputEl, show){
if(!inputEl) return;
inputEl.style.display = show ? "" : "none";
const wrap = inputEl.closest(".om-field");
if(wrap) wrap.style.display = show ? "" : "none";
}
function toggleSltpMode(){ function toggleSltpMode(){
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
const slEl = document.getElementById("order-sl"); const slEl = document.getElementById("order-sl");
@@ -1801,14 +1764,14 @@ function toggleSltpMode(){
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
const pct = mode === "pct"; const pct = mode === "pct";
const fixed = mode === "fixed_rr"; const fixed = mode === "fixed_rr";
slEl.style.display = pct ? "none" : ""; setOmFieldVisible(slEl, !pct);
tpEl.style.display = (pct || fixed) ? "none" : ""; setOmFieldVisible(tpEl, !(pct || fixed));
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; setOmFieldVisible(fixedRrEl, fixed);
slEl.required = !pct; slEl.required = !pct;
tpEl.required = !pct && !fixed; tpEl.required = !pct && !fixed;
if(fixedRrEl) fixedRrEl.required = fixed; if(fixedRrEl) fixedRrEl.required = fixed;
slPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(slPctEl, pct);
tpPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(tpPctEl, pct);
slPctEl.required = pct; slPctEl.required = pct;
tpPctEl.required = pct; tpPctEl.required = pct;
refreshOrderTpPreview(); refreshOrderTpPreview();
@@ -1,7 +1,7 @@
{# 统一顶栏:状态 + 筛选(上)· 统计条(下) #} {# 统一顶栏:状态 + 筛选(上)· 统计条(下) #}
<div class="instance-header-panel card"> <div class="instance-header-panel card">
<div class="instance-header-toolbar"> <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> <span class="list-window-label" title="列表按 UTC 时间筛选,默认本月">UTC {{ list_window.label }}</span>
<label class="list-window-preset">预设 <label class="list-window-preset">预设
<select id="win-preset-select" onchange="toggleListWindowCustom()"> <select id="win-preset-select" onchange="toggleListWindowCustom()">
@@ -37,4 +37,18 @@
<div class="instance-header-stats-wrap instance-desktop-only"> <div class="instance-header-stats-wrap instance-desktop-only">
{% include 'instance_header_stats.html' %} {% include 'instance_header_stats.html' %}
</div> </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> </div>
@@ -0,0 +1,76 @@
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
<form id="add-order-form" action="/add_order" method="post" class="order-monitor-form" data-risk-percent="{{ risk_percent }}">
<div class="om-row om-row-policy">
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode" title="止盈止损模式">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
</div>
<div class="om-row om-row-levels">
<label class="om-field" id="om-field-sl">
<span class="om-field-lab">止损价格</span>
<input id="order-sl" name="sl" step="any" placeholder="必填" required>
</label>
<label class="om-field" id="om-field-rr">
<span class="om-field-lab">盈亏比</span>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="默认1.5" value="1.5" title="止盈距离=止损距离×盈亏比">
</label>
<label class="om-field" id="om-field-tp" style="display:none">
<span class="om-field-lab">止盈价格</span>
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格">
</label>
<label class="om-field" id="om-field-sl-pct" style="display:none">
<span class="om-field-lab">止损%</span>
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%">
</label>
<label class="om-field" id="om-field-tp-pct" style="display:none">
<span class="om-field-lab">止盈%</span>
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%">
</label>
<div class="om-live-meta">
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">成交价以交易所回报为准</span>
</div>
</div>
<div class="om-row om-row-opts">
{% if not intraday_discipline %}
<label class="om-check">
<input type="checkbox" name="breakeven_enabled" value="1" checked>
<span>移动保本</span>
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap om-time-close">
<label class="om-check" style="margin:0">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb">
<span>时间平仓</span>
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label class="om-check" title="开仓后生成多周期K线图(各周期100根,含开平仓标记)">
<input type="checkbox" name="order_chart" value="true">
<span>开仓后生成多周期K线</span>
</label>
</div>
<div class="om-row om-row-action">
<button type="submit" class="om-submit">{{ open_position_button_label }}</button>
</div>
</form>
{% include 'order_plan_preview_bar.html' %}
+25 -2
View File
@@ -6,9 +6,21 @@
</div> </div>
{% if settings_tabs %} {% 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 %} {% 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 %}> {% 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" data-settings-tab="{{ tab.key }}"{% if loop.index0 == ns.active_idx %} checked{% endif %}>
{% endfor %} {% endfor %}
<div class="env-config-tabs" role="tablist" aria-label="系统设置分类"> <div class="env-config-tabs" role="tablist" aria-label="系统设置分类">
{% for tab in settings_tabs %} {% for tab in settings_tabs %}
@@ -52,3 +64,14 @@
{% include 'options_settings_panel.html' %} {% include 'options_settings_panel.html' %}
{% endif %} {% endif %}
</div> </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>
+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) 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( def calc_order_size(
*, *,
quote_per_unit: float, quote_per_unit: float,
+7 -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]: 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.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) raw = fetch_options_trading_usdc(ex)
if raw is None or float(raw) <= 0: if raw is None or float(raw) <= 0:
return None, "交易账户 USDC 可用余额不足" 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: def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
+6 -2
View File
@@ -1,6 +1,7 @@
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root" <div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}" data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}" 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 %}"> 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 %} {% 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> <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>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li> <li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;<strong>T 型</strong>默认 ATM ±5 档,可展开全部。</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> <li>平仓仅买一限价,详见说明文档。</li>
</ul> </ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p> <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" <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"> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div> </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="备注(关键位说明)" <input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly> data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
@@ -316,4 +320,4 @@
</div> </div>
</div> </div>
<script src="/static/options_expiry_countdown.js?v=1"></script> <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>
+12 -23
View File
@@ -12,8 +12,7 @@ from lib.hub.amp_stats_lib import (
build_export_csv, build_export_csv,
compute_amp_stats, compute_amp_stats,
export_filename, export_filename,
normalize_straddle_premium, normalize_move_points,
normalize_take_profit,
normalize_weekend_filter, normalize_weekend_filter,
reframe_amp_stats, reframe_amp_stats,
rows_page, rows_page,
@@ -25,8 +24,7 @@ class ComputeBody(BaseModel):
start_hour: int = 16 start_hour: int = 16
period: str = "2m" period: str = "2m"
custom_days: Optional[int] = None custom_days: Optional[int] = None
straddle_premium: Optional[float] = None move_points: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all" weekend_filter: str = "all"
page: int = 1 page: int = 1
page_size: int = 20 page_size: int = 20
@@ -37,15 +35,14 @@ class SaveBody(BaseModel):
class ReframeBody(BaseModel): class ReframeBody(BaseModel):
"""已有日表上改周末/权利金/止盈(不拉 K 线).""" """已有日表上改周末/波动点数(不拉 K 线)."""
rows_all: list[dict[str, Any]] = Field(default_factory=list) rows_all: list[dict[str, Any]] = Field(default_factory=list)
symbol: str = "eth" symbol: str = "eth"
start_hour: int = 16 start_hour: int = 16
period: str = "2m" period: str = "2m"
sample_days: int = 60 sample_days: int = 60
straddle_premium: Optional[float] = None move_points: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all" weekend_filter: str = "all"
price_source: str = "" price_source: str = ""
inst_id: str = "" inst_id: str = ""
@@ -84,7 +81,7 @@ def create_amp_stats_router() -> APIRouter:
"default_weekend_filter": "all", "default_weekend_filter": "all",
"timeframe": "1H", "timeframe": "1H",
"metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)",
"straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金", "move_points_note": "填波动点数后统计振幅≥该点数的天数占比;日表显示开→高/开→低两边点数与振幅是否达标",
} }
@router.post("/compute") @router.post("/compute")
@@ -95,8 +92,7 @@ def create_amp_stats_router() -> APIRouter:
start_hour=body.start_hour, start_hour=body.start_hour,
period=body.period, period=body.period,
custom_days=body.custom_days, custom_days=body.custom_days,
straddle_premium=body.straddle_premium, move_points=body.move_points,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter, weekend_filter=body.weekend_filter,
) )
except ValueError as exc: except ValueError as exc:
@@ -122,8 +118,7 @@ def create_amp_stats_router() -> APIRouter:
start_hour=body.start_hour, start_hour=body.start_hour,
period=body.period, period=body.period,
sample_days=body.sample_days, sample_days=body.sample_days,
straddle_premium=body.straddle_premium, move_points=body.move_points,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter, weekend_filter=body.weekend_filter,
price_source=body.price_source, price_source=body.price_source,
inst_id=body.inst_id, inst_id=body.inst_id,
@@ -165,8 +160,7 @@ def create_amp_stats_router() -> APIRouter:
start_hour: int = Query(default=16), start_hour: int = Query(default=16),
period: str = Query(default="2m"), period: str = Query(default="2m"),
custom_days: Optional[int] = Query(default=None), custom_days: Optional[int] = Query(default=None),
straddle_premium: Optional[float] = Query(default=None), move_points: Optional[float] = Query(default=None),
take_profit: Optional[float] = Query(default=None),
weekend_filter: str = Query(default="all"), weekend_filter: str = Query(default="all"),
): ):
if (history_id or "").strip(): if (history_id or "").strip():
@@ -174,6 +168,7 @@ def create_amp_stats_router() -> APIRouter:
if not item: if not item:
raise HTTPException(status_code=404, detail="历史不存在") raise HTTPException(status_code=404, detail="历史不存在")
rows_all = item.get("rows_all") or item.get("rows") or [] rows_all = item.get("rows_all") or item.get("rows") or []
use_mp = move_points if move_points is not None else item.get("move_points")
try: try:
payload = reframe_amp_stats( payload = reframe_amp_stats(
rows_all=rows_all, rows_all=rows_all,
@@ -181,10 +176,7 @@ def create_amp_stats_router() -> APIRouter:
start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour), 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), period=str(item.get("period") or period),
sample_days=int(item.get("sample_days_requested") or 60), sample_days=int(item.get("sample_days_requested") or 60),
straddle_premium=straddle_premium move_points=use_mp,
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", weekend_filter=weekend_filter or item.get("weekend_filter") or "all",
price_source=str(item.get("price_source") or ""), price_source=str(item.get("price_source") or ""),
inst_id=str(item.get("inst_id") or ""), inst_id=str(item.get("inst_id") or ""),
@@ -194,17 +186,14 @@ def create_amp_stats_router() -> APIRouter:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
else: else:
try: try:
# validate enums early
normalize_weekend_filter(weekend_filter) normalize_weekend_filter(weekend_filter)
normalize_straddle_premium(straddle_premium) normalize_move_points(move_points)
normalize_take_profit(take_profit)
payload = compute_amp_stats( payload = compute_amp_stats(
symbol=symbol, symbol=symbol,
start_hour=start_hour, start_hour=start_hour,
period=period, period=period,
custom_days=custom_days, custom_days=custom_days,
straddle_premium=straddle_premium, move_points=move_points,
take_profit=take_profit,
weekend_filter=weekend_filter, weekend_filter=weekend_filter,
) )
except ValueError as exc: except ValueError as exc:
+53
View File
@@ -1269,6 +1269,21 @@ class RollCalculatorBody(BaseModel):
base: str = "ETH" 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): class CompareOptionLegBody(BaseModel):
opt_type: str = "C" opt_type: str = "C"
strike: float | None = None strike: float | None = None
@@ -1350,6 +1365,44 @@ def api_calculator_roll(body: RollCalculatorBody):
return {"ok": True, "data": data} 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") @app.post("/api/compare/calc")
def api_compare_calc(body: CompareBody): def api_compare_calc(body: CompareBody):
from lib.hub.hub_compare_lib import run_compare from lib.hub.hub_compare_lib import run_compare
+53 -80
View File
@@ -1,5 +1,5 @@
/** /**
* 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选. * 中控振幅统计:OKX ETH/BTC + 波动点数振幅占比 + 周末筛选.
*/ */
(function () { (function () {
const page = document.getElementById("page-amp-stats"); const page = document.getElementById("page-amp-stats");
@@ -39,16 +39,8 @@
return (n * 100).toFixed(1) + "%"; return (n * 100).toFixed(1) + "%";
} }
function readPremium() { function readMovePoints() {
const raw = (el("amp-straddle-premium")?.value || "").trim(); const raw = (el("amp-move-points")?.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; if (!raw) return null;
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null; if (!Number.isFinite(n) || n <= 0) return null;
@@ -95,19 +87,13 @@
} }
} }
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) { function renderSummary(summary, result) {
const box = el("amp-summary"); const box = el("amp-summary");
if (!box) return; if (!box) return;
const s = summary || {}; const s = summary || {};
if (!s.sample_count) { if (!s.sample_count) {
box.innerHTML = '<p class="amp-empty">暂无汇总</p>'; box.innerHTML = '<p class="amp-empty">暂无汇总</p>';
renderStraddle(null); renderMoveStats(null);
return; return;
} }
box.innerHTML = box.innerHTML =
@@ -116,44 +102,31 @@
`<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.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.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.median_amplitude)}</span></div>` +
`<div><span class="amp-sum-k">两日最大振幅</span><span class="amp-sum-v">${esc(s.max_amplitude_2d)} <small>(${esc(s.max_amplitude_2d_day)})</small></span></div>` +
`<div><span class="amp-sum-k">两日振幅均值/中位</span><span class="amp-sum-v">${esc(s.avg_amplitude_2d)} / ${esc(s.median_amplitude_2d)}</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_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.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(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><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
`</div>`; `</div>`;
renderStraddle(s.straddle); renderMoveStats(s.move_points_stats);
} }
function renderStraddle(st) { function renderMoveStats(ms) {
const box = el("amp-straddle"); const box = el("amp-move-stats");
if (!box) return; if (!box) return;
if (!st) { if (!ms) {
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>'; box.innerHTML = '<p class="amp-empty">填写「波动点数」后计算,可看振幅≥该点数的天数占比</p>';
return; 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 = box.innerHTML =
`<div class="amp-sum-grid">` + `<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">双边权利金</span><span class="amp-sum-v">${esc(st.premium)}</span></div>` + `<div><span class="amp-sum-k">对照点数</span><span class="amp-sum-v">${esc(ms.move_points)}</span></div>` +
tpLine + `<div><span class="amp-sum-k">振幅≥点数</span><span class="amp-sum-v">${esc(ms.amp_hit_days)} 天 · <strong>${esc(pct(ms.amp_hit_ratio))}</strong></span></div>` +
`<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(ms.amp_2d_hit_days)} 天 · <strong>${esc(pct(ms.amp_2d_hit_ratio))}</strong></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(ms.up_hit_days)} 天 · ${esc(pct(ms.up_hit_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">${esc(ms.down_hit_days)} 天 · ${esc(pct(ms.down_hit_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">${esc(ms.abs_change_hit_days)} 天 · ${esc(pct(ms.abs_change_hit_ratio))}</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>`; `</div>`;
} }
@@ -165,21 +138,31 @@
return day; return day;
} }
function hitCell(r) {
if (r.move_points == null) return "—";
if (r.amp_hit) return '<span class="amp-pnl is-pos">是</span>';
return '<span class="amp-pnl is-neg">否</span>';
}
function renderTable(pagePayload) { function renderTable(pagePayload) {
const body = el("amp-table-body"); const body = el("amp-table-body");
const pager = el("amp-pager"); const pager = el("amp-pager");
if (!body) return; if (!body) return;
const rows = (pagePayload && pagePayload.rows) || []; const rows = (pagePayload && pagePayload.rows) || [];
if (!rows.length) { if (!rows.length) {
body.innerHTML = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>'; body.innerHTML = '<tr><td colspan="12" class="amp-empty">暂无数据</td></tr>';
} else { } else {
body.innerHTML = rows body.innerHTML = rows
.map((r) => { .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"' : ""; const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
const upCls = r.hit_up ? ' class="amp-pnl is-pos"' : "";
const downCls = r.hit_down ? ' class="amp-pnl is-pos"' : "";
const ampCls = r.amp_hit ? ' class="amp-pnl is-pos"' : "";
const amp2Cls = r.amp_hit_2d ? ' class="amp-pnl is-pos"' : "";
const amp2 =
r.amplitude_2d == null || r.amplitude_2d === ""
? "—"
: `<span title="${esc(r.window2_start || "")}${esc(r.window2_end || "")}">${esc(r.amplitude_2d)}</span>`;
return ( return (
`<tr${trClass}>` + `<tr${trClass}>` +
`<td>${dayLabel(r)}</td>` + `<td>${dayLabel(r)}</td>` +
@@ -188,11 +171,12 @@
`<td>${esc(r.high)}</td>` + `<td>${esc(r.high)}</td>` +
`<td>${esc(r.low)}</td>` + `<td>${esc(r.low)}</td>` +
`<td>${esc(r.close)}</td>` + `<td>${esc(r.close)}</td>` +
`<td>${esc(r.up_points)}</td>` + `<td${upCls}>${esc(r.up_points)}</td>` +
`<td>${esc(r.down_points)}</td>` + `<td${downCls}>${esc(r.down_points)}</td>` +
`<td><strong>${esc(r.amplitude)}</strong></td>` + `<td${ampCls}><strong>${esc(r.amplitude)}</strong></td>` +
`<td${amp2Cls}><strong>${amp2}</strong></td>` +
`<td>${esc(r.change)}</td>` + `<td>${esc(r.change)}</td>` +
`<td>${profit}</td>` + `<td>${hitCell(r)}</td>` +
`</tr>` `</tr>`
); );
}) })
@@ -226,7 +210,7 @@
async function reframe(resetPage) { async function reframe(resetPage) {
if (!lastResult) { if (!lastResult) {
renderStraddle(null); renderMoveStats(null);
return; return;
} }
if (resetPage) pageNo = 1; if (resetPage) pageNo = 1;
@@ -242,8 +226,7 @@
start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16), start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16),
period: lastResult.period || el("amp-period")?.value || "2m", period: lastResult.period || el("amp-period")?.value || "2m",
sample_days: lastResult.sample_days_requested || 60, sample_days: lastResult.sample_days_requested || 60,
straddle_premium: readPremium(), move_points: readMovePoints(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
price_source: lastResult.price_source || "", price_source: lastResult.price_source || "",
inst_id: lastResult.inst_id || "", inst_id: lastResult.inst_id || "",
@@ -284,8 +267,7 @@
start_hour: startHour, start_hour: startHour,
period, period,
custom_days: period === "custom" ? customDays : null, custom_days: period === "custom" ? customDays : null,
straddle_premium: readPremium(), move_points: readMovePoints(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
page: pageNo, page: pageNo,
page_size: 20, page_size: 20,
@@ -331,8 +313,7 @@
const startHour = Number(el("amp-start-hour")?.value || 16); const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m"; const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60); const customDays = Number(el("amp-custom-days")?.value || 60);
const prem = readPremium(); const mp = readMovePoints();
const tp = readTakeProfit();
const q = new URLSearchParams({ const q = new URLSearchParams({
symbol, symbol,
start_hour: String(startHour), start_hour: String(startHour),
@@ -340,8 +321,7 @@
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
}); });
if (period === "custom") q.set("custom_days", String(customDays)); if (period === "custom") q.set("custom_days", String(customDays));
if (prem != null) q.set("straddle_premium", String(prem)); if (mp != null) q.set("move_points", String(mp));
if (tp != null) q.set("take_profit", String(tp));
window.location.href = "/api/amp-stats/export?" + q.toString(); window.location.href = "/api/amp-stats/export?" + q.toString();
} }
@@ -375,16 +355,13 @@
const id = card.getAttribute("data-id"); const id = card.getAttribute("data-id");
card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id)); card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id));
card.querySelector(".amp-hist-dl")?.addEventListener("click", () => { card.querySelector(".amp-hist-dl")?.addEventListener("click", () => {
const prem = readPremium(); const mp = readMovePoints();
const tp = readTakeProfit(); const q = new URLSearchParams({
let url = history_id: id,
"/api/amp-stats/export?history_id=" + weekend_filter: readWeekend(),
encodeURIComponent(id) + });
"&weekend_filter=" + if (mp != null) q.set("move_points", String(mp));
encodeURIComponent(readWeekend()); window.location.href = "/api/amp-stats/export?" + q.toString();
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 () => { card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
if (!confirm("删除该历史记录?")) return; if (!confirm("删除该历史记录?")) return;
@@ -405,11 +382,8 @@
if (lastResult) { if (lastResult) {
if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth"; 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 (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16);
if (lastResult.straddle_premium != null && el("amp-straddle-premium")) { if (lastResult.move_points != null && el("amp-move-points")) {
el("amp-straddle-premium").value = String(lastResult.straddle_premium); el("amp-move-points").value = String(lastResult.move_points);
}
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")) { if (lastResult.weekend_filter && el("amp-weekend-filter")) {
el("amp-weekend-filter").value = lastResult.weekend_filter; el("amp-weekend-filter").value = lastResult.weekend_filter;
@@ -434,8 +408,7 @@
el("amp-btn-compute")?.addEventListener("click", () => void compute(true)); el("amp-btn-compute")?.addEventListener("click", () => void compute(true));
el("amp-btn-save")?.addEventListener("click", () => void saveHistory()); el("amp-btn-save")?.addEventListener("click", () => void saveHistory());
el("amp-btn-download")?.addEventListener("click", downloadCurrent); el("amp-btn-download")?.addEventListener("click", downloadCurrent);
el("amp-straddle-premium")?.addEventListener("input", scheduleReframe); el("amp-move-points")?.addEventListener("input", scheduleReframe);
el("amp-take-profit")?.addEventListener("input", scheduleReframe);
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true)); el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
syncCustomDays(); syncCustomDays();
} }
@@ -445,7 +418,7 @@
bind(); bind();
setView("stats"); setView("stats");
setStatus(""); setStatus("");
renderStraddle(null); renderMoveStats(null);
}, },
}; };
})(); })();
+29 -2
View File
@@ -4708,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="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; display: none;
} }
@@ -9776,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="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; 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 { body:not(.hub-phone) #page-calculator .calc-card {
padding: 0; padding: 0;
background: transparent; background: transparent;
+317 -1
View File
@@ -572,8 +572,312 @@
} }
} }
function renderSidewaysCase(sw) {
sw = sw || {};
return (
'<section class="calc-po-case">' +
"<h4>情景 C · 横盘(最大亏损)</h4>" +
'<div class="calc-summary">' +
"<div><span>永续盈亏</span><strong>" +
fmtU(sw.perp_pnl_u != null ? sw.perp_pnl_u : 0) +
"</strong></div>" +
"<div><span>权利金(全亏)</span><strong>" +
fmt(sw.premium_u, 2) +
"U</strong></div>" +
"<div><span>永续开平手续费</span><strong>" +
fmt(sw.fee_u, 2) +
"U</strong></div>" +
"<div><span>最大亏损</span><strong class=\"" +
pnlClass(sw.net_u) +
'">' +
fmt(sw.max_loss_u, 2) +
"U</strong></div>" +
"<div><span>组合净利</span><strong class=\"" +
pnlClass(sw.net_u) +
'">' +
fmtU(sw.net_u) +
"</strong></div>" +
"</div></section>"
);
}
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 class=\"" +
pnlClass((data.case_sideways || {}).net_u) +
'">' +
fmt((data.case_sideways || {}).max_loss_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>" +
renderSidewaysCase(data.case_sideways) +
"</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 class=\"" +
pnlClass((data.case_sideways || {}).net_u) +
'">' +
fmt((data.case_sideways || {}).max_loss_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 +
renderSidewaysCase(data.case_sideways) +
"</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) { function applyCalcTab(tab) {
const t = tab === "roll" ? "roll" : "trend"; const t = tab === "roll" || tab === "po" ? tab : "trend";
const layout = page.querySelector(".calc-layout"); const layout = page.querySelector(".calc-layout");
if (layout) layout.setAttribute("data-calc-tab", t); if (layout) layout.setAttribute("data-calc-tab", t);
page.querySelectorAll(".calc-m-tab").forEach(function (btn) { page.querySelectorAll(".calc-m-tab").forEach(function (btn) {
@@ -609,13 +913,25 @@
await loadCalculatorExchanges(); await loadCalculatorExchanges();
const trendForm = $("calc-trend-form"); const trendForm = $("calc-trend-form");
const rollForm = $("calc-roll-form"); const rollForm = $("calc-roll-form");
const poForm = $("calc-po-form");
const dirSel = $("calc-trend-direction"); const dirSel = $("calc-trend-direction");
const poMode = $("calc-po-move-mode");
const poCalcMode = $("calc-po-calc-mode");
if (trendForm) trendForm.addEventListener("submit", submitTrend); if (trendForm) trendForm.addEventListener("submit", submitTrend);
if (rollForm) rollForm.addEventListener("submit", submitRoll); if (rollForm) rollForm.addEventListener("submit", submitRoll);
if (poForm) poForm.addEventListener("submit", submitPerpOptions);
if (dirSel) { if (dirSel) {
dirSel.addEventListener("change", syncTrendAddLabel); dirSel.addEventListener("change", syncTrendAddLabel);
syncTrendAddLabel(); syncTrendAddLabel();
} }
if (poMode) {
poMode.addEventListener("change", syncPoMoveLabel);
syncPoMoveLabel();
}
if (poCalcMode) {
poCalcMode.addEventListener("change", syncPoCalcMode);
syncPoCalcMode();
}
bindRollLegsUI(); bindRollLegsUI();
bindMarket("calc-trend"); bindMarket("calc-trend");
bindMarket("calc-roll"); bindMarket("calc-roll");
+91 -14
View File
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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'" /> <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> <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=20260724-opt-archive" /> <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/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" /> <link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script> <script src="/assets/account_risk_badge.js?v=4"></script>
@@ -818,6 +818,9 @@
<button type="button" class="calc-m-tab" data-calc-tab="roll" role="tab" aria-selected="false"> <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> <span class="calc-tab-label-mobile">滚仓</span><span class="calc-tab-label-desktop">滚仓计算器</span>
</button> </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>
<div class="calc-layout" data-calc-tab="trend"> <div class="calc-layout" data-calc-tab="trend">
<section class="calc-card card" data-calc-pane="trend"> <section class="calc-card card" data-calc-pane="trend">
@@ -958,6 +961,84 @@
</aside> </aside>
</div> </div>
</section> </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">推点数模式:「永续币数/期权币数」按绝对仓位计算(填 2 与 4 → 永续 2 币 + 期权 4 币,不再归一成 1:2).单币权利金 = 现价 / 期权杠杆;权利金按全亏;只扣永续开平手续费(各 0.05%).「推仓位」仍固定永续 1 币、由波动反推期权数量.横盘最大亏损 = 权利金全亏 + 永续开平同价手续费.</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>
</div> </div>
@@ -1184,12 +1265,8 @@
</select> </select>
</label> </label>
<label class="amp-field"> <label class="amp-field">
<span>买跨·双边权利金(点)</span> <span>波动点数</span>
<input id="amp-straddle-premium" type="number" min="0" step="any" placeholder="如 30" /> <input id="amp-move-points" type="number" min="0" step="any" placeholder="如 50" />
</label>
<label class="amp-field">
<span>止盈点(点)</span>
<input id="amp-take-profit" type="number" min="0" step="any" placeholder="空=按涨跌" />
</label> </label>
<div class="amp-actions"> <div class="amp-actions">
<button type="button" id="amp-btn-compute" class="primary">计算</button> <button type="button" id="amp-btn-compute" class="primary">计算</button>
@@ -1198,22 +1275,22 @@
</div> </div>
</div> </div>
<p id="amp-status" class="toolbar-meta amp-status"></p> <p id="amp-status" class="toolbar-meta amp-status"></p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.</p> <p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.两日振幅=起点再往前推1天到当日16:00(例:25日16:00→27日16:00).填写波动点数后看振幅/两日振幅≥该点数的天数占比.周末按结算日标注/筛选.</p>
<h3 class="amp-block-title">汇总</h3> <h3 class="amp-block-title">汇总</h3>
<div id="amp-summary" class="amp-summary"></div> <div id="amp-summary" class="amp-summary"></div>
<h3 class="amp-block-title">买跨对照</h3> <h3 class="amp-block-title">振幅占比</h3>
<div id="amp-straddle" class="amp-summary amp-straddle"></div> <div id="amp-move-stats" class="amp-summary amp-move-stats"></div>
<h3 class="amp-block-title">日表明细</h3> <h3 class="amp-block-title">日表明细</h3>
<div class="amp-table-wrap"> <div class="amp-table-wrap">
<table class="amp-table"> <table class="amp-table">
<thead> <thead>
<tr> <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><th>振幅</th><th>两日振幅</th><th>涨跌</th><th>振幅达标</th>
</tr> </tr>
</thead> </thead>
<tbody id="amp-table-body"> <tbody id="amp-table-body">
<tr><td colspan="11" class="amp-empty">点击「计算」加载</td></tr> <tr><td colspan="12" class="amp-empty">点击「计算」加载</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1674,7 +1751,7 @@
<script src="/assets/chart_draw.js?v=20260720-option-day-1600"></script> <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/chart.js?v=20260720-option-day-1600"></script>
<script src="/assets/plan.js?v=20260720-autofill"></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=20260729-po-abs-coins"></script>
<script src="/assets/compare.js?v=20260723-compare"></script> <script src="/assets/compare.js?v=20260723-compare"></script>
<script src="/assets/trade_stats_calendar.js?v=3"></script> <script src="/assets/trade_stats_calendar.js?v=3"></script>
<script src="/assets/archive.js?v=20260724-opt-archive"></script> <script src="/assets/archive.js?v=20260724-opt-archive"></script>
@@ -1682,7 +1759,7 @@
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script> <script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script> <script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
<script src="/assets/strategy.js?v=11"></script> <script src="/assets/strategy.js?v=11"></script>
<script src="/assets/amp_stats.js?v=5"></script> <script src="/assets/amp_stats.js?v=20260728-hedge"></script>
<script src="/assets/help.js?v=1"></script> <script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.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/ai_review_render.js?v=3"></script>
+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()
+40 -54
View File
@@ -34,6 +34,14 @@ class AmpStatsLibTests(unittest.TestCase):
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-22 08:00") 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") self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-22 16:00")
def test_window_two_day_16_to_16(self):
# 结算 27 日 → 两日窗 25日16:00 → 27日16:00
start, end = window_bounds_for_settlement(date(2026, 7, 27), 16, span_days=2)
self.assertEqual(start.strftime("%Y-%m-%d %H:%M"), "2026-07-25 16:00")
self.assertEqual(end.strftime("%Y-%m-%d %H:%M"), "2026-07-27 16:00")
one_start, _ = window_bounds_for_settlement(date(2026, 7, 27), 16, span_days=1)
self.assertEqual(one_start.strftime("%Y-%m-%d %H:%M"), "2026-07-26 16:00")
def test_settlement_excludes_incomplete_today(self): def test_settlement_excludes_incomplete_today(self):
now = datetime(2026, 7, 22, 10, 0, tzinfo=TZ) now = datetime(2026, 7, 22, 10, 0, tzinfo=TZ)
days = list_settlement_dates(sample_days=3, now=now) days = list_settlement_dates(sample_days=3, now=now)
@@ -89,41 +97,38 @@ class AmpStatsLibTests(unittest.TestCase):
self.assertEqual(s["max_amplitude_day"], "2026-07-02") self.assertEqual(s["max_amplitude_day"], "2026-07-02")
self.assertEqual(s["max_up_points"], 500) self.assertEqual(s["max_up_points"], 500)
self.assertEqual(s["max_down_points"], 200) self.assertEqual(s["max_down_points"], 200)
self.assertIsNone(s["straddle"]) self.assertIsNone(s["move_points_stats"])
def test_long_straddle_stats(self): def test_move_points_amp_ratio(self):
rows = [ rows = [
# |chg|=40>30 win+10; up=40>30; down=10 {"amplitude": 100, "up_points": 40, "down_points": 60, "change": 10, "settlement_day": "2026-07-01"},
{"up_points": 40, "down_points": 10, "change": 40, "amplitude": 50, "settlement_day": "2026-07-01"}, {"amplitude": 40, "up_points": 10, "down_points": 30, "change": -5, "settlement_day": "2026-07-02"},
# |chg|=10 lose-20; up=5; down=35>30 {"amplitude": 50, "up_points": 50, "down_points": 0, "change": 20, "settlement_day": "2026-07-03"},
{"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) s = summarize_rows(rows, move_points=50)
st = s["straddle"] ms = s["move_points_stats"]
self.assertEqual(st["side"], "long_straddle") self.assertIsNotNone(ms)
self.assertEqual(st["premium"], 30) self.assertEqual(ms["move_points"], 50)
self.assertEqual(st["up_exceed_days"], 1) # only 40 self.assertEqual(ms["amp_hit_days"], 2) # 100, 50
self.assertEqual(st["down_exceed_days"], 1) # only 35 self.assertEqual(ms["amp_hit_ratio"], round(2 / 3, 4))
self.assertEqual(st["abs_change_exceed_days"], 1) # only 40 self.assertEqual(ms["up_hit_days"], 1) # 50
self.assertAlmostEqual(st["pnl_total"], 40 - 30 + 10 - 30 + 30 - 30) self.assertEqual(ms["down_hit_days"], 1) # 60
self.assertEqual(st["win_days"], 1)
self.assertEqual(st["win_ratio"], round(1 / 3, 4))
csv_text = build_export_csv( csv_text = build_export_csv(
{"exchange": "okx", "symbol_label": "ETH", "summary": s, "rows": rows, "start_hour": 22, "end_hour": 16} {
"exchange": "okx",
"symbol_label": "ETH",
"summary": s,
"rows": rows,
"start_hour": 16,
"end_hour": 16,
}
) )
self.assertIn("买跨对照", csv_text) self.assertIn("振幅占比", csv_text)
self.assertIn("买跨点数盈亏合计", csv_text) self.assertIn("振幅达标", csv_text)
def test_take_profit_and_weekend(self): def test_weekend_and_reframe_move_points(self):
from lib.hub.amp_stats_lib import ( from lib.hub.amp_stats_lib import enrich_rows, filter_weekend_rows, reframe_amp_stats
enrich_rows_pnl,
filter_weekend_rows,
reframe_amp_stats,
)
# Sat 2026-07-18, Sun 2026-07-19, Mon 2026-07-20
rows = [ rows = [
{ {
"settlement_day": "2026-07-18", "settlement_day": "2026-07-18",
@@ -159,39 +164,20 @@ class AmpStatsLibTests(unittest.TestCase):
only = filter_weekend_rows(rows, "only") only = filter_weekend_rows(rows, "only")
self.assertEqual(len(only), 2) self.assertEqual(len(only), 2)
# TP=80: day1 hit → move 80; day2 no → |12|; day3 no → 8 enriched = enrich_rows(rows, move_points=80)
enriched = enrich_rows_pnl(rows, straddle_premium=10, take_profit=80) self.assertTrue(enriched[0]["amp_hit"])
self.assertTrue(enriched[0]["take_profit_hit"]) self.assertFalse(enriched[1]["amp_hit"])
self.assertEqual(enriched[0]["effective_move"], 80) self.assertTrue(enriched[2]["amp_hit"])
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( reframed = reframe_amp_stats(
rows_all=rows, rows_all=rows,
symbol="eth", symbol="eth",
weekend_filter="exclude", weekend_filter="exclude",
straddle_premium=10, move_points=80,
take_profit=80,
) )
self.assertEqual(reframed["summary"]["sample_count"], 1) self.assertEqual(reframed["summary"]["sample_count"], 1)
# Mon: 未触达止盈 → |8|-10 self.assertTrue(reframed["rows"][0]["amp_hit"])
self.assertEqual(reframed["rows"][0]["profit"], -2) self.assertIn("振幅占比", build_export_csv(reframed))
self.assertIn("收益", build_export_csv(reframed))
def test_fetch_switches_to_history_endpoint(self): def test_fetch_switches_to_history_endpoint(self):
"""近期接口到头后应切 history 续拉.""" """近期接口到头后应切 history 续拉."""
+175
View File
@@ -0,0 +1,175 @@
"""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"])
sw = data["case_sideways"]
self.assertAlmostEqual(sw["premium_u"], data["premium_total_u"], places=6)
self.assertAlmostEqual(sw["fee_u"], 1.8, places=6)
self.assertAlmostEqual(sw["max_loss_u"], data["premium_total_u"] + 1.8, places=6)
self.assertAlmostEqual(sw["net_u"], -sw["max_loss_u"], places=6)
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)
# 横盘:权利金36 + 同价开平费 2*1800*0.0005=1.8 → 37.8
sw = data["case_sideways"]
self.assertAlmostEqual(sw["premium_u"], 36.0, places=6)
self.assertAlmostEqual(sw["fee_u"], 1.8, places=6)
self.assertAlmostEqual(sw["max_loss_u"], 37.8, places=6)
self.assertAlmostEqual(sw["net_u"], -37.8, places=6)
def test_points_absolute_2_to_4(self):
# 2:4 按绝对币数,不再归一成 1:2
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=2,
ratio_opt=4,
ct_mult=0.01,
)
self.assertIsNone(err)
assert data is not None
self.assertEqual(data["perp_coins"], 2.0)
self.assertEqual(data["opt_coins"], 4.0)
self.assertEqual(data["premium_total_u"], 72.0)
self.assertAlmostEqual(data["perp_margin_u"], 360.0, places=6)
expect_a = (15 + 72 + 2 * 1800 * 2 * 0.0005) / (2 * 0.9995)
self.assertAlmostEqual(data["case_a"]["move_points"], expect_a, places=6)
self.assertAlmostEqual(data["case_a"]["net_u"], 15.0, places=5)
self.assertAlmostEqual(data["case_b"]["move_points_portfolio"], 43.5, places=6)
self.assertAlmostEqual(data["case_b"]["portfolio_net_u"], 15.0, places=6)
sw = data["case_sideways"]
self.assertAlmostEqual(sw["fee_u"], 3.6, places=6)
self.assertAlmostEqual(sw["max_loss_u"], 75.6, 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()
+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