94 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:13:38 +08:00
dekun a4be294c06 Add personal options-and-Gate trading playbook doc.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:04:46 +08:00
dekun 1a163c0a43 Fix snapshot tag commit hash in docs table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:52:59 +08:00
dekun 2a60d47b2d Document git snapshot tags including snapshot/20260721.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:52:35 +08:00
dekun 64b24fd6a6 Add repository code statistics snapshot doc.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:48:10 +08:00
dekun 60ff45f098 Fix hedge option PnL match by parsing opened_at as Asia/Shanghai.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:21:52 +08:00
dekun 67a09b1de8 Fix options review light theme dark card/filter styles.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:14:26 +08:00
dekun 7e7666adfb Align hedge plan option leg PnL with OKX exchange history.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:09:44 +08:00
dekun 6876515160 Align options review PnL with OKX positions-history realizedPnl.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:05:27 +08:00
dekun 1bc12a32c6 Sync exchange PnL when hub loads trade records API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 09:40:42 +08:00
dekun 6def61fae3 Fix option day splits to daily Beijing 16:00 lines.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 16:18:46 +08:00
dekun 10ee7614b5 Hub market: add option expiry Friday yellow dashed splits.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 16:16:38 +08:00
dekun 221e8c3cad Desktop calculator: stretch left/right cards to equal height.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:56:59 +08:00
dekun 29c080f8e0 Desktop calculator: top tabs with input/result dual cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:54:13 +08:00
dekun 7a01535802 Enlarge dashboard KPI summary bar for readability.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:48:38 +08:00
dekun f467f94fe9 Replace dashboard perp profit column with stop-loss and take-profit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:47:16 +08:00
dekun 913c7d5be6 Show hedge plan id in options type column; drop group box UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:43:51 +08:00
dekun 0f5fe801e2 Fix dashboard TP profit display; add options ROI column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:41:32 +08:00
dekun 977d62bddf Group hedge option legs on dashboard; show long/short direction colors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:38:11 +08:00
dekun 3ed8dabc76 Align dashboard PnL columns with monitor; add back-to-dashboard button.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:34:09 +08:00
dekun eb2afb3c55 Restore position type badges; hide empty perp/options sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:30:05 +08:00
dekun ee3c1bcdca Unify hub dashboard positions into one card with exchange links.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:27:19 +08:00
dekun d2028ed0ee Show options budget buffer ratio in open-order rule tip.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:37:20 +08:00
dekun 3ae7def999 Show live hedge budget buffer ratio in options-options rule tips.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:32:06 +08:00
dekun c9229d64bf Move hedge rule tips into left parameter cards as inline collapses.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:25:31 +08:00
dekun 3923818508 Add collapsible rule tips for perp-options and options-options hedge tabs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:22:04 +08:00
dekun 4a3e6a2a1d Add global autofill guard for hub, env, and transfer inputs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:18:56 +08:00
dekun 00d76ba20f Stop browser autofill stuffing login username into transfer amount fields.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:14:14 +08:00
dekun 5b346a5760 Hedge start: re-quote ask and resize OO sheets; add HEDGE_PLAN_BUDGET_BUFFER.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:09:32 +08:00
dekun a43cb35d9a Add end-plan action; never show unfilled option legs as open.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 09:36:46 +08:00
dekun 8597e47596 Hedge options open: require full fill (IOC + wait) before success.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 09:14:03 +08:00
dekun 88d460d484 Options: collapse open rules; move open-guard tip into risk policy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:46:02 +08:00
dekun ed1ec6f14a Options: default nearest expiry and env ask-liquidity chain filter.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:37:39 +08:00
dekun 7eb098def6 Options: merge duplicate order dialog head CSS.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:16:23 +08:00
dekun 972ef4f910 Options: loosen order dialog spacing and stack mode/note rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:15:55 +08:00
dekun 4e614eb9ed Options: frame target and size-mode chips like cancel buttons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:11:23 +08:00
dekun 547eedeec4 Options: show order form as viewport modal on select.
Stop inserting the panel under the strike row; mount backdrop on body with cancel/Esc close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:06:07 +08:00
dekun 19b46d19fd Options: eth-amount dialog, stop note autofill, pending tab in positions.
Selecting 指定币数量 opens an order dialog; pending orders move into a 当前委托 tab after 当前持仓; harden remark autofill that showed dekun.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 08:00:39 +08:00
dekun debfb116fd Expand options review table columns and color result tags.
Show direction, hold time, and entry logic; paint 盈利 green and 亏损 red.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:49:35 +08:00
dekun 3c21680763 Bump hedge_plan.js cache for Chinese close-reason labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:45:27 +08:00
dekun bc08a5852d Show hedge close reasons in Chinese on options review.
Map plan/leg close_reason codes like target_down_win_leg and expiry to Chinese labels in detail, form, and stats.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:44:45 +08:00
dekun a2d4507028 Move options review tabs and filters above content cards.
Keep the category tabs and search toolbar page-level so they sit above the numbered trade/review/stats sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:39:34 +08:00
dekun 17cd835e5d Clarify options review layout with numbered sections and KPI tiles.
Separate trade/review/stats blocks, tuck filters into a toolbar, and hide empty stat groups so the page scans more easily.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:32:34 +08:00
dekun ac018cb618 Show options review image zoom above the detail modal.
Add a dedicated lightbox above the review dialog and raise global imgModal z-index so enlargements are no longer hidden behind it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:27:42 +08:00
dekun 220aab9b63 Keep reviewed trades in options list and stop search autofill.
Top trade list no longer hides reviewed rows; search box resists browser username autofill (dekun).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:26:02 +08:00
dekun 72dddc5106 Fix options review screenshots missing after journal upload hijack.
Stop journal_upload_slots from binding options slots; resolve journal_* files from static/images root so existing reviews display again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:23:37 +08:00
dekun 12574ae88a Fix options review empty lists when search box has symbol text.
Treat the filter as fuzzy q over underlying/inst/strategy (BTCUSDT->BTC) instead of exact strategy_tag, which always wiped pending rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:17:45 +08:00
dekun 426afb8dbe Make options review detail a modal and fix screenshot display.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:11:45 +08:00
dekun 38ac258497 Color 期期 preview PnL and show RR vs full premium loss.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 15:43:01 +08:00
dekun 7938628485 Fix 期期 sheets bias: split total 2n from same-sheets, not n.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 15:33:00 +08:00
dekun e3cd2a75de Replace 期期 equal-split with long/short bias sizing and env ratio controls.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 15:25:10 +08:00
dekun bc797cb1db Split options review open/close times into separate columns.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 15:08:23 +08:00
dekun 0ed2eabf0d Expand system guide with live trade, strategy, and key monitor chapters.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 09:29:56 +08:00
dekun 5e0ce43415 Add instance system guide nav (default off) with overview/options/hedge manual.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 09:23:30 +08:00
dekun eea4d4ff8f Show auto-close as off when partial manual-complete is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 09:11:00 +08:00
dekun d74d0aeae0 Park partial hedge plans for manual leg complete instead of auto-close.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 09:06:22 +08:00
dekun e11c13747a Add mutual-exclusion gate between hedge plans and standalone options.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:51:46 +08:00
dekun 899a2de931 Show options position source (纯期权/永期/期期) on holdings cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:45:17 +08:00
dekun fa2127d66c Make 永期 hedge UI clearer with dir segments and field grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:34:45 +08:00
dekun d5f3f315dc Show hedge-plan scenario preview in a modal with start/cancel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:28:54 +08:00
dekun 1c155328b4 Move 期期 USDC transfer into T-quote card to free left panel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:20:55 +08:00
dekun 3093d07167 期期: green index after lower target, USDC transfer, clearer selected state.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:16:44 +08:00
dekun 79420904f4 Tighten 期期 parameter UI: compact controls, less copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:10:12 +08:00
dekun 3119486105 Add 期期 close mode (全平/到期平) with scheme-C env toggle.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 08:06:27 +08:00
dekun b3548240cc Auto-fill 期期 sheets from trading balance with same-sheets default.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 00:56:35 +08:00
dekun f21e4d1166 Add env toggles to show/hide perp and options hedge plan tabs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 00:43:04 +08:00
dekun 9e1343981d Give strategy doc body more inner padding from card edges.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:44:28 +08:00
dekun 2f7e6355a1 Add section badges to strategy TOC/headings and fill cards to viewport height.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:41:04 +08:00
dekun 023bf6a814 Simplify strategy TOC to h2-only one level.
Drop nested h3 entries from the sidebar directory so the outline stays flat and easier to scan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:37:39 +08:00
dekun 9198aa0dcd Fix strategy checklist hidden class so tabs do not stack.
Generic .hidden was missing display:none, so 执行清单 stayed visible beside 策略正文.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:32:04 +08:00
dekun 82c910fbf8 Add blog-style TOC to strategy doc; checklist as separate tab.
Strategy page uses 策略正文 (MD + sticky h2/h3 TOC) and 执行清单 tabs so full playbook detail stays readable with jump navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:29:18 +08:00
123 changed files with 12364 additions and 1102 deletions
-18
View File
@@ -1,18 +0,0 @@
---
description: After each completed code change, commit, push origin/main, and deploy to zk.hyf2.cc
alwaysApply: true
---
# Auto push & deploy
When a user-facing code change is **finished** (not mid-debug / not "先不要改代码"):
1. Commit only the relevant files (skip unrelated CRLF-only docs noise).
2. `git push origin main` to `https://git.bz121.com/dekun/crypto_monitor.git`.
3. Deploy to production `zk.hyf2.cc`:`cd /opt/crypto_monitor && git pull && bash deploy/pull_and_restart.sh`.
4. Confirm PM2 processes are online; briefly report commit hash + deploy status.
Do **not** wait for the user to say "推送并部署" again unless they cancel this habit.
SSH: Prefer key auth; if BatchMode fails, use existing Paramiko root login path used in this project.
Do not print or put passwords in user-facing replies.
+5
View File
@@ -15,12 +15,17 @@
**/.env.backup*
**/.env.bak
**/.env.local
# Cursor 本机规则/配置(勿提交;只留本地)
.cursor/
manual_trading_hub/hub_settings.json
manual_trading_hub/hub_backup_state.json
manual_trading_hub/hub_fund_history.json
manual_trading_hub/hub_supervisor_state.json
manual_trading_hub/hub_ai_summaries.json
manual_trading_hub/hub_ai_chat.json
manual_trading_hub/amp_stats_history.json
manual_trading_hub/hub_ai_fund_history.json
manual_trading_hub/data/
backups/
+2
View File
@@ -158,6 +158,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+20
View File
@@ -2751,6 +2751,17 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -7482,6 +7493,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
@app.route("/system_guide")
@login_required
def system_guide_page():
redir = redirect_to_embed_shell_if_enabled("system_guide")
if redir is not None:
return redir
return render_main_page("system_guide")
@app.route("/env_config")
@login_required
def env_config_page():
+2
View File
@@ -160,6 +160,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+31 -1
View File
@@ -2440,6 +2440,22 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
# 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流)
try:
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -6942,7 +6958,11 @@ def sync_trade_records_from_exchange(conn, force=False):
matched += 1
stats["matched"] = matched
stats["ok"] = True
_LAST_EXCHANGE_PNL_SYNC_AT = now
# 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」
if matched < stats["pending"] and hist:
_LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0
else:
_LAST_EXCHANGE_PNL_SYNC_AT = now
try:
conn.commit()
except Exception:
@@ -7269,6 +7289,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
@app.route("/system_guide")
@login_required
def system_guide_page():
redir = redirect_to_embed_shell_if_enabled("system_guide")
if redir is not None:
return redir
return render_main_page("system_guide")
@app.route("/env_config")
@login_required
def env_config_page():
@@ -9369,6 +9398,7 @@ register_trade_records_api(
filter_trade_records_excluding_miss=filter_trade_records_excluding_miss,
app_tz=APP_TZ,
format_price_fn=format_price_for_symbol,
sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False),
)
def _dashboard_enrich_orders(items):
+21 -1
View File
@@ -112,6 +112,8 @@ OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
OKX_OPTIONS_MAX_DTE_DAYS=2
OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14
OKX_SUB_ACCOUNT_NAME=
@@ -120,19 +122,35 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# =============================================================================
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
# =============================================================================
HEDGE_PLAN_ENABLED=false
# 页面 Tab 显示(默认全部显示,可单独关闭;不影响已有进行中/历史计划)
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false
HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆
HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget
# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put
HEDGE_PLAN_OO_BIAS_RATIO=0.7
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false
# =============================================================================
# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)
@@ -201,6 +219,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+44 -1
View File
@@ -2359,6 +2359,22 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
# 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流)
try:
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -4090,7 +4106,11 @@ def sync_trade_records_from_exchange(conn, force=False):
matched += 1
stats["matched"] = matched
stats["ok"] = True
_LAST_EXCHANGE_PNL_SYNC_AT = now
# 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」
if matched < stats["pending"] and hist:
_LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0
else:
_LAST_EXCHANGE_PNL_SYNC_AT = now
try:
conn.commit()
except Exception:
@@ -6785,8 +6805,20 @@ def render_main_page(page="trade", embed_mode=None):
options_nav_visible=True,
hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
hedge_plan_show_perp_options=os.getenv("HEDGE_PLAN_SHOW_PERP_OPTIONS", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_show_options_options=os.getenv("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
).lower()
in ("1", "true", "yes", "on"),
risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
@@ -6806,6 +6838,7 @@ def render_main_page(page="trade", embed_mode=None):
risk_status=risk_status,
trade_policy=TRADE_POLICY,
data_export_version=3,
open_guard_enabled=open_guard_enabled,
),
**force_close_template_context(
FORCE_CLOSE_ENABLED,
@@ -6893,6 +6926,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
@app.route("/system_guide")
@login_required
def system_guide_page():
redir = redirect_to_embed_shell_if_enabled("system_guide")
if redir is not None:
return redir
return render_main_page("system_guide")
@app.route("/env_config")
@login_required
def env_config_page():
@@ -9044,6 +9086,7 @@ register_trade_records_api(
filter_trade_records_excluding_miss=filter_trade_records_excluding_miss,
app_tz=APP_TZ,
format_price_fn=format_price_for_symbol,
sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False),
)
+262
View File
@@ -0,0 +1,262 @@
# 标的时段振幅统计 — 开发方案
> 状态:**方案冻结**(按本文实现;改需求先改本文).
> 范围:**中控**新增只读统计工具;不改开平仓、不接 AI 教练(首版).
> 数据源:**仅 OKX**.
> 相关:[交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)(16:00 会话窗纪律) · [振幅统计说明.md](./振幅统计说明.md)
---
## 1. 目标
在中控提供 **自定义时段、固定 16:00 收窗** 的历史振幅档案:
- **标的下拉**:`ETH` / `BTC`(默认 ETH)
- 按整点起点 + **终点固定北京时间 16:00** 切出每日统计窗
- 回溯周期可选(1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义)
- 日表明细分页展示;下方为汇总统计
- 每次有效计算可写入 **历史**;支持 **下载**(明细 + 统计摘要)
定位:服务一天期期权开仓前的「空间」判断(已实现波动点数档案),**不算 IV / 权利金 / Greeks**.
---
## 2. 不做(首版外)
- 币安 / Gate 等非 OKX 价源
- 百分比振幅列(可后加「参考 %」,不进必须统计)
- 未完成窗(当天尚未到 16:00)计入样本
- 自动推送企业微信 / 注入交易教练
- 中控代下单或改期权仓
---
## 3. 时间与样本规则
### 3.1 时区与终点
- 时区:**Asia/Shanghai(北京时间)**
- **到期/收窗时刻固定 `16:00`**,不可改
- 起点时刻:**仅整点** `00:00``23:00`(下拉选择)
### 3.2 跨天切窗(结算日 D)
对每个结算日 **D**(窗终点 = `D 日 16:00`):
| 起点整点 T | 窗起点 | 窗终点 |
|------------|--------|--------|
| `T >= 16:00` | **D-1 日 T:00** | D 日 16:00 |
| `T < 16:00` | **D 日 T:00** | D 日 16:00 |
示例:
| 用户选择 | 某一结算日 D 的实际窗 |
|----------|------------------------|
| 16:00 → 16:00 | D-1 16:00 → D 16:00 |
| 22:00 → 16:00 | D-1 22:00 → D 16:00 |
| 08:00 → 16:00 | D 08:00 → D 16:00 |
### 3.3 回溯周期
| 选项 | 含义(完整收窗个数,约) |
|------|------------------------|
| 1 个月 | 约 30 个结算日 |
| 2 个月 | 约 60 个结算日(默认推荐) |
| 3 个月 | 约 90 个结算日 |
| 半年 | 约 180 个结算日 |
| 1 年 | 约 365 个结算日 |
| 自定义 | 用户输入天数 N(`7``400`,可配置上下限) |
说明:
- 「月」按 **日历回溯 + 完整 16:00 收窗** 计数,不足整天的末日不入样
- 仅纳入 **已结束** 的窗(`now >= D 16:00`);进行中的今天不入样
### 3.4 标的与价源(OKX)
| UI 下拉 | 价源(优先) | 降级(仅指数失败时) |
|---------|------------|---------------------|
| **ETH** | OKX **ETH-USD 指数** | OKX `ETH/USDT` 永续标记 |
| **BTC** | OKX **BTC-USD 指数** | OKX `BTC/USDT` 永续标记 |
约束:
- **交易所固定 OKX**,UI 不提供其它所
- 具体指数/合约符号以实现时 OKX 接口与 `hub_ohlcv` 对齐为准;结果与下载须标注 `exchange=okx` + 实际价源
- K 线粒度:**1H**(与整点起止对齐,优先);同一作业内不得混用粒度.若后续要更细高低点,可升 5m/1m(P2)
---
## 4. 指标口径(点数,非百分比)
全部为 **绝对价格点数**(标的报价差;BTC/ETH 各自用自身价格刻度).
设窗内:
- `O` = 起点时刻价(或起点分钟 K 的 open)
- `H` = 窗内最高
- `L` = 窗内最低
- `C` = 终点 16:00 价(或该分钟 close)
| 字段 | 算法 | 例(O=2000,H=2500,L=1800) |
|------|------|---------------------------|
| 开盘价 | `O` | 2000 |
| 最高价 | `H` | 2500 |
| 最低价 | `L` | 1800 |
| 收盘/窗末价 | `C` | (另算) |
| 开→高距离 | `H O` | **500** |
| 开→低距离 | `O L` | **200** |
| **振幅** | `(HO)+(OL)` = **`HL`** | **700** |
| 涨跌值 | `C O`(可正负) | 可选列,首版建议保留 |
**必须统计(汇总层):**
- **最大振幅**(值 + 对应结算日)
- **开→高距离**:最大、均值(建议)
- **开→低距离**:最大、均值(建议)
可选汇总(首版建议带上,成本低):
- 振幅均值 / 中位数
- 上涨窗占比(`C>O`)、下跌窗占比
- 振幅 ≥ 用户阈值 X 点数的天数(X 可填,默认空=不算)
---
## 5. 界面(中控)
### 5.1 入口
- 顶栏新增导航项:**「振幅统计」**或 **「期权统计」**(最终文案实现时定一处;设置里可隐藏)
- 手机端进「更多」
### 5.2 Tab
| Tab | 作用 |
|-----|------|
| **统计** | 配参数 → 计算 → 看日表+汇总 → 下载 / 存历史 |
| **历史** | 过往作业列表;打开复看;再下载 |
### 5.3 「统计」页布局
1. **参数区**
- **标的**:下拉 `ETH` / `BTC`(默认 ETH)
- 数据源:只读展示 `OKX`
- 起点整点:下拉 `00``23`(默认 `16`)
- 终点:固定展示 `16:00`(不可改)
- 周期:单选 `1月 / 2月 / 3月 / 半年 / 1年 / 自定义`
- 自定义天数:仅自定义时显示
- 按钮:`计算` · `保存到历史` · `下载`
2. **日表明细**(分页,如每页 20 行;排序默认结算日倒序)
3. **下方汇总区**(本次全样本,不是当前页)
### 5.4 「历史」页
每条记录至少:
- 创建时间、**标的**、起点整点、周期/天数、价源(OKX+指数/标记)、样本数
- 最大振幅(+日期)
- 操作:查看 / 下载 / 删除
**写入规则(建议):** 用户点击 **「保存到历史」** 才入库;仅点「计算」不自动灌历史(避免误点刷屏).若产品坚持「输入一次就算进历史」,可改为计算成功自动写入——实现前在本文改为冻结口径.
> 当前方案冻结倾向:**显式「保存到历史」**.
---
## 6. 下载
格式:优先 **CSV**(UTF-8 BOM,Excel 可开);或单文件双段.
必须包含:
1. **日表明细**(本次全部结算日,非当前页)
2. **统计摘要**:标的、交易所 OKX、价源、最大振幅(+日)、开→高最大/均值、开→低最大/均值、样本数、起点整点、终点 16:00、周期、生成时间
文件名示例:`okx_eth_amp_22to16_60d_20260723.csv` / `okx_btc_amp_16to16_90d_20260723.csv`
---
## 7. 数据与实现要点
### 7.1 复用
- 优先复用中控 `hub_ohlcv` / `hub_kline_store`,按 `exchange_key=okx` + 标的对应指数/合约拉齐历史 K 线并本地缓存
- 首次 1 年 × 1m 数据量较大:计算前检查缓存覆盖;缺口再增量拉取;UI 显示进度/耗时提示
- BTC / ETH 缓存键分离
### 7.2 后端模块(建议)
| 路径 | 职责 |
|------|------|
| `lib/hub/amp_stats_lib.py` | 标的映射、切窗、算日行、汇总 |
| `manual_trading_hub/` 路由 + 静态页 | UI / API |
| `manual_trading_hub/amp_stats_history.json`(或 sqlite) | 历史作业 |
### 7.3 API 草稿
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/amp-stats/compute` | body: `symbol`(eth\|btc), start_hour, period\|days → 日表+汇总 |
| `GET` | `/api/amp-stats/history` | 历史列表(可按 symbol 筛选) |
| `POST` | `/api/amp-stats/history` | 保存当前结果 |
| `GET` | `/api/amp-stats/history/{id}` | 详情 |
| `DELETE` | `/api/amp-stats/history/{id}` | 删除 |
| `GET` | `/api/amp-stats/export` | query 或 history id → 文件下载 |
### 7.4 性能
- 2 个月 × 1m:可接受同步(数十秒级需有 loading)
- 1 年:建议异步任务或分块拉齐后再算;首版可限制「自定义 > 180 天」需确认二次点击
---
## 8. 验收清单
- [ ] 标的下拉 ETH / BTC 可切换;数据源固定 OKX
- [ ] 起点仅整点;终点 UI 固定 16:00
- [ ] `22→16` / `16→16` / `08→16` 跨天规则与 §3.2 一致
- [ ] 周期六档 + 自定义天数生效;默认 2 个月
- [ ] 日表含:开高低收、开→高、开→低、振幅(点数)、涨跌值
- [ ] 例:O=2000,H=2500,L=1800 → 开→高 500、开→低 200、振幅 700
- [ ] 汇总含最大振幅(+日)、开→高/开→低统计
- [ ] 分页只影响展示;汇总与下载用全样本
- [ ] 未到 16:00 的当日不入样
- [ ] 保存历史含标的字段 / 回看 / 删除
- [ ] 下载含明细 + 统计摘要(含标的与 OKX)
- [ ] 电脑与手机均可完成计算与下载(手机下载走系统分享/保存即可)
---
## 9. 分期
| 阶段 | 内容 |
|------|------|
| **P0** | 统计 Tab:标的下拉(ETH/BTC) + 参数 + 计算 + 日表分页 + 汇总 + 下载(不经历史) |
| **P1** | 历史 Tab:保存 / 列表 / 回看 / 再下载 / 删除 |
| **P2** | 缓存加速、长周期异步、振幅阈值天数、可选 % 参考列 |
---
## 10. 待冻结(实现前确认)
| # | 问题 | 当前倾向 |
|---|------|----------|
| 1 | 历史写入:自动 vs 点保存 | **点保存** |
| 2 | 下载 CSV vs Excel | **CSV** |
| 3 | 价源 | **OKX 指数优先**(ETH-USD / BTC-USD);失败再降级永续标记 |
| 4 | K 线 1m vs 5m vs 1H | **1H**(整点窗) |
| 5 | 导航文案 | **「振幅统计」** |
**已冻结(开工口径):** 点保存进历史 · CSV · OKX 指数优先 · **1H K 线**(整点对齐,降低拉取量;与整点窗一致) · 导航「振幅统计」.
确认后将本文状态改为 **方案冻结**,再开工实现.
---
## 11. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 初稿:中控 ETH 时段振幅统计;点数口径;周期档位;16:00 固定收窗;历史+下载 |
| 2026-07-23 | 支持 BTC/ETH 下拉;数据源固定 OKX 指数(可降级永续标记);模块/API 改名为 amp-stats |
+7 -1
View File
@@ -41,6 +41,7 @@
|------|------|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
| 平仓亏损达 `RISK_DAILY_LOSS_LIMIT` 次(同一交易日) | **日冻结**(默认 2 次;`0`=不启用) |
| 复盘勾选任意情绪标签 | **日冻结** |
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
@@ -77,11 +78,15 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
TRADING_DAY_RESET_HOUR=8
APP_TIMEZONE=Asia/Shanghai
```
- `RISK_DAILY_LOSS_LIMIT`:任意已平仓交易若盈亏 < 0 计 1 次(含止损/止盈后仍亏损等);达上限当日冻结开仓;`0` 表示不因亏损次数冻结.
- `RISK_MANUAL_CLOSE_DAILY_LIMIT`:仅计**用户主动平仓**次数(与亏损次数独立).
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控).
## API 与 `risk_status` 字段
@@ -102,6 +107,7 @@ APP_TIMEZONE=Asia/Shanghai
| `can_trade` | 是否允许新开仓(仅风控维度) |
| `reason` | 悬停提示文案 |
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` |
| `daily_loss_count` / `daily_loss_limit` | 当日亏损笔数与上限(`0` 上限表示未启用) |
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
@@ -123,7 +129,7 @@ APP_TIMEZONE=Asia/Shanghai
## 相关代码
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`,`on_closed_trade_pnl`
- `hub_bridge.py``/api/hub/account-risk/user-close`
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
- `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控
+1
View File
@@ -131,6 +131,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 手动平仓冷静(小时) | |
| 复盘情绪冷静(小时) | |
| 日手动平仓次数上限 | |
| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 |
| 情绪标签日冻结 | |
详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
+2
View File
@@ -4,6 +4,8 @@
| 文档 | 实例 | 状态 |
|------|------|------|
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册 | 个人开单纪律 |
| [交易行为准则-开单三检.md](../交易行为准则-开单三检.md) | 中控「策略说明」·行为准则 | 开单前信号/流程/情绪三检 |
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 |
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 |
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
+32
View File
@@ -0,0 +1,32 @@
{
"exchange": "behavior",
"title": "开单三检清单",
"version": "v0.1",
"groups": [
{
"title": "信号判断",
"items": [
"最核心、最明确的一个点位/结构确认已写清",
"该确认本身足够清晰(不是靠一长串宏大叙事)",
"已过方向 → 空间 → 值不值得(不够格则空仓)"
]
},
{
"title": "流程确认",
"items": [
"账户资金与当日额度符合要求",
"单笔风险 / 组合敞口在手册预算内",
"无跳步;超限则暂停开单"
]
},
{
"title": "情绪自检",
"items": [
"心态是「符合系统所以做」,不是「证明自己」",
"无怕踏空 → 否则放弃",
"无回本 / 报复交易念头 → 否则放弃",
"不需要再找更多开单理由"
]
}
]
}
+148
View File
@@ -0,0 +1,148 @@
# 交易执行手册(期权为主 · Gate 为辅)
> 个人开单纪律与仓位规则(2026-07 起)。
> 目标:少而精、可控回撤、样本干净;**不保证收益**。
> 工具:OKX 期权(主)+ Gate 合约(辅);其它账户暂不做。
> **开单前先过** [交易行为准则-开单三检.md](./交易行为准则-开单三检.md)(信号 / 流程 / 情绪);本手册管怎么做单。
---
## 1. 总原则
1. **主做期权,合约为辅**;同一时段尽量只让一边「说话」。
2. **看不懂不做**;过滤比频率重要。
3. 动手前先过 **开单三检**(信号判断 → 流程确认 → 情绪自检);不过 → 空仓。详见 [行为准则](./交易行为准则-开单三检.md)。
4. 开仓前再过玩法三关:**方向 → 空间 → 值不值得**。不够格 → 空仓。
5. 期权离场只认:**止盈(规则触发)** 与 **到期**;**不手动平仓**(紧急例外单不算策略样本)。
6. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满理想上限。
---
## 2. 账户与分工
| 账户 | 角色 | 说明 |
|------|------|------|
| OKX 期权 | **主业** | 横盘对冲 / 方向单 / 偏置对冲 |
| Gate 合约 | **辅业** | 结构清楚时的波段;与期权尽量错开 |
| 其它 | 暂不做 | 减少分心与样本污染 |
**到期选择(期权)**
- 方向单、对冲默认 **一天期**
- 尽量在 **北京时间下午 4 点后****次日到期**,覆盖较完整的美盘 + 亚盘 + 欧盘窗口。
- Gate 波段样本里最长持仓约十余小时量级 → 一天期权通常够表达;更长故事优先考虑合约,不强行拉长期权。
---
## 3. 入场逻辑(三类)
开仓前先判断:当前是 **买波动** 还是 **买方向**
### 3.1 横盘 → 期期对冲
- **条件**:横盘已持续较久(例如满约 12 小时),方向不明。
- **工具**:一天期 Call + Put(对冲);总权利金预算见仓位章。
- **意图**:买接下来的波动,不赌单边。
- **期间**:一般 **不再开 Gate 方向单**(已在买波动,勿叠同一宏观暴露)。
### 3.2 方向明确 · 结构突破 → 期权
- **条件**:方向、空间、值不值得均过关;结构突破成立。
- **工具**:**一天期期权方向单**(或明显顺势结构)。
- **离场**:目标止盈或到期;不手平。
- **默认**:先只开期权,不上合约。
### 3.3 结构突破后 · 反向假突破确认 → 可加合约
- **条件**:已有结构突破的期权表达;随后出现反向假突破且确认失败、续原方向。
- **工具**Gate 合约 **小仓加强**(止损纪律见下)。
- **注意**:BTC 合约与 ETH 期权高度相关,属加重暴露,不是分散;仓位按「一笔故事」计风险。
- **假突破定义**需事先写死(相对哪段结构、如何确认收回),避免临场随便加仓。
### 3.4 独立假突破(没有先开突破期权时)
- 按「假破专用」处理:优先 **只做合约****空仓**,勿与「突破后再假破加仓」混用同一套仓。
---
## 4. 对冲偏好(偏置对冲)
在「尽量用对冲」的前提下:
- 对冲内常带 **做多/做空比例**;若略偏多,则 **做多一侧比例更高**
- 顺势侧尽量用 **实值(或更实)**
- 方向对了:可能 **少赚一点**(相对纯单边);
- 方向错了:争取 **不亏或少亏**(相对虚值双买两边磨光)。
- **总权利金仍锁在对冲预算内**(见仓位);偏置只调张数/行权远近,不偷偷加预算。
- **偏置有度**(例如勿极端到名存实亡的单边);完全没方向时更接近均分/近平值;方向非常明确时应走单边期权,不必硬套对冲壳。
- 复盘建议区分:**中性对冲** vs **偏多/偏空对冲**,以便检验偏置是否真压低亏损。
---
## 5. 仓位与风险预算
**总资金参考:约 800U。**
| 项目 | 规则 |
|------|------|
| 单笔期权 | 约 **10U** 权利金预算;**一次只持有一个期权仓位** |
| 期期对冲 | **合计约 10U**(两腿加总,不是各 10 |
| Gate 合约 | 日内保证金约 **50U**、约 **10 倍**;有单才用,无单为 0 |
| 合约止损 | 一般约 **5U**;单笔最大亏损不超过约 **10U** |
| 日损失心理框 | 期权+合约若都错:合计大约 **≤20U**;都对时期望可到 **40U+**(理想情形,非每日目标) |
相对 800U:单笔约 **1.25%** 量级;全错一天约 **2.5%** 量级——防守优先。
**叠加红线**
- 期权一仓 + 合约加仓同日存在时,按合计风险接受最坏约 20U,且尽量少「同向双开」。
- 不因「期权偏置可能少亏」而放大合约。
---
## 6. 合约日纪律(Gate
1. 只做 **很明确的位置**;不明确基本不做。
2. 动手前想清:**如何进场**。
3. **同一位置最多两次机会**:结构突破、假突破。
4. **两次都错 → 当日不再做单**(即使后面更「看起来清楚」也留到明天)。
5. 止损约 **5U**;波段规则(含是否时间离场)开仓前想清。
6. 已关闭「强制清仓」误伤策略意图时,离场以结构止盈/止损为准;历史里「强制清仓但盈利」按规则结果理解,复盘看盈亏与结构。
---
## 7. 期权日纪律(OKX
1. **不手动平仓**;只等规则止盈或到期(紧急手平标记为非策略样本)。
2. 一次一仓;对冲共 10U。
3. 横盘对冲期间一般不开 Gate 方向单。
4. 结构突破用期权表达;假破加强才考虑合约。
5. 默认一天期;优先完整会话窗口再开。
---
## 8. 开仓前自检清单
- [ ] 今天是否只动「期权 / Gate」,其它账户零操作?
- [ ] 买波动还是买方向?工具选对了吗?
- [ ] 方向 / 空间 / 值不值得是否都过关?
- [ ] 期权:止盈条件与「接受到期」是否写清?
- [ ] 对冲:比例与实值偏置是否有度?总预算是否仍 ≤10U?
- [ ] 合约:本位置第几次机会?止损约 5U 设好了吗?
- [ ] 若加合约:是否已有突破期权且假破确认?是否当成一笔故事控总风险?
- [ ] 今日合约两点机会是否已用完?(用完则收工)
---
## 9. 一句话版本
> **横盘对冲(可偏置实值);突破用一天期权;假破确认后小仓合约加强;先过方向/空间/值不值得;期权不手平;一位置两次,错完收工;单笔小亏、组合回撤可控。**
---
## 10. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-21 | 初版:根据实盘讨论整理(期权为主、Gate 为辅、仓位与日停手规则) |
| 2026-07-23 | 挂钩开单三检行为准则 |
+114
View File
@@ -0,0 +1,114 @@
# 交易行为准则(开单三检)
> 个人强制思维动作 · 初级版(2026-07)。
> **不是策略**,是开单前的「交易防火墙」:保证动作在可控轨道上,**不判断这笔会不会赚钱**。
> 来源:中控 AI 复盘对话(2026-07-22)与本人归纳。
> 仓位与玩法细则见 [交易执行手册-期权与Gate.md](./交易执行手册-期权与Gate.md)。
---
## 1. 一句话
> **信号够不够清晰?流程有没有跑通?情绪是不是在证明自己?三检不过 → 不开。**
复盘成败的第一标准:**三检是否完整完成**,而不是这笔盈亏。
---
## 2. 总循环
```
信号判断 → 流程确认 → 情绪自检 → 全部通过
→ 开仓 → 等待系统结果(止盈 / 止损 / 到期)
→ 本次结束 → 复盘整环 → 等待下一个信号
```
任一步否决 → **空仓离开**,不找补丁理由硬开。
---
## 3. 开单前:三秒停顿
手要动之前,强制停顿,把注意力从宏大叙事拉回内部三点:
1. 我的**核心信号**是什么?
2. **安全流程**跑通了吗?
3. 我现在是冷静执行,还是急着证明 / 怕踏空 / 想回本?
---
## 4. 三检细则
### 4.1 信号判断(Signal Judgment
**问:** 这次入场,最核心、最明确的那一个点位 / 结构确认是什么?它本身够不够清晰?
| 通过 | 否决 |
|------|------|
| 能用一句话说清「唯一核心确认」 | 说不清、要靠一长串宏观故事才能自圆其说 |
| 点位 / 结构本身已经够清楚 | 「好像有戏」但确认点模糊 |
| 只描述事实与系统条件 | 堆细节证明自己分析很厉害 |
对照执行手册时:先过 **方向 → 空间 → 值不值得**;不够格 → 空仓(见手册 §1、§3)。
### 4.2 流程确认(Process Confirmation
**问:** 决定执行前,有没有按设定步骤检查资金与风险敞口?内部安全流程跑通了吗?
| 通过 | 否决 / 暂停 |
|------|-------------|
| 账户资金与当日额度符合要求 | 资金或次数已触限 |
| 单笔风险 / 组合敞口在手册预算内 | 单笔或日最坏超限 → **暂停开单** |
| 该走的检查项没有跳步 | 「先开了再说」 |
细则数字以执行手册仓位章为准(单笔期权、对冲总权利金、Gate 止损与日停手等)。
### 4.3 情绪自检(Emotional Self-Check
**问:** 看到复杂结构与逻辑时,内心是什么?是「必须证明分析是对的」,还是「符合系统要求,所以做」?
| 通过 | 否决(果断放弃) |
|------|------------------|
| 「符合系统信号 + 账户没问题 → 开」 | 「怕踏空」 |
| 不需要再找更多开单理由 | 「上回亏了,这单要回本」 |
| 旁观者视角、可接受空仓 | 「必须证明我是对的」 |
**原则:** 不为开单找理由;情绪红灯亮了,信号再好看也不开。
---
## 5. 开仓后纪律(与手册一致)
- 开仓后:**等待系统结果**(规则止盈 / 止损 / 到期),不靠情绪手平(紧急例外不算策略样本)。
- 持仓期盯的是「程序与纪律是否正常」,不是浮盈浮亏数字本身。
- 无信号时的空档也算训练:反复在脑子里空跑三检,比硬找单更重要。
---
## 6. 复盘只记什么
每次交易(含未开成的冲动)建议只记:
1. 信号判断:做了吗?核心确认写了什么?是否清晰?
2. 流程确认:资金 / 敞口是否过关?有无跳步?
3. 情绪自检:当时心态是哪一类?有无怕踏空 / 回本?
4. 结果:止盈 / 止损 / 到期 / 未开 — **结果不推翻「三检是否完成」这一评分。**
---
## 7. 与执行手册的分工
| 文档 | 管什么 |
|------|--------|
| **本准则** | 能不能动手(防火墙 / 操作系统) |
| **执行手册** | 怎么做单(期权 / Gate、仓位、离场) |
先过本准则三检,再谈手册里的玩法与仓位。
---
## 8. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 初级版:三检 + 总循环 + 红线;对齐 AI 复盘与本人总结 |
+68
View File
@@ -0,0 +1,68 @@
# 仓库代码统计
> 统计时点:**2026-07-21 10:47(北京时间)**
> 基准提交:`60ff45f`
> 口径:仅统计 `git ls-files` **已跟踪**文件;不含未提交改动、`.venv`、本地数据库、日志等。
## 总览
| 项目 | 数量 |
|------|------|
| 已跟踪文件 | **626** |
| 其中二进制(如图片/ico,不计入行数) | 53 |
| 文本总行数(含空行) | **170,713** |
| 非空行 | **152,453** |
| 空行 | 18,260 |
## 源码规模(常用后缀)
以下按「源码向」后缀汇总:`.py` / `.js` / `.cjs` / `.css` / `.html` / `.sh` / `.sql` 等。
| 项目 | 数量 |
|------|------|
| 源码文件 | **472** |
| 源码行数(含空行) | **157,585** |
更宽的「代码/配置向」后缀(再含 `.md` / `.json` / `.example` / `.mdc` 等)约 **547** 个文件、**169,493** 行。
## 按扩展名明细
| 扩展名 | 文件数 | 行数(含空行) | 非空行 |
|--------|--------|----------------|--------|
| `.py` | 328 | 96,645 | 86,552 |
| `.js` | 44 | 30,131 | 28,073 |
| `.css` | 8 | 18,082 | 16,225 |
| `.md` | 71 | 11,712 | 8,173 |
| `.html` | 60 | 9,849 | 9,536 |
| `.sh` | 27 | 2,699 | 2,390 |
| `.example` | 4 | 872 | 800 |
| `.webmanifest` | 9 | 207 | 207 |
| `.cjs` | 5 | 179 | 169 |
| `.json` | 3 | 178 | 178 |
| `.svg` | 9 | 87 | 87 |
| 无扩展名 | 2 | 37 | 33 |
| `.mdc` | 1 | 18 | 13 |
| `.txt` | 2 | 17 | 17 |
| `.png` | 45 | —(二进制) | — |
| `.ico` | 8 | —(二进制) | — |
## 结构直觉
- **Python** 约占文本行数一半以上,是业务与交易所对接主体。
- **前端静态**`.js` + `.css` + `.html`)合计约 **5.8 万行**,实例页 / 中控 / 对冲与期权面板为主。
- **文档** `.md`**1.2 万行**,部署与策略说明较多。
- 二进制资源以快捷图标 / 图示为主(`.png` / `.ico`),不参与行数统计。
## 复算方式
在仓库根目录可用:
```bash
git ls-files | wc -l
```
更细的按扩展名行数统计,可用本地脚本对 `git ls-files` 结果逐文件按 UTF-8/GBK 解码计行;含 `\0` 的文件视为二进制并跳过行数。
---
*本文件为快照说明;仓库继续演进后数字会变,需要时再重跑统计更新本文。*
+24 -10
View File
@@ -93,7 +93,14 @@
- **T 型报价链**(复用期权页 T 型样式/数据结构).
- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部).
- 预算`OKX_OPTIONS_TRADE_BUDGET_USDC` 等既有约束;可拆预算到两腿.
- 预算:`B = min(交易户 USDC × OKX_OPTIONS_BUDGET_BUFFER, OKX_OPTIONS_TRADE_BUDGET_USDC)`(默认 buffer=0.95).
- 自动张数(选齐两腿后写入,可手改):
- **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n`
- **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改)
- 做多:主腿=Call;做空:主腿=Put
- 拆分口径 `HEDGE_PLAN_OO_BIAS_SPLIT_BY`:`budget`(默认,按权利金预算拆) / `sheets`(先按同张数得每腿 `n`,总张数 `2n` 再按比例拆到 Call/Put)
- 另受各自卖一深度上限约束
- 已移除页面「均分」;后端仍兼容旧 `split_budget` 入参(预算对半)
### 4.2 目标价
@@ -141,16 +148,17 @@
### 5.2 期期对冲
| 事件 | 盈利方 | 亏损方 | 计划是否结束 |
|------|--------|--------|--------------|
| **标的价到达用户目标价 S\*** | **自动平仓** | **不平**,持有至到期 | 平盈利腿后计划可标 `closing`;**全部腿终态后结束**(亏损腿到期后结账) |
| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 &lt; 0 的合计) |
| 到期时组合合计仍盈利 | — | 到期结算 | **算结束**;按实际结算盈亏入账 |
| 未达 S\* 至到期 | 两腿均到期 | | 同上,按结算合计结束 |
| 事件 | 盈利方 | 另一腿(残腿) | 计划是否结束 |
|------|--------|--------------|--------------|
| **标的价到达目标 + 平仓模式=到期平** | **自动平仓** | **不平**,持有至到期(`hold_expiry`) | 平盈利腿后仍 `active`;残腿到期后结账 |
| **标的价到达目标 + 平仓模式=全平**(默认) | **自动平仓** | **随即买一清残腿**(无 2×门控,失败则每轮重试) | 两腿都平完后 `closed` |
| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损** |
| **到期时组合合计仍盈利** | — | 到期结算 | **算结束**;按实际结算盈亏入账 |
判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损).
盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期).
- 界面「平仓模式」仅控制**盈利腿已平之后**另一腿的处理;须 `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true`(默认开)才显示,页面默认选 **全平**.
- 关闭方案C开关时行为固定为 **到期平**.
- 判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损).
- 盈利方判定:触达上破/下破时按浮盈较大一侧平仓;皆亏则等到期.
### 5.2.1 期权腿实盘平仓执行(与期权页共用)
@@ -553,11 +561,14 @@ realized_pnl_total = pnl_option_close - abs(pnl_perp_sl)
| 变量 | 前端标签 | 默认 | 控件 | 热更新 | 说明 |
|------|----------|------|------|--------|------|
| `HEDGE_PLAN_ENABLED` | 启用对冲计划 | false | bool | 热更优先 | 总开关:导航 + API |
| `HEDGE_PLAN_SHOW_PERP_OPTIONS` | 显示永期对冲 | true | bool | 热更 | 关则隐藏永期 Tab,不可测算/开仓 |
| `HEDGE_PLAN_SHOW_OPTIONS_OPTIONS` | 显示期期对冲 | true | bool | 热更 | 关则隐藏期期 Tab,不可测算/开仓 |
| `HEDGE_PLAN_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 |
| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 |
| `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** |
| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** |
| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 |
| `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED` | 期期平仓模式(方案C) | true | bool | 热更 | 开:页面可选到期平/全平;关:固定到期平 |
| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 |
| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 |
| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 |
@@ -577,11 +588,14 @@ realized_pnl_total = pnl_option_close - abs(pnl_perp_sl)
```env
# --- 对冲计划(仅 OKX;前端 env「对冲计划」) ---
HEDGE_PLAN_ENABLED=false
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false
HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
+37
View File
@@ -0,0 +1,37 @@
# Git 快照标签
代码级快照用 annotated tag 打在 `main` 上,便于回看某日仓库状态(不含 `.env` / 数据库)。
## 当前快照
| 标签 | 指向提交 | 说明 |
|------|----------|------|
| `snapshot/20260723-2` | `9e0591c` | 2026-07-23:策略对比页(合约/单期权/期期7:3)、监控与看板隐藏浮盈偏好、对比页卡片内边距等 |
| `snapshot/20260723-pre-amp-stats` | `40be3a5` | 2026-07-23:振幅统计开发前;含执行手册进教练、日亏损冻结、手机监控 UI、振幅统计开发方案等 |
| `snapshot/20260721-2` | `a721642` | 2026-07-21 晚:日亏损次数冻结、交易执行手册入中控策略说明、期权/Gate 执行手册文档等 |
| `snapshot/20260721` | `1a163c0` | 2026-07-21:仓库代码统计文档、期权复盘亮色主题、对冲腿盈亏时区修复、本快照说明等 |
## 历史标签(节选)
| 标签 | 说明 |
|------|------|
| `snapshot/pre-strategy-mindmap-20260718` | 策略脑图相关改动前 |
| `snapshot/pre-hub-order-popup` | 中控下单弹窗相关改动前 |
| `snapshot/pre-hub-market-20260528` | 中控行情相关改动前 |
| `pre-lib-modularization` | lib 模块化前 |
| `pre-remove-gate-bot` | 移除 gate_bot 前 |
## 用法
```bash
# 查看标签
git tag -l 'snapshot/*'
# 检出快照(只读查看,勿在此分支直接开发)
git checkout snapshot/20260723-2
# 回到主线
git checkout main
```
数据备份(SQLite / 中控 JSON)走中控备份或各所 `scripts/backup_data.sh`**不要**把含密钥的 `.env` 与库文件提交进 Git。
+111
View File
@@ -0,0 +1,111 @@
# 振幅统计(中控)
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
> **不改下单链路**;不算 IV / 权利金。
---
## 入口
- 顶栏 **振幅统计**`/amp-stats`
- 手机端:**更多 → 振幅统计**
- 可在系统设置里隐藏该导航
---
## 怎么用
1. 打开 **统计** Tab
2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
3. **起点整点**0023);终点固定 **16:00**
4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
5.**计算** → 下方看汇总 + 分页日表
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
**跨天例子**
| 起点 | 含义(结算日 D |
|------|------------------|
| 22:00 | 昨天 22:00 → 今天 16:00 |
| 16:00 | 昨天 16:00 → 今天 16:00 |
| 08:00 | 今天 08:00 → 今天 16:00 |
未到当日 16:00 的「今天」不入样本。
---
## 指标(点数)
设开盘 O、最高 H、最低 L、收盘 C:
| 字段 | 算法 |
|------|------|
| 开→高 | `H O` |
| 开→低 | `O L` |
| **振幅** | `H L`= 开→高 + 开→低) |
| 涨跌值 | `C O` |
例:O=2000H=2500L=1800 → 开→高 500,开→低 200,振幅 **700**
汇总必含:最大振幅(及日期)、开→高/开→低的最大与均值等。
K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / BTC-USD),失败再降级永续标记。
近期 K 线接口约仅 **1440** 根(1H≈60 天);更长周期自动续拉 `history-index-candles` / `history-candles`
分页带间隔,遇 OKX **429** 会自动退避重试(长周期首次会慢一些)。
---
## 买跨对照(赌波动)
表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空):
| 汇总项 | 口径 |
|--------|------|
| 开→高超过权利金 | `HO > 权利金` 的天数与占比 |
| 开→低超过权利金 | `OL > 权利金` 的天数与占比 |
| \|涨跌\|超过权利金 | `\|CO\| > 权利金` 的天数与占比 |
| 有效波动 | 若设止盈且 `开→高≥止盈``开→低≥止盈` → 用止盈点;否则用 `\|CO\|` |
| 买跨收益 | `有效波动 权利金`(日表「收益」列同口径) |
- 方向:**买跨**
- 权利金越过:严格 **`>`**;止盈触达:**`≥`**
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
### 周末
- 下拉:**全部**(默认)/ **排除周末** / **仅周末**
-**结算日** 北京时间星期判断;表中六、日带标注并高亮
---
## 历史 Tab
-**保存到历史** 后出现(不会一算就自动入库)
- 可查看、再下载、删除
- 数据文件:`manual_trading_hub/amp_stats_history.json`(勿当密钥提交)
---
## 相关代码
| 路径 | 说明 |
|------|------|
| `lib/hub/amp_stats_lib.py` | 切窗、汇总、OKX 拉取、CSV |
| `manual_trading_hub/amp_stats_routes.py` | API |
| `manual_trading_hub/amp_stats_store.py` | 历史 JSON |
| `manual_trading_hub/static/amp_stats.js` | 前端 |
| `tests/test_amp_stats_lib.py` | 单元测试 |
---
## 修订
| 日期 | 说明 |
|------|------|
| 2026-07-23 | 首版上线说明 |
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 |
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 |
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 |
+89
View File
@@ -4,6 +4,95 @@
---
## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复
### 修改原因
复盘详情嵌在列表下方不便查看;截图缩略图易裁切/偶发加载失败。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `options_review_panel.html` | 详情改为居中对话框;2×2 截图网格 |
| `options_review.js` | 点复盘记录打开弹窗;截图 basename + onerror |
| `options_review_register.py` | 截图静态路由不强制登录(防 iframe 401) |
### 交付之后的验收
点「复盘记录」行弹出对话框;5m/15m/1h/4h 截图完整可见(缺失显示提示);关闭/Esc/点遮罩可关。
---
## 2026-07-19 · 期期情景测算:盈亏配色 + 盈亏比(亏=全额保费)
### 修改原因
情景弹窗合计无红绿区分;需要一眼看盈亏,并按「最大亏损=权利金全亏」给出上破/下破盈亏比。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `hedge_plan.js` | 合计/腿盈亏用 `hp-pnl-pos/neg`;摘要显示盈亏比 |
| `hedge_plan_calc_lib.py` | summary 增加 `rr_at_up` / `rr_at_down` / `rr_risk_premium` |
### 交付之后的验收
正数为绿、负数为红;摘要可见「盈亏比 上破 x:1 / 下破 y:1(亏=全额保费)」。
---
## 2026-07-19 · 修复期期「按张数」拆分:用同张数总张数 2n
### 修改原因
`sheets` 口径误把同张数每腿 `n` 当总张数拆,规模偏小;应对齐「先算完同张数两侧合计总张数 `2n`,再按比例拆」。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `hedge_plan_calc_lib.py` / `hedge_plan.js` | `total = n_same * 2` 再拆 |
| 相关 docs / env 文案 | 口径说明改为总张数 `2n` |
### 交付之后的验收
同张数 `n=5` 时,`sheets`+做空(0.7) → Put 7 / Call 3(合计 10)。
---
## 2026-07-19 · 期期张数:做多/做空替代均分 + env 拆分口径
### 修改原因
期期「均分」与方向偏好无关;需要按 Call/Put 7:3(可配)做偏多/偏空自动张数,并可用 env 在「预算金额 / 张数」两种拆法间切换。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `lib/hedge_plan/hedge_plan_calc_lib.py` | `long_bias`/`short_bias``budget`/`sheets` + `bias_ratio` |
| `lib/hedge_plan/hedge_plan_register.py` | gates 下发 `oo_bias_split_by` / `oo_bias_ratio` |
| `lib/hedge_plan/templates/hedge_plan_panel.html` | 张数段:同张数 / 做多 / 做空 |
| `lib/common/static/hedge_plan.js` | 前端建议张数与 env 同步 |
| `crypto_monitor_okx/.env.example` + env UI/schema | `HEDGE_PLAN_OO_BIAS_SPLIT_BY``HEDGE_PLAN_OO_BIAS_RATIO` |
| `docs/系统说明.md` 等 | 同步操作与配置说明 |
### 达成的目标
1. 默认仍为同张数。
2. 做多=Call 主占比、做空=Put 主占比;默认比例 0.7,口径默认预算金额。
3. `sheets` 口径:先算同张数每腿 `n`,总张数 `2n` 再拆(见上一条修正)。
### 交付之后的验收
1. 期期页可见「同张数 / 做多 / 做空」,无「均分」。
2. env 配置可改口径与比例;生产 OKX `.env` 已补齐键。
3. 选一 Call 一 Put 后自动张数符合比例;非 C+P 时提示。
---
## 2026-07-17 · 修复 pip>=26 部署依赖安装失败
### 修改原因
+2
View File
@@ -83,6 +83,7 @@
5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期.
6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」.
7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节.
8. **期期自动张数**:对冲计划页为「同张数 / 做多 / 做空」(已无均分);做多/做空按 Call·Put 比例拆,口径与比例见 env `HEDGE_PLAN_OO_BIAS_SPLIT_BY``HEDGE_PLAN_OO_BIAS_RATIO`.
---
@@ -91,3 +92,4 @@
- [期权用法.md](./期权用法.md) — 资金兑划与页面操作
- [期权方案.md](./期权方案.md) — env 与架构
- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿
- [系统说明.md](./系统说明.md) — 实例操作与门禁总手册
+14 -2
View File
@@ -78,16 +78,26 @@ OKX_OPTIONS_API_PASSPHRASE=...
需已配置 `WECHAT_WEBHOOK`.
## 6. 与永续的关系
## 6. 与永续 / 对冲计划的关系
| | 永续(子账户) | 期权(主账户) |
|--|----------------|----------------|
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` |
| 页面 | 实盘下单 / 关键位 | 期权 |
| 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 |
| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 |
两套资金 **不合并** 显示.
**期期对冲张数**(对冲计划页,与单独开期权共用预算算法):
| 模式 | 说明 |
|------|------|
| 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 |
| 做多 | Call:Put 按主腿占比(默认 7:3) |
| 做空 | Put:Call 按主腿占比(默认 7:3) |
拆分口径与比例见 env:`HEDGE_PLAN_OO_BIAS_SPLIT_BY`(`budget` 默认 / `sheets`=先算同张数总张数 `2n` 再拆)、`HEDGE_PLAN_OO_BIAS_RATIO`(默认 `0.7`)。细则见 [对冲计划开发方案.md](./对冲计划开发方案.md) §4.1、[系统说明.md](./系统说明.md)。
## 7. 配置说明
| 变量 | 默认 | 含义 |
@@ -97,6 +107,8 @@ OKX_OPTIONS_API_PASSPHRASE=...
| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 |
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
| `HEDGE_PLAN_OO_BIAS_SPLIT_BY` | budget | 期期做多/做空:按预算或按张数拆 |
| `HEDGE_PLAN_OO_BIAS_RATIO` | 0.7 | 期期做多/做空主腿占比 |
## 8. 期权复盘(含对冲)
+62
View File
@@ -0,0 +1,62 @@
# 策略对比说明
中控独立页 **策略对比**`/compare`):在同一风险额 `R` 下,对比三种工具的止盈能力与止损/踏空路径。
## 用途
回答两件事:
1. **盈利时谁更厉害**:干净止盈路径下各赚多少 U
2. **谁更易亏 / 更易踏空**:合约止损后踏空;期权/对冲最坏亏满权利金,但踏空路径下常仍可持有到目标
不是精确概率模型。到期「小盈/小亏」与 4 点收盘相关,**未纳入主表与推荐**。
## 入口
- 顶栏「策略对比」;设置 → 显示与导航可隐藏(`show_nav_compare`
- API`POST /api/compare/calc`(页面即时调用,价格均为手填)
## 输入
| 区块 | 字段 |
|------|------|
| 公共 | 标的 ETH/BTC、方向、入场价、风险 R、统一止损、止盈 |
| 单期权 | Call/Put、行权价、卖一(每币)、可选目标价 |
| 期期 | 主腿/次腿 各自行权与卖一;预算固定 **7:3** |
卖一口径与对冲计划一致:`单张成本 = 卖一 × ct_mult`(默认 `ct_mult=0.01`)。
## 仓位
- **合约**`张数 = floor(R / (|入场−止损| × 面值))`,默认面值 0.01
- **单期权**`张数 = floor(R / 单张成本)`
- **期期**:主预算 `0.7R`、次预算 `0.3R`,各自 `floor(预算/单张成本)`
## 主情景(A/B/C
| 路径 | 合约 | 单期权 / 期期 |
|------|------|----------------|
| A 干净止盈 | 入场→止盈盈亏 | 目标价内在价值 − 已付权利金(近似) |
| B 打止损 | −实际止损额(≈R) | 止损价处内在−权利金;并注最坏 −权利金 |
| C 先止损再去止盈 | **本单仍为止损亏损**;旁注踏空未拿到的原止盈空间 | **仍持有**至目标价,结果同 A(抗踏空对照) |
期权止盈按**内在价值近似**,不是盘口卖出价。
## 推荐规则(可解释)
1. 比较三者 A / R
2. 若合约止盈明显高于另两者(≥1.15×)→ 倾向合约,并提示踏空
3. 否则若存在踏空对照(合约亏、期权类 C 仍为正)→ 倾向单期权或期期(期期与单腿接近时优先期期)
4. 平局:抗踏空优先期权类,赔付碾压则合约
## 手测示例
`ETH` 做多,入场 3500,止损 3400,止盈 3700R=10;单 Call 行权 3600 卖一 50;对冲主 Call 3600/50、次 Put 3400/30
- 合约约 10 张,止损 −10U,止盈约 +20U,踏空未拿到约 +20U
- 单期权约 20 张,权利金 10U,止盈约 +10U,最坏 −10U
- 期期主 14 / 次 10 张
## 不做
实盘下单、拉交易所卖一(二期可选)、历史回测入库。
+362
View File
@@ -0,0 +1,362 @@
# 系统说明(实例操作与逻辑手册)
本文是实例侧的**详细说明书**:既写「点哪里、先做什么」,也写「为什么这样设计、钱怎么算、门禁如何拦」。
默认不在顶栏显示;需要时到 **系统设置 → 导航显示** 打开「系统说明」。
覆盖:**总览 · 期权 · 对冲计划 · 实盘下单 · 策略交易 · 关键位监控**。复盘/统计字段级细则与风控参数表仍以对应专页为准。
---
## 一、总览:账户、资金与一天怎么用
### 1.1 两套账户(OKX
| 账户 | 典型用途 | 界面相关 |
|------|----------|----------|
| **合约账户** | 永续开仓、止盈止损 | 实盘下单、策略、关键位自动单、永期对冲的永续腿 |
| **期权账户** | 买期权、期期双腿、权利金结算(多为 USDC) | 期权页、对冲计划期权腿 |
逻辑要点:对冲计划里「永续腿 → 合约账户」「期权腿 → 期权账户」。资金不够时,要先划转,再开仓。Binance / Gate 实例主要是合约侧永续能力(无期权/对冲 Tab 时忽略期权相关章节即可)。
### 1.2 资金流(操作顺序)
1. 确认合约可用 USDT(及 OKX 期权交易账户 USDC)是否够用。
2. 期期 / 单独开期权:常在期权页或期期卡片做 **资金 ↔ 交易** USDC 划转。
3. 永期:合约侧按全仓建议张数;期权侧再买保险腿。
4. 实盘 / 策略 / 关键位自动单:只动合约账户,按计仓模式算张数。
5. 开仓后到对应页看持仓与监控状态;结束后看复盘 / 统计 / 策略记录。
### 1.3 推荐使用节奏
| 场景 | 建议路径 |
|------|----------|
| 人工永续单 + 监控 | **实盘下单** |
| 趋势分档 / 在已有仓上滚仓 | **策略交易** |
| 位到提醒或自动开仓 | **关键位监控** |
| 只做方向 + 保险 | **对冲计划 → 永期**(测算 → 启动) |
| 只做上下突破双买 | **对冲计划 → 期期** |
| 单独买一张期权并挂目标 | **期权** 页开仓 + 目标监控 |
| 看说明 / 改开关 | **系统说明** / **env 配置** / **系统设置** |
### 1.4 互斥与门禁(总原则)
- **实盘 ↔ 趋势**:有活跃下单监控或运行中趋势计划时,另一侧不能再开(预览/执行会被挡)。
- **滚仓 ↔ 趋势**:有运行中趋势计划时,顺势加仓不可用。
- **计仓模式**`risk`(以损定仓)才允许趋势与多数关键位自动单;`full_margin`(全仓)适合永期对冲与部分触价单,**禁止**趋势/滚仓。切换计仓须无仓后改 env 并重启。
- **对冲与期权互斥门控**(默认开):有进行中对冲计划时,不能再「单独开期权」;账户里已有「纯期权」持仓时,不能启动对冲计划。
- **半腿失败改手动补开**(默认开):对冲启动一腿成功、一腿失败 → 不自动平已成腿,挂「半腿待补」后在「进行中」补开。
- **顶栏可开仓状态**:实盘开关、持仓上限、单日开仓硬上限、冷静期/日冻结、切点前禁开等取交集;细则见 **风控说明**
---
## 二、期权模块
### 2.1 这块干什么
在期权账户上:**选合约 → 按卖一限价买入 → 持仓监控 → 买一平仓 / 目标到位平仓**。
也是对冲计划期权腿的共用能力。
### 2.2 操作:开仓
1. 打开 **期权**,选标的(ETH/BTC)、到期日、Call/Put。
2. 看清 **卖一价与深度**:无真实卖一深度时系统禁止开仓(链上带 `~` 的是参考估算,不能当真开仓价)。
3. 选张数 / 预算模式后下单。
4. 可选填写 **目标指数位**:到位后由目标监控按买一挂平(与对冲计划托管的目标不是同一套执行器)。
### 2.3 操作:平仓与目标
- **买一平仓**:按当前买一深度估算可回收金额与净盈亏;注意买卖价差,权利金一侧常见较大滑点。
- **目标监控**:手动委托的目标写在期权目标表;**期期对冲**的目标由对冲监控执行,持仓卡上会显示「由对冲计划监控」。
- 门控示例:可回收 < 权利金×2 时,目标平仓门控可能未过(保护「太亏别乱平」类规则,以页面提示为准)。
### 2.4 逻辑:持仓来源
持仓卡上的 **持仓来源** 表示这条仓和哪类计划绑定:
| 来源 | 含义 |
|------|------|
| 纯期权 | 未挂在进行中对冲计划腿上(含手动开、或计划已结束仍留着的仓) |
| 永期对冲 #N | 属于进行中永期计划的保险腿 |
| 期期对冲 #N | 属于进行中期期计划的腿 |
判定依据:数据库里进行中计划的 `open` 腿合约 ID。来源会影响互斥门控(「纯期权」会挡住新对冲启动)。
### 2.5 逻辑:盈亏怎么看
- **权利金**:买入成本(USDC)。
- **按买盘回收**:按当前买一深度卖掉大约能拿回多少。
- **净盈亏 ≈ 回收 − 权利金**(页面以买一回收为准,不是单纯看标记价浮动)。
- **到期平衡 / 平掉回本**:帮助判断「拿到到期」与「现在平掉」的盈亏分界,属于情景参考。
### 2.6 更多细则
期权开平仓字段级说明仍可打开独立页:[期权开平仓与监控说明](/options/guide)。
---
## 三、对冲计划
### 3.1 这块干什么
把「永续 + 期权」或「期权 + 期权」做成**可测算、可下单、可监控**的计划,与普通交易记录分开。
| 类型 | 组成 | 核心逻辑 |
|------|------|----------|
| **永期** | 合约账户永续 + 期权账户保险腿 | 全仓做方向;止盈/止损按规则处理期权 |
| **期期** | 期权账户两腿买方 | 上破/下破目标;盈利腿先平,残腿按模式处理 |
### 3.2 操作:永期
1. 选 ETH/BTC、做多/做空;看标记价与全仓建议张数。
2. 填开仓价、止盈、止损、张数;右侧选期权腿(列表)。
3.**计算** → 弹窗看情景测算 → **启动计划** 或取消。
4. 启动后在 **进行中的计划** 看状态;细节可点「成交细节」。
逻辑摘要:
- 永期开仓通常要求 **全仓计仓** + 实盘与对冲真实下单门禁。
- 止盈后是否强平期权、止损后是否强平期权,由 env 开关控制(止损强平默认开,止盈强平默认关)。
- 统计口径:止盈多为「永续盈利 − 权利金」;止损多为「期权盈亏 + 永续盈亏」有符号相加(以系统结案字段为准)。
- 启动前会校验:合约侧不宜再有「额外」永续仓与永期腿冲突(以页面提示为准)。
### 3.3 操作:期期
1. 填上破 / 下破目标;指数价作参考。
2. 张数模式:**同张数**(默认)、**做多**、**做空**;平仓模式:**全平**(默认)或 **到期平**(若 env 打开方案 C)。
3. T 型报价选用两腿(做多/做空须一 Call 一 Put);可先划转 USDC。
4. **计算** → 情景测算 → **启动计划**
逻辑摘要:
- **同张数**:最大 `n` 使两腿各 `n` 张且总权利金 ≤ 预算。
- **做多 / 做空**:主腿与次腿按 env 比例(默认 7:3)分配;做多主腿=Call,做空主腿=Put。拆分口径由 `HEDGE_PLAN_OO_BIAS_SPLIT_BY` 决定:`budget`(默认,按权利金预算拆)或 `sheets`(先按同张数算出每腿 `n`,总张数 `2n` 再按比例拆到 Call/Put)。
- 达目标价:通常只平盈利腿。
- **全平**:盈利腿平掉后立刻尝试清另一腿(无 2× 权利金门控,失败会重试)。
- **到期平**:残腿持有至到期再结。
- 旧计划若无平仓模式字段,按「到期平」更安全的口径处理。
### 3.4 半腿失败与手动补开
启动时两腿要连续下单。若一腿成功、一腿失败:
| 配置 | 行为 |
|------|------|
| **半腿失败改手动补开 = 开**(默认) | 已成腿留下;计划状态 **半腿待补**;「进行中」出现 **补开永续 / 补开腿B / 补开期权****不会**自动买一平已成腿 |
| 手动补开 = 关,且自动平 = 开 | 尝试自动平掉已成期权腿(会吃买卖价差,几乎必亏一笔) |
操作建议:半腿出现后,先看失败原因(深度、余额、权限),再点补开;确认补开会真实下单。
### 3.5 情景测算弹窗
测算不再占页面下方大块区域:点 **计算** 弹出结果,底部 **启动计划 / 取消**
取消只关窗;启动按当前参数真实下单(仍受门禁约束)。
期期弹窗:合计盈亏绿/红配色;摘要显示盈亏比(盈利÷全额保费,亏损按权利金全亏计)。
### 3.6 进行中 / 历史 / 统计
- **进行中**:含 `opening` / `active` / `partial`。半腿待补可补开。
- **历史**:已结束计划与成交细节。
- **统计**:按永期 / 期期分别看胜率、盈亏比、最大盈亏与回撤等(按结束时间累积)。
- 对冲成交 **不进** 普通「交易记录与复盘」/「策略交易记录」。
---
## 四、实盘下单
### 4.1 这块干什么
合约账户上的 **人工永续开仓 + 下单监控**:提交后进入监控列表,轮询标记价与交易所止盈止损,支持改委托、手动平仓、移动保本、时间平等;平仓后进 **交易记录与复盘**
### 4.2 操作
1. 打开 **实盘下单**,选币种、方向。
2. 选止盈止损模式(固定盈亏比 / 价格 / 百分比等,以页面选项为准)。
3. 趋势类账户可再选开仓类型(反转 / 顺势 / 波段等);Gate 日内类账户选项更窄,且可能无移动保本 / 时间平。
4. 填止损与止盈(或 RR),看 **预估盈亏比** 与计划预览。
5. 确认开仓(按钮文案随「是否实盘」变化;关实盘时不会发交易所单)。
6. 右侧 **实时持仓**:看浮盈亏、交易所 TP/SL;用 **委托** 改止盈止损,或 **平仓** 全平。
7. 需要时点 **放大 K 线**;若交易所已有仓但本地无监控,可用 **恢复监控**(孤儿仓恢复)。
8. 结束后到 **交易记录与复盘** / **统计分析** 查看。
### 4.3 逻辑与门禁
| 项 | 说明 |
|----|------|
| `LIVE_TRADING_ENABLED` | 关则不发真单,仅本地流程 |
| `MANUAL_MIN_PLANNED_RR` | 人工开仓计划 RR 下限(表单 + 服务端) |
| `POSITION_SIZING_MODE` | `risk` 以损定仓 / `full_margin` 全仓;须无仓切换并重启 |
| `can_trade` 交集 | 持仓上限、单日开仓硬上限、冷静期、切点前禁开等 |
| 方向 / 币种白名单 | 账户策略限制时,不符合的单会被拒 |
三所核心流程一致;折叠区「开仓规则说明」文案按交易所模板略有不同。
### 4.4 与策略 / 期权 / 对冲的关系
-**趋势回调** 互斥(见 1.4);**顺势加仓** 必须先有本页同向活跃监控单。
- 期权 / 对冲互斥门控 **不拦** 本页永续单。
- 永期计划 active 时,合约侧不宜再挂「额外」永续仓(启动对冲前会校验)。
- 关键位自动开仓成交后,也会进入同一套 **下单监控**
### 4.5 常见问题
**Q:预估 RR 已经够绿,仍开不了?**
A:看顶栏 / 返回文案:满仓、日上限、冷静期、方向白名单、实盘关、服务端 RR 口径等。
**Q:交易所有仓,本页没有监控?**
A:用孤儿仓 **恢复监控**;不要另开一笔同向重复仓。
---
## 五、策略交易
### 5.1 这块干什么
自动化永续策略页:**趋势回调**(预览 → 分档补仓计划)与 **顺势加仓**(在已有同向监控持仓上滚仓)。执行历史在 **策略交易记录**
部分「日内纪律」类账户整 Tab 隐藏,以导航是否出现为准。
### 5.2 操作:趋势回调
1. 填币种、方向、杠杆、风险%、止损、补仓边界(多=上沿 / 空=下沿)、止盈。
2. **生成预览**(有短时效;用快照余额算张数)。
3. 核对预览表后 **确认执行(实盘)**
4. 运行中可看补仓档与浮盈亏;可 **手动保本**,或 **保本移交下单监控**(计划结束,仓交给实盘监控继续管)。
5. **结束计划** 或止盈止损自动结束后,写入策略记录与交易记录(类型「趋势回调」)。
### 5.3 操作:顺势加仓
1. 先在 **实盘下单** 有一条 **同向** 活跃监控单。
2. 选持仓、加仓模式(市价 / 斐波 / 突破等)、新止损 → **执行滚仓**(无预览步;同时通常只允许一条监控中滚仓腿)。
3. 注意次数上限(如做多/做空各最多若干次已成交腿)与首仓 TP 锁定规则,以页面提示为准。
### 5.4 逻辑与门禁
| 项 | 说明 |
|----|------|
| 实盘 + 计仓 | 须 `LIVE_TRADING_ENABLED=true``POSITION_SIZING_MODE=risk`;全仓模式禁止趋势与滚仓 |
| 与下单监控互斥 | 有活跃监控单或运行中趋势时,不能开另一侧预览/执行 |
| 与滚仓互斥 | 运行中趋势时滚仓按钮禁用 |
| 余额漂移 | 预览后余额变化过大(约 5%)须重新预览 |
| 单日开仓上限 | 与人工开仓共用计数,同样可拦预览/执行 |
### 5.5 与期权 / 对冲
独立模块:不走对冲计划状态机;记录进策略库 / 普通交易记录,**不进** 对冲历史与对冲统计。
OKX 上可与期权/对冲并存,但仍须遵守合约侧「永期不得另挂额外永续仓」等规则。
### 5.6 常见问题
**Q:触价到了却没补仓?**
A:看页面 `block_reason`:实盘关、余额漂移、最小张数减档、日上限等。
**Q:中控「策略说明」是不是本页手册?**
A:不是。中控策略说明是玩法 playbook;本说明书讲本系统如何操作与门禁。
---
## 六、关键位监控
### 6.1 这块干什么
配置 **关键价位**(常见 5m 门控):支撑/阻力可 **微信提醒**;箱体/收敛/触价等类型可在开关打开后 **程序自动开仓**,成交后进入 **实盘下单监控**
### 6.2 操作
1. 打开 **关键位监控**,选类型、币种、方向,填上下沿 / 触价 / E·SL·TP 等。
2. 箱体类可选 SL/TP 方案、移动保本、时间平等(以类型是否支持为准)。
3. **添加** 后在列表看现价、距沿距离、**门控** 状态;不需要则 **删除**
4. 右侧 **关键位历史** 看失效 / 成交 / 提醒完成等原因。
5. 可用 **放大 K 线** 辅助画位。
### 6.3 类型与开关(逻辑)
| 类型(概括) | 关键位自动单开关 | 全仓模式 |
|--------------|------------------|----------|
| 关键支撑阻力 | 不需要(仅提醒) | 可用 |
| 箱体 / 收敛 / 斐波 / 假突破等 | 需要开启,且一般为 `risk` 计仓 | **不可用**(添加会拒;已有位在全仓下可能被撤销并通知) |
| 回调 / 突破触价开仓 | 需要开启 | **可用**(全仓下常见的自动单路径) |
其它要点:
- `KEY_AUTO_MIN_PLANNED_RR`:自动单计划 RR 须严格大于该值(默认约 1.5)。
- 箱体类门控常含双 K 确认、突破幅度、量能、24h 成交额排名等(阈值见 env,改后多需重启)。
- 自动成交计入 **单日开仓次数**,并受 `can_trade`、持仓上限、冷静期约束。
- 假突破等类型可能仅限 BTC/ETH,且同币种条数有限,以页面校验为准。
### 6.4 与期权 / 对冲
无直接耦合。自动开仓写入下单监控后,与期权/对冲并行存在;若同时做永期,注意合约侧持仓冲突。
### 6.5 常见问题
**Q:开了「关键位自动单」仍只有支撑阻力可选?**
A:检查是否 **全仓模式**,或自动单开关实际未生效(改后是否重启)。
**Q:微信提醒有了却没开仓?**
A:可能是仅提醒类型、门控未过、RR 不足、满仓/日上限,或实盘/可开仓状态未过。
---
## 七、env 与系统设置(和说明书相关的部分)
### 7.1 系统设置 → 导航显示
控制顶栏是否出现各板块。「系统说明」默认关闭,打开后顶栏才显示入口。
### 7.2 env → 交易与关键位(常用)
| 开关 | 作用 |
|------|------|
| 实盘交易 | 关则人工/策略/自动单都不发真单(对冲另有「允许真实下单」) |
| 计仓模式 | `risk` / `full_margin`;决定策略与多数关键位自动单能否用 |
| 人工最小计划 RR | 实盘下单 RR 下限 |
| 关键位自动单 | 关则箱体等不自动开仓;支撑阻力提醒仍可用 |
| 关键位自动单最小 RR | 自动开仓 RR 下限 |
### 7.3 env → 对冲计划(常用)
| 开关 | 作用 |
|------|------|
| 启用对冲计划 | 总开关;关则导航隐藏且不可开仓 |
| 显示永期 / 期期 | 单独隐藏某一 Tab |
| 允许对冲真实下单 | 与实盘开关一起才可启动永期 |
| 对冲与期权互斥门控 | 见 1.4 |
| 半腿失败改手动补开 | 见 3.4 |
| 半腿失败时自动平期权 | 手动补开开启时强制无效 |
| 期期平仓模式(方案 C) | 页面是否出现「全平 / 到期平」 |
| 期期做多做空拆分口径 | `budget` 预算金额(默认)/ `sheets` 张数 |
| 期期做多做空主腿占比 | 默认 `0.7`(即 7:3 |
含「需重启」标记的项保存后要用「保存并重启」;对冲多数开关可热更,以页面标注为准。
---
## 八、常见问题
**Q:为什么有对冲计划时单独开不了期权?**
A:互斥门控默认开启,避免计划仓与手开仓搅在一起。可在 env 关闭互斥。
**Q:为什么有一张「纯期权」就启动不了对冲?**
A:同上。先平掉或确认来源;若其实是对冲腿,看持仓来源是否显示计划编号。
**Q:半腿后为什么不自动平?**
A:默认改手动补开,避免买一平仓吃掉 ≥10% 量级价差。到「进行中」补开即可。
**Q:测算能过但启动按钮灰?**
A:看顶部门禁行:全仓、实盘、真实下单、活跃计划数、互斥、Tab 是否隐藏等。
**Q:为什么策略页不能预览 / 滚仓灰掉?**
A:常见原因:全仓模式、实盘关、已有活跃下单监控或运行中趋势、日上限。见第四、五章。
**Q:关键位只提醒不开仓?**
A:支撑阻力本就只提醒;其它类型看自动单开关、计仓模式、门控与 RR。见第六章。
**Q:说明书和「风控说明」什么关系?**
A:风控说明仍是独立页(冷却、当日次数等细则)。本说明书讲板块逻辑与操作;风控细则以风控说明 + env 为准。
---
## 九、版本与维护
- 文档路径:`docs/系统说明.md`
- 功能变更后应同步改本章(尤其门禁、半腿、互斥、计仓、关键位自动单、平仓模式)。
- 更偏开发/方案的材料仍在 `docs/对冲计划*.md``docs/期权对冲方案分析.md`、策略专项 md 等,不必与本说明书一一粘贴。
+148
View File
@@ -0,0 +1,148 @@
/**
* 全局防浏览器自动填充登录账号/密码进业务输入框.
* 跳过真正的登录/改密字段;对划转数量等易中招框用 readonly 到聚焦.
*/
(function () {
"use strict";
var GUARD_ATTRS = {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
spellcheck: "false",
"data-lpignore": "true",
"data-1p-ignore": "true",
"data-bwignore": "true",
"data-form-type": "other",
};
function looksLikeUsername(v) {
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
}
function isAuthField(el) {
if (!el || !el.getAttribute) return true;
var t = String(el.type || "").toLowerCase();
if (t === "hidden" || t === "checkbox" || t === "radio" || t === "file" || t === "submit" || t === "button") {
return true;
}
if (el.getAttribute("aria-hidden") === "true") return true;
if (el.tabIndex === -1 && String(el.getAttribute("autocomplete") || "").toLowerCase() === "username") {
return true; // 诱饵账号框
}
var idName = String(el.id || "") + " " + String(el.name || "");
if (/^(pwd-|hub-pwd-|login-)/i.test(String(el.id || ""))) return true;
if (el.closest) {
if (el.closest(".login-form, #login-form, form.login-form, .password-settings, [data-password-settings]")) {
return true;
}
}
// env API Key 等 type=password 仍要防登录密码灌入,不在此跳过
if (t === "password" && /^(username|password)$/i.test(String(el.name || ""))) {
if (el.closest && el.closest("form[method='post'], form[method='POST']")) return true;
}
return false;
}
function isAmountLike(el) {
var key = String(el.id || "") + " " + String(el.name || "") + " " + String(el.placeholder || "");
return /amount|xfer|transfer|划转|数量|金额/i.test(key);
}
function wipeBad(el) {
if (!el || isAuthField(el)) return;
var v = String(el.value || "").trim();
if (!looksLikeUsername(v)) return;
var t = String(el.type || "text").toLowerCase();
if (t === "number" || isAmountLike(el) || /price|sheets|qty|sl|tp|target|entry|strike/i.test(String(el.id || "") + String(el.name || ""))) {
el.value = "";
}
}
function harden(el) {
if (!el || el.nodeType !== 1) return;
if (isAuthField(el)) return;
if (el.getAttribute("aria-hidden") === "true") return;
if (el.dataset && el.dataset.autofillGuarded === "1") {
wipeBad(el);
return;
}
if (el.dataset) el.dataset.autofillGuarded = "1";
Object.keys(GUARD_ATTRS).forEach(function (k) {
var cur = el.getAttribute(k);
if (k === "autocomplete" && cur && /^(username|current-password)/i.test(cur)) {
return;
}
// env 密钥框用 new-password 更抗登录密码灌入
if (k === "autocomplete" && String(el.type || "").toLowerCase() === "password") {
el.setAttribute(k, "new-password");
return;
}
if (!cur || cur === "on") el.setAttribute(k, GUARD_ATTRS[k]);
});
if (String(el.type || "").toLowerCase() === "password" || isAmountLike(el)) {
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
}
wipeBad(el);
setTimeout(function () {
wipeBad(el);
}, 250);
setTimeout(function () {
wipeBad(el);
}, 900);
setTimeout(function () {
wipeBad(el);
}, 2000);
}
function scan(root) {
var scope = root && root.querySelectorAll ? root : document;
var list = scope.querySelectorAll(
'input[type="text"], input[type="number"], input[type="search"], input[type="url"], input[type="email"], input[type="tel"], input[type="password"], input:not([type]), textarea'
);
for (var i = 0; i < list.length; i++) harden(list[i]);
}
function boot() {
scan(document);
if (typeof MutationObserver === "undefined") return;
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.type === "childList") {
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.matches && n.matches("input, textarea")) harden(n);
else if (n.querySelectorAll) scan(n);
}
} else if (m.type === "attributes" && m.target) {
harden(m.target);
}
}
});
obs.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["value"],
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
window.cmAutofillGuardScan = scan;
})();
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -15,6 +15,7 @@
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
};
@@ -296,7 +297,8 @@
}
function syncShellChrome(tab) {
const hideTopBar = tab === "settings" || tab === "risk_policy" || tab === "env_config";
const hideTopBar =
tab === "settings" || tab === "risk_policy" || tab === "system_guide" || tab === "env_config";
document.querySelectorAll(".instance-top-bar").forEach((el) => {
el.hidden = hideTopBar;
});
+1 -1
View File
@@ -88,7 +88,7 @@
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:2100}
.modal img{max-width:90%;max-height:90%;border-radius:8px}
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
+3 -1
View File
@@ -20,7 +20,7 @@
}
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
const NAV_DEFAULT_OFF = { show_nav_dashboard: true };
const NAV_DEFAULT_OFF = { show_nav_dashboard: true, show_nav_system_guide: true };
function navPrefShow(display, key) {
if (!key) return true;
@@ -41,6 +41,7 @@
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
@@ -68,6 +69,7 @@
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
const key = map[tab];
+648 -72
View File
@@ -3198,6 +3198,33 @@ html[data-theme="light"] .opt-be-dist-down {
.hedge-plan-page-wrap .hp-head-card {
margin-bottom: 12px;
}
.hedge-plan-page-wrap .hp-rule-collapse {
margin: 0 0 10px;
border: none;
background: transparent;
box-shadow: none;
}
.hedge-plan-page-wrap .hp-rule-collapse > .tip-collapse-summary {
padding: 4px 0;
font-size: 0.78rem;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body {
padding: 6px 0 2px;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body.rule-tip {
margin: 0;
border: none;
background: transparent;
padding: 0;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body p {
margin: 0 0 6px;
font-size: 0.74rem;
line-height: 1.45;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body p:last-child {
margin-bottom: 0;
}
.hedge-plan-page-wrap .hp-head-row {
display: flex;
flex-wrap: wrap;
@@ -3249,15 +3276,276 @@ html[data-theme="light"] .opt-be-dist-down {
color: #7ee787;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-plan-partial {
color: #ffb454;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-hist-actions .hp-btn-complete {
margin-right: 6px;
padding: 3px 8px;
font-size: 0.72rem;
min-height: 26px;
}
.hedge-plan-page-wrap .hp-pnl-neg {
color: #ff8a8a;
}
.hedge-plan-page-wrap .hp-oo-legs {
margin-top: 8px;
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hedge-plan-page-wrap .hp-oo-target-row {
align-items: flex-end;
flex-wrap: wrap;
gap: 10px 14px;
}
.hedge-plan-page-wrap .hp-oo-index {
display: inline-flex;
align-items: center;
margin: 0 0 2px;
padding: 4px 0;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #3dd68c;
white-space: nowrap;
}
.hedge-plan-page-wrap .hp-oo-transfer {
margin: 10px 0 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hedge-plan-page-wrap .hp-oo-transfer--compact {
margin: 12px 0 8px;
padding: 8px 10px;
}
.hedge-plan-page-wrap .hp-oo-transfer-bals {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 8px;
margin-bottom: 6px;
font-size: 0.76rem;
}
.hedge-plan-page-wrap .hp-oo-transfer-bals strong {
color: var(--text, #e6edf3);
font-variant-numeric: tabular-nums;
}
.hedge-plan-page-wrap .hp-oo-transfer-unit {
opacity: 0.75;
}
.hedge-plan-page-wrap .hp-oo-transfer-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
}
.hedge-plan-page-wrap .hp-oo-transfer-form select,
.hedge-plan-page-wrap .hp-oo-transfer-form input[type="number"] {
font-size: 0.74rem;
}
.hedge-plan-page-wrap .hp-oo-transfer-form input[type="number"] {
width: 96px;
max-width: 30vw;
}
.hedge-plan-page-wrap #hp-oo-xfer-msg {
margin-left: auto;
font-size: 0.72rem;
min-height: 1.1em;
}
.hedge-plan-page-wrap #hp-oo-xfer-msg.is-err {
color: #ff8a8a;
}
.hedge-plan-page-wrap #hp-oo-xfer-msg.is-ok {
color: #3dd68c;
}
.hedge-plan-page-wrap .hp-oo-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
margin: 10px 0 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hedge-plan-page-wrap .hp-oo-ctrl {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.hedge-plan-page-wrap .hp-oo-ctrl-lab {
font-size: 0.72rem;
color: var(--muted, #8b949e);
letter-spacing: 0.02em;
}
.hedge-plan-page-wrap .hp-oo-seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hedge-plan-page-wrap .hp-oo-seg .btn-secondary {
flex: 1 1 auto;
min-width: 4.5em;
justify-content: center;
padding: 5px 8px;
position: relative;
}
.hedge-plan-page-wrap .hp-oo-check {
display: none;
margin-right: 4px;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-size-mode.is-selected,
.hedge-plan-page-wrap .hp-oo-close-mode.is-selected,
.hedge-plan-page-wrap .hp-po-dir.is-selected,
.hedge-plan-page-wrap .hp-oo-size-mode.active,
.hedge-plan-page-wrap .hp-oo-close-mode.active,
.hedge-plan-page-wrap .hp-po-dir.active {
border-color: var(--accent, #00d4ff);
color: var(--text, #fff);
background: rgba(0, 212, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.35);
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-size-mode.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-close-mode.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-po-dir.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-size-mode.active .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-close-mode.active .hp-oo-check,
.hedge-plan-page-wrap .hp-po-dir.active .hp-oo-check {
display: inline;
color: var(--accent, #00d4ff);
}
.hedge-plan-page-wrap .hp-po-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin: 8px 0 4px;
}
.hedge-plan-page-wrap .hp-po-dir-seg {
flex: 1 1 180px;
max-width: 220px;
}
.hedge-plan-page-wrap .hp-po-mark {
font-size: 0.95rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #3dd68c;
white-space: nowrap;
}
.hedge-plan-page-wrap .hp-po-meta {
margin: 2px 0 8px;
font-size: 0.74rem;
line-height: 1.4;
}
.hedge-plan-page-wrap .hp-po-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
margin: 8px 0 6px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hedge-plan-page-wrap .hp-po-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
margin: 0;
font-size: 0.78rem;
}
.hedge-plan-page-wrap .hp-po-field-lab {
color: #9aa4b2;
font-size: 0.72rem;
}
.hedge-plan-page-wrap .hp-po-field-lab em {
font-style: normal;
color: #6b7388;
margin-left: 2px;
}
.hedge-plan-page-wrap .hp-po-field input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.hedge-plan-page-wrap .hp-po-field--tp input {
border-color: rgba(61, 214, 140, 0.45);
}
.hedge-plan-page-wrap .hp-po-field--sl input {
border-color: rgba(255, 138, 138, 0.45);
}
.hedge-plan-page-wrap .hp-po-summary {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.hedge-plan-page-wrap .hp-po-pnl {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hedge-plan-page-wrap .hp-po-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.76rem;
}
.hedge-plan-page-wrap .hp-po-sizing {
font-size: 0.74rem;
line-height: 1.4;
}
.hedge-plan-page-wrap .hp-po-opt-toolbar {
align-items: center;
}
.hedge-plan-page-wrap .hp-po-index {
margin-left: auto;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #3dd68c;
white-space: nowrap;
}
@media (max-width: 720px) {
.hedge-plan-page-wrap .hp-po-fields {
grid-template-columns: 1fr;
}
.hedge-plan-page-wrap .hp-po-index {
margin-left: 0;
}
}
.hedge-plan-page-wrap #hp-oo-close-mode-row.hidden {
display: none !important;
}
.hedge-plan-page-wrap .hp-oo-controls:has(#hp-oo-close-mode-row.hidden) {
grid-template-columns: 1fr;
}
.hedge-plan-page-wrap .hp-oo-meta {
margin: 4px 0 0;
font-size: 0.75rem;
line-height: 1.4;
}
.hedge-plan-page-wrap #hp-oo-budget-line.hp-oo-budget-warn {
color: #ff8a8a;
}
@media (max-width: 720px) {
.hedge-plan-page-wrap .hp-oo-controls {
grid-template-columns: 1fr;
}
}
.hedge-plan-page-wrap .hp-oo-leg-row {
display: flex;
flex-wrap: wrap;
@@ -3394,15 +3682,24 @@ html[data-theme="light"] .opt-be-dist-down {
display: flex;
flex-direction: column;
}
.hedge-plan-page-wrap .hp-preview-card {
margin-top: 0;
clear: both;
}
.hedge-plan-page-wrap .hp-action-row {
margin-top: 10px;
gap: 8px;
justify-content: flex-end;
}
.hedge-plan-page-wrap .hp-preview-modal {
width: min(96vw, 920px);
}
.hedge-plan-page-wrap .hp-preview-summary {
margin: 0 0 10px;
font-size: 0.84rem;
line-height: 1.45;
}
.hedge-plan-page-wrap .hp-preview-actions {
margin-top: 14px;
justify-content: flex-end;
gap: 10px;
}
.hedge-plan-page-wrap .hp-pick.active,
.hedge-plan-page-wrap .opt-row-selected td {
background: rgba(90, 140, 255, 0.18);
@@ -3649,30 +3946,51 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
background: rgba(255, 152, 0, 0.08);
}
.opt-order-inline-row td {
padding: 14px 16px !important;
background: rgba(74, 124, 255, 0.07);
border-top: 1px solid rgba(74, 124, 255, 0.25);
border-bottom: 1px solid rgba(74, 124, 255, 0.25);
padding: 0 !important;
border: none !important;
background: transparent !important;
}
.opt-order-panel-inner {
border-radius: 8px;
.opt-order-backdrop {
position: fixed;
inset: 0;
z-index: 2100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
}
.opt-order-backdrop[hidden] {
display: none !important;
}
.opt-order-dialog {
width: min(96vw, 760px);
max-height: 92vh;
overflow: auto;
background: var(--card-bg, #121726);
color: inherit;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 14px;
padding: 18px 20px 20px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.opt-order-layout {
display: flex;
align-items: stretch;
gap: 14px;
display: block;
}
.opt-order-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.opt-order-pending {
flex: 0 0 280px;
max-width: 320px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(158, 192, 255, 0.2);
background: rgba(0, 0, 0, 0.18);
.opt-order-dialog #opt-order-inst,
.opt-order-dialog .options-order-inst {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.4;
word-break: break-all;
margin: 0;
}
.opt-order-pending-head {
display: flex;
@@ -3703,6 +4021,12 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
max-height: 220px;
overflow: auto;
}
.opt-pending-list--tab {
max-height: min(52vh, 420px);
}
.opt-pos-pending-pane {
padding: 4px 2px 8px;
}
.opt-pending-empty {
font-size: 0.72rem;
}
@@ -3740,101 +4064,174 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
font-size: 0.68rem;
padding: 2px 8px;
}
html[data-theme="light"] .opt-order-pending {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.08);
}
html[data-theme="light"] .opt-pending-item {
background: #fff;
border-color: rgba(0, 0, 0, 0.08);
}
@media (max-width: 900px) {
.opt-order-layout {
flex-direction: column;
}
.opt-order-pending {
flex: 1 1 auto;
max-width: none;
.opt-pending-list--tab {
max-height: min(46vh, 360px);
}
}
.opt-order-panel-inner .opt-order-title {
margin: 0 0 8px;
font-size: 0.85rem;
.opt-order-dialog-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.opt-order-dialog-head .opt-order-title {
margin: 0;
margin-right: auto;
font-size: 1.02rem;
color: #9ec0ff;
font-weight: 650;
}
.options-page-wrap .opt-order-panel-inner .opt-order-title {
font-size: 0.82rem;
.opt-order-dialog-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.opt-order-panel-host:not([hidden]) {
display: block;
.opt-order-dialog-actions .btn-primary,
.opt-order-dialog-actions .btn-secondary {
flex: 1 1 140px;
min-height: 38px;
font-size: 0.86rem;
padding: 8px 14px;
}
.opt-order-panel-host[hidden] {
display: none !important;
.opt-order-dialog #opt-order-msg {
margin-top: 2px;
min-height: 1.2em;
line-height: 1.4;
}
.options-order-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin: 10px 0;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 12px 14px;
margin: 0;
padding: 12px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.options-order-grid .k {
display: block;
font-size: 0.68rem;
font-size: 0.7rem;
color: #8892b0;
margin-bottom: 3px;
}
.options-order-grid .v,
.options-page-wrap .options-order-grid .v {
font-size: 0.8rem;
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
}
.options-estimate-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12);
font-size: 0.78rem;
}
.opt-est-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin: 8px 0 10px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.08);
font-size: 0.74rem;
gap: 10px 12px;
}
.options-estimate-row .opt-est-label {
color: #8892b0;
.opt-order-chip {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
cursor: pointer;
user-select: none;
font-size: 0.76rem;
padding: 6px 12px;
min-height: 32px;
line-height: 1.2;
white-space: nowrap;
border: 1px solid rgba(140, 160, 200, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: inherit;
box-sizing: border-box;
}
.opt-order-chip:hover {
border-color: rgba(140, 170, 230, 0.55);
background: rgba(255, 255, 255, 0.07);
}
.opt-size-mode-chip {
position: relative;
}
.opt-size-mode-chip input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
pointer-events: none;
}
.opt-size-mode-chip:has(input:checked),
.opt-size-mode-chip.is-selected,
.opt-size-mode-chip.active {
border-color: #5b8cff;
color: #cfe0ff;
background: rgba(74, 124, 255, 0.28);
box-shadow: inset 0 0 0 1px rgba(120, 160, 255, 0.45);
}
.options-estimate-row .opt-target-idx {
width: 120px;
font-size: 0.74rem;
padding: 3px 6px;
width: 140px;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-estimate-row .k {
color: #8892b0;
}
.options-estimate-row .v {
font-size: 0.82rem;
font-size: 0.86rem;
font-weight: 600;
}
.options-estimate-row .opt-est-note {
font-size: 0.66rem;
display: block;
font-size: 0.7rem;
line-height: 1.45;
opacity: 0.85;
}
html[data-theme="light"] .options-estimate-row {
html[data-theme="light"] .options-estimate-row,
html[data-theme="light"] .options-order-grid {
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.1);
}
html[data-theme="light"] .opt-order-chip {
border-color: rgba(0, 0, 0, 0.16);
background: #fff;
}
.options-hint {
font-size: 0.75rem;
margin-bottom: 6px;
}
.options-page-wrap .options-order-mode-row {
font-size: 0.74rem;
gap: 6px;
font-size: 0.78rem;
gap: 10px;
}
.options-page-wrap .options-order-mode-row input[type="number"],
.options-page-wrap .options-order-mode-row input[type="text"] {
font-size: 0.74rem;
padding: 3px 6px;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-page-wrap .options-order-mode-row .btn-primary {
font-size: 0.74rem;
padding: 4px 10px;
font-size: 0.8rem;
padding: 6px 12px;
}
.options-page-wrap .opt-row-actions .btn-primary,
.options-page-wrap .opt-row-actions .btn-secondary {
@@ -3876,11 +4273,29 @@ html[data-theme="light"] .options-estimate-row {
background: rgba(255, 209, 102, 0.12);
}
.options-order-mode-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
margin: 0;
}
.opt-size-mode-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
gap: 10px;
}
.options-order-mode-row input[type="number"] {
width: 88px;
width: 96px;
}
.options-order-mode-row .opt-signal-note,
.options-order-mode-row #opt-signal-note {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.options-order-mode-row .opt-order-chip {
flex: 0 0 auto;
}
.options-dual-grid {
display: grid;
@@ -4170,6 +4585,52 @@ html[data-theme="light"] .options-stats-pnl-summary .options-stat-item {
font-size: 0.62rem;
line-height: 1;
}
.opt-source-badge {
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
padding: 2px 7px;
border-radius: 6px;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.2;
border: 1px solid transparent;
}
.opt-source-badge--plain {
background: rgba(255, 255, 255, 0.06);
color: #9aa4b2;
border-color: rgba(255, 255, 255, 0.1);
}
.opt-source-badge--po {
background: rgba(100, 160, 255, 0.16);
color: #8ec0ff;
border-color: rgba(100, 160, 255, 0.35);
}
.opt-source-badge--oo {
background: rgba(0, 212, 255, 0.14);
color: #5ee4ff;
border-color: rgba(0, 212, 255, 0.35);
}
.opt-pos-bar .opt-source-badge {
padding: 2px 6px;
font-size: 0.6rem;
}
html[data-theme="light"] .opt-source-badge--plain {
background: rgba(15, 23, 42, 0.06);
color: #5a6578;
border-color: rgba(15, 23, 42, 0.12);
}
html[data-theme="light"] .opt-source-badge--po {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.25);
}
html[data-theme="light"] .opt-source-badge--oo {
background: rgba(8, 145, 178, 0.1);
color: #0e7490;
border-color: rgba(8, 145, 178, 0.28);
}
.opt-pos-bar-meta {
font-size: 0.66rem;
color: #8b95b0;
@@ -4357,14 +4818,19 @@ html[data-theme="light"] .options-stats-pnl-summary .options-stat-item {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.options-page-wrap .opt-close-rule {
.options-page-wrap .opt-close-rule,
.options-page-wrap .opt-open-rule {
margin-top: 8px;
margin-bottom: 10px;
padding: 0;
border: 1px solid rgba(67, 82, 118, 0.55);
border-radius: 10px;
background: rgba(14, 19, 30, 0.58);
overflow: hidden;
}
.options-page-wrap .opt-open-rule {
margin-top: 4px;
}
.options-page-wrap .opt-close-rule summary {
display: flex;
align-items: center;
@@ -4691,7 +5157,10 @@ html[data-theme="light"] .options-chain-toolbar .btn-secondary.active,
html[data-theme="light"] .opt-uly-btn.active,
html[data-theme="light"] .opt-type-btn.active,
html[data-theme="light"] .opt-money-btn.active,
html[data-theme="light"] .opt-pos-tab.active {
html[data-theme="light"] .opt-pos-tab.active,
html[data-theme="light"] .opt-size-mode-chip.is-selected,
html[data-theme="light"] .opt-size-mode-chip.active,
html[data-theme="light"] .opt-size-mode-chip:has(input:checked) {
border-color: rgba(0, 95, 140, 0.45) !important;
color: #004d6e !important;
background: rgba(0, 110, 154, 0.14) !important;
@@ -4943,6 +5412,113 @@ html[data-theme="light"] .settings-account-summary {
box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06);
}
/* 期权复盘 · 亮色主题(覆盖面板内暗色默认变量) */
html[data-theme="light"] .options-review-wrap {
--or-section-bg: #fff;
--or-section-shadow: 0 1px 3px rgba(20, 34, 50, 0.06);
--or-border: #9eb0c4;
--or-border-soft: #c8d4e0;
--or-border-faint: #dce4ec;
--or-text: #142232;
--or-title: #142232;
--or-muted: #3a5068;
--or-filters-bg: #eef3f8;
--or-tile-bg: #f6f9fc;
--or-badge-bg: rgba(0, 110, 154, 0.1);
--or-accent-bg: rgba(0, 110, 154, 0.12);
--or-accent-fg: #004d6e;
--or-accent-border: rgba(0, 95, 140, 0.28);
--or-row-active-bg: rgba(0, 110, 154, 0.08);
--or-row-hover-bg: rgba(0, 110, 154, 0.06);
--or-modal-bg: #fff;
--or-modal-shadow: 0 12px 40px rgba(20, 34, 50, 0.18);
--or-backdrop: rgba(20, 34, 50, 0.45);
--or-img-bg: #eef3f8;
color: #142232;
}
html[data-theme="light"] .options-review-wrap .or-section {
background: #fff !important;
border-color: #9eb0c4 !important;
box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06);
color: #142232 !important;
}
html[data-theme="light"] .options-review-wrap .or-section-title,
html[data-theme="light"] .options-review-wrap .or-detail-modal-head h3,
html[data-theme="light"] .options-review-wrap .or-page-head h2 {
color: #142232 !important;
}
html[data-theme="light"] .options-review-wrap .or-section-desc,
html[data-theme="light"] .options-review-wrap .muted,
html[data-theme="light"] .options-review-wrap .sub {
color: #3a5068 !important;
}
html[data-theme="light"] .options-review-wrap .or-filters {
background: #eef3f8 !important;
border-color: #c8d4e0 !important;
}
html[data-theme="light"] .options-review-wrap .or-kpi-tile,
html[data-theme="light"] .options-review-wrap .or-stat-card,
html[data-theme="light"] .options-review-wrap .or-detail-img-cell {
background: #f6f9fc !important;
border-color: #c8d4e0 !important;
}
html[data-theme="light"] .options-review-wrap .or-tab {
background: #fff !important;
color: #006e9a !important;
border-color: rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .options-review-wrap .or-tab.active {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border-color: rgba(0, 95, 140, 0.28) !important;
}
html[data-theme="light"] .options-review-wrap .or-step {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border-color: rgba(0, 95, 140, 0.28) !important;
}
html[data-theme="light"] .options-review-wrap .or-badge {
background: rgba(0, 110, 154, 0.1) !important;
color: #004d6e !important;
}
html[data-theme="light"] .options-review-wrap .or-detail-modal {
background: #fff !important;
color: #142232 !important;
border-color: #9eb0c4 !important;
box-shadow: 0 12px 40px rgba(20, 34, 50, 0.18);
}
html[data-theme="light"] .options-review-wrap .or-detail-backdrop {
background: rgba(20, 34, 50, 0.45) !important;
}
html[data-theme="light"] .options-review-wrap .options-strike-table thead th {
background: #eef3f8 !important;
color: #334155 !important;
border-bottom: 1px solid #c8d4e0 !important;
}
html[data-theme="light"] .options-review-wrap .options-strike-table th,
html[data-theme="light"] .options-review-wrap .options-strike-table td {
color: #142232 !important;
border-bottom-color: #d0dae4 !important;
}
html[data-theme="light"] .options-review-wrap .or-trades-table tr.or-row-active,
html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover {
background: rgba(0, 110, 154, 0.08) !important;
}
.pos-pnl-profit {
color: #7ee787;
}
@@ -93,8 +93,16 @@
});
}
function isOptionsReviewSlot(input) {
if (!input) return false;
if (input.classList && input.classList.contains("or-upload-input")) return true;
return !!(input.closest && input.closest("#or-upload-slots, #options-review-root"));
}
function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return;
// 期权复盘槽位由 options_review.js 处理,勿被合约复盘上传抢走
if (isOptionsReviewSlot(input)) return;
input.dataset.journalSlotBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
+200 -41
View File
@@ -14,6 +14,13 @@
moneyFilter: "all",
chainView: "list",
strikeExpandAll: false,
/** 环境 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED;链接口可热更新 */
askLiqFilter: root.dataset.askLiqFilter !== "0",
budgetBuffer: (function () {
const raw = root.dataset.budgetBuffer;
const n = raw != null && raw !== "" ? Number(raw) : NaN;
return !Number.isNaN(n) && n > 0 ? n : 0.95;
})(),
chain: panelCache.chain || null,
selectedInst: null,
orderQuote: null,
@@ -91,16 +98,17 @@
}
function parkOrderPanel() {
stopPendingOrdersPoll();
const panel = orderPanel();
const host = orderPanelHost();
// 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM
if (host && orderPanelHome && host.parentElement !== orderPanelHome) {
orderPanelHome.appendChild(host);
} else if (panel && host && panel.parentElement !== host) {
host.appendChild(panel);
// 弹窗挂到 body;关闭后收回原位,绝不插入期权链表格
if (panel && host && panel.parentElement !== host) host.appendChild(panel);
if (host) {
host.hidden = true;
host.setAttribute("aria-hidden", "true");
if (orderPanelHome && host.parentElement !== orderPanelHome) {
orderPanelHome.appendChild(host);
}
}
if (host) host.hidden = true;
if (panel) panel.style.display = "none";
const inline = document.querySelector(".opt-order-inline-row");
if (inline) inline.remove();
@@ -121,32 +129,27 @@
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') ||
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') ||
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]');
if (!row) {
syncPickButtons(null);
return false;
}
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
r.classList.toggle("opt-row-selected", r === row);
r.classList.toggle("opt-row-selected", !!row && r === row);
});
syncPickButtons(instId);
const oldInline = document.querySelector(".opt-order-inline-row");
if (oldInline) oldInline.remove();
if (panel.parentElement !== host) host.appendChild(panel);
const tr = document.createElement("tr");
tr.className = "opt-order-inline-row";
const td = document.createElement("td");
td.colSpan = strikeTableColspan();
td.appendChild(host);
tr.appendChild(td);
row.after(tr);
// 挂到 body,避免被卡片 overflow 裁成「行内展开」
if (host.parentElement !== document.body) document.body.appendChild(host);
host.hidden = false;
host.setAttribute("aria-hidden", "false");
panel.style.display = "";
tr.scrollIntoView({ behavior: "smooth", block: "nearest" });
refreshPendingOrders();
startPendingOrdersPoll();
return true;
}
function closeOrderDialog() {
state.selectedInst = null;
state.orderQuote = null;
parkOrderPanel();
}
function fmtPendingAge(sec) {
if (sec == null || Number.isNaN(Number(sec))) return "—";
let s = Math.max(0, Math.round(Number(sec)));
@@ -279,6 +282,42 @@
const ethEl = document.getElementById("opt-eth-amount");
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
const radio = chip.querySelector('input[name="opt-size-mode"]');
chip.classList.toggle("is-selected", !!(radio && radio.checked));
chip.classList.toggle("active", !!(radio && radio.checked));
});
}
function hardenOrderAutofill() {
function looksLikeUsername(v) {
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
}
function harden(el) {
if (!el) return;
function wipe() {
if (looksLikeUsername(el.value)) el.value = "";
}
wipe();
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
setTimeout(wipe, 200);
setTimeout(wipe, 800);
setTimeout(wipe, 2000);
}
const note = document.getElementById("opt-signal-note");
harden(note);
[
"opt-sheets-amount",
"opt-eth-amount",
"opt-target-idx",
].forEach(function (id) {
harden(document.getElementById(id));
});
}
function quoteUrl(instId) {
@@ -341,21 +380,41 @@
return "";
}
function askLiqFilterOn() {
return !!state.askLiqFilter;
}
function hasAskLiquidity(c) {
if (!c) return false;
if (c.ask_estimated) return false;
const a = Number(c.ask);
const s = Number(c.ask_sz);
return Number.isFinite(a) && a > 0 && Number.isFinite(s) && s >= 1;
}
function syncAskLiqFilterFromChain(d) {
if (!d || d.ask_liq_filter_enabled == null) return;
state.askLiqFilter = !!d.ask_liq_filter_enabled;
root.dataset.askLiqFilter = state.askLiqFilter ? "1" : "0";
}
function countContractsForType(contracts) {
if (state.chainView === "t") {
return countStraddleStrikes(contracts);
}
return (contracts || []).filter(function (c) {
return c.opt_type === state.optType;
if (c.opt_type !== state.optType) return false;
if (askLiqFilterOn() && !hasAskLiquidity(c)) return false;
return true;
}).length;
}
function countStraddleStrikes(contracts) {
const strikes = new Set();
(contracts || []).forEach(function (c) {
if (c.strike != null) strikes.add(String(c.strike));
const rows = buildStraddleRows(contracts).filter(function (row) {
if (!askLiqFilterOn()) return true;
return hasAskLiquidity(row.call) || hasAskLiquidity(row.put);
});
return strikes.size;
return rows.length;
}
function buildStraddleRows(contracts) {
@@ -398,7 +457,11 @@
function filterStraddleRows(rows, indexPx) {
const atmStrike = findAtmStrike(rows, indexPx);
return rows.filter(function (row) {
return matchesStrikeRowFilter(row.strike, indexPx, atmStrike);
if (!matchesStrikeRowFilter(row.strike, indexPx, atmStrike)) return false;
if (askLiqFilterOn() && !hasAskLiquidity(row.call) && !hasAskLiquidity(row.put)) {
return false;
}
return true;
});
}
@@ -459,7 +522,10 @@
function filterChainContracts(contracts) {
return (contracts || []).filter(function (c) {
return c.opt_type === state.optType && matchesMoneyFilter(c.moneyness);
if (c.opt_type !== state.optType) return false;
if (!matchesMoneyFilter(c.moneyness)) return false;
if (askLiqFilterOn() && !hasAskLiquidity(c)) return false;
return true;
});
}
@@ -473,6 +539,28 @@
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function sourceText(p) {
const lab = (p && p.source_label) || "纯期权";
const src = (p && p.source) || "option";
let pid = p && p.source_plan_id;
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
pid = p.hedge_plan_target.plan_id;
}
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
return lab;
}
function sourceBadgeHtml(p) {
const src = (p && p.source) || "option";
const cls =
src === "options_options"
? "opt-source-badge opt-source-badge--oo"
: src === "perp_options"
? "opt-source-badge opt-source-badge--po"
: "opt-source-badge opt-source-badge--plain";
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
}
function expLabel(ms) {
try {
const dt = new Date(Number(ms));
@@ -485,6 +573,15 @@
}
}
function applyBudgetBuffer(raw) {
if (raw == null || raw === "") return;
const buf = Number(raw);
if (Number.isNaN(buf) || buf <= 0) return;
state.budgetBuffer = buf;
const el = document.getElementById("opt-budget-buf");
if (el) el.textContent = fmt(buf, 2);
}
function renderIndexLine() {
const idx = state.chain && state.chain.index_px;
const dte = state.chain && state.chain.chain_max_dte_days;
@@ -492,13 +589,37 @@
const el = document.getElementById("opt-chain-dte");
if (el) el.textContent = String(Math.round(dte));
}
if (state.chain && state.chain.budget_buffer != null) {
applyBudgetBuffer(state.chain.budget_buffer);
}
const line = document.getElementById("opt-index-line");
if (line) {
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
line.textContent =
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 默认显示全部 · 实值含平值 · 虚值=价外";
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外";
}
}
function pickNearestExpiry(exps) {
if (!exps || !exps.length) return "";
const now = Date.now();
let best = null;
let bestDelta = Infinity;
exps.forEach(function (e) {
const t = Number(e.exp_time);
if (!Number.isFinite(t)) return;
const delta = t - now;
if (delta < -60000) return;
if (delta < bestDelta) {
bestDelta = delta;
best = e;
}
});
if (best) return String(best.exp_time);
return String(exps[0].exp_time);
}
function renderExpiryOptions(preserveSelection) {
const sel = document.getElementById("opt-exp-select");
if (!sel) return;
@@ -513,6 +634,8 @@
});
if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) {
sel.value = prev;
} else if (exps.length) {
sel.value = pickNearestExpiry(exps);
}
}
@@ -810,7 +933,8 @@
if (!list.length) {
const label = moneyFilterLabel();
const suffix = label ? label : optTypeLabel(state.optType);
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "合约</td></tr>";
const liqTip = askLiqFilterOn() ? "(卖一深度≥1 时才显示,可在环境配置关闭筛选)" : "";
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "合约" + liqTip + "</td></tr>";
state.selectedInst = null;
return;
}
@@ -869,8 +993,10 @@
const atmStrike = findAtmStrike(rows, indexPx);
let matchedSelected = false;
rows.forEach(function (row) {
const call = row.call;
const put = row.put;
const callRaw = row.call;
const putRaw = row.put;
const call = callRaw && (!askLiqFilterOn() || hasAskLiquidity(callRaw)) ? callRaw : null;
const put = putRaw && (!askLiqFilterOn() || hasAskLiquidity(putRaw)) ? putRaw : null;
const combined = straddleAskPerUnit(call && call.ask, put && put.ask);
const tr = document.createElement("tr");
tr.className = "opt-strike-row opt-strike-row-t";
@@ -1048,6 +1174,7 @@
panelCache.chain = d;
panelCache.underlying = uly;
panelCache.optType = state.optType;
syncAskLiqFilterFromChain(d);
if (!soft) {
state.selectedInst = null;
resetMoneyFilterToAll();
@@ -1084,16 +1211,16 @@
async function openPosition() {
if (!state.selectedInst) {
alert("请先选择合约");
return;
return false;
}
const q = state.orderQuote;
if (!q || !q.ok || !q.can_open) {
alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓");
return;
return false;
}
if (q.sizing && q.sizing.ok === false) {
alert(q.sizing.msg || "张数无效");
return;
return false;
}
const btn = document.getElementById("opt-open-btn");
btn.disabled = true;
@@ -1114,7 +1241,7 @@
const tgt = parseFloat(tgtRaw);
if (!Number.isFinite(tgt) || tgt <= 0) {
alert("目标位无效");
return;
return false;
}
body.target_index = tgt;
}
@@ -1124,16 +1251,19 @@
body: JSON.stringify(body),
});
const msgEl = document.getElementById("opt-order-msg");
msgEl.textContent = d.ok ? "下单已提交,右侧可查看/撤销未成交委托" : (d.msg || "失败");
msgEl.textContent = d.ok ? "下单已提交,可在「当前委托」查看/撤销" : (d.msg || "失败");
msgEl.classList.toggle("opt-error", !d.ok);
if (d.ok) {
refreshPendingOrders();
startPendingOrdersPoll();
refreshAllPositions();
if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot();
} else {
alert(d.msg || "下单失败");
closeOrderDialog();
setOptionsPosTab("pending");
return true;
}
alert(d.msg || "下单失败");
return false;
} finally {
const latest = state.orderQuote;
btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false));
@@ -1158,11 +1288,14 @@
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</div>" +
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div></div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
@@ -1320,6 +1453,7 @@
'<span class="opt-pos-bar-id-group">' +
'<strong class="opt-pos-bar-title" title="' + inst + '">' + inst + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</span>" +
'<span class="opt-pos-bar-meta">行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张</span>" +
"</span>" +
@@ -1561,6 +1695,10 @@
if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer();
}
if (tab === "pending") {
refreshPendingOrders();
startPendingOrdersPoll();
}
}
function bindOptionsPosTabs() {
@@ -1934,6 +2072,7 @@
}
function bootOptionsPanel() {
applyBudgetBuffer(state.budgetBuffer);
updateSizeInputs();
syncMoneyFilterButtons();
syncChainViewUI();
@@ -2021,6 +2160,7 @@
});
}
bindOptionsPosTabs();
hardenOrderAutofill();
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
r.addEventListener("change", function () {
@@ -2029,6 +2169,25 @@
});
});
function bindOrderDialogChrome() {
const host = orderPanelHost();
const closeBtn = document.getElementById("opt-order-close-btn");
const cancelBtn = document.getElementById("opt-order-cancel-btn");
if (closeBtn) closeBtn.addEventListener("click", closeOrderDialog);
if (cancelBtn) cancelBtn.addEventListener("click", closeOrderDialog);
if (host) {
host.addEventListener("click", function (ev) {
if (ev.target === host) closeOrderDialog();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
const h = orderPanelHost();
if (h && !h.hidden) closeOrderDialog();
});
}
bindOrderDialogChrome();
["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
+43 -11
View File
@@ -38,6 +38,28 @@
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function sourceText(p) {
const lab = (p && p.source_label) || "纯期权";
const src = (p && p.source) || "option";
let pid = p && p.source_plan_id;
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
pid = p.hedge_plan_target.plan_id;
}
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
return lab;
}
function sourceBadgeHtml(p) {
const src = (p && p.source) || "option";
const cls =
src === "options_options"
? "opt-source-badge opt-source-badge--oo"
: src === "perp_options"
? "opt-source-badge opt-source-badge--po"
: "opt-source-badge opt-source-badge--plain";
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
}
function pnlCls(upl, hub) {
if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
@@ -125,9 +147,10 @@
opts = opts || {};
const hub = !!opts.hub;
const readOnly = !!opts.readOnly;
const net = netPnlFromPos(p);
const roi = netRoiFromPos(p, net);
const uplCls = pnlCls(net, hub);
const hidePnl = !!opts.hidePnl;
const net = hidePnl ? null : netPnlFromPos(p);
const roi = hidePnl ? null : netRoiFromPos(p, net);
const uplCls = hidePnl ? "" : pnlCls(net, hub);
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
@@ -144,13 +167,22 @@
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div>";
}
const pnlCells = hidePnl
? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</div>" +
headActions +
"</div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
@@ -164,15 +196,12 @@
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
pnlCells +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, p.premium_paid, hub)) + "</span></div>" +
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" +
"</div>" +
(function () {
const hint = closeGateHint(closePreview);
@@ -192,19 +221,22 @@
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
}
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const profitSpan = hidePnl
? ""
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
return (
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
'<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>" +
profitSpan +
'<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
"</span></div>"
+334 -98
View File
@@ -67,23 +67,74 @@
return s;
}
function closeReasonLabel(r) {
var map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期",
expiry: "到期",
manual: "人工结束",
partial_fail: "半腿失败",
cancelled: "已取消",
tp: "止盈",
sl: "止损",
};
var key = String(r || "").trim();
if (!key) return "—";
return map[key] || key;
}
function legRoleLabel(role) {
var map = {
perp: "永续腿",
option_hedge: "保险期权",
option_a: "期期腿A",
option_b: "期期腿B",
};
var key = String(role || "").trim();
if (!key) return "—";
return map[key] || key;
}
function tradeTitle(t) {
if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || "—";
return (
(t.underlying || "") +
(t.direction ? " " + t.direction : "") +
(t.plan_close_reason ? " · " + t.plan_close_reason : "")
(t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "")
);
}
function pnlStyle(v) {
function pnlClass(v) {
var n = Number(v);
if (n > 0) return "color:#3dd68c";
if (n < 0) return "color:#f07178";
if (n > 0) return "pos-pnl-profit";
if (n < 0) return "pos-pnl-loss";
return "";
}
function resultClass(tag) {
var t = String(tag || "").trim();
if (t === "盈利") return "pos-pnl-profit";
if (t === "亏损") return "pos-pnl-loss";
return "";
}
function tradeContractLabel(t) {
if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || t.underlying || "—";
return t.underlying || "—";
}
function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, "");
@@ -98,12 +149,12 @@
p.set("source_type", activeSource);
var uly = ($("or-filter-uly") || {}).value || "";
var opt = ($("or-filter-opt") || {}).value || "";
var strategy = (($("or-filter-strategy") || {}).value || "").trim();
var q = (($("or-filter-q") || $("or-filter-strategy") || {}).value || "").trim();
var from = ($("or-filter-from") || {}).value || "";
var to = ($("or-filter-to") || {}).value || "";
if (uly) p.set("underlying", uly);
if (opt) p.set("opt_type", opt);
if (strategy) p.set("strategy_tag", strategy);
if (q) p.set("q", q);
if (from) p.set("closed_from", from.replace("T", " ") + ":00");
if (to) p.set("closed_to", to.replace("T", " ") + ":00");
if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1");
@@ -285,10 +336,10 @@
if (!tbody) return;
var wrap = beginListLoad("or-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载中…</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载中…</td></tr>';
}
var p = baseQs();
p.set("reviewed", "0");
// 交易记录保留已复盘条目,不再只显示待复盘
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE));
if (!doSync) p.set("sync", "0");
@@ -299,7 +350,7 @@
.then(function (data) {
if (doSync) setSyncStatus("本地记录已加载");
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
@@ -311,7 +362,7 @@
tradesCache = {};
if (!rows.length) {
tbody.innerHTML =
'<tr><td colspan="6" class="muted">暂无待复盘记录</td></tr>';
'<tr><td colspan="7" class="muted">暂无交易记录</td></tr>';
endListLoad(wrap);
return;
}
@@ -319,6 +370,17 @@
.map(function (t) {
tradesCache[t.id] = t;
var active = currentTradeId === t.id ? " or-row-active" : "";
var reviewed = !!t.reviewed;
var actionBtn = reviewed
? '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">编辑</button>'
: '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">复盘</button>';
var badgeExtra = reviewed
? ' <span class="or-badge" style="background:rgba(61,214,140,.2)">已复盘</span>'
: "";
return (
'<tr class="or-trade-row' +
active +
@@ -327,26 +389,29 @@
'">' +
"<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) +
"</span></td>" +
"</span>" +
badgeExtra +
"</td>" +
"<td>" +
escapeHtml(tradeTitle(t)) +
"</td>" +
'<td style="' +
pnlStyle(t.realized_pnl_total) +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px">' +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
"<br>" +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") +
"</td>" +
"<td>" +
fmtHold(t.hold_seconds) +
"</td>" +
'<td><button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">复盘</button> ' +
"<td>" +
actionBtn +
" " +
'<button type="button" class="btn-secondary or-hide-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">删除</button></td>' +
@@ -371,7 +436,7 @@
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
@@ -384,7 +449,7 @@
if (!tbody) return;
var wrap = beginListLoad("or-reviewed-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载中…</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载中…</td></tr>';
}
var p = baseQs();
p.set("reviewed", "1");
@@ -397,7 +462,7 @@
})
.then(function (data) {
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
@@ -408,13 +473,16 @@
var rows = data.trades || [];
reviewedCache = {};
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">暂无复盘记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">暂无复盘记录</td></tr>';
endListLoad(wrap);
return;
}
tbody.innerHTML = rows
.map(function (t) {
reviewedCache[t.id] = t;
var entry = t.entry || {};
var direction = t.direction_view || entry.direction_view || "";
var entryLogic = t.entry_logic || entry.entry_logic || "";
return (
'<tr class="or-reviewed-row" data-id="' +
t.id +
@@ -423,20 +491,37 @@
escapeHtml(t.source_label || t.source_type) +
"</span></td>" +
"<td>" +
escapeHtml(tradeTitle(t)) +
escapeHtml(tradeContractLabel(t)) +
"</td>" +
'<td style="' +
pnlStyle(t.realized_pnl_total) +
"<td>" +
escapeHtml(direction || "—") +
"</td>" +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") +
"</td>" +
"<td>" +
escapeHtml(fmtHold(t.hold_seconds)) +
"</td>" +
"<td>" +
escapeHtml(t.strategy_tag || "—") +
"</td>" +
"<td>" +
escapeHtml(entryLogic || "—") +
"</td>" +
'<td class="' +
resultClass(t.result_tag) +
'">' +
escapeHtml(t.result_tag || "—") +
"</td>" +
'<td class="muted" style="font-size:12px">' +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.reviewed_at || "—") +
"</td>" +
"</tr>"
@@ -451,25 +536,51 @@
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
function hideLightbox() {
var box = $("or-img-lightbox");
if (box) box.hidden = true;
var img = $("or-img-lightbox-img");
if (img) img.src = "";
}
function showLightbox(src) {
var url = String(src || "").trim();
if (!url) return;
var box = $("or-img-lightbox");
var img = $("or-img-lightbox-img");
if (box && img) {
img.src = url;
box.hidden = false;
return;
}
if (typeof global.showImage === "function") {
global.showImage(url);
} else if (typeof window.showImage === "function") {
window.showImage(url);
} else {
global.open(url, "_blank");
}
}
function hideDetail() {
var panel = $("or-detail-panel");
if (panel) panel.classList.add("hidden");
hideLightbox();
var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true;
}
function openDetail(tradeId) {
var panel = $("or-detail-panel");
if (!panel) return;
panel.classList.remove("hidden");
var backdrop = $("or-detail-backdrop");
if (!backdrop) return;
backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = "";
panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
@@ -487,6 +598,91 @@
});
}
function optionsJournalImgSrc(file) {
var name = String(file || "").trim().replace(/\\/g, "/");
var slash = name.lastIndexOf("/");
if (slash >= 0) name = name.slice(slash + 1);
if (!name) return "";
// options_journal_* 在子目录;误走合约上传的 journal_* 在 static/images 根目录
var base =
name.toLowerCase().indexOf("options_journal_") === 0
? "/static/images/options_journal/"
: "/static/images/";
return base + encodeURIComponent(name);
}
function renderDetailImages(images) {
var imagesHost = $("or-detail-images");
if (!imagesHost) return;
var byTf = {};
(images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
var file = String((img && img.file) || "").trim();
if (!file) return;
var key = tf || "_";
byTf[key] = file;
});
var order = ["5m", "15m", "1h", "4h"];
var keys = order.slice();
Object.keys(byTf).forEach(function (k) {
if (keys.indexOf(k) < 0) keys.push(k);
});
var cells = keys
.map(function (tf) {
var file = byTf[tf];
if (!file) {
if (order.indexOf(tf) < 0) return "";
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
escapeHtml(tf) +
"</span>" +
'<div class="or-detail-img-miss">未上传</div>' +
"</div>"
);
}
var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf);
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
label +
"</span>" +
'<img class="or-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" data-src="' +
src +
'" loading="lazy">' +
"</div>"
);
})
.filter(Boolean);
if (!cells.length) {
imagesHost.innerHTML = '<div class="muted">无截图</div>';
return;
}
imagesHost.innerHTML = cells.join("");
imagesHost.querySelectorAll("img").forEach(function (img) {
img.addEventListener("error", function () {
var cell = img.closest(".or-detail-img-cell");
if (!cell) return;
var label = cell.querySelector(".or-detail-img-label");
var tf = label ? label.textContent : "截图";
cell.innerHTML =
'<span class="or-detail-img-label">' +
escapeHtml(tf) +
"</span>" +
'<div class="or-detail-img-miss">文件缺失或无法加载</div>';
});
img.addEventListener("click", function () {
var src = img.getAttribute("data-src") || img.src;
showLightbox(src);
});
});
}
function renderDetail(t) {
var e = t.entry || {};
reviewedCache[t.id] = t;
@@ -502,8 +698,8 @@
["合约/计划", tradeTitle(t)],
["盈亏", fmtPnl(t.realized_pnl_total)],
["持有", fmtHold(t.hold_seconds)],
["开仓", t.opened_at || "—"],
["平仓", t.closed_at || "—"],
["开仓时间", t.opened_at || "—"],
["平仓时间", t.closed_at || "—"],
["策略", e.strategy_tag || "—"],
["方向", e.direction_view || "—"],
["结果", e.result_tag || "—"],
@@ -517,10 +713,20 @@
}
meta.innerHTML = cells
.map(function (pair) {
var cls = "";
if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") {
cls = pnlClass(t.realized_pnl_total);
if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp);
if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options);
} else if (pair[0] === "结果") {
cls = resultClass(e.result_tag);
}
return (
"<div><div class=\"muted\" style=\"font-size:11px\">" +
escapeHtml(pair[0]) +
"</div><div>" +
'</div><div class="' +
cls +
'">' +
escapeHtml(pair[1]) +
"</div></div>"
);
@@ -540,13 +746,15 @@
.map(function (leg) {
return (
"<tr><td>" +
escapeHtml(leg.leg_role || "") +
escapeHtml(legRoleLabel(leg.leg_role)) +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
"</td><td class=\"" +
pnlClass(leg.realized_pnl) +
"\">" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(leg.close_reason || "") +
escapeHtml(closeReasonLabel(leg.close_reason)) +
"</td></tr>"
);
})
@@ -559,64 +767,25 @@
var imagesHost = $("or-detail-images");
if (imagesHost) {
var images = e.images || [];
if (!images.length) {
imagesHost.innerHTML = '<div class="muted">无截图</div>';
} else {
imagesHost.innerHTML = images
.map(function (img) {
var file = String(img.file || "").trim();
if (!file) return "";
var src = "/static/images/options_journal/" + encodeURIComponent(file).replace(/%2F/g, "/");
var label = escapeHtml(img.tf || "截图");
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
label +
"</span>" +
'<img class="or-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" data-src="' +
src +
'">' +
"</div>"
);
})
.join("");
imagesHost.querySelectorAll("img").forEach(function (img) {
img.addEventListener("click", function () {
if (typeof global.showImage === "function") {
global.showImage(img.getAttribute("data-src"));
} else {
global.open(img.getAttribute("data-src"), "_blank");
}
});
});
}
renderDetailImages(e.images || []);
}
}
function renderGroup(title, items) {
if (!items || !items.length) {
return (
'<div class="or-stat-card"><div class="muted">' +
title +
'</div><div class="muted">无数据</div></div>'
);
}
if (!items || !items.length) return "";
var lines = items
.slice(0, 8)
.map(function (g) {
var keyLabel =
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
return (
'<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px">' +
"<span>" +
escapeHtml(g.key) +
'<div class="or-stat-row">' +
'<span class="or-stat-key">' +
escapeHtml(keyLabel) +
" · " +
g.count +
"笔</span>" +
"<span>" +
'<span class="or-stat-val">' +
fmtPnl(g.pnl_sum) +
" / 胜" +
(g.win_rate || 0) +
@@ -626,7 +795,7 @@
})
.join("");
return (
'<div class="or-stat-card"><div style="font-weight:600;margin-bottom:6px">' +
'<div class="or-stat-card"><div class="or-stat-card-title">' +
title +
"</div>" +
lines +
@@ -654,23 +823,31 @@
["平均持有", fmtHold(k.avg_hold_sec)],
]
.map(function (pair) {
var cls = "";
if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum);
if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl);
return (
'<div><div class="muted" style="font-size:12px">' +
'<div class="or-kpi-tile"><div class="or-kpi-label">' +
pair[0] +
'</div><div style="font-weight:600">' +
'</div><div class="or-kpi-value' +
(cls ? " " + cls : "") +
'">' +
pair[1] +
"</div></div>"
);
})
.join("");
groups.innerHTML = [
var html = [
renderGroup("按类型", data.by_source_type),
renderGroup("按标的", data.by_underlying),
renderGroup("按策略", data.by_strategy),
renderGroup("对冲结束原因", data.by_close_reason),
renderGroup("持有周期", data.by_hold_bucket),
renderGroup("Call/Put", data.by_opt_type),
].join("");
]
.filter(Boolean)
.join("");
groups.innerHTML = html || '<div class="muted" style="font-size:.76rem">暂无分组数据</div>';
})
.catch(function () {});
}
@@ -849,12 +1026,13 @@
($("or-f-inst") || {}).value =
t.source_type === "option_spot"
? t.inst_id || ""
: (t.source_label || "") + (t.plan_close_reason ? " · " + t.plan_close_reason : "");
: (t.source_label || "") +
(t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "");
($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total);
($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
setSelectValue($("or-f-strategy"), e.strategy_tag || "");
setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t));
($("or-f-exit") || {}).value = e.exit_reason || t.plan_close_reason || "";
($("or-f-exit") || {}).value = e.exit_reason || closeReasonLabel(t.plan_close_reason) || "";
($("or-f-followed") || {}).value = e.followed_plan || "";
setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total));
setSelectValue($("or-f-entry"), e.entry_logic || "");
@@ -883,7 +1061,21 @@
);
if (hidden && img.file) {
hidden.value = img.file;
if (status) status.textContent = "已有 " + img.file;
if (status) {
var src = optionsJournalImgSrc(img.file);
status.innerHTML =
'已有 <a href="' +
src +
'" target="_blank" rel="noopener">' +
escapeHtml(img.file) +
'</a><br><img class="or-slot-thumb" src="' +
src +
'" alt="' +
escapeHtml(img.tf || "") +
'" loading="lazy">';
status.className =
"journal-upload-status or-upload-status journal-upload-status--ok";
}
}
});
@@ -896,13 +1088,13 @@
.map(function (leg) {
return (
"<tr><td>" +
escapeHtml(leg.leg_role || "") +
escapeHtml(legRoleLabel(leg.leg_role)) +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(leg.close_reason || "") +
escapeHtml(closeReasonLabel(leg.close_reason)) +
"</td></tr>"
);
})
@@ -1068,9 +1260,34 @@
if (detailEdit) {
detailEdit.addEventListener("click", function () {
var id = Number(detailEdit.getAttribute("data-id") || 0);
if (id) openJournalForm(id);
if (id) {
hideDetail();
openJournalForm(id);
}
});
}
var detailBackdrop = $("or-detail-backdrop");
if (detailBackdrop) {
detailBackdrop.addEventListener("click", function (ev) {
if (ev.target === detailBackdrop) hideDetail();
});
}
var lightbox = $("or-img-lightbox");
if (lightbox) {
lightbox.addEventListener("click", function () {
hideLightbox();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
var lb = $("or-img-lightbox");
if (lb && !lb.hidden) {
hideLightbox();
return;
}
var bd = $("or-detail-backdrop");
if (bd && !bd.hidden) hideDetail();
});
["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) {
var el = $(id);
if (el) {
@@ -1081,7 +1298,7 @@
});
}
});
["or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) {
["or-filter-q", "or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) {
var el = $(id);
if (el) {
el.addEventListener("change", function () {
@@ -1094,9 +1311,28 @@
bindUploadSlots();
hideJournalForm();
hideDetail();
hardenSearchAutofill();
setActiveTab("option_spot");
}
function hardenSearchAutofill() {
var qEl = $("or-filter-q");
if (!qEl) return;
function wipe() {
qEl.value = "";
}
wipe();
qEl.addEventListener("focus", function () {
qEl.removeAttribute("readonly");
});
qEl.addEventListener("blur", function () {
if (!qEl.value) qEl.setAttribute("readonly", "readonly");
});
// 密码管理器常延后写入用户名,加载后再清两次
setTimeout(wipe, 200);
setTimeout(wipe, 800);
}
global.OptionsReview = {
init: init,
openJournalForm: openJournalForm,
+43
View File
@@ -342,4 +342,47 @@
}
});
}
function hardenAmountAutofill(ids) {
ids.forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
function wipe() {
const v = String(el.value || "").trim();
if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = "";
}
wipe();
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
setTimeout(wipe, 200);
setTimeout(wipe, 800);
setTimeout(wipe, 2000);
});
}
// 全部划转/兑换前去掉 readonly,避免写不进数量
["opt-set-swap-all-btn", "opt-set-int-all-btn", "opt-set-cross-all-btn"].forEach(function (btnId) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener(
"click",
function () {
const map = {
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
"opt-set-cross-all-btn": "opt-set-cross-amount",
};
const input = document.getElementById(map[btnId]);
if (input) input.removeAttribute("readonly");
},
true
);
});
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount", "opt-set-cross-amount"]);
})();
+14
View File
@@ -58,6 +58,7 @@ HOT_RELOAD_EXACT = frozenset({
"RISK_COOLING_HOURS_MANUAL",
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE",
"KEY_AUTO_ORDER_ENABLED",
"TRADE_DIRECTION_RESTRICT_ENABLED",
@@ -83,11 +84,20 @@ HOT_RELOAD_EXACT = frozenset({
"APP_AUTH_DISABLED",
"WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_OO_BIAS_RATIO",
"HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
@@ -116,6 +126,10 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("long_only", "仅做多"),
("short_only", "仅做空"),
),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
}
_SELECT_ALIASES: dict[str, dict[str, str]] = {
+92 -2
View File
@@ -67,7 +67,11 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""),
(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"切点前禁止新开仓",
"默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"),
@@ -90,6 +94,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
@@ -126,6 +131,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
),
],
}
@@ -134,14 +144,50 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = {
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "默认 true;关闭后隐藏永期 Tab,不可测算/开仓"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "默认 true;关闭后隐藏期期 Tab,不可测算/开仓"),
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算按比例分两腿;sheets=先算同张数总张数(2n)再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划(期期可用预算=交易户×本比例);与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"对冲与期权互斥门控",
"默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平",
),
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效(不会自动平)",
),
],
}
@@ -152,7 +198,17 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_COOLING_HOURS_MANUAL": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
}
@@ -167,6 +223,10 @@ def _effective_env_value(key: str, file_values: dict[str, str], schema_default:
return _RUNTIME_ENV_DEFAULTS.get(key, "")
def _env_truthy(raw: str) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "yes", "on")
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for group in parse_env_example_schema(example_path):
@@ -185,6 +245,13 @@ def _build_field(
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default)
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
)
if _env_truthy(manual):
val = "false"
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key)
@@ -296,3 +363,26 @@ def validate_env_ui_updates(
)
groups.append({"title": sec["title"], "fields": fields})
return validate_env_updates(groups, updates)
def coerce_hedge_partial_close_with_manual(
clean: dict[str, str],
*,
env_path: str = "",
) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
out = dict(clean or {})
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
if manual is None and env_path:
try:
from lib.env.env_file_lib import env_get_all, read_env_lines
file_vals = env_get_all(read_env_lines(env_path))
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
)
except Exception:
manual = "true"
if _env_truthy(str(manual or "")):
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
return out
+119 -2
View File
@@ -962,6 +962,119 @@ def cancel_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
return {"ok": False, "msg": _okx_trade_error_message(e)}
def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str, Any]:
"""查询单笔期权订单状态."""
inst_id = (inst_id or "").strip()
ord_id = (ord_id or "").strip()
if not inst_id or not ord_id:
return {"ok": False, "msg": "缺少 inst_id 或 ord_id"}
try:
resp = ex.private_get_trade_order({"instId": inst_id, "ordId": ord_id})
data = (resp or {}).get("data") or []
if not data or not isinstance(data[0], dict):
return {"ok": False, "msg": "订单不存在或暂不可查", "raw": resp}
o = data[0]
sz = _safe_float(o.get("sz"))
acc = _safe_float(o.get("accFillSz"))
if acc is None:
acc = _safe_float(o.get("fillSz")) or 0.0
avg = _safe_float(o.get("avgPx"))
fill_px = _safe_float(o.get("fillPx"))
if avg is None or avg <= 0:
avg = fill_px
state = str(o.get("state") or "").strip().lower()
return {
"ok": True,
"ord_id": str(o.get("ordId") or ord_id),
"inst_id": str(o.get("instId") or inst_id),
"state": state,
"sz": int(sz) if sz is not None else None,
"acc_fill_sz": float(acc or 0),
"avg_px": avg,
"side": str(o.get("side") or "").lower(),
"ord_type": str(o.get("ordType") or ""),
"raw": o,
}
except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e)}
def wait_option_order_full_fill(
ex: ccxt.okx,
*,
inst_id: str,
ord_id: str,
need_sheets: int,
timeout_sec: float = 12.0,
poll_sec: float = 0.35,
cancel_on_timeout: bool = True,
) -> dict[str, Any]:
"""轮询至完全成交;超时则撤单.未完全成交返回 ok=False."""
need = max(1, int(need_sheets))
deadline = time.time() + max(0.5, float(timeout_sec))
last: dict[str, Any] = {}
while time.time() < deadline:
last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id)
if not last.get("ok"):
time.sleep(max(0.15, float(poll_sec)))
continue
acc = float(last.get("acc_fill_sz") or 0)
state = str(last.get("state") or "")
if acc + 1e-9 >= need or state == "filled":
if acc + 1e-9 < need:
return {
"ok": False,
"msg": f"订单已结束但成交不足 {need} 张(已成 {acc:g})",
"filled_sheets": acc,
"order": last,
}
return {
"ok": True,
"filled_sheets": int(round(acc)),
"avg_px": last.get("avg_px"),
"state": state,
"order": last,
}
if state in ("canceled", "cancelled", "mmp_canceled"):
if acc + 1e-9 >= need:
return {
"ok": True,
"filled_sheets": int(round(acc)),
"avg_px": last.get("avg_px"),
"state": state,
"order": last,
}
return {
"ok": False,
"msg": f"订单已撤销且未完全成交(已成 {acc:g}/{need})",
"filled_sheets": acc,
"order": last,
}
time.sleep(max(0.15, float(poll_sec)))
if cancel_on_timeout:
cancel_option_order(ex, inst_id=inst_id, ord_id=ord_id)
time.sleep(0.25)
last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id)
acc = float((last or {}).get("acc_fill_sz") or 0) if (last or {}).get("ok") else 0.0
if acc + 1e-9 >= need:
return {
"ok": True,
"filled_sheets": int(round(acc)),
"avg_px": (last or {}).get("avg_px"),
"state": (last or {}).get("state"),
"order": last,
"timed_out": True,
}
return {
"ok": False,
"msg": f"等待成交超时({float(timeout_sec):g}s),已撤未成交部分;已成 {acc:g}/{need}",
"filled_sheets": acc,
"order": last,
"timed_out": True,
}
def place_option_limit_order(
ex: ccxt.okx,
*,
@@ -973,12 +1086,16 @@ def place_option_limit_order(
tick_sz: Any = None,
reduce_only: bool = False,
pos_side: str | None = None,
ord_type: str = "limit",
) -> dict[str, Any]:
side_l = (side or "").lower()
if side_l not in ("buy", "sell"):
return {"ok": False, "msg": "side 必须为 buy 或 sell"}
if sheets < 1:
return {"ok": False, "msg": "张数至少为 1"}
ot = (ord_type or "limit").strip().lower()
if ot not in ("limit", "ioc", "fok", "post_only"):
return {"ok": False, "msg": f"不支持的 ordType: {ord_type}"}
px = round_option_px(float(price), tick_sz, side_l)
if px <= 0:
return {"ok": False, "msg": "价格无效"}
@@ -986,7 +1103,7 @@ def place_option_limit_order(
"instId": inst_id,
"tdMode": td_mode,
"side": side_l,
"ordType": "limit",
"ordType": ot,
"px": format_option_px(px, tick_sz),
"sz": str(int(sheets)),
}
@@ -998,7 +1115,7 @@ def place_option_limit_order(
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {"ok": True, "data": data[0], "raw": resp, "px": px}
return {"ok": True, "data": data[0], "raw": resp, "px": px, "ord_type": ot}
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px}
except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e), "px": px}
@@ -0,0 +1,86 @@
"""对冲计划与单独期权开仓互斥门控.
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
def _env_bool(key: str, default: bool = False) -> bool:
v = (os.getenv(key) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on")
def mutual_exclusive_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
"""若应拦截单独开期权,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
try:
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
init_hedge_plan_tables(conn)
if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return None
return None
def _pos_nonzero(raw: dict[str, Any]) -> bool:
try:
return abs(float(raw.get("pos") or 0)) > 1e-12
except (TypeError, ValueError):
return False
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
if not raw_positions:
return False
from lib.instance.instance_dashboard_lib import _resolve_options_source
for p in raw_positions:
if not isinstance(p, dict) or not _pos_nonzero(p):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
source, _, _ = _resolve_options_source(conn, inst)
if source == "option":
return True
return False
def block_hedge_plan_start_msg(
conn: Any,
*,
fetch_positions: Optional[Callable[[Any], Any]] = None,
exchange: Any = None,
raw_positions: list[dict[str, Any]] | None = None,
) -> Optional[str]:
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
rows = raw_positions
if rows is None:
if fetch_positions is None or exchange is None:
return None
try:
rows = fetch_positions(exchange) or []
except Exception:
return None
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return None
return None
+244
View File
@@ -86,6 +86,227 @@ def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
return math.floor(raw * scale + 1e-12) / scale
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
"""单张权利金(USDC) = 卖一价 × ct_mult."""
a = _f(ask)
if a is None or a <= 0:
return 0.0
return float(a) * float(ct_mult or 0.01)
def resolve_oo_budget_usdc(
*,
trading_usdc: Any,
trade_budget_usdc: Any,
buffer_ratio: Any = 0.95,
) -> dict[str, Any]:
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
import math
trading = _f(trading_usdc)
cap = _f(trade_budget_usdc)
buf = _f(buffer_ratio)
if buf is None or buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
trade_cap = None if cap is None else max(0.0, float(cap))
if trading_cap is None and trade_cap is None:
return {
"ok": False,
"budget_usdc": 0.0,
"trading_cap": None,
"trade_budget_cap": None,
"buffer_ratio": float(buf),
"msg": "缺少交易户余额与单笔预算",
}
if trading_cap is None:
budget = float(trade_cap or 0.0)
elif trade_cap is None:
budget = float(trading_cap)
else:
budget = min(float(trading_cap), float(trade_cap))
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
return {
"ok": budget > 0,
"budget_usdc": budget,
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
"buffer_ratio": float(buf),
"msg": "" if budget > 0 else "可用预算为 0",
}
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
import math
n = max(0, int(sheets))
depth = _f(ask_sz)
if depth is None:
return n
if depth <= 0:
return 0
return min(n, int(math.floor(float(depth) + 1e-12)))
def _normalize_oo_sheets_mode(mode: str) -> str:
m = (mode or "same_sheets").strip().lower()
if m in ("long_bias", "bias_long", "long", "做多"):
return "long_bias"
if m in ("short_bias", "bias_short", "short", "做空"):
return "short_bias"
# 旧「均分」兼容:按预算 50/50(页面已移除)
if m in ("split", "equal_budget", "split_budget", "均分"):
return "split_budget"
return "same_sheets"
def _normalize_oo_bias_split_by(raw: Any) -> str:
v = str(raw or "budget").strip().lower()
if v in ("sheets", "qty", "quantity", "张数"):
return "sheets"
return "budget"
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
try:
r = float(raw)
except (TypeError, ValueError):
r = float(default)
if r <= 0 or r >= 1:
r = float(default)
return r
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
a = (opt_type_a or "").strip().upper()
b = (opt_type_b or "").strip().upper()
if a.startswith("C"):
a = "C"
elif a.startswith("P"):
a = "P"
if b.startswith("C"):
b = "C"
elif b.startswith("P"):
b = "P"
if {a, b} != {"C", "P"}:
return None, None, "做多/做空需一腿 Call、一腿 Put"
call_side = "a" if a == "C" else "b"
put_side = "b" if call_side == "a" else "a"
return call_side, put_side, ""
def suggest_oo_sheets(
*,
mode: str,
budget_usdc: float,
ask_a: float,
ct_mult_a: float = 0.01,
ask_sz_a: Any = None,
opt_type_a: str = "",
ask_b: float,
ct_mult_b: float = 0.01,
ask_sz_b: Any = None,
opt_type_b: str = "",
bias_split_by: str = "budget",
bias_ratio: float = 0.7,
) -> dict[str, Any]:
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
import math
m = _normalize_oo_sheets_mode(mode)
split_by = _normalize_oo_bias_split_by(bias_split_by)
ratio = _clamp_oo_bias_ratio(bias_ratio)
budget = max(0.0, float(budget_usdc or 0.0))
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
"ok": False,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
if budget <= 0:
return _fail("可用预算为 0")
if cost_a <= 0 or cost_b <= 0:
return _fail("缺少有效卖一价,无法建议张数")
pair = cost_a + cost_b
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
n_same = min(
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
)
if m == "same_sheets":
n_a = n_same
n_b = n_same
elif m == "split_budget":
half = budget / 2.0
n_a = int(math.floor(half / cost_a + 1e-12))
n_b = int(math.floor(half / cost_b + 1e-12))
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
else:
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
if err:
return _fail(err)
major_is_call = m == "long_bias"
if split_by == "sheets":
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
total = int(n_same) * 2
if total < 2:
return _fail("同张数总规模不足 2,无法按比例拆分")
major_n = int(round(total * ratio))
major_n = max(1, min(major_n, total - 1))
minor_n = total - major_n
n_call = major_n if major_is_call else minor_n
n_put = minor_n if major_is_call else major_n
else:
maj_budget = budget * ratio
min_budget = budget * (1.0 - ratio)
cost_call = cost_a if call_side == "a" else cost_b
cost_put = cost_b if call_side == "a" else cost_a
if major_is_call:
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
else:
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_a = n_call if call_side == "a" else n_put
n_b = n_put if call_side == "a" else n_call
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
prem = cost_a * n_a + cost_b * n_b
ok = n_a >= 1 and n_b >= 1
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(prem, 6),
"ok": ok,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
def build_perp_options_preview(
*,
direction: str,
@@ -320,6 +541,10 @@ def build_options_options_preview(
"expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
},
}
@@ -334,6 +559,10 @@ def gate_status(
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
show_perp_options: bool = True,
show_options_options: bool = True,
mutual_exclusive: bool = True,
has_standalone_option: bool = False,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
@@ -349,12 +578,23 @@ def gate_status(
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if pt == "perp_options" and not show_perp_options:
can_preview = False
can_start = False
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
if pt == "options_options" and not show_options_options:
can_preview = False
can_start = False
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options":
if not full:
can_start = False
@@ -379,6 +619,10 @@ def gate_status(
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"show_perp_options": bool(show_perp_options),
"show_options_options": bool(show_options_options),
"mutual_exclusive": bool(mutual_exclusive),
"has_standalone_option": bool(has_standalone_option),
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
+28 -2
View File
@@ -72,6 +72,8 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
)
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
@@ -126,6 +128,22 @@ def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
for leg in legs or []:
if str(leg.get("status") or "").strip().lower() == "pending":
role = str(leg.get("leg_role") or "").strip()
if role:
return role
return None
def list_plans(
conn: sqlite3.Connection,
*,
@@ -182,15 +200,22 @@ def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = []
for leg in legs:
role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
if st == "pending":
suffix = "(待补)"
elif st in ("cancelled", "canceled"):
suffix = "(未成交)"
else:
suffix = ""
if role == "perp":
name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}")
parts.append(f"永续 {name}{suffix}")
else:
inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
parts.append(label)
parts.append(f"{label}{suffix}")
return " · ".join(parts) if parts else ""
@@ -201,6 +226,7 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
row = dict(p)
row["legs"] = legs
row["contracts_summary"] = legs_contract_summary(legs)
row["missing_leg"] = missing_leg_role(legs)
out.append(row)
return out
+205 -9
View File
@@ -8,7 +8,11 @@ from typing import Any, Optional
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, list_plans, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_hedge, notify_plan_end, build_hedge_alert_message
from lib.hedge_plan.hedge_plan_orders_lib import _sell_option
from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot
from lib.hedge_plan.hedge_plan_settle_lib import (
leg_is_expired,
resolve_option_leg_realized_pnl,
settle_option_leg_at_spot,
)
def _now() -> str:
@@ -69,6 +73,7 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "msg": "get_db missing"}
conn = get_db()
acted: list[dict[str, Any]] = []
backfill_stats: dict[str, int] = {}
try:
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
@@ -80,10 +85,22 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
acted.append(r)
orphaned = _settle_orphaned_after_tp(cfg, conn)
acted.extend(orphaned)
try:
ex = cfg.get("exchange_options")
if ex is not None:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_stats = backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
conn.commit()
finally:
conn.close()
return {"ok": True, "acted": acted}
return {"ok": True, "acted": acted, "pnl_backfill": backfill_stats}
def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
@@ -92,6 +109,55 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
notify_plan_end(cfg, conn, plan)
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(全平) / hold_expiry(到期平).
- 方案C关闭 强制到期平
- 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿
- 新开仓默认写入 close_all
"""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
return "hold_expiry"
raw = plan.get("oo_close_mode")
if raw is None or str(raw).strip() == "":
return "hold_expiry"
v = str(raw).strip().lower()
if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"):
return "hold_expiry"
return "close_all"
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
out = []
for x in legs:
if not str(x.get("leg_role") or "").startswith("option"):
continue
if str(x.get("status") or "") in statuses:
out.append(x)
return out
def _finalize_oo_all_closed(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
) -> dict[str, Any]:
closed_opts = _oo_option_legs(legs, statuses=("closed",))
total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts)
close_reason = reason or "oo_rest_closed"
bucket = "oo_target" if total_opts > 0 else "oo_expiry_loss"
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=close_reason,
realized_pnl_options=round(total_opts, 4),
realized_pnl_total=round(total_opts, 4),
stats_bucket=bucket,
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": close_reason, "total": total_opts}
def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]:
pt = plan.get("plan_type")
legs = get_plan_legs(conn, int(plan["id"]))
@@ -101,6 +167,9 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[
return r
if pt == "options_options":
r = _tick_oo_expiry(cfg, conn, plan, legs)
if r:
return r
r = _tick_oo_close_rest(cfg, conn, plan, legs)
if r:
return r
return _tick_oo_target(cfg, conn, plan, legs)
@@ -178,9 +247,10 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
ask_open = _sf(opt.get("avg_open"))
if bid is not None and ask_open is not None:
ct = float(opt.get("ct_mult") or 0.01)
opt_pnl = (bid - ask_open) * float(opt.get("size") or 1) * ct
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
else:
opt_pnl = -premium
est = -premium
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), opt_pnl, opt["id"]),
@@ -232,10 +302,101 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
return {"plan_id": plan["id"], "close_reason": reason, "total": total}
def _option_leg_pnl_after_close(
cfg: dict[str, Any],
leg: dict[str, Any],
*,
fallback: float,
) -> float:
"""平仓后写腿盈亏:优先交易所历史,否则用估算."""
ex = cfg.get("exchange_options")
pnl, _src = resolve_option_leg_realized_pnl(ex=ex, leg=leg, fallback=fallback)
return float(pnl if pnl is not None else fallback)
def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Optional[float]) -> float:
"""残腿平仓盈亏估算:优先买一回收 − 权利金;无买一则用内在价值."""
premium = float(leg.get("premium") or 0)
sheets = float(leg.get("size") or 1)
ct = float(leg.get("ct_mult") or 0.01)
if bid is not None and float(bid) > 0:
return float(bid) * sheets * ct - premium
if idx is None:
return -premium
strike = _sf(leg.get("strike")) or 0
o = (leg.get("opt_type") or "").upper()
intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx)
return intrinsic * sheets * ct - premium
def _tick_oo_close_rest(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""盈利腿已平后:全平模式清残腿(无2×门控,买一失败则下轮重试)."""
if resolve_oo_rest_close_mode(plan) != "close_all":
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
closed_legs = _oo_option_legs(legs, statuses=("closed",))
# 至少已平一条,且仍有残腿;避免双腿都还 open 时误清
if len(closed_legs) < 1 or len(open_legs) < 1:
return None
reason0 = str(plan.get("close_reason") or "")
allowed_reasons = (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"oo_rest_closing",
"",
)
if reason0 not in allowed_reasons and not (
len(closed_legs) >= 1 and len(open_legs) == 1
):
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
acted = False
for leg in list(open_legs):
close_r = _sell_option(
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期全平·残腿平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
bid = _sf(close_r.get("bid"))
est = _estimate_leg_close_pnl(leg, idx, bid)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "oo_rest_close", _now(), round(pnl, 4), leg["id"]),
)
leg["status"] = "closed"
leg["realized_pnl"] = round(pnl, 4)
acted = True
if not acted:
return None
legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
if still_open:
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿部分已平,继续重试", "remaining": len(still_open)}
return _finalize_oo_all_closed(
cfg, conn, plan, legs2, reason="oo_rest_closed"
)
def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期期:触及上破或下破目标价时平盈利腿."""
"""期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None:
return None
@@ -261,7 +422,7 @@ def _tick_oo_target(
return None
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = [x for x in legs if x.get("status") == "open" and str(x.get("leg_role") or "").startswith("option")]
open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 2:
return None
winners = []
@@ -288,21 +449,51 @@ def _tick_oo_target(
)
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
# 选腿用内在估算;落库优先交易所已实现盈亏
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), best_pnl, best["id"]),
("closed", reason, _now(), closed_pnl, best["id"]),
)
rest_mode = resolve_oo_rest_close_mode(plan)
update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan)
mid["close_reason"] = reason
mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid)
# 全平:同轮尝试清残腿;失败则下轮 _tick_oo_close_rest 重试
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
out = {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
if rest:
out["rest"] = rest
return out
# 到期平:显式标记残腿 hold_to_expiry
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
)
return {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
@@ -347,7 +538,8 @@ def _tick_oo_expiry(
settled_sum = 0.0
for leg in pending:
pnl = settle_option_leg_at_spot(leg, float(spot))
est = settle_option_leg_at_spot(leg, float(spot))
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
settled_sum += pnl
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
@@ -396,7 +588,11 @@ def _settle_orphaned_after_tp(cfg: dict[str, Any], conn: Any) -> list[dict[str,
spot = _index_px(cfg, str(leg.get("underlying") or "ETH"))
if spot is None:
continue
pnl = settle_option_leg_at_spot(leg, float(spot))
pnl_est = settle_option_leg_at_spot(leg, float(spot))
# orphan row uses leg_id; map to id for resolver
leg_for_pnl = dict(leg)
leg_for_pnl["id"] = leg.get("leg_id")
pnl = _option_leg_pnl_after_close(cfg, leg_for_pnl, fallback=pnl_est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "expiry", _now(), round(pnl, 4), leg["leg_id"]),
+9 -1
View File
@@ -81,6 +81,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"oo_rest_closing": "期期全平·清残腿中",
"oo_rest_closed": "期期全平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
@@ -150,14 +152,20 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
side = "上破" if "up" in str(plan.get("close_reason")) else (
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
)
mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平"):
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)"
else:
rest_txt = "另一腿到期平(持有至到期结算)"
notify_hedge(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿,亏损腿继续持有至到期",
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=(
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
+593 -21
View File
@@ -23,6 +23,18 @@ def open_order_mode() -> str:
return v if v in ("options_first", "perp_first") else "options_first"
def manual_complete_on_partial() -> bool:
"""半腿失败后挂 partial 并手动补开(默认 true)."""
return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True)
def partial_auto_close_enabled() -> bool:
"""手动补开开启时强制关闭自动平,避免吃买卖价差."""
if manual_complete_on_partial():
return False
return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True)
def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易)."""
mode = open_order_mode()
@@ -70,6 +82,13 @@ def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
]
def _option_open_fill_timeout_sec() -> float:
try:
return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
return 12.0
def _buy_option(
cfg: dict[str, Any],
*,
@@ -80,6 +99,7 @@ def _buy_option(
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
wait_option_order_full_fill,
)
ex = cfg.get("exchange_options")
@@ -134,6 +154,7 @@ def _buy_option(
td = "isolated"
if callable(td_buy):
td = td_buy(cfg.get("options_td_mode") or "isolated")
# IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交
order = place_fn(
ex,
inst_id=inst_id,
@@ -142,14 +163,42 @@ def _buy_option(
price=float(ask),
td_mode=td,
tick_sz=q.get("tick_sz"),
ord_type="ioc",
)
if not order.get("ok"):
return order
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
return {"ok": False, "msg": "下单成功但未返回订单号", "order": order}
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=sheets_i,
timeout_sec=_option_open_fill_timeout_sec(),
cancel_on_timeout=True,
)
if not fill.get("ok"):
return {
"ok": False,
"msg": fill.get("msg") or "未完全成交,开仓失败",
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"exchange_ord_id": ord_id,
"filled_sheets": fill.get("filled_sheets"),
"order": order,
"fill": fill,
"can_open": False,
}
fill_px = float(fill.get("avg_px") or ask)
filled_n = int(fill.get("filled_sheets") or sheets_i)
premium = fill_px * filled_n * ct_mult
return {
"ok": True,
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"sheets": filled_n,
"ask": fill_px,
"ask_sz": float(ask_sz),
"premium": premium,
"ct_mult": ct_mult,
@@ -158,8 +207,9 @@ def _buy_option(
"strike": q.get("strike"),
"exp_time": q.get("exp_time"),
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
"exchange_ord_id": (order.get("data") or {}).get("ordId"),
"exchange_ord_id": ord_id,
"order": order,
"fill": fill,
"can_open": True,
}
@@ -257,6 +307,244 @@ def _sell_option(
return order if order.get("ok") else order
def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results)
except Exception:
pass
def _park_partial(
cfg: dict[str, Any],
*,
plan_type: str,
body: dict[str, Any],
missing_leg: str,
msg: str,
path: list[dict[str, Any]],
results: list[dict[str, Any]],
persist: Optional[Callable[..., Any]],
dry_run: bool,
**filled: Any,
) -> dict[str, Any]:
"""半腿失败:保留已成腿,挂 partial 供手动补开."""
if not dry_run:
_notify_partial(cfg, plan_type, msg, results)
out: dict[str, Any] = {
"ok": True,
"partial": True,
"status": "partial",
"dry_run": dry_run,
"plan_type": plan_type,
"missing_leg": missing_leg,
"msg": msg,
"path": path,
"results": results,
"opened_at": _now(),
**filled,
}
if persist and not dry_run:
out["plan_id"] = persist(out, body)
return out
def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float:
"""对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立."""
raw = None
if cfg is not None:
raw = cfg.get("budget_buffer")
if raw is None or raw == "":
raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"
try:
buf = float(raw)
except (TypeError, ValueError):
buf = 0.95
if buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
return float(buf)
def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]:
from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by
split = None
ratio = None
if cfg is not None:
split = cfg.get("oo_bias_split_by")
ratio = cfg.get("oo_bias_ratio")
if split in (None, ""):
split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget"
if ratio in (None, ""):
ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7"
return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio)
def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*.
方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额.
"""
from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok
from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets
leg_a = dict(body.get("leg_a") or {})
leg_b = dict(body.get("leg_b") or {})
inst_a = str(leg_a.get("inst_id") or "").strip()
inst_b = str(leg_b.get("inst_id") or "").strip()
if not inst_a or not inst_b:
return {"ok": False, "msg": "缺少期权合约"}
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
qa = quote_fn(ex, inst_a)
if not qa.get("ok"):
return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa}
qb = quote_fn(ex, inst_b)
if not qb.get("ok"):
return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb}
for tag, q in (("A", qa), ("B", qb)):
can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not can_open:
return {
"ok": False,
"msg": f"{tag}: {block_msg or '暂无卖一深度,无法买入'}",
"quote_a": qa,
"quote_b": qb,
}
trading = fetch_options_trading_usdc(ex)
buf = _hedge_budget_buffer(cfg)
budget_info = resolve_oo_budget_usdc(
trading_usdc=trading,
trade_budget_usdc=cfg.get("trade_budget_usdc"),
buffer_ratio=buf,
)
if not budget_info.get("ok"):
return {
"ok": False,
"msg": budget_info.get("msg") or "可用预算不足",
"budget": budget_info,
"quote_a": qa,
"quote_b": qb,
}
mode = str(body.get("oo_sheets_mode") or "same_sheets")
split_by, bias_ratio = _oo_bias_settings(cfg)
opt_a = str(
leg_a.get("opt_type")
or (qa.get("meta") or {}).get("optType")
or qa.get("opt_type")
or ""
)
opt_b = str(
leg_b.get("opt_type")
or (qb.get("meta") or {}).get("optType")
or qb.get("opt_type")
or ""
)
sug = suggest_oo_sheets(
mode=mode,
budget_usdc=float(budget_info["budget_usdc"]),
ask_a=float(qa["ask"]),
ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01),
ask_sz_a=qa.get("ask_sz"),
opt_type_a=opt_a,
ask_b=float(qb["ask"]),
ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01),
ask_sz_b=qb.get("ask_sz"),
opt_type_b=opt_b,
bias_split_by=split_by,
bias_ratio=bias_ratio,
)
if not sug.get("ok"):
return {
"ok": False,
"msg": sug.get("msg") or "按最新卖一无法建议张数",
"sizing": sug,
"budget": budget_info,
"quote_a": qa,
"quote_b": qb,
}
prev_a = leg_a.get("sheets")
prev_b = leg_b.get("sheets")
leg_a["sheets"] = int(sug["sheets_a"])
leg_a["ask"] = float(qa["ask"])
leg_a["ask_sz"] = qa.get("ask_sz")
leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01)
if opt_a:
leg_a["opt_type"] = opt_a
leg_b["sheets"] = int(sug["sheets_b"])
leg_b["ask"] = float(qb["ask"])
leg_b["ask_sz"] = qb.get("ask_sz")
leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01)
if opt_b:
leg_b["opt_type"] = opt_b
body["leg_a"] = leg_a
body["leg_b"] = leg_b
return {
"ok": True,
"buffer_ratio": buf,
"budget": budget_info,
"sizing": sug,
"quote_a": qa,
"quote_b": qb,
"prev_sheets_a": prev_a,
"prev_sheets_b": prev_b,
"sheets_a": int(sug["sheets_a"]),
"sheets_b": int(sug["sheets_b"]),
"ask_a": float(qa["ask"]),
"ask_b": float(qb["ask"]),
"premium_est": sug.get("premium_est"),
"msg": (
f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + "
f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U"
),
}
def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""永期启动前再拉保险腿卖一(张数沿用页面值,不按预算重算)."""
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
inst = str(body.get("opt_inst_id") or "").strip()
if not inst:
return {"ok": False, "msg": "缺少期权合约"}
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
q = quote_fn(ex, inst)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q}
can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"quote": q,
}
body["ask"] = float(q["ask"])
body["ask_sz"] = q.get("ask_sz")
if q.get("ct_mult") is not None:
body["ct_mult"] = float(q.get("ct_mult") or 0.01)
return {
"ok": True,
"ask": float(q["ask"]),
"ask_sz": q.get("ask_sz"),
"sheets": body.get("sheets"),
"quote": q,
"msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}",
}
def execute_perp_options_start(
cfg: dict[str, Any],
body: dict[str, Any],
@@ -264,6 +552,9 @@ def execute_perp_options_start(
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
refresh = refresh_po_option_quote_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh}
path = build_po_path_plan(body)
results: list[dict[str, Any]] = []
opt_res: Optional[dict[str, Any]] = None
@@ -278,6 +569,27 @@ def execute_perp_options_start(
)
results.append({"step": step["step"], **opt_res})
if not opt_res.get("ok"):
# 永续已成、期权失败 → 可挂 partial 等补开期权
if (
perp_res
and perp_res.get("ok")
and not dry_run
and manual_complete_on_partial()
and persist
):
return _park_partial(
cfg,
plan_type="perp_options",
body=body,
missing_leg="option_hedge",
msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
option=None,
perp=perp_res,
)
return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else:
perp_res = _open_perp(
@@ -292,24 +604,45 @@ def execute_perp_options_start(
)
results.append({"step": step["step"], **perp_res})
if not perp_res.get("ok"):
# 半腿补偿:期权已成 + 配置允许则平期权
if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled():
close_r = _sell_option(
cfg,
inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""),
sheets=float(opt_res.get("sheets") or body.get("sheets") or 1),
)
results.append({"step": "options_auto_close_on_perp_fail", **close_r})
msg = perp_res.get("msg") or "永续开仓失败"
_notify_partial(cfg, "perp_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
}
if (
opt_res
and opt_res.get("ok")
and not dry_run
and manual_complete_on_partial()
and persist
):
return _park_partial(
cfg,
plan_type="perp_options",
body=body,
missing_leg="perp",
msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
option=opt_res,
perp=None,
)
msg = perp_res.get("msg") or "永续开仓失败"
if not dry_run:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(
cfg, plan_type="perp_options", msg=msg, results=results
)
except Exception:
pass
_notify_partial(cfg, "perp_options", msg, results)
return {
"ok": False,
"msg": msg,
@@ -326,6 +659,7 @@ def execute_perp_options_start(
"results": results,
"option": opt_res,
"perp": perp_res,
"refresh": refresh,
"opened_at": _now(),
}
if persist and not dry_run:
@@ -340,6 +674,9 @@ def execute_options_options_start(
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
refresh = refresh_oo_sizing_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh}
path = build_oo_path_plan(body)
results: list[dict[str, Any]] = []
leg_a = body.get("leg_a") or {}
@@ -347,27 +684,55 @@ def execute_options_options_start(
a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "a", **a_res})
if not a_res.get("ok"):
return {"ok": False, "msg": a_res.get("msg") or "腿A开仓失败", "path": path, "results": results}
return {
"ok": False,
"msg": a_res.get("msg") or "腿A开仓失败",
"path": path,
"results": results,
"refresh": refresh,
}
b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "b", **b_res})
if not b_res.get("ok"):
if not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
if not dry_run and partial_auto_close_enabled():
close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1))
results.append({"step": "options_auto_close_leg_a", **close_r})
msg = b_res.get("msg") or "腿B开仓失败"
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
}
if not dry_run and manual_complete_on_partial() and persist:
out_p = _park_partial(
cfg,
plan_type="options_options",
body=body,
missing_leg="option_b",
msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
leg_a=a_res,
leg_b=None,
)
out_p["refresh"] = refresh
return out_p
msg = b_res.get("msg") or "腿B开仓失败"
if not dry_run:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(cfg, plan_type="options_options", msg=msg, results=results)
except Exception:
pass
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
}
out = {
"ok": True,
@@ -377,6 +742,7 @@ def execute_options_options_start(
"results": results,
"leg_a": a_res,
"leg_b": b_res,
"refresh": refresh,
"opened_at": _now(),
}
if persist and not dry_run:
@@ -384,6 +750,73 @@ def execute_options_options_start(
return out
def execute_complete_missing_leg(
cfg: dict[str, Any],
plan: dict[str, Any],
legs: list[dict[str, Any]],
start_body: dict[str, Any],
*,
dry_run: bool = False,
) -> dict[str, Any]:
"""对 partial 计划补开缺失腿;成功后由调用方把计划升为 active."""
missing = None
for leg in legs:
if str(leg.get("status") or "").lower() == "pending":
missing = leg
break
if not missing:
return {"ok": False, "msg": "没有待补开的腿"}
role = str(missing.get("leg_role") or "")
results: list[dict[str, Any]] = []
if role == "perp":
res = _open_perp(
cfg,
symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""),
direction=str(start_body.get("direction") or "long"),
contracts=float(start_body.get("contracts") or missing.get("size") or 0),
leverage=int(start_body.get("leverage") or 10),
tp=float(start_body["tp"]),
sl=float(start_body["sl"]),
dry_run=dry_run,
)
results.append({"step": "perp_market_open", "complete": True, **res})
if not res.get("ok"):
return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role}
return {
"ok": True,
"leg_role": role,
"leg_id": missing.get("id"),
"results": results,
"fill": res,
"opened_at": _now(),
}
if role in ("option_hedge", "option_b", "option_a"):
if role == "option_b":
src = start_body.get("leg_b") or {}
inst = str(src.get("inst_id") or missing.get("inst_id") or "")
sheets = float(src.get("sheets") or missing.get("size") or 1)
elif role == "option_a":
src = start_body.get("leg_a") or {}
inst = str(src.get("inst_id") or missing.get("inst_id") or "")
sheets = float(src.get("sheets") or missing.get("size") or 1)
else:
inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "")
sheets = float(start_body.get("sheets") or missing.get("size") or 1)
res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run)
results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res})
if not res.get("ok"):
return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role}
return {
"ok": True,
"leg_role": role,
"leg_id": missing.get("id"),
"results": results,
"fill": res,
"opened_at": _now(),
}
return {"ok": False, "msg": f"未知待补腿: {role}"}
def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower()
if pt == "perp_options":
@@ -427,3 +860,142 @@ def dump_preview(preview: Any) -> str:
return json.dumps(preview, ensure_ascii=False)[:8000]
except Exception:
return ""
def _live_option_pos_sheets(ex: Any, inst_id: str) -> float:
from lib.exchange.okx_options_lib import fetch_option_positions
inst_id = (inst_id or "").strip()
if not inst_id or ex is None:
return 0.0
rows = fetch_option_positions(ex)
if rows is None:
return -1.0 # API 失败:未知
for r in rows:
if str(r.get("instId") or "").strip() != inst_id:
continue
try:
return abs(float(r.get("pos") or 0))
except (TypeError, ValueError):
return 0.0
return 0.0
def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None:
"""腿状态校正后:有 open + pending → partial."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
plan = get_plan(conn, int(plan_id))
if not plan:
return
pst = str(plan.get("status") or "")
if pst not in ("opening", "active", "partial"):
return
legs = get_plan_legs(conn, int(plan_id))
statuses = [str(l.get("status") or "").lower() for l in legs]
n_open = sum(1 for s in statuses if s == "open")
n_pending = sum(1 for s in statuses if s == "pending")
if n_pending and n_open:
update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail")
def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]:
"""未成交却标 open 的期权腿 → pending(可补开);不显示成持仓."""
from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg
from lib.exchange.okx_options_lib import fetch_option_order
ex = cfg.get("exchange_options")
notes: list[str] = []
legs = get_plan_legs(conn, int(plan_id))
for leg in legs:
role = str(leg.get("leg_role") or "")
if not role.startswith("option"):
continue
st = str(leg.get("status") or "").lower()
if st != "open":
continue
inst = str(leg.get("inst_id") or "").strip()
oid = str(leg.get("exchange_ord_id") or "").strip()
leg_id = int(leg["id"])
sheets = _live_option_pos_sheets(ex, inst)
if sheets < 0:
continue # 查仓失败不改
if sheets >= 1:
continue
# 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单)
if ex is not None and inst and oid:
od = fetch_option_order(ex, inst_id=inst, ord_id=oid)
if od.get("ok"):
acc = float(od.get("acc_fill_sz") or 0)
ostate = str(od.get("state") or "")
if acc >= 1 or ostate == "filled":
continue # 有成交但仓位暂未同步,暂不改
update_leg(
conn,
leg_id,
status="pending",
close_reason=None,
closed_at=None,
avg_open=None,
premium=0,
)
notes.append(f"{inst} 无成交却标open→pending")
if notes:
_sync_plan_status_after_leg_fix(conn, int(plan_id))
return notes
def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]:
"""人工结束进行中计划:不自动平仓;未成交腿标 cancelled."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end
from lib.exchange.okx_options_lib import cancel_option_order
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
if st not in ("opening", "active", "partial"):
return {"ok": False, "msg": f"当前状态 {st or ''} 不可结束"}
notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
ex = cfg.get("exchange_options")
legs = get_plan_legs(conn, int(plan_id))
for leg in legs:
lst = str(leg.get("status") or "").lower()
inst = str(leg.get("inst_id") or "").strip()
oid = str(leg.get("exchange_ord_id") or "").strip()
if lst == "pending":
if ex is not None and inst and oid:
cancel_option_order(ex, inst_id=inst, ord_id=oid)
update_leg(
conn,
int(leg["id"]),
status="cancelled",
close_reason="manual_end",
closed_at=_now(),
avg_open=None,
premium=0,
)
notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled")
update_plan(
conn,
int(plan_id),
status="closed",
close_reason="manual",
closed_at=_now(),
note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500],
)
plan2 = get_plan(conn, int(plan_id))
if plan2:
try:
notify_plan_end(cfg, conn, plan2)
except Exception:
pass
return {
"ok": True,
"plan_id": int(plan_id),
"msg": "计划已结束(未自动平仓;有持仓请自行平掉)",
"notes": notes,
}
+291 -41
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import os
from typing import Any
from typing import Any, Optional
from flask import Flask, jsonify, request
from jinja2 import ChoiceLoader, FileSystemLoader
@@ -97,6 +97,11 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"),
"perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(),
"options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(),
"trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"),
# 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立
"budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
"oo_bias_split_by": _oo_bias_split_by(),
"oo_bias_ratio": _oo_bias_ratio(),
"live_trading": _env_bool("LIVE_TRADING_ENABLED", False),
"send_wechat": getattr(app_module, "send_wechat_msg", None),
}
@@ -106,6 +111,40 @@ def _hedge_enabled() -> bool:
return _env_bool("HEDGE_PLAN_ENABLED", False)
def _show_perp_options() -> bool:
return _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
def _show_options_options() -> bool:
return _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
def _oo_close_mode_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True)
def _oo_bias_split_by() -> str:
from lib.hedge_plan.hedge_plan_calc_lib import _normalize_oo_bias_split_by
return _normalize_oo_bias_split_by(os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget")
def _oo_bias_ratio() -> float:
from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio
return _clamp_oo_bias_ratio(os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7")
def _normalize_oo_close_mode(raw: Any) -> str:
"""方案C关闭时强制 hold_expiry;开启时默认 close_all."""
if not _oo_close_mode_enabled():
return "hold_expiry"
v = str(raw or "close_all").strip().lower()
if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"):
return "hold_expiry"
return "close_all"
def _live_order() -> bool:
return _env_bool("HEDGE_PLAN_LIVE_ORDER", False)
@@ -119,18 +158,35 @@ def _max_active() -> int:
def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
active = 0
has_standalone = False
mutual = True
try:
from lib.hedge_plan.hedge_options_exclusive_lib import (
has_standalone_option_position,
mutual_exclusive_enabled,
)
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
mutual = mutual_exclusive_enabled()
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
active = count_active_plans(conn)
if mutual:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
ex = cfg.get("exchange_options") or cfg.get("exchange")
raw = fetch_option_positions(ex) if ex is not None else []
has_standalone = has_standalone_option_position(conn, raw or [])
except Exception:
has_standalone = False
conn.commit()
finally:
conn.close()
except Exception:
active = 0
has_standalone = False
return gate_status(
hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(),
@@ -140,9 +196,23 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False),
active_count=active,
max_active=_max_active(),
show_perp_options=_show_perp_options(),
show_options_options=_show_options_options(),
mutual_exclusive=mutual,
has_standalone_option=has_standalone,
)
def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
g = _gates_dict(cfg, plan_type)
g["oo_close_mode_enabled"] = _oo_close_mode_enabled()
g["oo_close_mode_default"] = "close_all" if _oo_close_mode_enabled() else "hold_expiry"
g["oo_bias_split_by"] = _oo_bias_split_by()
g["oo_bias_ratio"] = _oo_bias_ratio()
g["budget_buffer"] = float(cfg.get("budget_buffer") or 0.95)
return g
def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
if not _hedge_enabled():
return
@@ -171,6 +241,18 @@ def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
cfg["hedge_monitor_thread"] = t
def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str:
import json
try:
return json.dumps(
{"start_body": body, "missing_leg": missing_leg},
ensure_ascii=False,
)[:8000]
except Exception:
return ""
def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
from lib.hedge_plan.hedge_plan_db import (
get_plan,
@@ -184,25 +266,36 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
is_partial = bool(result.get("partial"))
missing = str(result.get("missing_leg") or "") if is_partial else ""
opt = result.get("option") or {}
perp = result.get("perp") or {}
premium = float(opt.get("premium") or 0)
if is_partial:
opt_ok = missing != "option_hedge" and bool(result.get("option"))
perp_ok = missing != "perp" and bool(result.get("perp"))
else:
opt_ok = True
perp_ok = True
premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
plan_id = insert_plan(
conn,
{
"plan_type": "perp_options",
"status": "active",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"direction": str(body.get("direction") or "long"),
"entry_mark": float(body.get("entry") or 0),
"tp": float(body.get("tp") or 0),
"sl": float(body.get("sl") or 0),
"sizing_mode_at_open": load_position_sizing_mode(),
"perp_size": float(perp.get("contracts") or body.get("contracts") or 0),
"perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"margin": body.get("margin"),
"leverage": float(body.get("leverage") or 10),
"premium_total": premium,
"preview_json": _start_body_json(body, missing or None),
"close_reason": "partial_fail" if is_partial else None,
"opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
},
)
insert_leg(
@@ -212,11 +305,11 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"leg_role": "perp",
"symbol": str(body.get("exchange_symbol") or ""),
"side": str(body.get("direction") or "long"),
"size": float(perp.get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0),
"status": "open",
"exchange_ord_id": str(perp.get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"),
"size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0) if perp_ok else None,
"status": "open" if perp_ok else "pending",
"exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if perp_ok else None,
},
)
insert_leg(
@@ -224,24 +317,25 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
{
"plan_id": plan_id,
"leg_role": "option_hedge",
"inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""),
"strike": opt.get("strike") or body.get("strike"),
"inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
"strike": (opt or {}).get("strike") or body.get("strike"),
"side": "buy",
"size": float(opt.get("sheets") or body.get("sheets") or 1),
"avg_open": float(opt.get("ask") or 0),
"premium": premium,
"status": "open",
"exchange_ord_id": str(opt.get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"),
"size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
"avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None,
"premium": premium if opt_ok else 0,
"status": "open" if opt_ok else "pending",
"exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if opt_ok else None,
},
)
conn.commit()
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
return plan_id
finally:
conn.close()
@@ -260,14 +354,20 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
is_partial = bool(result.get("partial"))
missing = str(result.get("missing_leg") or "") if is_partial else ""
a = result.get("leg_a") or {}
b = result.get("leg_b") or {}
premium = float(a.get("premium") or 0) + float(b.get("premium") or 0)
a_ok = True if not is_partial else bool(result.get("leg_a"))
b_ok = True if not is_partial else (missing != "option_b" and bool(result.get("leg_b")))
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0
)
plan_id = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "active",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float(
body.get("target_price_up")
@@ -286,33 +386,41 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
),
"sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
"preview_json": _start_body_json(body, missing or None),
"close_reason": "partial_fail" if is_partial else None,
"opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
},
)
for role, res, src in (("option_a", a, body.get("leg_a") or {}), ("option_b", b, body.get("leg_b") or {})):
for role, res, src, ok in (
("option_a", a, body.get("leg_a") or {}, a_ok),
("option_b", b, body.get("leg_b") or {}, b_ok),
):
insert_leg(
conn,
{
"plan_id": plan_id,
"leg_role": role,
"inst_id": str(res.get("inst_id") or src.get("inst_id") or ""),
"opt_type": str(res.get("opt_type") or src.get("opt_type") or ""),
"strike": res.get("strike") or src.get("strike"),
"inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""),
"opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""),
"strike": (res or {}).get("strike") or src.get("strike"),
"side": "buy",
"size": float(res.get("sheets") or src.get("sheets") or 1),
"avg_open": float(res.get("ask") or 0),
"premium": float(res.get("premium") or 0),
"status": "open",
"exchange_ord_id": str(res.get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"),
"size": float((res or {}).get("sheets") or src.get("sheets") or 1),
"avg_open": float((res or {}).get("ask") or 0) if ok else None,
"premium": float((res or {}).get("premium") or 0) if ok else 0,
"status": "open" if ok else "pending",
"exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if ok else None,
},
)
conn.commit()
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
return plan_id
finally:
conn.close()
@@ -335,7 +443,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
@lr
def api_hedge_gates():
plan_type = (request.args.get("plan_type") or "perp_options").strip()
return jsonify({"ok": True, **_gates_dict(cfg, plan_type)})
return jsonify({"ok": True, **_gates_public(cfg, plan_type)})
@app.route("/api/hedge-plan/market")
@lr
@@ -395,6 +503,8 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
"account_label": cfg.get("options_account_label") or "期权账户",
"account_note": "期权腿使用期权账户(交易 USDC)",
"options_account": opt_acct,
"trade_budget_usdc": cfg.get("trade_budget_usdc"),
"budget_buffer": cfg.get("budget_buffer"),
}
)
@@ -482,6 +592,130 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400)
@app.route("/api/hedge-plan/<int:plan_id>/end", methods=["POST"])
@lr
def api_hedge_end_plan(plan_id: int):
"""人工结束进行中计划:不自动平仓;未成交腿改为 cancelled."""
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
out = execute_manual_end_plan(cfg, conn, plan_id)
if not out.get("ok"):
return jsonify(out), 400
conn.commit()
finally:
conn.close()
return jsonify(out)
@app.route("/api/hedge-plan/<int:plan_id>/complete-leg", methods=["POST"])
@lr
def api_hedge_complete_leg(plan_id: int):
"""半腿待补:手动补开缺失腿,成功后升为 active."""
import json
from lib.hedge_plan.hedge_plan_db import (
get_plan,
get_plan_legs,
init_hedge_plan_tables,
update_leg,
update_plan,
)
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
from lib.hedge_plan.hedge_plan_orders_lib import execute_complete_missing_leg
body = request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
plan = get_plan(conn, plan_id)
if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404
if str(plan.get("status") or "") != "partial":
return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400
legs = get_plan_legs(conn, plan_id)
start_body: dict[str, Any] = {}
try:
meta = json.loads(plan.get("preview_json") or "{}")
if isinstance(meta, dict):
start_body = dict(meta.get("start_body") or {})
except Exception:
start_body = {}
if not start_body:
return jsonify({"ok": False, "msg": "缺少开仓参数,无法补开"}), 400
# 允许请求体覆盖少量字段
for k in ("contracts", "leverage", "sheets", "tp", "sl"):
if body.get(k) not in (None, ""):
start_body[k] = body.get(k)
out = execute_complete_missing_leg(
cfg, plan, legs, start_body, dry_run=dry_run
)
if not out.get("ok"):
return jsonify(out), 400
if dry_run:
return jsonify(out)
fill = out.get("fill") or {}
leg_id = out.get("leg_id")
role = str(out.get("leg_role") or "")
opened_at = out.get("opened_at")
if leg_id:
if role == "perp":
update_leg(
conn,
int(leg_id),
status="open",
size=float(fill.get("contracts") or start_body.get("contracts") or 0),
avg_open=float(start_body.get("entry") or plan.get("entry_mark") or 0),
exchange_ord_id=str(fill.get("exchange_ord_id") or ""),
opened_at=opened_at,
)
update_plan(
conn,
plan_id,
status="active",
close_reason=None,
note=None,
perp_size=float(fill.get("contracts") or start_body.get("contracts") or 0),
)
else:
prem = float(fill.get("premium") or 0)
update_leg(
conn,
int(leg_id),
status="open",
size=float(fill.get("sheets") or start_body.get("sheets") or 1),
avg_open=float(fill.get("ask") or 0),
premium=prem,
exchange_ord_id=str(fill.get("exchange_ord_id") or ""),
opened_at=opened_at,
inst_id=str(fill.get("inst_id") or ""),
)
old_prem = float(plan.get("premium_total") or 0)
update_plan(
conn,
plan_id,
status="active",
close_reason=None,
note=None,
premium_total=old_prem + prem,
)
conn.commit()
plan2 = get_plan(conn, plan_id)
legs2 = get_plan_legs(conn, plan_id)
if plan2:
notify_plan_start(cfg, conn, plan2, legs2)
conn.commit()
out["plan_id"] = plan_id
out["status"] = "active"
out["plan"] = plan2
out["legs"] = legs2
return jsonify(out)
finally:
conn.close()
@app.route("/api/hedge-plan/list")
@lr
def api_hedge_list():
@@ -530,6 +764,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables,
list_plans,
)
from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]()
try:
@@ -538,6 +773,16 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
for row in rows:
try:
reconcile_unfilled_option_legs(cfg, conn, int(row["id"]))
except Exception:
pass
# 校正后可能 status 变化,重新拉一遍
rows = []
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows)
conn.commit()
finally:
@@ -567,6 +812,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables,
legs_contract_summary,
)
from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]()
try:
@@ -574,6 +820,10 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
plan = get_plan(conn, plan_id)
if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404
# 打开细节时校正:无成交却标 open → cancelled
if str(plan.get("status") or "") in ("opening", "active", "partial"):
reconcile_unfilled_option_legs(cfg, conn, plan_id)
plan = get_plan(conn, plan_id) or plan
legs = get_plan_legs(conn, plan_id)
conn.commit()
finally:
+153 -2
View File
@@ -1,12 +1,17 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations
import os
import time
from typing import Any, Optional
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _sf(v: Any) -> Optional[float]:
try:
@@ -60,3 +65,149 @@ def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int]
if not opts:
return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
def _parse_opened_ms(raw: Any) -> Optional[int]:
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if raw is None or raw == "":
return None
s = str(raw).strip()
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def resolve_option_leg_realized_pnl(
*,
ex: Any = None,
leg: dict[str, Any],
fallback: Optional[float] = None,
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
hist_rows: Optional[list[dict[str, Any]]] = None,
) -> tuple[Optional[float], str]:
"""
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
返回 (pnl, source) source=exchange|fallback|none.
"""
inst_id = str(leg.get("inst_id") or "").strip()
open_ms = _parse_opened_ms(leg.get("opened_at"))
rows = hist_rows
if rows is None and inst_id:
try:
if callable(fetch_history_fn):
rows = fetch_history_fn(inst_id)
elif ex is not None:
from lib.exchange.okx_options_lib import fetch_option_position_history
rows = fetch_option_position_history(ex, inst_id)
except Exception:
rows = None
if rows:
info = resolve_option_close_from_history(rows, open_ms=open_ms)
pnl = _sf((info or {}).get("realized_pnl")) if info else None
if pnl is not None:
return round(float(pnl), 4), "exchange"
if fallback is not None:
return round(float(fallback), 4), "fallback"
return None, "none"
def backfill_hedge_option_legs_realized_pnl(
conn: Any,
hist_rows: list[dict[str, Any]],
*,
update_plan_fn: Optional[Callable[..., Any]] = None,
) -> dict[str, int]:
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if inst:
by_inst.setdefault(inst, []).append(raw)
legs = conn.execute(
"""
SELECT * FROM hedge_plan_legs
WHERE status = 'closed'
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
ORDER BY id DESC
LIMIT 400
"""
).fetchall()
updated_legs = 0
touched_plans: set[int] = set()
for row in legs:
leg = dict(row)
inst = str(leg.get("inst_id") or "").strip()
if not inst or inst not in by_inst:
continue
pnl, src = resolve_option_leg_realized_pnl(
leg=leg,
hist_rows=by_inst[inst],
fallback=None,
)
if src != "exchange" or pnl is None:
continue
local = _sf(leg.get("realized_pnl"))
if local is not None and abs(local - pnl) < 1e-6:
continue
conn.execute(
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
(pnl, int(leg["id"])),
)
updated_legs += 1
touched_plans.add(int(leg["plan_id"]))
updated_plans = 0
updater = update_plan_fn or update_plan
for pid in touched_plans:
plan = get_plan(conn, pid)
if not plan or str(plan.get("status") or "") != "closed":
continue
plan_legs = get_plan_legs(conn, pid)
opt_sum = 0.0
for lg in plan_legs:
role = str(lg.get("leg_role") or "")
if not (role.startswith("option") or lg.get("opt_type")):
continue
if str(lg.get("status") or "") != "closed":
continue
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
ptype = str(plan.get("plan_type") or "")
if ptype == "options_options":
total = opt_sum
kwargs: dict[str, Any] = {
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
else:
total = perp + opt_sum
kwargs = {
"realized_pnl_perp": round(perp, 4),
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
old_total = _sf(plan.get("realized_pnl_total"))
old_opts = _sf(plan.get("realized_pnl_options"))
if (
old_total is not None
and abs(old_total - total) < 1e-6
and old_opts is not None
and abs(old_opts - opt_sum) < 1e-6
):
continue
updater(conn, pid, **kwargs)
updated_plans += 1
return {"legs": updated_legs, "plans": updated_plans}
+141 -74
View File
@@ -2,6 +2,10 @@
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options | default(true) else '0' }}"
data-show-oo="{{ '1' if hedge_plan_show_options_options | default(true) else '0' }}"
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled | default(true) else '0' }}"
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %}
@@ -10,6 +14,9 @@
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %}
{% if hedge_plan_enabled and not (hedge_plan_show_perp_options | default(true)) and not (hedge_plan_show_options_options | default(true)) %}
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:可在 <code>env配置 → 对冲计划</code> 打开显示开关;进行中/历史仍可查看.</div>
{% endif %}
<div class="card hp-head-card">
<div class="hp-head-row">
@@ -19,8 +26,12 @@
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
<button type="button" class="hp-tab active" role="tab" aria-selected="true" data-tab="perp_options">永期对冲</button>
{% if hedge_plan_show_perp_options | default(true) %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %}
{% if hedge_plan_show_options_options | default(true) %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% endif %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
@@ -31,37 +42,61 @@
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout">
<div class="card">
<div class="card hp-po-perp-card">
<h2>永续 · <span id="hp-perp-uly-label">ETH</span> <span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);保险期权走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>下单</strong>:先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁。</p>
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。止盈后保险腿默认可持有;止损会联动平期权。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
<select id="hp-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</div>
<div id="hp-perp-quote" class="muted hp-quote-line">加载中…</div>
<p class="muted hp-unit-hint">单位说明:价格=USDT · 张数=交易所<strong>永续合约张</strong>(精度与 OKX 下单一致) · 盈亏=USDT</p>
<div class="form-row" style="flex-wrap:wrap">
<label>开仓价 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-entry" /></label>
<label>止盈 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-tp" /></label>
<label>止损 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-sl" /></label>
<label>张数 <span class="hp-unit">合约张</span> <input type="number" step="any" id="hp-contracts" /></label>
<div class="hp-po-top">
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多永续"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空永续"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<div class="hp-po-fields">
<label class="hp-po-field">
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--tp">
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--sl">
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-po-summary">
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div>
<p class="muted" id="hp-perp-pnl-line"></p>
<p class="muted" id="hp-sizing-line"></p>
</div>
<div class="card hp-opt-card">
<h2>期权(列表) · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar">
<h2>期权 · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-money-btn active" data-money="all">全部</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="itm">实值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</button>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
</div>
<div id="hp-index-line" class="muted hp-quote-line"></div>
<div class="options-strike-table-wrap hp-strike-table-wrap--5">
<table class="options-strike-table" id="hp-strike-table">
<thead>
@@ -80,66 +115,67 @@
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" /></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<p class="muted hp-unit-hint">单位说明:权利金结算币=<strong>USDC</strong> · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度&gt;0;无深度不可开仓(链上~为参考估算).</p>
<div id="hp-opt-bal-line" class="muted hp-quote-line hp-opt-bal-line"></div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn">计算</button>
<button type="button" class="btn-secondary" id="hp-start-btn" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
<div class="card hp-preview-card" id="hp-preview-card-po">
<h2 style="margin:0 0 8px">情景测算</h2>
<div id="hp-summary" class="muted" style="margin:8px 0"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th>永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">填写参数后点计算</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="options-dual-grid" id="hp-oo-layout">
<div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" /></label>
<div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div>
<div id="hp-oo-index" class="muted hp-quote-line"></div>
<div id="hp-oo-bal-line" class="muted hp-quote-line"></div>
<p class="muted hp-unit-hint">震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=<strong>期权张</strong> · 权利金=USDC</p>
<div class="hp-oo-controls">
<div class="hp-oo-ctrl">
<span class="hp-oo-ctrl-lab">张数</span>
<div class="hp-oo-seg" role="group" aria-label="自动张数">
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true"></span>同张数</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
</div>
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>全平</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div>
</div>
</div>
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="1" id="hp-oo-sheets-a" value="1" disabled />
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="1" id="hp-oo-sheets-b" value="1" disabled />
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<p class="muted" id="hp-oo-prem-line"></p>
@@ -170,33 +206,33 @@
</tbody>
</table>
</div>
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
<div class="hp-oo-transfer-bals muted">
<span>资金 <strong id="hp-oo-funding-usdc"></strong></span>
<span class="hp-oo-transfer-sep">·</span>
<span>交易 <strong id="hp-oo-trading-usdc"></strong></span>
<span class="hp-oo-transfer-unit">USDC</span>
<span class="muted" id="hp-oo-xfer-msg"></span>
</div>
<div class="form-row hp-oo-transfer-form" autocomplete="off">
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
<option value="funding_to_trading" selected>资金 → 交易</option>
<option value="trading_to_funding">交易 → 资金</option>
</select>
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
</div>
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
<button type="button" class="btn-secondary" id="hp-start-btn-oo" title="需开启 HEDGE_PLAN_LIVE_ORDER">启动计划</button>
</div>
</div>
</div>
<div class="card hp-preview-card">
<h2 style="margin:0 0 8px">情景测算</h2>
<div id="hp-summary-oo" class="muted" style="margin:8px 0"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th>腿盈亏</th>
<th>期权</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody-oo">
<tr><td colspan="6" class="muted">选用两腿并填上破/下破目标后点计算</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
@@ -254,5 +290,36 @@
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div>
</div>
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
<div class="hp-modal-head">
<h3 id="hp-preview-title">情景测算</h3>
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
</div>
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th id="hp-preview-mid-th">永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">计算中…</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=13"></script>
<script src="/static/hedge_plan.js?v=33"></script>
+833
View File
@@ -0,0 +1,833 @@
"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径.
仅只读行情;不触及下单链路.
"""
from __future__ import annotations
import csv
import io
import statistics
import time
from datetime import date, datetime, timedelta
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
import httpx
APP_TZ = ZoneInfo("Asia/Shanghai")
END_HOUR = 16
EXCHANGE = "okx"
TIMEFRAME = "1H"
SYMBOLS: dict[str, dict[str, str]] = {
"eth": {
"label": "ETH",
"index_inst": "ETH-USD",
"swap_inst": "ETH-USDT-SWAP",
},
"btc": {
"label": "BTC",
"index_inst": "BTC-USD",
"swap_inst": "BTC-USDT-SWAP",
},
}
PERIOD_DAYS: dict[str, int] = {
"1m": 30,
"2m": 60,
"3m": 90,
"6m": 180,
"1y": 365,
}
OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles"
OKX_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles"
OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles"
OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-candles"
def normalize_symbol(raw: str) -> str:
s = (raw or "").strip().lower()
if s in ("eth", "ethereum"):
return "eth"
if s in ("btc", "bitcoin"):
return "btc"
raise ValueError("symbol 仅支持 eth / btc")
def resolve_sample_days(period: str, custom_days: Any = None) -> int:
p = (period or "2m").strip().lower()
if p == "custom":
try:
n = int(custom_days)
except (TypeError, ValueError):
raise ValueError("自定义天数无效") from None
return max(7, min(400, n))
if p not in PERIOD_DAYS:
raise ValueError("周期无效")
return PERIOD_DAYS[p]
def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]:
"""返回 [start, end) 的本地时刻;end 为结算日 16:00."""
if not (0 <= int(start_hour) <= 23):
raise ValueError("起点须为 0-23 整点")
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
sh = int(start_hour)
if sh >= END_HOUR:
prev = settlement - timedelta(days=1)
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
else:
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
return start, end
def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]:
"""最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00)."""
now = now or datetime.now(APP_TZ)
if now.tzinfo is None:
now = now.replace(tzinfo=APP_TZ)
else:
now = now.astimezone(APP_TZ)
today = now.date()
today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
latest = today if now >= today_end else today - timedelta(days=1)
return [latest - timedelta(days=i) for i in range(int(sample_days))]
def _safe_float(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
"""open_time_ms -> {o,h,l,c}."""
m: dict[int, dict[str, float]] = {}
for b in bars or []:
if not isinstance(b, dict):
continue
ts = b.get("ts")
if ts is None:
ts = b.get("open_time_ms")
try:
ts_i = int(ts)
except (TypeError, ValueError):
continue
o = _safe_float(b.get("o") if "o" in b else b.get("open"))
h = _safe_float(b.get("h") if "h" in b else b.get("high"))
l = _safe_float(b.get("l") if "l" in b else b.get("low"))
c = _safe_float(b.get("c") if "c" in b else b.get("close"))
if None in (o, h, l, c):
continue
m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)}
return m
def compute_day_row(
settlement: date,
start_hour: int,
bar_map: dict[int, dict[str, float]],
) -> Optional[dict[str, Any]]:
start, end = window_bounds_for_settlement(settlement, start_hour)
start_ms = int(start.timestamp() * 1000)
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
if start_ms not in bar_map or last_bar_ms not in bar_map:
return None
opens = bar_map[start_ms]["o"]
close = bar_map[last_bar_ms]["c"]
hi = bar_map[start_ms]["h"]
lo = bar_map[start_ms]["l"]
t = start_ms
while t <= last_bar_ms:
b = bar_map.get(t)
if b:
hi = max(hi, b["h"])
lo = min(lo, b["l"])
t += 3600 * 1000
up = hi - opens
down = opens - lo
amp = hi - lo
change = close - opens
wd = settlement.weekday() # Mon=0 … Sun=6
is_we = wd >= 5
return {
"settlement_day": settlement.isoformat(),
"window_start": start.strftime("%Y-%m-%d %H:%M"),
"window_end": end.strftime("%Y-%m-%d %H:%M"),
"weekday": wd,
"weekday_label": "" if wd == 5 else ("" if wd == 6 else ""),
"is_weekend": is_we,
"open": round(opens, 4),
"high": round(hi, 4),
"low": round(lo, 4),
"close": round(close, 4),
"up_points": round(up, 4),
"down_points": round(down, 4),
"amplitude": round(amp, 4),
"change": round(change, 4),
}
def normalize_straddle_premium(raw: Any) -> Optional[float]:
"""双边权利金(点数).空/≤0 表示不做跨式对照."""
if raw is None or raw == "":
return None
try:
v = float(raw)
except (TypeError, ValueError):
raise ValueError("双边权利金须为数字") from None
if v <= 0:
return None
return v
def normalize_take_profit(raw: Any) -> Optional[float]:
"""止盈点.空/≤0 表示不止盈,有效波动用 |涨跌|."""
if raw is None or raw == "":
return None
try:
v = float(raw)
except (TypeError, ValueError):
raise ValueError("止盈点须为数字") from None
if v <= 0:
return None
return v
def normalize_weekend_filter(raw: Any) -> str:
"""all | exclude | only;默认全部."""
s = (str(raw) if raw is not None else "all").strip().lower()
if s in ("", "all", "全部"):
return "all"
if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"):
return "exclude"
if s in ("only", "weekend_only", "only_weekend", "仅周末"):
return "only"
raise ValueError("周末筛选须为 all / exclude / only")
def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]:
mode = normalize_weekend_filter(weekend_filter)
if mode == "all":
return list(rows or [])
out: list[dict[str, Any]] = []
for r in rows or []:
is_we = bool(r.get("is_weekend"))
if "is_weekend" not in r and r.get("settlement_day"):
try:
is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5
except ValueError:
is_we = False
if mode == "exclude" and is_we:
continue
if mode == "only" and not is_we:
continue
out.append(r)
return out
def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float:
"""触达止盈(≥)用止盈点,否则用 |涨跌|."""
abs_chg = abs(float(row.get("change") or 0))
if take_profit is None:
return abs_chg
tp = float(take_profit)
up = float(row.get("up_points") or 0)
down = float(row.get("down_points") or 0)
if up >= tp or down >= tp:
return tp
return abs_chg
def enrich_rows_pnl(
rows: list[dict[str, Any]],
*,
straddle_premium: Optional[float] = None,
take_profit: Optional[float] = None,
) -> list[dict[str, Any]]:
"""为日表附加有效波动 / 是否触达止盈 / 收益(有权利金时)."""
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
out: list[dict[str, Any]] = []
for r in rows or []:
item = dict(r)
if "is_weekend" not in item and item.get("settlement_day"):
try:
wd = date.fromisoformat(str(item["settlement_day"])).weekday()
item["weekday"] = wd
item["weekday_label"] = "" if wd == 5 else ("" if wd == 6 else "")
item["is_weekend"] = wd >= 5
except ValueError:
item.setdefault("weekday_label", "")
item.setdefault("is_weekend", False)
move = effective_move_points(item, tp)
hit = False
if tp is not None:
hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp
item["effective_move"] = round(move, 4)
item["take_profit_hit"] = hit
item["profit"] = round(move - prem, 4) if prem is not None else None
out.append(item)
return out
def straddle_long_stats(
rows: list[dict[str, Any]],
premium: float,
*,
take_profit: Any = None,
) -> dict[str, Any]:
"""买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|)."""
prem = float(premium)
if prem <= 0:
raise ValueError("双边权利金须 > 0")
tp = normalize_take_profit(take_profit)
enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp)
if not enriched:
return {
"side": "long_straddle",
"premium": prem,
"take_profit": tp,
"sample_count": 0,
"up_exceed_days": 0,
"up_exceed_ratio": None,
"down_exceed_days": 0,
"down_exceed_ratio": None,
"abs_change_exceed_days": 0,
"abs_change_exceed_ratio": None,
"tp_hit_days": 0,
"tp_hit_ratio": None,
"pnl_total": None,
"pnl_avg": None,
"win_days": 0,
"win_ratio": None,
"pnl_max": None,
"pnl_min": None,
}
n = len(enriched)
up_ex = sum(1 for r in enriched if float(r["up_points"]) > prem)
down_ex = sum(1 for r in enriched if float(r["down_points"]) > prem)
abs_ex = sum(1 for r in enriched if abs(float(r["change"])) > prem)
tp_hits = sum(1 for r in enriched if r.get("take_profit_hit"))
pnls = [float(r["profit"]) for r in enriched if r.get("profit") is not None]
win = sum(1 for p in pnls if p > 0)
return {
"side": "long_straddle",
"premium": round(prem, 4),
"take_profit": round(tp, 4) if tp is not None else None,
"sample_count": n,
"up_exceed_days": up_ex,
"up_exceed_ratio": round(up_ex / n, 4),
"down_exceed_days": down_ex,
"down_exceed_ratio": round(down_ex / n, 4),
"abs_change_exceed_days": abs_ex,
"abs_change_exceed_ratio": round(abs_ex / n, 4),
"tp_hit_days": tp_hits,
"tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None,
"pnl_total": round(sum(pnls), 4),
"pnl_avg": round(statistics.fmean(pnls), 4),
"win_days": win,
"win_ratio": round(win / n, 4),
"pnl_max": round(max(pnls), 4),
"pnl_min": round(min(pnls), 4),
}
def summarize_rows(
rows: list[dict[str, Any]],
*,
straddle_premium: Any = None,
take_profit: Any = None,
) -> dict[str, Any]:
if not rows:
out = {
"sample_count": 0,
"max_amplitude": None,
"max_amplitude_day": None,
"avg_amplitude": None,
"median_amplitude": None,
"max_up_points": None,
"avg_up_points": None,
"max_down_points": None,
"avg_down_points": None,
"up_day_ratio": None,
"down_day_ratio": None,
"straddle": None,
}
prem = normalize_straddle_premium(straddle_premium)
if prem is not None:
out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit)
return out
amps = [float(r["amplitude"]) for r in rows]
ups = [float(r["up_points"]) for r in rows]
downs = [float(r["down_points"]) for r in rows]
max_amp = max(amps)
max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp)
up_days = sum(1 for r in rows if float(r["change"]) > 0)
down_days = sum(1 for r in rows if float(r["change"]) < 0)
n = len(rows)
out: dict[str, Any] = {
"sample_count": n,
"max_amplitude": round(max_amp, 4),
"max_amplitude_day": max_amp_day,
"avg_amplitude": round(statistics.fmean(amps), 4),
"median_amplitude": round(statistics.median(amps), 4),
"max_up_points": round(max(ups), 4),
"avg_up_points": round(statistics.fmean(ups), 4),
"max_down_points": round(max(downs), 4),
"avg_down_points": round(statistics.fmean(downs), 4),
"up_day_ratio": round(up_days / n, 4),
"down_day_ratio": round(down_days / n, 4),
"straddle": None,
}
prem = normalize_straddle_premium(straddle_premium)
if prem is not None:
out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit)
return out
def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]:
if not row or len(row) < 5:
return None
try:
ts = int(row[0])
o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4])
except (TypeError, ValueError, IndexError):
return None
return {"ts": ts, "o": o, "h": h, "l": l, "c": c}
def _okx_get_json(
client: httpx.Client,
url: str,
params: dict[str, str],
*,
retries: int = 8,
) -> dict[str, Any]:
"""GET OKX 公共行情;遇 429 指数退避重试."""
last_err: Optional[BaseException] = None
for attempt in range(max(1, int(retries))):
try:
r = client.get(url, params=params)
if r.status_code == 429:
wait = min(12.0, 0.7 * (2**attempt))
time.sleep(wait)
last_err = httpx.HTTPStatusError(
f"429 Too Many Requests for url '{r.url}'",
request=r.request,
response=r,
)
continue
r.raise_for_status()
body = r.json()
if not isinstance(body, dict):
raise RuntimeError("OKX 返回非对象 JSON")
return body
except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else None
if status == 429 and attempt + 1 < retries:
wait = min(12.0, 0.7 * (2**attempt))
time.sleep(wait)
last_err = exc
continue
raise
except httpx.TransportError as exc:
if attempt + 1 < retries:
time.sleep(min(8.0, 0.5 * (2**attempt)))
last_err = exc
continue
raise
if last_err is not None:
raise last_err
raise RuntimeError("OKX 请求失败")
def fetch_okx_candles(
*,
url: str,
inst_id: str,
since_ms: int,
until_ms: int,
bar: str = "1H",
client: Optional[httpx.Client] = None,
timeout: float = 30.0,
history_url: Optional[str] = None,
max_pages: int = 200,
page_pause_sec: float = 0.12,
history_page_pause_sec: float = 0.22,
) -> list[dict[str, Any]]:
"""拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).
OKX 近期接口约仅 1440 ;更早需 history_* 端点续拉.
分页带间隔,429 自动退避重试.
"""
own = client is None
client = client or httpx.Client(
timeout=timeout,
trust_env=False,
headers={"User-Agent": "crypto_monitor-amp-stats/1.0"},
)
try:
out: dict[int, dict[str, Any]] = {}
after: Optional[str] = None
active_url = url
switched_history = False
for page_i in range(max(20, int(max_pages))):
if page_i > 0:
pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec
if pause > 0:
time.sleep(pause)
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
if after:
params["after"] = after
body = _okx_get_json(client, active_url, params)
if str(body.get("code") or "") not in ("0", "0.0", ""):
raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}")
data = body.get("data") or []
if not data:
# 近期接口到头 → 切历史端点再试
if history_url and not switched_history and after is not None:
active_url = history_url
switched_history = True
time.sleep(max(history_page_pause_sec, 0.35))
continue
break
oldest_ts = None
for row in data:
parsed = _parse_okx_candle_row(row)
if not parsed:
continue
ts = int(parsed["ts"])
oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts)
if ts < since_ms - 3600 * 1000:
continue
if ts > until_ms + 3600 * 1000:
continue
out[ts] = parsed
if oldest_ts is None:
break
if oldest_ts <= since_ms:
break
# 无新进度时避免死循环
if after is not None and str(oldest_ts) == after:
if history_url and not switched_history:
active_url = history_url
switched_history = True
time.sleep(max(history_page_pause_sec, 0.35))
continue
break
after = str(oldest_ts)
# 近期接口返回变少且仍未覆盖 since → 切历史
if (
history_url
and not switched_history
and len(data) < 100
and oldest_ts > since_ms
):
active_url = history_url
switched_history = True
time.sleep(max(history_page_pause_sec, 0.35))
return [out[k] for k in sorted(out.keys())]
finally:
if own:
client.close()
def fetch_symbol_bars(
symbol: str,
*,
since_ms: int,
until_ms: int,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
) -> tuple[list[dict[str, Any]], str, str]:
"""返回 (bars, price_source_label, inst_id)."""
key = normalize_symbol(symbol)
meta = SYMBOLS[key]
if fetch_fn:
bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms)
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
index_err: Optional[BaseException] = None
try:
bars = fetch_okx_candles(
url=OKX_INDEX_CANDLES,
history_url=OKX_HISTORY_INDEX_CANDLES,
inst_id=meta["index_inst"],
since_ms=since_ms,
until_ms=until_ms,
)
if bars:
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
except Exception as exc:
index_err = exc
# 指数侧已触发限频时先冷却,再降级永续,避免连环 429
time.sleep(1.2)
try:
bars = fetch_okx_candles(
url=OKX_SWAP_CANDLES,
history_url=OKX_HISTORY_SWAP_CANDLES,
inst_id=meta["swap_inst"],
since_ms=since_ms,
until_ms=until_ms,
)
except Exception as exc:
detail = f"index={index_err}; swap={exc}" if index_err else str(exc)
raise RuntimeError(f"OKX K线拉取失败({detail})") from exc
if not bars:
detail = f"index={index_err}" if index_err else "empty"
raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})")
return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"]
def compute_amp_stats(
*,
symbol: str = "eth",
start_hour: int = 16,
period: str = "2m",
custom_days: Any = None,
straddle_premium: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all",
now: Optional[datetime] = None,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
) -> dict[str, Any]:
key = normalize_symbol(symbol)
sh = int(start_hour)
if sh < 0 or sh > 23:
raise ValueError("起点须为 0-23 整点")
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter)
sample_days = resolve_sample_days(period, custom_days)
settlements = list_settlement_dates(sample_days=sample_days, now=now)
if not settlements:
raise RuntimeError("无可用结算日")
# 最远窗起点
oldest = settlements[-1]
newest = settlements[0]
start0, _ = window_bounds_for_settlement(oldest, sh)
_, end1 = window_bounds_for_settlement(newest, sh)
since_ms = int(start0.timestamp() * 1000)
until_ms = int(end1.timestamp() * 1000)
bars, price_source, inst_id = fetch_symbol_bars(
key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn
)
bar_map = bars_to_map(bars)
rows_all: list[dict[str, Any]] = []
missing: list[str] = []
for d in settlements:
row = compute_day_row(d, sh, bar_map)
if row is None:
missing.append(d.isoformat())
continue
rows_all.append(row)
return build_amp_result(
rows_all=rows_all,
symbol_key=key,
start_hour=sh,
period=period,
sample_days=sample_days,
straddle_premium=prem,
take_profit=tp,
weekend_filter=we_mode,
price_source=price_source,
inst_id=inst_id,
missing=missing,
)
def build_amp_result(
*,
rows_all: list[dict[str, Any]],
symbol_key: str,
start_hour: int,
period: str,
sample_days: int,
straddle_premium: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all",
price_source: str = "",
inst_id: str = "",
missing: Optional[list[str]] = None,
) -> dict[str, Any]:
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter)
filtered = filter_weekend_rows(rows_all, we_mode)
rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp)
summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp)
if period == "custom" or str(period).startswith("custom:"):
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
else:
period_label = str(period)
miss = missing or []
return {
"ok": True,
"exchange": EXCHANGE,
"symbol": symbol_key,
"symbol_label": SYMBOLS[symbol_key]["label"],
"start_hour": start_hour,
"end_hour": END_HOUR,
"period": period_label,
"sample_days_requested": sample_days,
"straddle_premium": prem,
"take_profit": tp,
"weekend_filter": we_mode,
"timeframe": TIMEFRAME,
"price_source": price_source,
"inst_id": inst_id,
"timezone": "Asia/Shanghai",
"rows_all": rows_all,
"rows": rows,
"summary": summary,
"missing_days": miss[:30],
"missing_count": len(miss),
}
def reframe_amp_stats(
*,
rows_all: list[dict[str, Any]],
symbol: str = "eth",
start_hour: int = 16,
period: str = "2m",
sample_days: int = 60,
straddle_premium: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all",
price_source: str = "",
inst_id: str = "",
missing: Optional[list[str]] = None,
) -> dict[str, Any]:
"""已有日表上改周末/权利金/止盈,不拉 K 线."""
key = normalize_symbol(symbol)
return build_amp_result(
rows_all=list(rows_all or []),
symbol_key=key,
start_hour=int(start_hour),
period=period,
sample_days=int(sample_days or 60),
straddle_premium=straddle_premium,
take_profit=take_profit,
weekend_filter=weekend_filter,
price_source=price_source,
inst_id=inst_id,
missing=missing,
)
def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
page = max(1, int(page or 1))
page_size = max(5, min(100, int(page_size or 20)))
total = len(rows)
start = (page - 1) * page_size
chunk = rows[start : start + page_size]
return {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": max(1, (total + page_size - 1) // page_size) if total else 1,
"rows": chunk,
}
def build_export_csv(payload: dict[str, Any]) -> str:
buf = io.StringIO()
# Excel 友好 BOM
buf.write("\ufeff")
w = csv.writer(buf)
s = payload.get("summary") or {}
w.writerow(["【统计摘要】"])
w.writerow(["交易所", payload.get("exchange")])
w.writerow(["标的", payload.get("symbol_label")])
w.writerow(["价源", payload.get("price_source")])
w.writerow(["起点整点", f"{payload.get('start_hour')}:00"])
w.writerow(["终点", f"{payload.get('end_hour')}:00"])
w.writerow(["周期", payload.get("period")])
w.writerow(["周末筛选", payload.get("weekend_filter")])
w.writerow(["样本数", s.get("sample_count")])
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")])
w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")])
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
st = s.get("straddle") or {}
if st:
w.writerow([])
w.writerow(["【买跨对照·双边权利金】", st.get("premium"), "止盈点", st.get("take_profit")])
w.writerow(["开→高超过权利金", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")])
w.writerow(["开→低超过权利金", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")])
w.writerow(["|涨跌|超过权利金", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")])
if st.get("take_profit") is not None:
w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_ratio")])
w.writerow(
[
"买跨点数盈亏合计",
st.get("pnl_total"),
"日均",
st.get("pnl_avg"),
"赚钱天数",
st.get("win_days"),
"胜率",
st.get("win_ratio"),
]
)
w.writerow(["单日最大赚", st.get("pnl_max"), "单日最大亏", st.get("pnl_min")])
w.writerow([])
w.writerow(["【日表明细】"])
w.writerow(
[
"结算日",
"星期",
"周末",
"窗起点",
"窗终点",
"开盘",
"最高",
"最低",
"收盘",
"开→高",
"开→低",
"振幅",
"涨跌值",
"有效波动",
"触达止盈",
"收益",
]
)
for r in payload.get("rows") or []:
w.writerow(
[
r.get("settlement_day"),
r.get("weekday_label") or "",
"" if r.get("is_weekend") else "",
r.get("window_start"),
r.get("window_end"),
r.get("open"),
r.get("high"),
r.get("low"),
r.get("close"),
r.get("up_points"),
r.get("down_points"),
r.get("amplitude"),
r.get("change"),
r.get("effective_move"),
"" if r.get("take_profit_hit") else "",
r.get("profit"),
]
)
return buf.getvalue()
def export_filename(payload: dict[str, Any]) -> str:
sym = (payload.get("symbol") or "eth").lower()
sh = int(payload.get("start_hour") or 16)
period = str(payload.get("period") or "2m").replace(":", "")
day = datetime.now(APP_TZ).strftime("%Y%m%d")
return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"
+1
View File
@@ -61,6 +61,7 @@ def install_instance_theme_static(app) -> None:
"records_review_page.js": "application/javascript; charset=utf-8",
"ai_review_render.js": "application/javascript; charset=utf-8",
"form_submit_guard.js": "application/javascript; charset=utf-8",
"autofill_guard.js": "application/javascript; charset=utf-8",
"key_monitor_form.js": "application/javascript; charset=utf-8",
"time_close_ui.js": "application/javascript; charset=utf-8",
"manual_order_rr_preview.js": "application/javascript; charset=utf-8",
+400
View File
@@ -0,0 +1,400 @@
"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数)."""
from __future__ import annotations
import math
from typing import Any, Optional
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def default_contract_size(base: str) -> float:
"""OKX 线性永续常用面值(币/张);与计算器缺省一致."""
b = (base or "ETH").strip().upper()
return 0.01
def default_ct_mult(base: str) -> float:
return 0.01
def floor_sheets(n: float, step: float = 1.0) -> float:
if n is None or not math.isfinite(n) or n <= 0:
return 0.0
s = float(step) if step and step > 0 else 1.0
return math.floor(n / s + 1e-12) * s
def option_unit_cost(*, ask: float, ct_mult: float) -> float:
return float(ask) * float(ct_mult or 0.01)
def option_intrinsic_value(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
) -> float:
o = (opt_type or "").strip().upper()
k = float(strike)
s = float(spot)
if o == "C":
intrinsic = max(0.0, s - k)
elif o == "P":
intrinsic = max(0.0, k - s)
else:
intrinsic = 0.0
return intrinsic * float(sheets) * float(ct_mult or 0.01)
def option_pnl_at_spot(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
return option_intrinsic_value(
opt_type=opt_type,
strike=strike,
spot=spot,
sheets=sheets,
ct_mult=ct_mult,
) - float(premium_paid)
def perp_pnl(
*,
direction: str,
entry: float,
exit_px: float,
contracts: float,
contract_size: float,
) -> float:
coins = float(contracts) * float(contract_size or 0.01)
d = (direction or "long").strip().lower()
if d == "short":
return (float(entry) - float(exit_px)) * coins
return (float(exit_px) - float(entry)) * coins
def _validate_common(inp: dict[str, Any]) -> Optional[str]:
base = str(inp.get("base") or "ETH").strip().upper()
if base not in ("ETH", "BTC"):
return "标的仅支持 ETH / BTC"
direction = str(inp.get("direction") or "long").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long / short"
s0 = _f(inp.get("entry"))
sl = _f(inp.get("sl"))
tp = _f(inp.get("tp"))
risk = _f(inp.get("risk_u"))
if s0 is None or s0 <= 0:
return "请填写有效入场价"
if sl is None or sl <= 0:
return "请填写有效止损价"
if tp is None or tp <= 0:
return "请填写有效止盈价"
if risk is None or risk <= 0:
return "请填写有效风险额 R"
if direction == "long" and not (sl < s0 < tp):
return "做多须满足 止损 < 入场 < 止盈"
if direction == "short" and not (tp < s0 < sl):
return "做空须满足 止盈 < 入场 < 止损"
return None
def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]:
direction = str(inp.get("direction") or "long").strip().lower()
s0 = float(inp["entry"])
sl = float(inp["sl"])
tp = float(inp["tp"])
risk = float(inp["risk_u"])
per_sheet_sl = abs(s0 - sl) * contract_size
sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0
actual_sl_loss = abs(perp_pnl(
direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size
))
tp_pnl = perp_pnl(
direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size
)
# 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利
path_a = round(tp_pnl, 4)
path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4)
path_c_realized = path_b
path_c_missed = path_a
return {
"kind": "perp",
"sheets": sheets,
"contract_size": contract_size,
"per_sheet_sl_u": round(per_sheet_sl, 6),
"risk_used_u": round(actual_sl_loss, 4),
"path_a_tp": path_a,
"path_b_sl": path_b,
"path_c_realized": path_c_realized,
"path_c_missed": path_c_missed,
"path_c_note": "本单已止损;踏空未拿到原止盈空间",
"worst_u": path_b,
}
def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
direction = str(inp.get("direction") or "long").strip().lower()
risk = float(inp["risk_u"])
tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"])
sl = float(inp["sl"])
opt = inp.get("option") if isinstance(inp.get("option"), dict) else {}
default_type = "C" if direction == "long" else "P"
opt_type = str(opt.get("opt_type") or default_type).strip().upper()
if opt_type not in ("C", "P"):
opt_type = default_type
strike = _f(opt.get("strike"))
ask = _f(opt.get("ask"))
if strike is None or strike <= 0:
return {"ok": False, "msg": "请填写单期权行权价"}
if ask is None or ask <= 0:
return {"ok": False, "msg": "请填写单期权卖一价"}
unit = option_unit_cost(ask=ask, ct_mult=ct_mult)
sheets = floor_sheets(risk / unit) if unit > 0 else 0.0
premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0
# 若张数为 0
path_a = option_pnl_at_spot(
opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
)
path_b_at_sl = option_pnl_at_spot(
opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
)
path_b_worst = -premium
# 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈
path_c = path_a
return {
"ok": True,
"kind": "option",
"opt_type": opt_type,
"strike": strike,
"ask": ask,
"ct_mult": ct_mult,
"sheets": sheets,
"unit_cost_u": round(unit, 6),
"premium_u": round(premium, 4),
"path_a_tp": round(path_a, 4),
"path_b_sl": round(path_b_at_sl, 4),
"path_b_worst": round(path_b_worst, 4),
"path_c_hold_to_tp": round(path_c, 4),
"path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)",
"worst_u": round(path_b_worst, 4),
}
def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
direction = str(inp.get("direction") or "long").strip().lower()
risk = float(inp["risk_u"])
tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"])
sl = float(inp["sl"])
hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {}
main_default = "C" if direction == "long" else "P"
side_default = "P" if direction == "long" else "C"
main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {}
side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {}
main_type = str(main.get("opt_type") or main_default).strip().upper()
side_type = str(side.get("opt_type") or side_default).strip().upper()
if main_type not in ("C", "P"):
main_type = main_default
if side_type not in ("C", "P"):
side_type = side_default
main_k = _f(main.get("strike"))
main_ask = _f(main.get("ask"))
side_k = _f(side.get("strike"))
side_ask = _f(side.get("ask"))
if None in (main_k, main_ask, side_k, side_ask) or min(
main_k or 0, main_ask or 0, side_k or 0, side_ask or 0
) <= 0:
return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"}
main_budget = 0.7 * risk
side_budget = 0.3 * risk
main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult)
side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult)
main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0
side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0
main_prem = main_unit * main_sheets
side_prem = side_unit * side_sheets
premium = main_prem + side_prem
def combo_at(spot: float) -> float:
a = option_pnl_at_spot(
opt_type=main_type,
strike=float(main_k),
spot=spot,
sheets=main_sheets,
ct_mult=ct_mult,
premium_paid=main_prem,
)
b = option_pnl_at_spot(
opt_type=side_type,
strike=float(side_k),
spot=spot,
sheets=side_sheets,
ct_mult=ct_mult,
premium_paid=side_prem,
)
return a + b
path_a = combo_at(tp)
path_b_at_sl = combo_at(sl)
path_b_worst = -premium
path_c = path_a
return {
"ok": True,
"kind": "hedge",
"ratio": "7:3",
"ct_mult": ct_mult,
"main": {
"opt_type": main_type,
"strike": main_k,
"ask": main_ask,
"sheets": main_sheets,
"premium_u": round(main_prem, 4),
"budget_u": round(main_budget, 4),
},
"side": {
"opt_type": side_type,
"strike": side_k,
"ask": side_ask,
"sheets": side_sheets,
"premium_u": round(side_prem, 4),
"budget_u": round(side_budget, 4),
},
"premium_u": round(premium, 4),
"path_a_tp": round(path_a, 4),
"path_b_sl": round(path_b_at_sl, 4),
"path_b_worst": round(path_b_worst, 4),
"path_c_hold_to_tp": round(path_c, 4),
"path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)",
"worst_u": round(path_b_worst, 4),
}
def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]:
"""可解释规则推荐."""
candidates: list[tuple[str, float, dict[str, Any]]] = []
if perp and perp.get("sheets", 0) > 0:
candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp))
if opt and opt.get("ok") and opt.get("sheets", 0) > 0:
candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt))
if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0:
candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge))
if not candidates:
return {
"choice": "",
"reason": "输入不足,无法推荐",
"bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"],
}
best_name, best_a, _ = max(candidates, key=lambda x: x[1])
perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0
opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0
hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0
# 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A
perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0
opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None
hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None
anti_whipsaw = False
if perp_miss > 0 and (
(opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0)
):
anti_whipsaw = True
# 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约
others_max = max(opt_a, hedge_a, 0.0)
choice = best_name
if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99:
choice = "合约"
if anti_whipsaw:
reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约"
else:
reason = "同风险下合约干净止盈赔付最高"
elif anti_whipsaw and (opt_a > 0 or hedge_a > 0):
# 抗踏空优先期权类;期期与单腿接近时推期期
if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85):
choice = "期期对冲"
reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护"
else:
choice = "单期权"
reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约"
else:
reason = f"同风险下「{best_name}」干净止盈赔付最高"
bullets = [
f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)",
(
"止损与踏空:合约打止损即结束并可能踏空;"
"期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标"
if anti_whipsaw
else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈"
),
f"选用建议:{reason}",
]
return {"choice": choice, "reason": reason, "bullets": bullets}
def run_compare(inp: dict[str, Any]) -> dict[str, Any]:
err = _validate_common(inp)
if err:
return {"ok": False, "msg": err}
base = str(inp.get("base") or "ETH").strip().upper()
risk = float(inp["risk_u"])
cs = _f(inp.get("contract_size")) or default_contract_size(base)
ct = _f(inp.get("ct_mult")) or default_ct_mult(base)
perp = _calc_perp(inp, contract_size=float(cs))
opt = _calc_single_option(inp, ct_mult=float(ct))
hedge = _calc_hedge(inp, ct_mult=float(ct))
rec = recommend(
perp,
opt if opt.get("ok") else {"ok": False},
hedge if hedge.get("ok") else {"ok": False},
risk,
)
warnings: list[str] = []
if perp.get("sheets", 0) <= 0:
warnings.append("合约张数为 0:止损距过大或 R 过小")
if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0:
warnings.append("单期权张数为 0:卖一过高或 R 过小")
if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0:
warnings.append("期期对冲未开出张数:卖一过高或 R 过小")
if isinstance(opt, dict) and not opt.get("ok"):
warnings.append(str(opt.get("msg") or "单期权输入不完整"))
if isinstance(hedge, dict) and not hedge.get("ok"):
warnings.append(str(hedge.get("msg") or "期期对冲输入不完整"))
return {
"ok": True,
"base": base,
"direction": str(inp.get("direction") or "long").strip().lower(),
"entry": float(inp["entry"]),
"sl": float(inp["sl"]),
"tp": float(inp["tp"]),
"risk_u": risk,
"contract_size": float(cs),
"ct_mult": float(ct),
"perp": perp,
"option": opt,
"hedge": hedge,
"recommend": rec,
"warnings": warnings,
"notes": [
"期权止盈按标的到价的内在价值近似,非盘口卖出价",
"到期小盈/小亏未纳入主表与推荐",
"仅本地测算,不下单",
],
}
+15 -1
View File
@@ -10,9 +10,20 @@ from typing import Any
from lib.paths import REPO_ROOT
STRATEGY_EXCHANGES: tuple[str, ...] = ("binance", "okx", "gate")
STRATEGY_EXCHANGES: tuple[str, ...] = ("playbook", "behavior", "binance", "okx", "gate")
STRATEGY_META: dict[str, dict[str, str]] = {
"playbook": {
"label": "执行手册",
"title": "交易执行手册(期权为主 · Gate 为辅)",
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
"md_rel": "docs/交易执行手册-期权与Gate.md",
},
"behavior": {
"label": "行为准则",
"title": "交易行为准则(开单三检)",
"md_rel": "docs/交易行为准则-开单三检.md",
},
"binance": {
"label": "币安",
"title": "币安·山寨多头趋势",
@@ -43,6 +54,9 @@ def _md_path(exchange_key: str) -> Path:
meta = STRATEGY_META.get((exchange_key or "").strip().lower())
if not meta:
raise KeyError(exchange_key)
md_rel = (meta.get("md_rel") or "").strip()
if md_rel:
return REPO_ROOT / md_rel
return _strategy_dir() / meta["md_file"]
+21 -13
View File
@@ -87,14 +87,15 @@ OPTIONS_SOURCE_LABELS = {
HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"})
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]:
"""根据进行中对冲计划腿判定来源;默认纯期权."""
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
"""根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id)."""
default = ("option", OPTIONS_SOURCE_LABELS["option"], None)
if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"):
return "option", OPTIONS_SOURCE_LABELS["option"]
return default
try:
row = conn.execute(
"""
SELECT p.plan_type
SELECT p.plan_type, p.id
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.status IN ('opening', 'active', 'partial')
@@ -106,13 +107,18 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]:
(inst_id,),
).fetchone()
except Exception:
return "option", OPTIONS_SOURCE_LABELS["option"]
return default
if not row:
return "option", OPTIONS_SOURCE_LABELS["option"]
pt = str((_row_dict(row).get("plan_type") if isinstance(row, dict) else row[0]) or "").strip()
if pt in OPTIONS_SOURCE_LABELS:
return pt, OPTIONS_SOURCE_LABELS[pt]
return "option", OPTIONS_SOURCE_LABELS["option"]
return default
d = _row_dict(row)
pt = str(d.get("plan_type") or "").strip()
try:
plan_id = int(d["id"]) if d.get("id") is not None else None
except (TypeError, ValueError):
plan_id = None
if pt in OPTIONS_SOURCE_LABELS and pt != "option":
return pt, OPTIONS_SOURCE_LABELS[pt], plan_id
return default
def _format_options_target(p: dict[str, Any]) -> str:
@@ -152,9 +158,10 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None
except (TypeError, ValueError):
exp_ms = None
source_key, source_label = (
_resolve_options_source(conn, inst) if conn is not None else ("option", OPTIONS_SOURCE_LABELS["option"])
)
if conn is not None:
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
else:
source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None
return {
"id": inst,
"kind": "options",
@@ -166,6 +173,7 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
"opt_type_label": label,
"source": source_key,
"source_label": source_label,
"source_plan_id": source_plan_id,
"pos": pos,
"exp_time_ms": exp_ms,
"target_monitor": _format_options_target(p),
@@ -14,6 +14,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_records": True,
"show_nav_stats": True,
"show_nav_risk_policy": True,
"show_nav_system_guide": False,
"show_nav_env_config": True,
"show_nav_options": True,
"show_nav_options_review": True,
@@ -32,6 +33,7 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析",
"show_nav_risk_policy": "风控说明",
"show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置",
"show_nav_options": "期权",
"show_nav_options_review": "期权复盘",
@@ -50,6 +52,7 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"records": "show_nav_records",
"stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy",
"system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config",
"options": "show_nav_options",
"options_review": "show_nav_options_review",
@@ -112,6 +115,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_records",
"show_nav_stats",
"show_nav_risk_policy",
"show_nav_system_guide",
"show_nav_env_config",
"show_nav_options",
"show_nav_options_review",
+2
View File
@@ -22,6 +22,7 @@ EMBED_TABS: tuple[str, ...] = (
"records",
"stats",
"risk_policy",
"system_guide",
"env_config",
"settings",
)
@@ -41,6 +42,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/records": "records",
"/stats": "stats",
"/risk_policy": "risk_policy",
"/system_guide": "system_guide",
"/env_config": "env_config",
"/settings": "settings",
}
+26
View File
@@ -8,6 +8,7 @@ from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled
from lib.trade.account_risk_lib import (
cooling_hours_manual,
cooling_hours_manual_journal,
daily_loss_limit,
manual_close_daily_limit,
max_active_positions_from_env,
mood_issues_daily_freeze_enabled,
@@ -53,6 +54,7 @@ def build_instance_settings_view(
risk_status: Optional[dict[str, Any]] = None,
trade_policy: Optional[TradePolicy] = None,
data_export_version: int = 3,
open_guard_enabled: Optional[bool] = None,
) -> dict[str, Any]:
rs = risk_status or {}
sizing_mode = load_position_sizing_mode()
@@ -63,6 +65,11 @@ def build_instance_settings_view(
force_close_on = _env_bool("FORCE_CLOSE_ENABLED", False)
force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0)
auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False)
guard_on = (
bool(open_guard_enabled)
if open_guard_enabled is not None
else _env_bool("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", True)
)
sections: list[dict[str, Any]] = []
@@ -79,6 +86,12 @@ def build_instance_settings_view(
f"北京时间 {reset_hour}:00",
"新交易日统计与部分开仓限制以此为准",
),
_row(
"允许北京时间切点前开仓",
"已放开(允许开仓)" if not guard_on else "已限制(禁止开仓)",
f"关闭限制后,{reset_hour}:00 前也可斐波成交登记与人工下单;"
"环境配置「切点前禁止新开仓」(TRADING_DAY_RESET_OPEN_GUARD_ENABLED)",
),
_row(
"单日开仓提醒",
f"{alert_threshold}",
@@ -101,6 +114,15 @@ def build_instance_settings_view(
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
_row("日手动平仓上限", f"{manual_close_daily_limit()}", "超限当日冻结"),
_row(
"日亏损次数上限",
(
f"{daily_loss_limit()}"
if daily_loss_limit() > 0
else "未启用"
),
"平仓亏损达限后当日冻结开仓;0=不启用" if daily_loss_limit() > 0 else "RISK_DAILY_LOSS_LIMIT=0",
),
_row(
"复盘情绪日冻结",
_on_off(mood_issues_daily_freeze_enabled()),
@@ -202,6 +224,10 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]:
p = (page or "").strip()
if p == "system_guide":
from lib.instance.instance_system_guide_lib import system_guide_template_context
return system_guide_template_context()
if p not in ("settings", "risk_policy", "env_config"):
return {}
display = kwargs.pop("display", None)
@@ -11,6 +11,7 @@ from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
from lib.env.env_ui_manifest import (
build_env_ui_payload,
filter_updates_for_ui,
coerce_hedge_partial_close_with_manual,
validate_env_ui_updates,
)
from lib.env.env_schema import parse_env_example_schema
@@ -102,6 +103,7 @@ def register_instance_settings_routes(
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
if errors:
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
changed = apply_env_updates(env_path, clean)
+69
View File
@@ -0,0 +1,69 @@
"""实例「系统说明」:加载 Markdown,生成 h2 目录与带锚点正文."""
from __future__ import annotations
import re
from functools import lru_cache
from html import escape
from pathlib import Path
from typing import Any
from lib.hub.hub_strategy_lib import render_markdown_html
from lib.paths import REPO_ROOT
def system_guide_md_path() -> Path:
return REPO_ROOT / "docs" / "系统说明.md"
def _slugify(text: str) -> str:
raw = re.sub(r"<[^>]+>", "", text or "")
raw = re.sub(r"\s+", "-", raw.strip())
raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw)
return raw[:80] or "section"
def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]:
"""为 h2 注入 id,并收集目录(仅 h2)."""
toc: list[dict[str, str]] = []
used: dict[str, int] = {}
def repl(m: re.Match[str]) -> str:
inner = m.group(1)
base = _slugify(inner)
n = used.get(base, 0) + 1
used[base] = n
hid = base if n == 1 else f"{base}-{n}"
toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()})
return f'<h2 id="{escape(hid)}">{inner}</h2>'
out = re.sub(r"<h2>(.*?)</h2>", repl, html, flags=re.I | re.S)
return out, toc
@lru_cache(maxsize=4)
def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]:
path = Path(path_str)
try:
md_text = path.read_text(encoding="utf-8")
except OSError:
md_text = "# 系统说明缺失\n\n未找到 `docs/系统说明.md`。"
body = render_markdown_html(md_text)
body, toc = _inject_h2_ids(body)
return {"html": body, "toc": toc, "mtime_ns": mtime_ns}
def load_system_guide_payload() -> dict[str, Any]:
path = system_guide_md_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
return dict(_load_payload_cached(mtime_ns, str(path)))
def system_guide_template_context() -> dict[str, Any]:
payload = load_system_guide_payload()
return {
"system_guide_html": payload.get("html") or "",
"system_guide_toc": payload.get("toc") or [],
}
+10
View File
@@ -19,7 +19,12 @@ def register_trade_records_api(
filter_trade_records_excluding_miss: Callable[[list], list],
app_tz: Any,
format_price_fn: Callable[[Any, Any], str] | None = None,
sync_exchange_pnl_fn: Callable[[Any], Any] | None = None,
) -> None:
"""
sync_exchange_pnl_fn(conn): 可选,列表前节流回填交易所已实现盈亏.
中控只走本 API,不经实例整页渲染,必须在此触发,否则盈亏U会一直显示.
"""
from lib.instance.records_list_lib import list_trade_records_page
@app.route("/api/trade_records")
@@ -40,6 +45,11 @@ def register_trade_records_api(
offset = 0
conn = get_db()
try:
if sync_exchange_pnl_fn is not None:
try:
sync_exchange_pnl_fn(conn)
except Exception:
pass
payload = list_trade_records_page(
conn,
start_bj,
@@ -10,7 +10,7 @@
<div class="display-prefs-checks">
{% for item in group.entries %}
<label class="chk-label">
<input type="checkbox" data-pref-key="{{ item.key }}"{% if display.get(item.key, true) %} checked{% endif %}>
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
{{ item.label }}
</label>
{% endfor %}
@@ -314,6 +314,10 @@
{% include 'risk_policy_panel.html' %}
{% endif %}
{% if page == 'system_guide' %}
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
+7 -5
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=50"></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/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=10">
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title>
@@ -56,6 +57,7 @@
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide" class="{% if initial_tab == 'system_guide' %}active{% endif %}"{% 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" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
{% endif %}
@@ -64,7 +66,7 @@
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
{% include 'instance_header_panel.html' %}
{% if initial_tab not in ('settings', 'risk_policy', 'env_config') and include_transfer_block %}
{% if initial_tab not in ('settings', 'risk_policy', 'system_guide', 'env_config') and include_transfer_block %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -92,7 +94,7 @@
</div>
<script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
@@ -116,7 +118,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=14"></script>
<script src="/static/instance_settings_prefs.js?v=15"></script>
<script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=27"></script>
</body>
+15 -1
View File
@@ -64,7 +64,13 @@
type="password"
data-env-key="{{ field.key }}"
placeholder="{% if field.has_value %}修改时填写新值,留空不修改{% else %}请输入{% endif %}"
autocomplete="off"
autocomplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
readonly
onfocus="this.removeAttribute('readonly')"
>
{% else %}
<input
@@ -73,6 +79,14 @@
type="text"
data-env-key="{{ field.key }}"
value="{{ field.current or field.default or '' }}"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
>
{% endif %}
</div>
+11 -5
View File
@@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=50"></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/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=4"></script>
@@ -16,8 +17,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=10">
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
</head>
<body
@@ -143,6 +144,7 @@
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
<a href="/system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
{% endif %}
@@ -151,7 +153,7 @@
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %}
{% if page not in ('settings', 'risk_policy', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -390,6 +392,10 @@
{% include 'risk_policy_panel.html' %}
{% endif %}
{% if page == 'system_guide' %}
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
@@ -451,7 +457,7 @@
</div>
<script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
@@ -2014,6 +2020,6 @@ document.addEventListener("DOMContentLoaded", function () {
});
{% endif %}
</script>
<script src="/static/instance_settings_prefs.js?v=14"></script>
<script src="/static/instance_settings_prefs.js?v=15"></script>
</body>
</html>
+1 -12
View File
@@ -1,15 +1,4 @@
{# 三所统一顶栏:实时价 + 可选整点前开仓开关(划转已移至系统设置) #}
{# 三所统一顶栏:实时价(划转已移至系统设置;切点前开仓说明见风控说明·交易执行) #}
<div class="rule-tip instance-price-bar">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
</div>
{% if ui_open_guard_enabled %}
<div class="rule-tip" id="open-guard-bar" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:#cfd3ef">
<input type="checkbox" id="allow-open-before-reset" {% if not open_guard_enabled %}checked{% endif %}>
允许北京时间 {{ reset_hour }}:00 前开仓(斐波成交登记,人工下单)
</label>
<span id="open-guard-status" style="color:#8892b0;font-size:.75rem">
{% if open_guard_enabled %}已限制:{{ reset_hour }}:00 前不可开仓{% else %}已放开:{{ reset_hour }}:00 前允许开仓{% endif %}
</span>
</div>
{% endif %}
@@ -8,14 +8,17 @@
不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
<strong>持仓中不划转</strong>并微信通知.
</p>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required>
<select name="from_account" aria-label="划出账户">
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" id="manual-xfer-amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" aria-label="划出账户" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account" aria-label="划入账户">
<select name="to_account" aria-label="划入账户" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
@@ -1,7 +1,7 @@
{# 系统设置 · 账户密码(外层 card 由 settings_panel 提供) #}
<h2>账户密码修改</h2>
<p class="settings-subcard-desc">修改网页登录账号密码,写入 <code>.env</code> 后需重启实例生效.</p>
<div class="settings-password-form">
<div class="settings-password-form password-settings" data-password-settings="1">
<label>当前密码 <input type="password" id="pwd-old" autocomplete="current-password"></label>
<label>新用户名(可选) <input type="text" id="pwd-new-username" autocomplete="username"></label>
<label>新密码 <input type="password" id="pwd-new" autocomplete="new-password"></label>
+1 -1
View File
@@ -92,7 +92,7 @@
<label><input type="checkbox" name="mood_issues" value="扛单">扛单</label>
<label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label>
</div>
<textarea name="note" rows="2" placeholder="备注"></textarea>
<textarea name="note" rows="2" placeholder="备注" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other"></textarea>
<button type="submit" style="margin-top:8px">保存复盘记录</button>
</form>
</div>
@@ -0,0 +1,87 @@
{# 系统说明: docs/系统说明.md + h2 目录 #}
<div class="system-guide-page full">
<div class="card system-guide-card">
<div class="system-guide-head">
<h2 style="margin:0">系统说明</h2>
<p class="muted" style="margin:6px 0 0;font-size:.85rem">操作与逻辑按章节混排。默认不在顶栏显示;可在系统设置 → 导航显示中打开。</p>
</div>
<div class="system-guide-layout">
{% if system_guide_toc %}
<aside class="system-guide-toc" aria-label="章节目录">
<div class="system-guide-toc-title">目录</div>
<nav>
{% for item in system_guide_toc %}
<a href="#{{ item.id }}">{{ item.title }}</a>
{% endfor %}
</nav>
</aside>
{% endif %}
<article class="system-guide-body prose">
{{ system_guide_html|safe }}
</article>
</div>
</div>
</div>
<style>
.system-guide-page { grid-column: 1 / -1; }
.system-guide-card { padding: 16px 18px 28px; }
.system-guide-layout {
display: grid;
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
gap: 18px;
margin-top: 14px;
align-items: start;
}
.system-guide-toc {
position: sticky;
top: 8px;
padding: 10px 12px;
border: 1px solid rgba(127,127,127,.25);
border-radius: 8px;
background: rgba(127,127,127,.06);
max-height: calc(100vh - 120px);
overflow: auto;
}
.system-guide-toc-title {
font-size: .78rem;
font-weight: 600;
opacity: .75;
margin-bottom: 8px;
}
.system-guide-toc a {
display: block;
font-size: .84rem;
line-height: 1.35;
padding: 5px 0;
text-decoration: none;
color: inherit;
opacity: .9;
}
.system-guide-toc a:hover { opacity: 1; text-decoration: underline; }
.system-guide-body { min-width: 0; line-height: 1.65; font-size: .92rem; }
.system-guide-body h1 { font-size: 1.35rem; margin: 0 0 12px; }
.system-guide-body h2 { font-size: 1.12rem; margin: 22px 0 10px; padding-top: 4px; scroll-margin-top: 12px; }
.system-guide-body h3 { font-size: 1rem; margin: 16px 0 8px; }
.system-guide-body p, .system-guide-body li { margin: 0 0 8px; }
.system-guide-body ul, .system-guide-body ol { padding-left: 1.35em; margin: 0 0 10px; }
.system-guide-body table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: .86rem; }
.system-guide-body th, .system-guide-body td {
border: 1px solid rgba(127,127,127,.35);
padding: 7px 9px;
text-align: left;
vertical-align: top;
}
.system-guide-body code {
font-family: ui-monospace, Consolas, monospace;
font-size: .86em;
padding: 1px 4px;
border-radius: 4px;
background: rgba(127,127,127,.12);
}
.system-guide-body hr { border: 0; border-top: 1px solid rgba(127,127,127,.28); margin: 18px 0; }
@media (max-width: 820px) {
.system-guide-layout { grid-template-columns: 1fr; }
.system-guide-toc { position: static; max-height: none; }
.system-guide-toc nav { display: flex; flex-wrap: wrap; gap: 4px 12px; }
}
</style>
+3 -1
View File
@@ -52,12 +52,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
)
inst = str(p.get("inst_id") or "")
source_key, source_label = _resolve_options_source(conn, inst)
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
p["source"] = source_key
p["source_label"] = source_label
p["source_plan_id"] = source_plan_id
p["target_monitor_text"] = _format_options_target(p)
except Exception:
p.setdefault("source_label", "")
p.setdefault("source_plan_id", None)
p.setdefault("target_monitor_text", "")
finally:
conn.close()
+99 -2
View File
@@ -1,13 +1,17 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime, timezone
from typing import Any, Callable
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _safe_float(v: Any) -> float | None:
if v is None:
@@ -121,20 +125,113 @@ def run_options_profit_alerts(
def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if not created_at:
return None
raw = str(created_at).strip()
if not raw:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%f"):
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(raw[:26], fmt).replace(tzinfo=timezone.utc)
dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def _group_key_for_closed_trade(row: Any) -> str:
inst = str(row["inst_id"] or "").strip()
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
if ord_id:
return f"{inst}|ord:{ord_id}"
closed = str(row["closed_at"] or "").strip()
return f"{inst}|close:{(closed[:16] if closed else '')}"
def backfill_closed_options_realized_pnl_from_history(
conn: sqlite3.Connection,
hist_rows: list[dict[str, Any]],
*,
trade_limit: int = 200,
) -> int:
"""
OKX positions-history realizedPnl 覆盖本地已平记录.
同一次平仓多笔本地 open(加仓)按权利金占比分摊交易所总盈亏.
"""
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if not inst:
continue
by_inst.setdefault(inst, []).append(raw)
rows = conn.execute(
"""
SELECT id, inst_id, sheets, premium_paid, realized_pnl, created_at, closed_at, close_ord_id
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT ?
""",
(int(trade_limit),),
).fetchall()
if not rows:
return 0
groups: dict[str, list[Any]] = {}
for row in rows:
inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst:
continue
groups.setdefault(_group_key_for_closed_trade(row), []).append(row)
updated = 0
for group in groups.values():
inst = str(group[0]["inst_id"] or "").strip()
open_candidates = [_created_at_ms(r["created_at"]) for r in group]
open_ms = min((x for x in open_candidates if x is not None), default=None)
close_info = resolve_option_close_from_history(by_inst.get(inst) or [], open_ms=open_ms)
if not close_info:
continue
ex_pnl = _safe_float(close_info.get("realized_pnl"))
if ex_pnl is None:
continue
close_quote = _safe_float(close_info.get("close_quote"))
total_paid = 0.0
for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
allocated = 0.0
for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0)
if i == len(group) - 1:
share = round(float(ex_pnl) - allocated, 4)
elif total_paid > 0:
share = round(float(ex_pnl) * (paid / total_paid), 4)
allocated += share
else:
share = round(float(ex_pnl) / len(group), 4)
allocated += share
local = _safe_float(r["realized_pnl"])
if local is not None and abs(local - share) < 1e-6:
continue
prem_recv = round(paid + share, 4)
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = ?,
premium_received = ?,
close_quote = COALESCE(?, close_quote)
WHERE id = ?
""",
(share, prem_recv, close_quote, int(r["id"])),
)
updated += 1
return updated
def sync_open_options_trades(
conn: sqlite3.Connection,
*,
+51 -3
View File
@@ -108,6 +108,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
"chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True),
"itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
# 市价平仓已硬关闭(忽略 env),仅买一限价
@@ -262,8 +263,12 @@ def _sync_options_trades(
if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
return
_OPTIONS_SYNC_LAST_AT = now
from lib.exchange.okx_options_lib import fetch_option_position_history
from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history
from lib.options.options_monitor_lib import (
backfill_closed_options_realized_pnl_from_history,
reconcile_live_open_trades,
sync_open_options_trades,
)
if raw_positions is None:
raw = cfg["fetch_option_positions"](ex)
@@ -281,6 +286,11 @@ def _sync_options_trades(
init_options_tables(conn)
reconcile_live_open_trades(conn, live_inst_ids=live_ids)
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
try:
hist_all = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist_all)
except Exception:
pass
conn.commit()
finally:
conn.close()
@@ -370,6 +380,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
expiries = chain.get("expiries") or []
chain_err = chain.get("chain_error")
# 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if not expiries:
return jsonify(
{
@@ -377,9 +390,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"msg": chain_err or "暂无到期日,请稍后点「刷新链」",
**chain,
"chain_max_dte_days": cfg["chain_max_dte_days"],
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
}
)
return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]})
return jsonify(
{
"ok": True,
**chain,
"chain_max_dte_days": cfg["chain_max_dte_days"],
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
}
)
@app.route("/api/options/quote")
@lr
@@ -509,6 +534,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
try:
from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg
conn_gate = cfg["get_db"]()
try:
block_msg = block_standalone_option_open_msg(conn_gate)
finally:
conn_gate.close()
if block_msg:
return jsonify({"ok": False, "msg": block_msg})
except Exception:
pass
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
mode = (data.get("mode") or "budget_full").strip()
@@ -763,6 +800,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
hedge_target = hedge_target_map.get(inst)
if hedge_target:
row["hedge_plan_target"] = hedge_target
try:
from lib.instance.instance_dashboard_lib import _resolve_options_source
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
row["source"] = source_key
row["source_label"] = source_label
row["source_plan_id"] = source_plan_id
except Exception:
row.setdefault("source", "option")
row.setdefault("source_label", "纯期权")
row.setdefault("source_plan_id", None)
rows.append(row)
finally:
conn.close()
+11 -5
View File
@@ -112,17 +112,23 @@ def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
def options_review_image_paths(row: Any, upload_folder: str) -> List[str]:
upload_folder = os.path.abspath(upload_folder or "")
upload_root = os.path.abspath(upload_folder or "")
options_dir = options_review_upload_dir(upload_root)
paths: List[str] = []
seen: set[str] = set()
def _add(name: Optional[str]) -> None:
if not name:
return
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
if os.path.isfile(p) and p not in seen:
seen.add(p)
paths.append(p)
base = os.path.basename(str(name).strip())
if not base:
return
for folder in (options_dir, upload_root):
p = os.path.abspath(os.path.join(folder, base))
if os.path.isfile(p) and p not in seen:
seen.add(p)
paths.append(p)
return
try:
keys = row.keys() if hasattr(row, "keys") else ()
+66 -4
View File
@@ -556,8 +556,28 @@ def sync_all_review_sources(
return out
def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源."""
def ensure_local_review_synced(
conn: sqlite3.Connection,
*,
ex: Any | None = None,
backfill_exchange_pnl: bool = True,
) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
if backfill_exchange_pnl and ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
from lib.options.options_monitor_lib import (
backfill_closed_options_realized_pnl_from_history,
)
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist)
backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
return sync_all_review_sources(conn, from_exchange=False)
@@ -581,22 +601,43 @@ def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -
out["entry"] = dict(entry)
out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json"))
out["strategy_tag"] = entry.get("strategy_tag")
out["direction_view"] = entry.get("direction_view")
out["entry_logic"] = entry.get("entry_logic")
out["result_tag"] = entry.get("result_tag")
out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at")
else:
out["entry"] = None
out["strategy_tag"] = None
out["direction_view"] = None
out["entry_logic"] = None
out["result_tag"] = None
out["reviewed_at"] = None
return out
def _review_search_tokens(q: str) -> list[str]:
"""自由搜索词:BTCUSDT 同时匹配 BTC / BTCUSDT."""
raw = str(q or "").strip()
if not raw:
return []
tokens = [raw]
u = raw.upper()
for suf in ("-USDT", "-USD", "-USDC", "USDT", "USD", "USDC"):
if u.endswith(suf) and len(u) > len(suf):
base = u[: -len(suf)].rstrip("-_")
if base and base not in {t.upper() for t in tokens}:
tokens.append(base)
break
return tokens
def _review_trades_filters(
*,
source_type: str | None = None,
underlying: str | None = None,
opt_type: str | None = None,
strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None,
include_hedge_legs: bool = False,
closed_from: str | None = None,
@@ -635,9 +676,26 @@ def _review_trades_filters(
if closed_to:
wheres.append("COALESCE(t.closed_at,'')<=?")
args.append(closed_to)
if strategy_tag:
wheres.append("e.strategy_tag=?")
# 兼容旧参数:精确策略标签;前端已改用 q 模糊搜索
if strategy_tag and not q:
wheres.append("UPPER(COALESCE(e.strategy_tag,''))=UPPER(?)")
args.append(strategy_tag)
search_tokens = _review_search_tokens(q or "")
if search_tokens:
token_ors: list[str] = []
for tok in search_tokens:
like = f"%{tok}%"
token_ors.append(
"""(
UPPER(COALESCE(t.underlying,'')) LIKE UPPER(?)
OR UPPER(COALESCE(t.inst_id,'')) LIKE UPPER(?)
OR UPPER(COALESCE(t.legs_json,'')) LIKE UPPER(?)
OR UPPER(COALESCE(e.strategy_tag,'')) LIKE UPPER(?)
OR UPPER(COALESCE(e.result_tag,'')) LIKE UPPER(?)
)"""
)
args.extend([like, like, like, like, like])
wheres.append("(" + " OR ".join(token_ors) + ")")
if reviewed == "1" or reviewed == "yes":
wheres.append("e.id IS NOT NULL")
elif reviewed == "0" or reviewed == "no":
@@ -653,6 +711,7 @@ def count_review_trades(
underlying: str | None = None,
opt_type: str | None = None,
strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None,
include_hedge_legs: bool = False,
closed_from: str | None = None,
@@ -664,6 +723,7 @@ def count_review_trades(
underlying=underlying,
opt_type=opt_type,
strategy_tag=strategy_tag,
q=q,
reviewed=reviewed,
include_hedge_legs=include_hedge_legs,
closed_from=closed_from,
@@ -688,6 +748,7 @@ def list_review_trades(
underlying: str | None = None,
opt_type: str | None = None,
strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None,
include_hedge_legs: bool = False,
closed_from: str | None = None,
@@ -701,6 +762,7 @@ def list_review_trades(
underlying=underlying,
opt_type=opt_type,
strategy_tag=strategy_tag,
q=q,
reviewed=reviewed,
include_hedge_legs=include_hedge_legs,
closed_from=closed_from,
+16 -7
View File
@@ -26,7 +26,6 @@ from lib.options.options_review_lib import (
hide_review_trade,
list_review_trades,
save_review_entry,
sync_all_review_sources,
)
@@ -94,23 +93,30 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
return send_file(path, mimetype="application/javascript; charset=utf-8")
@app.route("/static/images/options_journal/<path:filename>")
@lr
def static_options_review_image(filename: str):
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
folder = options_review_upload_dir(cfg["upload_folder"])
safe = os.path.basename(filename or "")
path = os.path.join(folder, safe)
if not os.path.isfile(path):
return ("not found", 404)
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
root = os.path.abspath(cfg["upload_folder"] or "")
alt = os.path.join(root, safe)
if os.path.isfile(alt):
path = alt
else:
return ("not found", 404)
return send_file(path)
@app.route("/api/options/review/sync", methods=["POST"])
@lr
def api_options_review_sync():
"""刷新本地 options_trades + 已结束对冲计划(不访问交易所)."""
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
conn = cfg["get_db"]()
try:
init_options_review_tables(conn)
result = sync_all_review_sources(conn, from_exchange=False)
ex, _err = _require_ex(cfg)
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
return jsonify(result)
finally:
@@ -128,13 +134,15 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
"no",
)
if do_sync:
ensure_local_review_synced(conn)
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
filt = dict(
source_type=(request.args.get("source_type") or "").strip() or None,
underlying=(request.args.get("underlying") or "").strip() or None,
opt_type=(request.args.get("opt_type") or "").strip() or None,
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
q=(request.args.get("q") or "").strip() or None,
reviewed=(request.args.get("reviewed") or "").strip() or None,
include_hedge_legs=(request.args.get("include_hedge_legs") or "")
.strip()
@@ -259,7 +267,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
def api_options_review_stats():
conn = cfg["get_db"]()
try:
ensure_local_review_synced(conn)
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
stats = compute_review_stats(
conn,
+73 -31
View File
@@ -1,13 +1,29 @@
<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-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %}
<div class="options-dual-grid">
<div class="card options-order-card">
<h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 <strong>~</strong>(仅参考).<strong>开仓只认真实卖一价且卖一深度&gt;0</strong>;无深度时面板显示参考标记价并禁用买入.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
<h2>期权下单</h2>
<details class="opt-close-rule opt-open-rule">
<summary>开仓规则说明</summary>
<div class="opt-close-rule-body">
<p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
<ul>
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;<strong>T 型</strong>默认 ATM ±5 档,可展开全部。</li>
<li>「按可用余额打满」可用额度 = min(交易 USDC × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「预算缓冲比例」改。</li>
<li>平仓仅买一限价,详见说明文档。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
<div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
@@ -64,11 +80,14 @@
</tbody>
</table>
</div>
<div id="opt-order-panel-host" class="opt-order-panel-host" hidden aria-hidden="true">
<div id="opt-order-panel" class="opt-order-panel-inner" style="display:none">
<div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
<div id="opt-order-panel" class="opt-order-dialog" role="dialog" aria-modal="true" aria-labelledby="opt-order-dialog-title" style="display:none">
<div class="opt-order-dialog-head">
<h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3>
<button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button>
</div>
<div class="opt-order-layout">
<div class="opt-order-main">
<h3 class="opt-order-title">下单</h3>
<div id="opt-order-inst" class="options-order-inst"></div>
<div class="options-order-grid">
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v"></span></div>
@@ -82,37 +101,48 @@
<div><span class="k">距平衡</span><span id="opt-order-dist-be" class="v"></span></div>
</div>
<div class="options-estimate-row">
<label class="opt-est-label" for="opt-target-idx">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
</div>
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
</div>
<div class="form-row options-order-mode-row">
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数">
<label><input type="radio" name="opt-size-mode" value="budget_full"> 按可用余额打满</label>
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
<div class="opt-size-mode-bar">
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="sheets" checked>
<span>指定张数</span>
</label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="budget_full">
<span>按可用余额打满</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span>
</label>
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
</div>
<div class="opt-order-dialog-actions">
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
</div>
<div id="opt-order-msg" class="muted"></div>
</div>
<aside class="opt-order-pending" aria-label="未成交委托">
<div class="opt-order-pending-head">
<h4 class="opt-order-pending-title">委托</h4>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
</div>
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint">平仓限价超 10 分未成交将自动撤销</p>
<div id="opt-pending-list" class="opt-pending-list">
<div class="muted opt-pending-empty">暂无未成交委托</div>
</div>
</aside>
</div>
</div>
</div>
@@ -125,6 +155,7 @@
</div>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
<button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="history" role="tab" aria-selected="false" id="opt-pos-tab-history">期权历史</button>
</div>
@@ -152,6 +183,17 @@
</div>
</details>
</div>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
<div class="opt-pos-pending-pane">
<div class="opt-order-pending-head">
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
</div>
<div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab">
<div class="muted opt-pending-empty">暂无未成交委托</div>
</div>
</div>
</div>
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
<div class="options-stats-panel">
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
@@ -274,4 +316,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=39"></script>
<script src="/static/options_panel.js?v=49"></script>
+255 -71
View File
@@ -1,20 +1,110 @@
{# OKX 期权复盘:交易记录(5行)复盘表单 → 复盘记录 → 统计 #}
{# OKX 期权复盘:交易记录 → 复盘表单 → 复盘记录 → 统计 #}
<div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px;font-size:.82rem">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.</div>
{% endif %}
<style>
.options-review-wrap{font-size:.82rem}
.options-review-wrap h2{font-size:1rem;margin:0 0 8px}
.options-review-wrap h3{font-size:.9rem;margin:0 0 8px}
.options-review-wrap{font-size:.82rem;display:flex;flex-direction:column;gap:14px}
.options-review-wrap h2,.options-review-wrap h3{margin:0}
.or-page-head{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
padding:2px 2px 0;
}
.or-page-head h2{font-size:1.05rem;font-weight:650;margin-right:auto;letter-spacing:.02em}
.or-section{
margin:0;padding:14px 16px 16px;
border:1px solid var(--or-border, rgba(127,127,127,.28));
border-radius:12px;
background:var(--or-section-bg, rgba(18,23,38,.55));
box-shadow:var(--or-section-shadow, 0 1px 0 rgba(255,255,255,.03) inset);
color:var(--or-text, inherit);
}
.or-section-head{
display:flex;align-items:flex-start;gap:10px;flex-wrap:wrap;
margin-bottom:12px;padding-bottom:10px;
border-bottom:1px solid var(--or-border-soft, rgba(127,127,127,.22));
}
.or-section-head > div{min-width:0;flex:1}
.or-step{
flex-shrink:0;width:1.55rem;height:1.55rem;border-radius:999px;
display:inline-flex;align-items:center;justify-content:center;
font-size:.72rem;font-weight:700;
background:var(--or-accent-bg, rgba(99,102,241,.28));
color:var(--or-accent-fg, #c7c9ff);
border:1px solid var(--or-accent-border, rgba(129,140,248,.45));
}
.or-section-title{font-size:.95rem;font-weight:650;line-height:1.3;color:var(--or-title, inherit)}
.or-section-desc{margin:4px 0 0;font-size:.72rem;opacity:.72;line-height:1.45;color:var(--or-muted, inherit)}
.or-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.or-tab{border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;padding:5px 10px;border-radius:6px;cursor:pointer;font-size:.78rem}
.or-tab.active{background:rgba(59,130,246,.25);border-color:rgba(59,130,246,.55)}
.or-badge{display:inline-block;padding:1px 6px;border-radius:999px;background:rgba(127,127,127,.2);font-size:.7rem}
.or-stat-card{border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:8px;font-size:.78rem}
.or-trades-table{font-size:.78rem}
.or-trades-table tr.or-row-active{outline:1px solid rgba(59,130,246,.55);background:rgba(59,130,246,.08)}
.or-toolbar{display:flex;flex-direction:column;gap:0;margin:0}
.or-tab{
border:1px solid var(--or-border, rgba(127,127,127,.35));background:transparent;color:inherit;
padding:6px 12px;border-radius:8px;cursor:pointer;font-size:.78rem;
}
.or-tab.active{
background:var(--or-accent-bg, rgba(99,102,241,.28));
border-color:var(--or-accent-border, rgba(129,140,248,.55));
color:var(--or-accent-fg, #e8e9ff);font-weight:600;
}
.or-filters{
display:flex;flex-wrap:wrap;gap:8px;align-items:center;
margin:0;padding:10px 12px;border-radius:10px;
background:var(--or-filters-bg, rgba(0,0,0,.22));
border:1px solid var(--or-border-soft, rgba(127,127,127,.18));
}
.or-filters select,.or-filters input[type="search"],.or-filters input[type="datetime-local"]{
font-size:.76rem;min-height:2rem;
}
.or-filters #or-filter-q{max-width:168px}
.or-filters label{display:flex;align-items:center;gap:5px;font-size:.72rem;opacity:.85}
.or-badge{
display:inline-block;padding:1px 7px;border-radius:999px;
background:var(--or-badge-bg, rgba(127,127,127,.22));font-size:.7rem;vertical-align:middle;
}
.or-list-title{display:none}
.or-trades-table,.or-reviewed-table{font-size:.78rem}
.or-trades-table tr.or-row-active{
outline:1px solid var(--or-accent-border, rgba(129,140,248,.55));
background:var(--or-row-active-bg, rgba(99,102,241,.1));
}
.or-reviewed-table tbody tr{cursor:pointer}
.or-reviewed-table tbody tr:hover{background:var(--or-row-hover-bg, rgba(99,102,241,.08))}
.or-pager{
display:flex;align-items:center;gap:8px;margin-top:10px;
padding-top:8px;border-top:1px dashed var(--or-border-soft, rgba(127,127,127,.2));font-size:.74rem;
}
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem;overflow-x:auto}
.or-reviewed-table{min-width:980px}
.or-kpi-row{
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
gap:8px;margin-bottom:12px;
}
.or-kpi-tile{
border:1px solid var(--or-border-soft, rgba(127,127,127,.22));border-radius:10px;
padding:10px 12px;background:var(--or-tile-bg, rgba(0,0,0,.2));min-width:0;
}
.or-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
.or-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
.or-stats-grid{
display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;
}
.or-stat-card{
border:1px solid var(--or-border-soft, rgba(127,127,127,.22));border-radius:10px;
padding:10px 12px;background:var(--or-tile-bg, rgba(0,0,0,.16));font-size:.76rem;
}
.or-stat-card-title{
font-weight:650;margin-bottom:8px;font-size:.78rem;
padding-bottom:6px;border-bottom:1px solid var(--or-border-soft, rgba(127,127,127,.18));
}
.or-stat-row{
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid var(--or-border-faint, rgba(127,127,127,.1));
}
.or-stat-row:last-child{border-bottom:none;padding-bottom:0}
.or-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
.or-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
.or-journal-card{font-size:.78rem}
.or-journal-card h2{font-size:.92rem}
.or-journal-card input,
@@ -22,56 +112,117 @@
.or-journal-card textarea,
.or-journal-card button{font-size:.76rem}
.or-journal-card .or-form-grid,
.or-journal-card .or-form-grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:6px}
.or-journal-card .or-form-grid2{
display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:6px;
}
.or-journal-card .or-mood-grid{display:flex;flex-wrap:wrap;gap:6px 12px;margin:8px 0;font-size:.74rem}
.or-journal-card .muted,
.or-journal-card .sub{font-size:.7rem}
.or-journal-card .muted,.or-journal-card .sub{font-size:.7rem}
.or-journal-card.hidden{display:none!important}
.or-reviewed-table tbody tr{cursor:pointer}
.or-detail-panel{margin-top:10px;padding-top:10px;border-top:1px solid rgba(127,127,127,.25)}
.or-detail-panel.hidden{display:none!important}
.or-detail-backdrop{
position:fixed;inset:0;z-index:1300;
display:flex;align-items:center;justify-content:center;
padding:16px;background:var(--or-backdrop, rgba(0,0,0,.72));
}
.or-detail-backdrop[hidden]{display:none!important}
.or-img-lightbox{
position:fixed;inset:0;z-index:2200;
display:flex;align-items:center;justify-content:center;
padding:16px;background:rgba(0,0,0,.86);cursor:zoom-out;
}
.or-img-lightbox[hidden]{display:none!important}
.or-img-lightbox img{
max-width:min(96vw,1200px);max-height:92vh;
object-fit:contain;border-radius:8px;
box-shadow:0 12px 40px rgba(0,0,0,.55);
}
.or-detail-modal{
width:min(96vw,920px);max-height:90vh;overflow:auto;
background:var(--or-modal-bg, var(--card-bg, #121726));color:var(--or-text, inherit);
border:1px solid var(--or-border, rgba(127,127,127,.35));border-radius:10px;
padding:14px 16px 18px;box-shadow:var(--or-modal-shadow, 0 12px 40px rgba(0,0,0,.45));
}
.or-detail-modal-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.or-detail-modal-head h3{margin:0;margin-right:auto;font-size:.95rem;color:var(--or-title, inherit)}
.or-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px 12px;font-size:.76rem;margin-bottom:8px}
.or-detail-images{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:8px 0}
.or-detail-img-cell{border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:6px;text-align:center}
.or-detail-img-label{display:block;font-size:.7rem;margin-bottom:4px;opacity:.8}
.or-detail-img-thumb{max-width:100%;max-height:160px;border-radius:4px;cursor:pointer}
.or-pager{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:.74rem}
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem}
.or-detail-images{
display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px;margin:10px 0 4px;
}
.or-detail-img-cell{
min-width:0;border:1px solid var(--or-border-soft, rgba(127,127,127,.25));border-radius:8px;
padding:8px;display:flex;flex-direction:column;gap:6px;
background:var(--or-tile-bg, rgba(0,0,0,.18));
}
.or-detail-img-label{font-size:.72rem;opacity:.85;font-weight:600}
.or-detail-img-thumb{
width:100%;max-height:280px;object-fit:contain;
border-radius:6px;cursor:zoom-in;background:var(--or-img-bg, rgba(0,0,0,.25));
}
.or-detail-img-miss{
min-height:120px;display:flex;align-items:center;justify-content:center;
font-size:.72rem;opacity:.65;border-radius:6px;background:rgba(127,127,127,.12);
}
.or-slot-thumb{
display:block;margin-top:6px;max-width:160px;max-height:90px;
object-fit:contain;border-radius:4px;border:1px solid var(--or-border, rgba(127,127,127,.3));
background:var(--or-img-bg, rgba(0,0,0,.2));cursor:zoom-in;
}
@media (max-width:900px){
.or-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:640px){
.or-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
.or-detail-images{grid-template-columns:1fr}
.or-detail-img-thumb{max-height:220px}
}
</style>
{# 1. 交易记录(含 Tab/筛选,固定约5行) #}
<div class="card" style="margin-bottom:10px">
<div class="form-row" style="flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:6px">
<h2 style="margin:0;margin-right:auto">期权复盘</h2>
<span class="muted" id="or-sync-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
<div class="or-page-head">
<h2>期权复盘</h2>
<span class="muted" id="or-sync-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
{# Tab + 筛选:放在各内容卡片上方,全局作用于下方列表/统计 #}
<div class="or-toolbar">
<div class="or-tabs" role="tablist" aria-label="复盘分类">
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
<button type="button" class="or-tab" data-source="options_options" role="tab">期期对冲记录</button>
<button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button>
</div>
<p class="muted" style="margin:0 0 8px;font-size:.72rem">待复盘交易(每页5条).点「复盘」填写表单;保存后进入下方复盘记录.</p>
<div class="form-row" style="flex-wrap:wrap;gap:6px;margin-bottom:8px">
<select id="or-filter-uly" style="font-size:.76rem">
<div class="or-filters">
<select id="or-filter-uly" autocomplete="off">
<option value="">标的:全部</option>
<option value="ETH">ETH</option>
<option value="BTC">BTC</option>
</select>
<select id="or-filter-opt" style="font-size:.76rem">
<select id="or-filter-opt" autocomplete="off">
<option value="">Call/Put:全部</option>
<option value="C">Call</option>
<option value="P">Put</option>
</select>
<input type="text" id="or-filter-strategy" placeholder="策略标签" style="max-width:110px;font-size:.76rem">
<input type="datetime-local" id="or-filter-from" title="平仓起" style="font-size:.76rem">
<input type="datetime-local" id="or-filter-to" title="平仓止" style="font-size:.76rem">
<label class="muted" style="display:flex;align-items:center;gap:4px;font-size:.72rem">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input type="search" id="or-filter-q" name="or_filter_q" placeholder="搜索标的/合约/策略"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
<input type="datetime-local" id="or-filter-from" title="平仓起" autocomplete="off">
<input type="datetime-local" id="or-filter-to" title="平仓止" autocomplete="off">
<label class="muted">
<input type="checkbox" id="or-include-hedge-legs"> 含已归属对冲的期权腿
</label>
</div>
<h3 id="or-list-title" style="margin-top:0">期权交易记录</h3>
</div>
{# 1. 交易记录 #}
<section class="or-section" aria-labelledby="or-list-title">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">1</span>
<div>
<div class="or-section-title" id="or-list-title">期权交易记录</div>
<p class="or-section-desc">点「复盘」填写表单;已复盘仍保留在此,也可在下方查看详情。</p>
</div>
</div>
<div class="options-strike-table-wrap or-trades-table-wrap" id="or-trades-wrap">
<table class="options-strike-table or-trades-table" id="or-trades-table">
<thead>
@@ -79,13 +230,14 @@
<th>类型</th>
<th>标的/合约</th>
<th>盈亏</th>
<th>/平</th>
<th>仓时间</th>
<th>平仓时间</th>
<th>持有</th>
<th>操作</th>
</tr>
</thead>
<tbody id="or-trades-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr>
<tr><td colspan="7" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
@@ -94,23 +246,33 @@
<span class="muted" id="or-trades-page-label">第 1 / 1 页</span>
<button type="button" class="btn-secondary" id="or-trades-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div>
</div>
</section>
{# 2. 复盘上传(默认隐藏,点交易「复盘」后显示) #}
<div class="card journal-card or-journal-card hidden" id="or-journal-card" style="margin-bottom:10px">
<h2>复盘记录上传(含截图)</h2>
<p class="muted" id="or-journal-summary" style="margin-top:0">截图槽位与合约复盘相同(5m / 15m / 1h / 4h).</p>
{# 2. 复盘上传(默认隐藏) #}
<section class="or-section journal-card or-journal-card hidden" id="or-journal-card">
<div class="or-section-head">
<span class="or-step" aria-hidden="true"></span>
<div>
<div class="or-section-title">填写复盘</div>
<p class="or-section-desc" id="or-journal-summary">截图槽位 5m / 15m / 1h / 4h,选文件后即时上传。</p>
</div>
</div>
<div class="or-journal-body">
<form id="or-journal-form" onsubmit="return false;">
<input type="hidden" id="or-trade-id" value="">
<input type="hidden" id="or-draft-id" value="">
<div class="or-form-grid">
<input type="datetime-local" id="or-f-open" title="开仓时间">
<input type="datetime-local" id="or-f-close" title="平仓时间">
<input type="text" id="or-f-coin" placeholder="标的(如 ETH)">
<input type="text" id="or-f-inst" placeholder="合约/计划">
<input type="text" id="or-f-pnl" placeholder="盈亏(U)">
<input type="text" id="or-f-hold" placeholder="持有时长" readonly>
<input type="datetime-local" id="or-f-open" title="开仓时间" autocomplete="off">
<input type="datetime-local" id="or-f-close" title="平仓时间" autocomplete="off">
<input type="text" id="or-f-coin" name="or_f_coin" placeholder="标的(如 ETH)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-inst" name="or_f_inst" placeholder="合约/计划"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-pnl" name="or_f_pnl" placeholder="盈亏(U)"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-hold" name="or_f_hold" placeholder="持有时长" readonly autocomplete="off">
</div>
<div class="or-form-grid2">
<select id="or-f-strategy" title="策略标签" required>
@@ -145,13 +307,12 @@
<option value="">入场逻辑</option>
</select>
<input type="hidden" id="journal-draft-id" value="">
<div class="journal-upload-slots" id="or-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %}
<div class="journal-upload-row" data-tf="{{ tf }}">
<span class="journal-upload-slot-label">{{ tf }}</span>
<input type="file" accept="image/*" class="journal-upload-slot-input or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="journal-upload-hidden-file or-upload-hidden" data-tf="{{ tf }}" value="">
<input type="file" accept="image/*" class="or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="or-upload-hidden" data-tf="{{ tf }}" value="">
<span class="journal-upload-status or-upload-status" data-tf="{{ tf }}" aria-live="polite"></span>
</div>
{% endfor %}
@@ -176,26 +337,36 @@
<div id="or-legs-host" style="margin-top:10px;font-size:.74rem"></div>
</form>
</div>
</div>
</section>
{# 3. 已复盘记录 + 详情 #}
<div class="card" style="margin-bottom:10px">
<h3>复盘记录</h3>
<p class="muted" style="margin:0 0 8px;font-size:.72rem">已保存的复盘(每页5条).点一行查看详情.</p>
{# 3. 已复盘记录 #}
<section class="or-section" aria-labelledby="or-reviewed-heading">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">2</span>
<div>
<div class="or-section-title" id="or-reviewed-heading">复盘记录</div>
<p class="or-section-desc">已保存的复盘内容,点一行查看详情与截图。</p>
</div>
</div>
<div class="options-strike-table-wrap or-reviewed-table-wrap" id="or-reviewed-wrap">
<table class="options-strike-table or-reviewed-table" id="or-reviewed-table">
<thead>
<tr>
<th>类型</th>
<th>标的/合约</th>
<th>方向</th>
<th>盈亏</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持仓时长</th>
<th>策略</th>
<th>入场逻辑</th>
<th>结果</th>
<th>复盘时间</th>
</tr>
</thead>
<tbody id="or-reviewed-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr>
<tr><td colspan="11" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
@@ -204,24 +375,37 @@
<span class="muted" id="or-reviewed-page-label">第 1 / 1 页</span>
<button type="button" class="btn-secondary" id="or-reviewed-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div>
<div class="or-detail-panel hidden" id="or-detail-panel">
<div class="form-row" style="align-items:center;gap:8px;margin-bottom:6px">
<h3 style="margin:0;margin-right:auto" id="or-detail-title">复盘详情</h3>
</section>
{# 详情 / 放大 #}
<div id="or-detail-backdrop" class="or-detail-backdrop" hidden>
<div class="or-detail-modal" role="dialog" aria-modal="true" aria-labelledby="or-detail-title" id="or-detail-panel">
<div class="or-detail-modal-head">
<h3 id="or-detail-title">复盘详情</h3>
<button type="button" class="btn-secondary" id="or-detail-edit-btn" style="font-size:.72rem;padding:2px 8px">编辑</button>
<button type="button" class="btn-secondary" id="or-detail-close-btn" style="font-size:.72rem;padding:2px 8px">收起</button>
<button type="button" class="btn-secondary" id="or-detail-close-btn" style="font-size:.72rem;padding:2px 8px">关闭</button>
</div>
<div class="or-detail-grid" id="or-detail-meta"></div>
<div id="or-detail-text" style="font-size:.76rem;line-height:1.5;margin-bottom:8px"></div>
<div class="or-detail-images" id="or-detail-images"></div>
</div>
</div>
<div id="or-img-lightbox" class="or-img-lightbox" hidden>
<img id="or-img-lightbox-img" src="" alt="截图放大">
</div>
{# 4. 统计 #}
<div class="card" style="margin-bottom:10px">
<h3>统计</h3>
<div id="or-kpi" class="form-row" style="flex-wrap:wrap;gap:10px"></div>
<div id="or-stats-groups" style="margin-top:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px"></div>
</div>
<section class="or-section" aria-labelledby="or-stats-heading">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">3</span>
<div>
<div class="or-section-title" id="or-stats-heading">统计</div>
<p class="or-section-desc">跟随上方 Tab 与筛选条件汇总。</p>
</div>
</div>
<div id="or-kpi" class="or-kpi-row"></div>
<div id="or-stats-groups" class="or-stats-grid"></div>
</section>
</div>
<script src="/static/options_review.js?v=10"></script>
<script src="/static/options_review.js?v=22"></script>
@@ -1,4 +1,4 @@
{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #}
<div id="options-settings-root" hidden
data-sub-account="{{ instance_settings.options_sub_account | default('', true) }}"></div>
<script src="/static/options_settings.js?v=8"></script>
<script src="/static/options_settings.js?v=9"></script>
@@ -1,11 +1,14 @@
<div class="options-settings-section">
<p class="options-settings-hint">主账户资金账户:USDT ↔ USDC 现货市价单.</p>
<div class="form-row settings-transfer-form options-settings-row">
<select id="opt-set-swap-dir" aria-label="兑换方向">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-swap-dir" aria-label="兑换方向" autocomplete="off">
<option value="usdt_to_usdc" selected>USDT → USDC</option>
<option value="usdc_to_usdt">USDC → USDT</option>
</select>
<input type="number" id="opt-set-swap-amount" min="0.01" step="0.01" placeholder="数量">
<input type="number" id="opt-set-swap-amount" name="cm_opt_swap_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-swap-all-btn">全部兑换</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-swap-btn">市价兑换</button>
</div>
@@ -1,7 +1,9 @@
<div class="options-settings-section">
<div class="options-settings-subtitle">主账户内</div>
<div class="form-row settings-transfer-form options-settings-row">
<select id="opt-set-int-ccy" aria-label="币种">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-int-ccy" aria-label="币种" autocomplete="off">
<option value="USDC" selected>USDC</option>
<option value="USDT">USDT</option>
</select>
@@ -13,7 +15,8 @@
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-set-int-amount" min="0.01" step="0.01" placeholder="数量">
<input type="number" id="opt-set-int-amount" name="cm_opt_int_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-int-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-int-btn">划转</button>
</div>
@@ -26,7 +29,9 @@
<span class="muted">({{ instance_settings.options_sub_account or '未配置' }})</span>
</div>
<div class="form-row settings-transfer-form options-settings-row">
<select id="opt-set-cross-dir" aria-label="主子方向">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-cross-dir" aria-label="主子方向" autocomplete="off">
<option value="main_to_sub" selected>主 → 子</option>
<option value="sub_to_main">子 → 主</option>
</select>
@@ -42,7 +47,8 @@
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-set-cross-amount" min="0.01" step="0.01" placeholder="数量">
<input type="number" id="opt-set-cross-amount" name="cm_opt_cross_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-cross-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-cross-btn">划转</button>
</div>
@@ -7,14 +7,17 @@
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};<strong>持仓中不划转</strong>并微信通知)
</div>
</details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
<select name="from_account">
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account">
<select name="to_account" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
@@ -1,13 +1,13 @@
{# 复盘表单:首行按字段宽度比例;下单类型/开仓类型与离场触发同一行 #}
{% macro journal_form_fields(entry_reason_options, order_type_options) -%}
<div class="form-grid journal-form-row1">
<input type="datetime-local" name="open_datetime" class="journal-field-datetime" required>
<input type="datetime-local" name="close_datetime" class="journal-field-datetime" required>
<input name="coin" class="journal-field-coin" placeholder="BTC" required>
<input name="tf" class="journal-field-tf" placeholder="5m" required>
<input name="pnl" class="journal-field-num" placeholder="盈亏(U)" required>
<input name="expect_rr" class="journal-field-num" placeholder="预期RR">
<input name="real_rr" class="journal-field-num" placeholder="实际RR">
<input type="datetime-local" name="open_datetime" class="journal-field-datetime" required autocomplete="off">
<input type="datetime-local" name="close_datetime" class="journal-field-datetime" required autocomplete="off">
<input name="coin" class="journal-field-coin" placeholder="BTC" required autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="tf" class="journal-field-tf" placeholder="5m" required autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="pnl" class="journal-field-num" placeholder="盈亏(U)" required autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="expect_rr" class="journal-field-num" placeholder="预期RR" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="real_rr" class="journal-field-num" placeholder="实际RR" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<div class="form-grid journal-form-row2">
<select name="direction" id="journal-direction" class="journal-field-direction" required title="做多/做空">
@@ -38,7 +38,7 @@
<option value="止损">止损</option>
<option value="其他">其他</option>
</select>
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
</div>
{%- endmacro %}
@@ -11,7 +11,7 @@
{% endfor %}
</select>
{% else %}
<input id="{{ id }}" name="{{ name }}" placeholder="{{ placeholder }}" {% if required %}required{% endif %} value="{{ value }}">
<input id="{{ id }}" name="{{ name }}" placeholder="{{ placeholder }}" {% if required %}required{% endif %} value="{{ value }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
{% endif %}
{%- endmacro %}
+69 -2
View File
@@ -86,6 +86,14 @@ def manual_close_daily_limit() -> int:
return 2
def daily_loss_limit() -> int:
"""日亏损次数上限:达限当日冻结开仓;0=不因亏损次数冻结."""
try:
return max(0, int(os.getenv("RISK_DAILY_LOSS_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def max_active_positions_from_env(default: int = 1) -> int:
try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default))))
@@ -116,6 +124,7 @@ def ensure_account_risk_schema(conn) -> None:
id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
daily_loss_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
@@ -124,10 +133,18 @@ def ensure_account_risk_schema(conn) -> None:
updated_at TEXT
)"""
)
cols = {
str(r[1])
for r in conn.execute("PRAGMA table_info(account_risk_state)").fetchall()
}
if "daily_loss_count" not in cols:
conn.execute(
"ALTER TABLE account_risk_state ADD COLUMN daily_loss_count INTEGER DEFAULT 0"
)
row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone()
if not row:
conn.execute(
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_loss_count, daily_frozen) VALUES (1, '', 0, 0, 0)"
)
@@ -268,6 +285,7 @@ def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) ->
"""UPDATE account_risk_state SET
trading_day=?,
manual_close_count=0,
daily_loss_count=0,
daily_frozen=0,
cooloff_until_ms=?,
cooloff_hours=?,
@@ -600,6 +618,43 @@ def on_manual_close(
)
def on_closed_trade_pnl(
conn,
*,
pnl_amount: Any,
trading_day: str,
now: Optional[datetime] = None,
) -> None:
"""
已平仓交易记盈亏后调用:亏损笔数达 RISK_DAILY_LOSS_LIMIT 则当日冻结开仓.
上限为 0 时不启用本规则.
"""
if not risk_control_enabled():
return
limit = daily_loss_limit()
if limit <= 0:
return
try:
pnl = float(pnl_amount)
except (TypeError, ValueError):
return
if pnl >= 0:
return
row = _sync_trading_day(conn, trading_day, now=now)
if int(_row_get(row, "daily_frozen") or 0) == 1:
return
count = int(_row_get(row, "daily_loss_count") or 0) + 1
conn.execute(
"""UPDATE account_risk_state SET
daily_loss_count=?,
updated_at=?
WHERE id=1""",
(count, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
)
if count >= limit:
_set_daily_frozen(conn, trading_day=trading_day, now=now)
def on_journal_saved(
conn,
*,
@@ -762,6 +817,7 @@ def compute_account_risk_status(
"cooloff_until_ms": None,
"cooloff_until": None,
"manual_close_count": 0,
"daily_loss_count": 0,
"daily_frozen": False,
}
row = _sync_trading_day(conn, trading_day, now=now)
@@ -784,12 +840,21 @@ def compute_account_risk_status(
row = _load_state(conn)
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
daily_loss_count = int(_row_get(row, "daily_loss_count") or 0)
loss_limit = daily_loss_limit()
status = STATUS_NORMAL
reason = ""
if daily_frozen:
status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
parts = []
if loss_limit > 0 and daily_loss_count >= loss_limit:
parts.append(f"日亏损 {daily_loss_count}/{loss_limit}")
if manual_close_count >= manual_close_daily_limit():
parts.append(f"手动平仓 {manual_close_count}")
if not parts:
parts.append("手动平仓/日亏损达限或复盘情绪标签")
reason = "账户今日已冻结(" + "".join(parts) + ")"
elif cooloff_until_ms is not None:
remaining_ms = cooloff_until_ms - now_ms
hours = _cooloff_hours_value(row)
@@ -818,6 +883,8 @@ def compute_account_risk_status(
if fmt_local_ms and cooloff_until_ms
else None,
"manual_close_count": manual_close_count,
"daily_loss_count": daily_loss_count,
"daily_loss_limit": loss_limit,
"daily_frozen": daily_frozen,
"pending_journal_trade_id": pending,
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
+1 -1
View File
@@ -6,7 +6,7 @@
| 功能 | 说明 |
|------|------|
| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
| **交易教练** | 口语化陪聊;注入三户监控快照(**含 OKX 期权持仓明细**)、执行手册短摘要与今日总结摘要(后台自动生成,不在页面展示) |
| **普通聊天** | 不绑交易数据,适合闲聊,答疑 |
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
| **会话历史** | 右侧列表:切换,删除;消息一键复制 |
+222
View File
@@ -0,0 +1,222 @@
"""中控振幅统计 API."""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from amp_stats_store import delete_history, get_history, list_history, save_history
from lib.hub.amp_stats_lib import (
build_export_csv,
compute_amp_stats,
export_filename,
normalize_straddle_premium,
normalize_take_profit,
normalize_weekend_filter,
reframe_amp_stats,
rows_page,
)
class ComputeBody(BaseModel):
symbol: str = "eth"
start_hour: int = 16
period: str = "2m"
custom_days: Optional[int] = None
straddle_premium: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all"
page: int = 1
page_size: int = 20
class SaveBody(BaseModel):
result: dict[str, Any] = Field(default_factory=dict)
class ReframeBody(BaseModel):
"""已有日表上改周末/权利金/止盈(不拉 K 线)."""
rows_all: list[dict[str, Any]] = Field(default_factory=list)
symbol: str = "eth"
start_hour: int = 16
period: str = "2m"
sample_days: int = 60
straddle_premium: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all"
price_source: str = ""
inst_id: str = ""
page: int = 1
page_size: int = 20
def create_amp_stats_router() -> APIRouter:
router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"])
@router.get("/meta")
def api_meta():
return {
"ok": True,
"exchange": "okx",
"symbols": [
{"key": "eth", "label": "ETH"},
{"key": "btc", "label": "BTC"},
],
"end_hour": 16,
"start_hours": list(range(24)),
"periods": [
{"key": "1m", "label": "1个月"},
{"key": "2m", "label": "2个月"},
{"key": "3m", "label": "3个月"},
{"key": "6m", "label": "半年"},
{"key": "1y", "label": "1年"},
{"key": "custom", "label": "自定义"},
],
"weekend_filters": [
{"key": "all", "label": "全部"},
{"key": "exclude", "label": "排除周末"},
{"key": "only", "label": "仅周末"},
],
"default_period": "2m",
"default_weekend_filter": "all",
"timeframe": "1H",
"metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)",
"straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金",
}
@router.post("/compute")
def api_compute(body: ComputeBody):
try:
result = compute_amp_stats(
symbol=body.symbol,
start_hour=body.start_hour,
period=body.period,
custom_days=body.custom_days,
straddle_premium=body.straddle_premium,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size)
return {
"ok": True,
"result": result,
"page": page,
}
@router.post("/reframe")
def api_reframe(body: ReframeBody):
rows_all = body.rows_all or []
if not rows_all:
raise HTTPException(status_code=400, detail="无日表可重算")
try:
result = reframe_amp_stats(
rows_all=rows_all,
symbol=body.symbol,
start_hour=body.start_hour,
period=body.period,
sample_days=body.sample_days,
straddle_premium=body.straddle_premium,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter,
price_source=body.price_source,
inst_id=body.inst_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
page = rows_page(result.get("rows") or [], page=body.page, page_size=body.page_size)
return {"ok": True, "result": result, "page": page}
@router.get("/history")
def api_history(symbol: str = "", limit: int = 50):
return {"ok": True, "items": list_history(symbol=symbol, limit=limit)}
@router.post("/history")
def api_history_save(body: SaveBody):
payload = body.result if isinstance(body.result, dict) else {}
if not payload.get("rows") and not payload.get("rows_all") and not payload.get("summary"):
raise HTTPException(status_code=400, detail="无可保存的结果")
item = save_history(payload)
return {"ok": True, "item": item}
@router.get("/history/{item_id}")
def api_history_detail(item_id: str):
item = get_history(item_id)
if not item:
raise HTTPException(status_code=404, detail="历史不存在")
return {"ok": True, "item": item}
@router.delete("/history/{item_id}")
def api_history_delete(item_id: str):
if not delete_history(item_id):
raise HTTPException(status_code=404, detail="历史不存在")
return {"ok": True}
@router.get("/export")
def api_export(
history_id: str = Query(default=""),
symbol: str = Query(default="eth"),
start_hour: int = Query(default=16),
period: str = Query(default="2m"),
custom_days: Optional[int] = Query(default=None),
straddle_premium: Optional[float] = Query(default=None),
take_profit: Optional[float] = Query(default=None),
weekend_filter: str = Query(default="all"),
):
if (history_id or "").strip():
item = get_history(history_id.strip())
if not item:
raise HTTPException(status_code=404, detail="历史不存在")
rows_all = item.get("rows_all") or item.get("rows") or []
try:
payload = reframe_amp_stats(
rows_all=rows_all,
symbol=item.get("symbol") or symbol,
start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour),
period=str(item.get("period") or period),
sample_days=int(item.get("sample_days_requested") or 60),
straddle_premium=straddle_premium
if straddle_premium is not None
else item.get("straddle_premium"),
take_profit=take_profit if take_profit is not None else item.get("take_profit"),
weekend_filter=weekend_filter or item.get("weekend_filter") or "all",
price_source=str(item.get("price_source") or ""),
inst_id=str(item.get("inst_id") or ""),
missing=item.get("missing_days") or [],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
else:
try:
# validate enums early
normalize_weekend_filter(weekend_filter)
normalize_straddle_premium(straddle_premium)
normalize_take_profit(take_profit)
payload = compute_amp_stats(
symbol=symbol,
start_hour=start_hour,
period=period,
custom_days=custom_days,
straddle_premium=straddle_premium,
take_profit=take_profit,
weekend_filter=weekend_filter,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
csv_text = build_export_csv(payload)
name = export_filename(payload)
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
return router
+122
View File
@@ -0,0 +1,122 @@
"""振幅统计历史作业存储(中控 JSON)."""
from __future__ import annotations
import json
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
_LOCK = threading.Lock()
_STORE_NAME = "amp_stats_history.json"
_MAX_ITEMS = 80
def _store_path() -> Path:
return Path(__file__).resolve().parent / _STORE_NAME
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _load() -> dict[str, Any]:
path = _store_path()
if not path.is_file():
return {"items": []}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"items": []}
if not isinstance(data, dict):
return {"items": []}
items = data.get("items")
if not isinstance(items, list):
items = []
return {"items": items}
def _save(data: dict[str, Any]) -> None:
path = _store_path()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def list_history(*, symbol: str = "", limit: int = 50) -> list[dict[str, Any]]:
with _LOCK:
items = list(_load().get("items") or [])
sym = (symbol or "").strip().lower()
if sym:
items = [x for x in items if str(x.get("symbol") or "").lower() == sym]
limit = max(1, min(200, int(limit or 50)))
out = []
for it in items[:limit]:
out.append(
{
"id": it.get("id"),
"created_at": it.get("created_at"),
"symbol": it.get("symbol"),
"symbol_label": it.get("symbol_label"),
"start_hour": it.get("start_hour"),
"end_hour": it.get("end_hour"),
"period": it.get("period"),
"price_source": it.get("price_source"),
"sample_count": (it.get("summary") or {}).get("sample_count"),
"max_amplitude": (it.get("summary") or {}).get("max_amplitude"),
"max_amplitude_day": (it.get("summary") or {}).get("max_amplitude_day"),
}
)
return out
def get_history(item_id: str) -> Optional[dict[str, Any]]:
iid = (item_id or "").strip()
if not iid:
return None
with _LOCK:
for it in _load().get("items") or []:
if str(it.get("id")) == iid:
return dict(it)
return None
def save_history(payload: dict[str, Any]) -> dict[str, Any]:
item = {
"id": uuid.uuid4().hex[:12],
"created_at": _now_iso(),
"exchange": payload.get("exchange"),
"symbol": payload.get("symbol"),
"symbol_label": payload.get("symbol_label"),
"start_hour": payload.get("start_hour"),
"end_hour": payload.get("end_hour"),
"period": payload.get("period"),
"timeframe": payload.get("timeframe"),
"price_source": payload.get("price_source"),
"inst_id": payload.get("inst_id"),
"timezone": payload.get("timezone"),
"summary": payload.get("summary") or {},
"rows": payload.get("rows") or [],
"missing_count": payload.get("missing_count") or 0,
}
with _LOCK:
data = _load()
items = list(data.get("items") or [])
items.insert(0, item)
data["items"] = items[:_MAX_ITEMS]
_save(data)
return item
def delete_history(item_id: str) -> bool:
iid = (item_id or "").strip()
if not iid:
return False
with _LOCK:
data = _load()
items = list(data.get("items") or [])
new_items = [x for x in items if str(x.get("id")) != iid]
if len(new_items) == len(items):
return False
data["items"] = new_items
_save(data)
return True
+2 -1
View File
@@ -7,7 +7,8 @@
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **策略说明** | 执行手册 + 行为准则(开单三检) + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **振幅统计** | OKX ETH/BTC 时段点数振幅档案(只读,固定 16:00 收窗) |
| **使用说明** | 本页:中控与实例怎么用 |
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
+58
View File
@@ -777,6 +777,17 @@ _ACCOUNT_RISK_BADGE_CSS = _REPO_STATIC / "account_risk_badge.css"
_ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js"
_OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js"
_OPTIONS_POSITION_CARDS_JS = _REPO_STATIC / "options_position_cards.js"
_AUTOFILL_GUARD_JS = _REPO_STATIC / "autofill_guard.js"
@app.get("/assets/autofill_guard.js")
def hub_autofill_guard_js():
if not _AUTOFILL_GUARD_JS.is_file():
raise HTTPException(status_code=404, detail="autofill_guard.js not found")
return FileResponse(
str(_AUTOFILL_GUARD_JS),
media_type="application/javascript; charset=utf-8",
)
@app.get("/assets/account_risk_badge.css")
@@ -980,6 +991,7 @@ def root_redirect():
@app.get("/monitor")
@app.get("/plan")
@app.get("/calculator")
@app.get("/compare")
@app.get("/market")
@app.get("/archive")
@app.get("/quotes")
@@ -987,6 +999,7 @@ def root_redirect():
@app.get("/funds")
@app.get("/ai")
@app.get("/strategy")
@app.get("/amp-stats")
@app.get("/help")
@app.get("/logs")
@app.get("/settings")
@@ -1001,8 +1014,10 @@ def _all_exchanges_for_ai() -> list:
from hub_ai.routes import create_hub_ai_router
from hub_dashboard import build_dashboard_payload, default_trading_day
from amp_stats_routes import create_amp_stats_router
app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai))
app.include_router(create_amp_stats_router())
async def _run_dashboard_aggregate() -> dict:
@@ -1099,7 +1114,9 @@ class SettingsDisplayBody(BaseModel):
show_nav_quotes: bool = True
show_nav_ai: bool = True
show_nav_calculator: bool = True
show_nav_compare: bool = True
show_nav_strategy: bool = True
show_nav_amp_stats: bool = True
show_nav_help: bool = True
show_nav_logs: bool = True
@@ -1197,6 +1214,27 @@ class RollCalculatorBody(BaseModel):
base: str = "ETH"
class CompareOptionLegBody(BaseModel):
opt_type: str = "C"
strike: float | None = None
ask: float | None = None
class CompareBody(BaseModel):
base: str = "ETH"
direction: str = "long"
entry: float = Field(gt=0)
sl: float = Field(gt=0)
tp: float = Field(gt=0)
risk_u: float = Field(gt=0)
tp_opt: float | None = None
tp_hedge: float | None = None
contract_size: float | None = None
ct_mult: float | None = None
option: CompareOptionLegBody | None = None
hedge: dict | None = None
@app.get("/api/calculator/exchanges")
def api_calculator_exchanges():
from lib.hub.hub_calculator_market_lib import list_calculator_exchanges
@@ -1257,6 +1295,26 @@ def api_calculator_roll(body: RollCalculatorBody):
return {"ok": True, "data": data}
@app.post("/api/compare/calc")
def api_compare_calc(body: CompareBody):
from lib.hub.hub_compare_lib import run_compare
payload = body.model_dump()
hedge = payload.get("hedge") if isinstance(payload.get("hedge"), dict) else {}
# normalize hedge legs from nested dicts
if hedge:
payload["hedge"] = {
"main": hedge.get("main") if isinstance(hedge.get("main"), dict) else {},
"side": hedge.get("side") if isinstance(hedge.get("side"), dict) else {},
}
if payload.get("option") is None:
payload["option"] = {}
data = run_compare(payload)
if not data.get("ok"):
return JSONResponse(data, status_code=400)
return data
def _find_exchange_by_key(exchange_key: str) -> dict | None:
key = (exchange_key or "").strip().lower()
if not key:
+6
View File
@@ -25,6 +25,7 @@ from hub_ai.context import (
format_chat_context_for_chat,
format_chat_position_overview,
)
from hub_ai.playbook_brief import format_playbook_brief_for_chat
from hub_ai.prompts import (
CHAT_GENERAL_SYSTEM,
CHAT_SYSTEM,
@@ -217,6 +218,10 @@ def send_chat_message(
ctx = build_chat_context(exchanges, trading_day=day)
day = ctx["trading_day"]
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count)
# 首轮带完整手册摘要;续聊缩短,避免挤占对话上下文
playbook = format_playbook_brief_for_chat(
max_chars=1200 if prior_count <= 0 else 700
)
user_prompt = build_chat_user_prompt(
context_text=brief_ctx,
trading_day=day,
@@ -225,6 +230,7 @@ def send_chat_message(
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
playbook_brief=playbook,
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
+223 -11
View File
@@ -86,7 +86,81 @@ def _filter_open_positions(positions: list) -> list[dict]:
def _account_open_position_count(ac: dict) -> int:
return len(_filter_open_positions(ac.get("positions") or []))
perp = len(_filter_open_positions(ac.get("positions") or []))
opt = int(ac.get("options_open_position_count") or 0)
if opt <= 0:
opt = len(_iter_options_position_dicts(ac))
return perp + opt
def _iter_options_position_dicts(ac: dict) -> list[dict]:
snap = ac.get("options_snapshot")
if not isinstance(snap, dict):
return []
if snap.get("ok") is False or snap.get("enabled") is False:
return []
out: list[dict] = []
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
inst = str(p.get("inst_id") or p.get("instId") or "").strip()
if not inst:
continue
out.append(p)
return out
def _format_options_position_detail_line(p: dict) -> str:
inst = p.get("inst_id") or p.get("instId") or "?"
opt_type = (p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
src = _options_source_label(p)
sheets = p.get("pos")
if sheets is None:
sheets = p.get("sheets")
if sheets is None:
sheets = p.get("contracts")
if sheets is None:
sheets = "?"
parts = [f"期权 {inst} {label}", f"来源{src}", f"张数{sheets}"]
paid = _safe_float(p.get("premium_paid"))
if paid is not None:
parts.append(f"权利金{paid:g}U")
net: Optional[float] = None
try:
from lib.options.options_positions_lib import net_pnl_from_display_row
net = net_pnl_from_display_row(p)
except Exception:
net = None
if net is None:
net = _safe_float(p.get("net_pnl"))
if net is None:
net = _safe_float(p.get("upl"))
if net is not None:
parts.append(f"净盈亏{net:.4f}U")
tgt = _options_target_monitor_text(p)
if tgt and tgt not in ("", "-", ""):
parts.append(f"目标{tgt}")
return " - " + " ".join(parts)
def _append_options_position_lines(lines: list[str], ac: dict, *, limit: int = 6, indent: str = " - ") -> None:
rows = _iter_options_position_dicts(ac)
if not rows:
return
if indent.startswith(" "):
# chat slim: already under account bullet
for p in rows[:limit]:
lines.append(f" · {_format_options_position_detail_line(p).lstrip(' - ')}")
if len(rows) > limit:
lines.append(f" · …共{len(rows)}笔期权持仓")
return
lines.append("期权持仓明细(交易所实盘,含目标位若已挂):")
for p in rows[:limit]:
lines.append(_format_options_position_detail_line(p))
if len(rows) > limit:
lines.append(f" - …共{len(rows)}笔期权持仓")
def _monitor_counts(ac: dict) -> dict[str, int]:
@@ -291,25 +365,45 @@ def _find_plan_tpsl_for_position(
if not isinstance(hub_mon, dict):
return None, None, False
side_l = (side or "").lower()
if side_l in ("buy",):
side_l = "long"
elif side_l in ("sell",):
side_l = "short"
for o in hub_mon.get("orders") or []:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
if (o.get("direction") or "").lower() != side_l:
o_side = (o.get("direction") or "").lower()
if o_side and o_side != side_l:
continue
return (
_safe_float(o.get("stop_loss")),
_safe_float(o.get("take_profit")),
False,
)
for r in hub_mon.get("rolls") or []:
if not isinstance(r, dict):
continue
o_sym = r.get("exchange_symbol") or r.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
o_side = (r.get("direction") or "").lower()
if o_side and o_side != side_l:
continue
return (
_safe_float(r.get("stop_loss")),
_safe_float(r.get("take_profit")),
False,
)
for t in hub_mon.get("trends") or []:
if not isinstance(t, dict):
continue
if not _symbols_match(symbol, t.get("symbol") or ""):
continue
if (t.get("direction") or "").lower() != side_l:
t_side = (t.get("direction") or "").lower()
if t_side and t_side != side_l:
continue
plan_tp = t.get("take_profit")
tp = _safe_float(plan_tp) if plan_tp not in (None, "") else None
@@ -768,7 +862,9 @@ def format_context_text(payload: dict) -> str:
lines.append(
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
f"实盘持仓 {totals.get('open_position_count', 0)} | "
f"实盘持仓 {totals.get('open_position_count', 0)}"
f"(永续{totals.get('perpetual_open_position_count', totals.get('open_position_count', 0))}/"
f"期权{totals.get('options_open_position_count', 0)}) | "
f"浮盈亏 {totals.get('float_pnl_u')}U | "
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}"
@@ -835,6 +931,7 @@ def format_context_text(payload: dict) -> str:
if not isinstance(p, dict):
continue
lines.append(_format_position_detail_line(p, hub_mon))
_append_options_position_lines(lines, ac, limit=8)
lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
)
@@ -865,7 +962,9 @@ def format_summary_context_text(payload: dict) -> str:
lines.append(
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
f"实盘持仓 {totals.get('open_position_count', 0)} | "
f"实盘持仓 {totals.get('open_position_count', 0)}"
f"(永续{totals.get('perpetual_open_position_count', totals.get('open_position_count', 0))}/"
f"期权{totals.get('options_open_position_count', 0)}) | "
f"浮盈亏 {totals.get('float_pnl_u')}U | "
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}"
@@ -923,6 +1022,7 @@ def format_summary_context_text(payload: dict) -> str:
if not isinstance(p, dict):
continue
lines.append(_format_position_detail_line(p, hub_mon))
_append_options_position_lines(lines, ac, limit=8)
lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
)
@@ -1059,6 +1159,76 @@ def resolve_position_monitor_source(pos: dict, hub_mon: Optional[dict]) -> str:
return candidates[0][1]
def resolve_position_reward_at_tp(pos: dict, hub_mon: Optional[dict]) -> Optional[float]:
"""与监控区「盈利金额」一致:有字段用字段,否则按止盈价×张数推算."""
sym = str(pos.get("symbol") or "")
side = str(pos.get("side") or "").lower()
if side in ("buy",):
side = "long"
elif side in ("sell",):
side = "short"
matched: Optional[dict] = None
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False and sym:
for bucket in ("orders", "rolls", "trends"):
for o in hub_mon.get(bucket) or []:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(sym, str(o_sym)):
continue
o_side = str(o.get("direction") or "").lower()
# 与前端 findMonitorOrder 一致:方向为空也可匹配
if o_side and o_side != side:
continue
matched = o
v = _safe_float(o.get("reward_at_tp_usdt"))
if v is not None:
return v
break
if matched is not None:
break
v = _safe_float(pos.get("reward_at_tp_usdt"))
if v is not None:
return v
entry = _safe_float(pos.get("entry_price"))
if entry is None and matched is not None:
entry = _safe_float(
matched.get("avg_entry_price")
or matched.get("entry_price")
or matched.get("avg_px")
)
tp = None
if matched is not None:
tp = _safe_float(matched.get("take_profit"))
if tp is None:
tp = _safe_float(matched.get("take_profit_display"))
if tp is None:
tpsl = _resolve_position_tpsl(pos, hub_mon)
tp = tpsl.get("tp")
contracts = pos.get("contracts")
if contracts is None:
contracts = pos.get("size")
if contracts is None and matched is not None:
contracts = matched.get("contracts")
try:
qty = abs(float(contracts)) if contracts is not None else None
except (TypeError, ValueError):
qty = None
cs = _safe_float(pos.get("contract_size"))
if cs is None or cs <= 0:
cs = 1.0
if entry is None or tp is None or not qty:
return None
try:
from lib.strategy.strategy_roll_ui_lib import reward_at_tp_usdt
return reward_at_tp_usdt(side or "long", float(entry), float(tp), float(qty), contract_size=float(cs))
except Exception:
return None
def _options_source_label(p: dict) -> str:
"""看板期权来源:期期/永期对冲,其余为纯期权."""
source = str(p.get("source") or "").strip()
@@ -1100,6 +1270,13 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
contracts = p.get("size")
upnl = _position_float_pnl(p)
source = resolve_position_monitor_source(p, hub_mon)
entry = _safe_float(p.get("entry_price"))
mark = _safe_float(p.get("mark_price"))
notional = _safe_float(p.get("notional_usdt"))
if notional is None:
notional = _safe_float(p.get("notional"))
reward_tp = resolve_position_reward_at_tp(p, hub_mon)
tpsl = _resolve_position_tpsl(p, hub_mon)
position_lines.append(
{
"kind": "position",
@@ -1107,6 +1284,15 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
"symbol": sym,
"side": side,
"contracts": contracts,
"entry_price": entry,
"entry_price_fmt": p.get("entry_price_fmt"),
"mark_price": mark,
"mark_price_fmt": p.get("mark_price_fmt"),
"notional_usdt": notional,
"stop_loss": tpsl.get("sl"),
"take_profit": tpsl.get("tp"),
"tp_note": tpsl.get("tp_note") or "",
"reward_at_tp_usdt": round(reward_tp, 4) if reward_tp is not None else None,
"text": f"{sym} {side}",
"pnl": round(upnl, 4),
}
@@ -1183,21 +1369,30 @@ def collect_closed_trades_snapshot(
def format_chat_position_overview(payload: dict) -> str:
totals = payload.get("totals") or {}
total_open = int(totals.get("open_position_count") or 0)
opt_total = int(totals.get("options_open_position_count") or 0)
perp_total = int(
totals.get("perpetual_open_position_count")
if totals.get("perpetual_open_position_count") is not None
else max(0, total_open - opt_total)
)
if total_open <= 0:
head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓).浮盈亏 0U 表示无持仓,不是「有仓但不动」."
else:
head = (
f"【实盘持仓总览】监控户合计 {total_open},"
f"【实盘持仓总览】监控户合计 {total_open}"
f"(永续{perp_total}/期权{opt_total}),"
f"浮盈亏合计 {totals.get('float_pnl_u')}U."
)
lines = [
head,
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.",
"【区分】只有带「持仓明细/交易所实盘/期权持仓」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.",
]
for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控":
continue
n = int(ac.get("open_position_count") or _account_open_position_count(ac))
opt_n = int(ac.get("options_open_position_count") or len(_iter_options_position_dicts(ac)))
perp_n = len(_filter_open_positions(ac.get("positions") or []))
mc = _monitor_counts(ac)
mon_parts = []
if mc["trends"]:
@@ -1213,8 +1408,11 @@ def format_chat_position_overview(payload: dict) -> str:
lines.append(f"- {ac.get('name')}:空仓{mon_txt}")
else:
lines.append(
f"- {ac.get('name')}:{n} 浮盈亏{ac.get('float_pnl_u')}U{mon_txt}"
f"- {ac.get('name')}:{n}(永续{perp_n}/期权{opt_n}) "
f"浮盈亏{ac.get('float_pnl_u')}U{mon_txt}"
)
for p in _iter_options_position_dicts(ac)[:4]:
lines.append(f" · {_format_options_position_detail_line(p).lstrip(' - ')}")
return "\n".join(lines)
@@ -1222,11 +1420,19 @@ def format_chat_context_slim(payload: dict) -> str:
"""聊天专用:不含 180 日资金曲线与昨日平仓明细,避免挤占对话上下文."""
totals = payload.get("totals") or {}
day = totals.get("trading_day")
opt_total = int(totals.get("options_open_position_count") or 0)
perp_total = int(
totals.get("perpetual_open_position_count")
if totals.get("perpetual_open_position_count") is not None
else max(0, int(totals.get("open_position_count") or 0) - opt_total)
)
lines = [
f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
f"实盘持仓 {totals.get('open_position_count', 0)} | 浮盈亏 {totals.get('float_pnl_u')}U",
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓.持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致).",
f"实盘持仓 {totals.get('open_position_count', 0)}"
f"(永续{perp_total}/期权{opt_total}) | 浮盈亏 {totals.get('float_pnl_u')}U",
"【说明】持仓=交易所实盘(含期权);趋势/关键位/监控单=本地计划,不等于已开仓."
"永续行「止损/止盈」=条件单或监控计划价;期权行含合约/来源/权利金/净盈亏/目标位.",
]
for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控":
@@ -1234,7 +1440,12 @@ def format_chat_context_slim(payload: dict) -> str:
continue
st = ac.get("trade_stats") or {}
open_n = int(ac.get("open_position_count") or _account_open_position_count(ac))
pos_txt = "空仓" if open_n <= 0 else f"{open_n}仓 浮盈亏{ac.get('float_pnl_u')}U"
opt_n = int(ac.get("options_open_position_count") or len(_iter_options_position_dicts(ac)))
perp_n = len(_filter_open_positions(ac.get("positions") or []))
if open_n <= 0:
pos_txt = "空仓"
else:
pos_txt = f"{open_n}仓(永续{perp_n}/期权{opt_n}) 浮盈亏{ac.get('float_pnl_u')}U"
mc = _monitor_counts(ac)
mon = []
if mc["trends"]:
@@ -1263,6 +1474,7 @@ def format_chat_context_slim(payload: dict) -> str:
if not isinstance(p, dict):
continue
lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}")
_append_options_position_lines(lines, ac, limit=6, indent=" · ")
return "\n".join(lines)
@@ -0,0 +1,29 @@
"""交易教练用的执行手册短摘要(来源 docs/交易执行手册-期权与Gate.md)."""
from __future__ import annotations
from pathlib import Path
from lib.paths import REPO_ROOT
# 控制 token:保持简短;手册大改时同步修订本摘要.
_PLAYBOOK_BRIEF = """【用户策略执行手册·摘要】(来源:docs/交易执行手册-期权与Gate.md + docs/交易行为准则-开单三检.md)
开单防火墙(强制):信号判断(核心点位是否清晰)流程确认(资金/单笔敞口超限则暂停)情绪自检(符合系统才做;怕踏空/回本/证明自己放弃)三检不过不开成败先看三检是否跑完,不看这笔盈亏
一句话:横盘对冲(可偏置实值);突破用一天期权;假破确认后小仓合约加强;先过方向/空间/值不值得;期权不手平;一位置两次,错完收工;单笔小亏组合回撤可控.
分工:OKX 期权=主业;Gate 合约=辅业;其它账户暂不做.同一时段尽量只让一边说话.
入场三类:横盘较久期期对冲(一天 Call+Put,总权利金约10U),期间一般不开 Gate;方向/空间/值不值得过关且结构突破一天期权方向单,默认不上合约;已有突破期权后出现反向假破确认Gate 小仓加强(加重暴露,按一笔故事控风险).
仓位(总资约800U):单笔期权约10U且一次一仓;期期对冲合计约10U;Gate 保证金约50U×约10x,止损约5U,单笔最亏约10U;日最坏约20U.
期权纪律:不手动平仓,只认规则止盈或到期(紧急手平非策略样本);默认一天期,尽量北京时间16:00后开次日到期.
Gate 纪律:只做很明确位置;同一位置最多两次机会(结构突破/假突破);两次都错当日收工.
教练用法:对照上述纪律讨论执行与心态;开单前优先提醒三检;勿另造策略或鼓励期权手平/超仓."""
def playbook_md_path() -> Path:
return REPO_ROOT / "docs" / "交易执行手册-期权与Gate.md"
def format_playbook_brief_for_chat(max_chars: int = 1200) -> str:
"""返回注入交易教练上下文的短摘要."""
text = _PLAYBOOK_BRIEF.strip()
if len(text) <= max_chars:
return text
return text[: max(200, max_chars - 1)].rstrip() + ""
+11 -1
View File
@@ -44,10 +44,12 @@ CHAT_SYSTEM = """
- 不要第1点第2点你应该;不要作为你的教练我必须.
- 不预测涨跌,不保证收益,不替用户做决定.
- 只能依据提供的监控与交易数据说话;看不到的就说我这边看不到,你可以去 xx 实例页确认.
- **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动.
- **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘 / 期权持仓才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动.
- **期权持仓**:快照中期权 行与永续同样是实盘;须分开提及.期权净盈亏/目标位以快照为准.
- **监控单 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓.
- 用户口述与快照冲突时,以快照为准并口语说明我这边看到是空仓/有N仓.
- 若附带今日总结摘要,那是较早生成的缓存,**实盘持仓以当前多账户快照里的实盘持仓总览为准**,摘要里若提到持仓可能已过时.
- 若附带用户策略执行手册·摘要,须按该纪律理解账户分工与离场规则(如期权通常不手平Gate 一位置两次等);勿另造策略或鼓励违反摘要纪律.
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清.
- **优先接住用户现在说对话核心摘要**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字.
- **接续对话**:对话核心摘要时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行.
@@ -143,12 +145,20 @@ def build_chat_user_prompt(
history_lines: str = "",
user_message: str,
attachment_note: str = "",
playbook_brief: str = "",
) -> str:
parts = [f"【交易日】{trading_day}"]
if rolling_summary.strip():
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()])
if playbook_brief.strip():
parts.extend(
[
"【用户策略执行手册·摘要(须知悉分工与纪律)】",
playbook_brief.strip(),
]
)
parts.extend([
"【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】",
context_text.strip() or "(无监控数据)",
+2
View File
@@ -29,7 +29,9 @@ DEFAULT_DISPLAY = {
"show_nav_quotes": True,
"show_nav_ai": True,
"show_nav_calculator": True,
"show_nav_compare": True,
"show_nav_strategy": True,
"show_nav_amp_stats": True,
"show_nav_help": True,
"show_nav_logs": True,
}
+451
View File
@@ -0,0 +1,451 @@
/**
* 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选.
*/
(function () {
const page = document.getElementById("page-amp-stats");
if (!page) return;
let lastResult = null;
let pageNo = 1;
let bound = false;
let reframeTimer = null;
const el = (id) => document.getElementById(id);
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && (data.detail || data.msg)) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pct(ratio) {
if (ratio == null || ratio === "") return "—";
const n = Number(ratio);
if (!Number.isFinite(n)) return "—";
return (n * 100).toFixed(1) + "%";
}
function readPremium() {
const raw = (el("amp-straddle-premium")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function readTakeProfit() {
const raw = (el("amp-take-profit")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function readWeekend() {
return el("amp-weekend-filter")?.value || "all";
}
function setStatus(msg) {
const s = el("amp-status");
if (s) s.textContent = msg || "";
}
function setView(view) {
const isHist = view === "history";
el("amp-panel-stats")?.classList.toggle("hidden", isHist);
el("amp-panel-history")?.classList.toggle("hidden", !isHist);
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
const on = btn.getAttribute("data-view") === view;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
if (isHist) void loadHistory();
}
function syncCustomDays() {
const period = el("amp-period")?.value || "2m";
const wrap = el("amp-custom-wrap");
if (wrap) wrap.classList.toggle("hidden", period !== "custom");
}
function fillMetaControls() {
const hourSel = el("amp-start-hour");
if (hourSel && !hourSel.options.length) {
for (let h = 0; h < 24; h++) {
const opt = document.createElement("option");
opt.value = String(h);
opt.textContent = String(h).padStart(2, "0") + ":00";
if (h === 16) opt.selected = true;
hourSel.appendChild(opt);
}
}
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "is-pos" : "is-neg";
}
function renderSummary(summary, result) {
const box = el("amp-summary");
if (!box) return;
const s = summary || {};
if (!s.sample_count) {
box.innerHTML = '<p class="amp-empty">暂无汇总</p>';
renderStraddle(null);
return;
}
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">样本</span><span class="amp-sum-v">${esc(s.sample_count)}</span></div>` +
`<div><span class="amp-sum-k">最大振幅</span><span class="amp-sum-v">${esc(s.max_amplitude)} <small>(${esc(s.max_amplitude_day)})</small></span></div>` +
`<div><span class="amp-sum-k">振幅均值</span><span class="amp-sum-v">${esc(s.avg_amplitude)}</span></div>` +
`<div><span class="amp-sum-k">振幅中位</span><span class="amp-sum-v">${esc(s.median_amplitude)}</span></div>` +
`<div><span class="amp-sum-k">开→高最大/均</span><span class="amp-sum-v">${esc(s.max_up_points)} / ${esc(s.avg_up_points)}</span></div>` +
`<div><span class="amp-sum-k">开→低最大/均</span><span class="amp-sum-v">${esc(s.max_down_points)} / ${esc(s.avg_down_points)}</span></div>` +
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
`</div>`;
renderStraddle(s.straddle);
}
function renderStraddle(st) {
const box = el("amp-straddle");
if (!box) return;
if (!st) {
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>';
return;
}
const verdict =
st.pnl_total == null
? "—"
: Number(st.pnl_total) > 0
? "样本合计盈利"
: Number(st.pnl_total) < 0
? "样本合计亏损"
: "样本合计持平";
const tpLine =
st.take_profit != null
? `<div><span class="amp-sum-k">止盈点 / 触达</span><span class="amp-sum-v">${esc(st.take_profit)} · ${esc(st.tp_hit_days)} 天 · ${esc(pct(st.tp_hit_ratio))}</span></div>`
: `<div><span class="amp-sum-k">止盈点</span><span class="amp-sum-v">未设(按|涨跌|)</span></div>`;
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">双边权利金</span><span class="amp-sum-v">${esc(st.premium)}</span></div>` +
tpLine +
`<div><span class="amp-sum-k">开→高超过权利金</span><span class="amp-sum-v">${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}</span></div>` +
`<div><span class="amp-sum-k">开→低超过权利金</span><span class="amp-sum-v">${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}</span></div>` +
`<div><span class="amp-sum-k">|涨跌|超过权利金</span><span class="amp-sum-v">${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}</span></div>` +
`<div><span class="amp-sum-k">买跨盈亏合计</span><span class="amp-sum-v ${pnlClass(st.pnl_total)}">${esc(st.pnl_total)} <small>(${esc(verdict)})</small></span></div>` +
`<div><span class="amp-sum-k">日均盈亏</span><span class="amp-sum-v ${pnlClass(st.pnl_avg)}">${esc(st.pnl_avg)}</span></div>` +
`<div><span class="amp-sum-k">赚钱天数/胜率</span><span class="amp-sum-v">${esc(st.win_days)} · ${esc(pct(st.win_ratio))}</span></div>` +
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(st.pnl_max)} / ${esc(st.pnl_min)}</span></div>` +
`</div>`;
}
function dayLabel(r) {
const day = esc(r.settlement_day);
if (r.is_weekend && r.weekday_label) {
return `${day}<span class="amp-wd-tag">${esc(r.weekday_label)}</span>`;
}
return day;
}
function renderTable(pagePayload) {
const body = el("amp-table-body");
const pager = el("amp-pager");
if (!body) return;
const rows = (pagePayload && pagePayload.rows) || [];
if (!rows.length) {
body.innerHTML = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
} else {
body.innerHTML = rows
.map((r) => {
const profit =
r.profit == null || r.profit === ""
? "—"
: `<span class="amp-pnl ${pnlClass(r.profit)}">${esc(r.profit)}</span>`;
const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
return (
`<tr${trClass}>` +
`<td>${dayLabel(r)}</td>` +
`<td>${esc(r.window_start)}</td>` +
`<td>${esc(r.open)}</td>` +
`<td>${esc(r.high)}</td>` +
`<td>${esc(r.low)}</td>` +
`<td>${esc(r.close)}</td>` +
`<td>${esc(r.up_points)}</td>` +
`<td>${esc(r.down_points)}</td>` +
`<td><strong>${esc(r.amplitude)}</strong></td>` +
`<td>${esc(r.change)}</td>` +
`<td>${profit}</td>` +
`</tr>`
);
})
.join("");
}
if (pager && pagePayload) {
pager.innerHTML =
`<button type="button" class="ghost" id="amp-page-prev" ${pagePayload.page <= 1 ? "disabled" : ""}>上一页</button>` +
`<span class="amp-pager-meta">第 ${esc(pagePayload.page)} / ${esc(pagePayload.total_pages)} 页 · 共 ${esc(pagePayload.total)} 天</span>` +
`<button type="button" class="ghost" id="amp-page-next" ${pagePayload.page >= pagePayload.total_pages ? "disabled" : ""}>下一页</button>`;
el("amp-page-prev")?.addEventListener("click", () => {
if (pageNo > 1) {
pageNo -= 1;
void reframe(false);
}
});
el("amp-page-next")?.addEventListener("click", () => {
if (pagePayload.page < pagePayload.total_pages) {
pageNo += 1;
void reframe(false);
}
});
}
}
function rowsAllFromLast() {
if (!lastResult) return [];
if (Array.isArray(lastResult.rows_all) && lastResult.rows_all.length) return lastResult.rows_all;
return lastResult.rows || [];
}
async function reframe(resetPage) {
if (!lastResult) {
renderStraddle(null);
return;
}
if (resetPage) pageNo = 1;
const rowsAll = rowsAllFromLast();
if (!rowsAll.length) return;
try {
const data = await apiFetch("/api/amp-stats/reframe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rows_all: rowsAll,
symbol: lastResult.symbol || el("amp-symbol")?.value || "eth",
start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16),
period: lastResult.period || el("amp-period")?.value || "2m",
sample_days: lastResult.sample_days_requested || 60,
straddle_premium: readPremium(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(),
price_source: lastResult.price_source || "",
inst_id: lastResult.inst_id || "",
page: pageNo,
page_size: 20,
}),
});
const prevAll = rowsAll;
lastResult = data.result || lastResult;
if (!lastResult.rows_all || !lastResult.rows_all.length) lastResult.rows_all = prevAll;
renderSummary(lastResult.summary, lastResult);
renderTable(data.page);
setStatus(`完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`);
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function scheduleReframe() {
if (!lastResult) return;
if (reframeTimer) clearTimeout(reframeTimer);
reframeTimer = setTimeout(() => void reframe(true), 280);
}
async function compute(resetPage) {
if (resetPage) pageNo = 1;
const symbol = el("amp-symbol")?.value || "eth";
const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60);
setStatus("计算中…(长周期会分页拉 OKX,遇限频会自动重试,请稍候)");
try {
const data = await apiFetch("/api/amp-stats/compute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol,
start_hour: startHour,
period,
custom_days: period === "custom" ? customDays : null,
straddle_premium: readPremium(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(),
page: pageNo,
page_size: 20,
}),
});
lastResult = data.result || null;
renderSummary(lastResult && lastResult.summary, lastResult);
renderTable(data.page);
const miss = (lastResult && lastResult.missing_count) || 0;
setStatus(
miss
? `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0} · 缺 ${miss}`
: `完成 · 样本 ${(lastResult.summary || {}).sample_count || 0}`
);
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
async function saveHistory() {
if (!lastResult) {
setStatus("请先计算");
return;
}
try {
await apiFetch("/api/amp-stats/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result: lastResult }),
});
setStatus("已保存到历史");
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function downloadCurrent() {
if (!lastResult) {
setStatus("请先计算");
return;
}
const symbol = el("amp-symbol")?.value || "eth";
const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60);
const prem = readPremium();
const tp = readTakeProfit();
const q = new URLSearchParams({
symbol,
start_hour: String(startHour),
period,
weekend_filter: readWeekend(),
});
if (period === "custom") q.set("custom_days", String(customDays));
if (prem != null) q.set("straddle_premium", String(prem));
if (tp != null) q.set("take_profit", String(tp));
window.location.href = "/api/amp-stats/export?" + q.toString();
}
async function loadHistory() {
const box = el("amp-history-list");
if (!box) return;
box.innerHTML = '<p class="amp-empty">加载中…</p>';
try {
const data = await apiFetch("/api/amp-stats/history?limit=50");
const items = data.items || [];
if (!items.length) {
box.innerHTML = '<p class="amp-empty">暂无历史</p>';
return;
}
box.innerHTML = items
.map(
(it) =>
`<div class="amp-hist-card" data-id="${esc(it.id)}">` +
`<div class="amp-hist-main">` +
`<strong>${esc(it.symbol_label || it.symbol)}</strong> · ${esc(String(it.start_hour).padStart(2, "0"))}:00→16:00 · ${esc(it.period)}` +
`<div class="amp-hist-sub">${esc(it.created_at)} · 样本 ${esc(it.sample_count)} · 最大振幅 ${esc(it.max_amplitude)} (${esc(it.max_amplitude_day)})</div>` +
`</div>` +
`<div class="amp-hist-actions">` +
`<button type="button" class="ghost amp-hist-view">查看</button>` +
`<button type="button" class="ghost amp-hist-dl">下载</button>` +
`<button type="button" class="danger amp-hist-del">删除</button>` +
`</div></div>`
)
.join("");
box.querySelectorAll(".amp-hist-card").forEach((card) => {
const id = card.getAttribute("data-id");
card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id));
card.querySelector(".amp-hist-dl")?.addEventListener("click", () => {
const prem = readPremium();
const tp = readTakeProfit();
let url =
"/api/amp-stats/export?history_id=" +
encodeURIComponent(id) +
"&weekend_filter=" +
encodeURIComponent(readWeekend());
if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem));
if (tp != null) url += "&take_profit=" + encodeURIComponent(String(tp));
window.location.href = url;
});
card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
if (!confirm("删除该历史记录?")) return;
await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id), { method: "DELETE" });
void loadHistory();
});
});
} catch (e) {
box.innerHTML = `<p class="amp-empty">${esc(String(e && e.message ? e.message : e))}</p>`;
}
}
async function openHistory(id) {
try {
const data = await apiFetch("/api/amp-stats/history/" + encodeURIComponent(id));
lastResult = data.item || null;
setView("stats");
if (lastResult) {
if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth";
if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16);
if (lastResult.straddle_premium != null && el("amp-straddle-premium")) {
el("amp-straddle-premium").value = String(lastResult.straddle_premium);
}
if (lastResult.take_profit != null && el("amp-take-profit")) {
el("amp-take-profit").value = String(lastResult.take_profit);
}
if (lastResult.weekend_filter && el("amp-weekend-filter")) {
el("amp-weekend-filter").value = lastResult.weekend_filter;
}
pageNo = 1;
setStatus("已载入历史 " + id);
await reframe(true);
}
} catch (e) {
setStatus(String(e && e.message ? e.message : e));
}
}
function bind() {
if (bound) return;
bound = true;
fillMetaControls();
page.querySelectorAll(".amp-view-tab").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.getAttribute("data-view")));
});
el("amp-period")?.addEventListener("change", syncCustomDays);
el("amp-btn-compute")?.addEventListener("click", () => void compute(true));
el("amp-btn-save")?.addEventListener("click", () => void saveHistory());
el("amp-btn-download")?.addEventListener("click", downloadCurrent);
el("amp-straddle-premium")?.addEventListener("input", scheduleReframe);
el("amp-take-profit")?.addEventListener("input", scheduleReframe);
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
syncCustomDays();
}
window.hubAmpStatsPage = {
init() {
bind();
setView("stats");
setStatus("");
renderStraddle(null);
},
};
})();
+560 -21
View File
@@ -392,6 +392,11 @@ button.ghost:hover:not(:disabled) {
display: none;
}
/* 通用隐藏:策略正文/执行清单等 Tab 面板依赖此类互斥显示 */
.hidden {
display: none !important;
}
.page-head {
margin: 24px 0 16px;
}
@@ -2352,6 +2357,12 @@ html[data-theme="light"] .hub-pos-card .pos-tp-profit {
margin-bottom: 12px;
}
.stat-row-options .stat-label {
color: var(--accent);
text-transform: none;
letter-spacing: 0;
}
.stat-box {
background: var(--inset-surface);
border: 1px solid var(--border-soft);
@@ -4296,6 +4307,70 @@ body.login-page {
gap: 6px;
}
/* 手机:隐藏「操作·刷新/紧急全平」,桌面不变 */
body.hub-phone #monitor-ops-fold {
display: none !important;
}
/* 手机收起态:今日统计固定两行(左标题/交易日,右总浮盈亏) */
body.hub-phone .monitor-stats-card.is-collapsed {
padding: 0;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
column-gap: 10px;
row-gap: 2px;
align-items: center;
padding: 10px 12px;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-head-main {
display: contents;
}
body.hub-phone .monitor-stats-card.is-collapsed .card-title-row {
grid-column: 1;
grid-row: 1;
flex-wrap: nowrap;
min-width: 0;
}
body.hub-phone .monitor-stats-card.is-collapsed .card-title {
font-size: 13px;
margin: 0;
}
body.hub-phone .monitor-stats-card.is-collapsed .card-sub {
grid-column: 1;
grid-row: 2;
margin: 0;
font-size: 10px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-float-summary {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
padding: 0;
text-align: right;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-float-summary .monitor-stat-label {
margin-bottom: 0;
font-size: 10px;
}
body.hub-phone .monitor-stats-card.is-collapsed .monitor-stats-float-value {
font-size: 16px;
}
body.hub-phone .monitor-stat-cell {
padding: 8px 6px;
}
@@ -4633,7 +4708,16 @@ body.login-page {
display: none;
}
/* 行情全屏:竖屏提示转横;横屏吃满 */
body.hub-phone #page-calculator .calc-result-title,
body.hub-phone #page-calculator .calc-result-placeholder {
display: none !important;
}
body.hub-phone #page-calculator .calc-result {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border-soft);
}
@media (orientation: portrait) {
body.hub-phone.market-chart-fs-open .market-chart-wrap.is-fullscreen::before {
content: "全屏看图请横持手机";
@@ -9564,32 +9648,56 @@ body.funds-fullscreen-open {
}
}
/* 电脑端计算器改为单页 Tab;手机继续使用原有紧凑 Tab 样式 */
/* 电脑端计算器:上方 Tab + 左输入 / 右结果双卡;手机端保持原布局 */
.calc-tab-label-desktop {
display: none;
}
.calc-pane-split {
display: block;
}
.calc-result-title {
display: none;
margin: 0 0 10px;
font-size: 1rem;
color: var(--text);
}
.calc-result-placeholder {
display: none;
margin: 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
.calc-result-panel:has(.calc-result.hidden) .calc-result-placeholder {
display: block;
}
body:not(.hub-phone) #page-calculator .calc-workspace {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
display: flex;
flex-direction: column;
gap: 14px;
align-items: start;
align-items: stretch;
}
body:not(.hub-phone) #page-calculator .calc-mobile-tabs {
display: grid;
grid-template-columns: minmax(0, 1fr);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
max-width: none;
margin: 0;
padding: 5px;
padding: 6px;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: var(--nav-bg);
}
body:not(.hub-phone) #page-calculator .calc-m-tab {
min-height: 42px;
min-height: 40px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
@@ -9598,8 +9706,8 @@ body:not(.hub-phone) #page-calculator .calc-m-tab {
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
padding: 9px 12px;
text-align: center;
padding: 8px 16px;
}
body:not(.hub-phone) #page-calculator .calc-m-tab:hover {
@@ -9632,9 +9740,61 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
display: none;
}
body:not(.hub-phone) #page-calculator .calc-card {
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
height: auto;
}
body:not(.hub-phone) #page-calculator .calc-pane-split {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 14px;
align-items: stretch;
}
body:not(.hub-phone) #page-calculator .calc-input-panel,
body:not(.hub-phone) #page-calculator .calc-result-panel {
padding: 16px 18px;
border-radius: 12px;
background: var(--panel, var(--dash-card-bg, #121820));
border: 1px solid var(--border-soft, var(--border));
box-shadow: var(--card-glow, none);
min-height: 280px;
height: 100%;
box-sizing: border-box;
}
body:not(.hub-phone) #page-calculator .calc-result-panel {
display: flex;
flex-direction: column;
}
body:not(.hub-phone) #page-calculator .calc-result-title {
display: block;
}
body:not(.hub-phone) #page-calculator .calc-result {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
body:not(.hub-phone) #page-calculator .calc-result.hidden {
display: none !important;
}
@media (min-width: 1200px) {
body:not(.hub-phone) #page-calculator .calc-form-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
body:not(.hub-phone) #page-calculator .calc-pane-split {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -9659,6 +9819,26 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
flex-wrap: wrap;
margin-bottom: 12px;
}
.strategy-view-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.strategy-view-tab {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-soft);
cursor: pointer;
font-size: 0.88rem;
}
.strategy-view-tab.is-active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--text);
font-weight: 600;
}
.strategy-tabs {
display: flex;
gap: 8px;
@@ -9678,6 +9858,127 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
border-color: var(--accent);
color: var(--text);
}
.strategy-doc-panel {
padding: 18px 22px 20px;
min-width: 0;
display: flex;
flex-direction: column;
height: calc(100dvh - 168px);
min-height: 420px;
max-height: calc(100dvh - 140px);
}
.strategy-blog-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
gap: 16px;
align-items: stretch;
flex: 1 1 auto;
min-height: 0;
}
.strategy-blog-main {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.strategy-toc-card {
padding: 14px 12px 16px;
position: sticky;
top: 0;
align-self: stretch;
height: 100%;
max-height: none;
overflow: auto;
display: flex;
flex-direction: column;
}
.strategy-toc-title {
margin: 0 0 12px;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-soft);
text-transform: uppercase;
}
.strategy-toc-nav {
display: flex;
flex-direction: column;
gap: 4px;
border-left: 2px solid var(--border-soft);
padding-left: 2px;
flex: 1 1 auto;
min-height: 0;
}
.strategy-toc-item {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: start;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
border-radius: 0 8px 8px 0;
background: transparent;
color: var(--text-soft);
font-size: 0.84rem;
line-height: 1.35;
cursor: pointer;
text-decoration: none;
border-left: 2px solid transparent;
margin-left: -2px;
}
.strategy-toc-item.level-3 {
padding-left: 18px;
font-size: 0.8rem;
}
.strategy-toc-item:hover {
background: var(--surface-2, rgba(255, 255, 255, 0.04));
color: var(--text);
}
.strategy-toc-item.is-active {
background: var(--accent-soft, rgba(0, 212, 255, 0.12));
color: var(--accent);
font-weight: 600;
border-left-color: var(--accent);
}
.strategy-toc-num {
flex: 0 0 auto;
min-width: 1.7em;
height: 1.7em;
padding: 0 5px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
background: rgba(0, 212, 255, 0.12);
border: 1px solid rgba(0, 212, 255, 0.35);
}
.strategy-toc-tag {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.68rem;
line-height: 1.4;
color: var(--muted);
background: var(--inset-surface, rgba(0, 0, 0, 0.28));
border: 1px solid var(--border-soft);
white-space: nowrap;
}
.strategy-toc-item.is-active .strategy-toc-tag {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.35);
}
.strategy-toc-text {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
}
.strategy-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
@@ -9717,26 +10018,69 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
.strategy-doc-body {
flex: 1 1 auto;
min-height: 0;
max-height: 100%;
max-height: none;
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
overflow-y: auto;
overflow-x: hidden;
padding: 2px 10px 2px 4px;
padding: 8px 22px 20px 18px;
scrollbar-gutter: stable;
}
.strategy-doc-body h2 {
font-size: 1rem;
margin: 0.6em 0 0.5em;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 1.05rem;
margin: 1.1em 0 0.55em;
color: var(--text);
scroll-margin-top: 12px;
}
.strategy-doc-body h2:first-child {
margin-top: 0;
}
.strategy-sec-mark {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 2px;
}
.strategy-sec-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.7em;
height: 1.7em;
padding: 0 6px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #041018;
background: var(--accent);
}
.strategy-sec-tag {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--accent);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.35);
}
.strategy-checklist-card {
height: calc(100dvh - 168px);
min-height: 420px;
max-height: calc(100dvh - 140px);
}
.strategy-doc-body h3 {
font-size: 0.92rem;
font-size: 0.94rem;
margin: 1em 0 0.4em;
scroll-margin-top: 12px;
}
.strategy-doc-body table {
width: 100%;
@@ -9769,8 +10113,9 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
color: var(--muted);
}
.strategy-checklist-body {
flex: 0 0 auto;
overflow: visible;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 2px 10px 2px 4px;
}
.strategy-checklist-body ul {
@@ -9818,12 +10163,28 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
.strategy-layout {
grid-template-columns: 1fr;
}
.strategy-doc-panel,
.strategy-checklist-card {
height: auto;
max-height: none;
min-height: 0;
}
.strategy-blog-layout {
grid-template-columns: 1fr;
}
.strategy-toc-card {
position: static;
height: auto;
max-height: min(36vh, 280px);
order: -1;
}
.strategy-doc-card,
.strategy-checklist-card {
align-self: stretch;
}
.strategy-doc-body {
max-height: min(50vh, 520px);
.strategy-doc-body,
.strategy-checklist-body {
max-height: min(58vh, 560px);
}
}
@@ -10584,3 +10945,181 @@ html[data-theme="light"] .hub-logs-card-hint {
min-height: 360px;
}
}
/* —— 振幅统计 —— */
.amp-view-tabs { display: flex; gap: 8px; margin: 0 0 12px; }
.amp-view-tab {
min-height: 34px; padding: 6px 14px; border: 1px solid var(--border-soft);
border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer;
}
.amp-view-tab.is-active { color: var(--text); border-color: var(--accent); background: rgba(0, 212, 255, 0.08); }
.amp-panel { padding: 14px 16px 18px; }
.amp-form {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px 12px; align-items: end; margin-bottom: 8px;
}
.amp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.amp-field select, .amp-field input {
min-height: 34px; padding: 6px 8px; border-radius: 8px;
border: 1px solid var(--border-soft); background: var(--panel-solid); color: var(--text);
}
.amp-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.amp-hint { font-size: 12px; color: var(--muted); margin: 4px 0 12px; }
.amp-status { margin: 0 0 8px; }
.amp-block-title { font-size: 14px; margin: 14px 0 8px; }
.amp-sum-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px;
}
.amp-sum-grid > div {
border: 1px solid var(--border-soft); border-radius: 8px; padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.amp-sum-k { font-size: 11px; color: var(--muted); }
.amp-sum-v { font-size: 14px; font-weight: 600; color: var(--text); }
.amp-sum-v.is-pos { color: var(--green); }
.amp-sum-v.is-neg { color: var(--red); }
.amp-pnl.is-pos { color: var(--green); font-weight: 600; }
.amp-pnl.is-neg { color: var(--red); font-weight: 600; }
.amp-straddle { margin-bottom: 4px; }
.amp-table tr.amp-row-weekend td { background: rgba(255, 180, 60, 0.08); }
.amp-wd-tag {
display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 4px;
font-size: 11px; font-weight: 600; color: #f0c14b;
border: 1px solid rgba(240, 193, 75, 0.45);
}
.amp-table-wrap { overflow-x: auto; }
.amp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.amp-table th, .amp-table td {
border-bottom: 1px solid var(--border-soft); padding: 7px 8px; text-align: right; white-space: nowrap;
}
.amp-table th:first-child, .amp-table td:first-child,
.amp-table th:nth-child(2), .amp-table td:nth-child(2) { text-align: left; }
.amp-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.amp-pager-meta { font-size: 12px; color: var(--muted); }
.amp-empty { color: var(--muted); text-align: center; padding: 16px; }
.amp-history-list { display: flex; flex-direction: column; gap: 10px; }
.amp-hist-card {
display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px;
}
.amp-hist-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
.amp-hist-actions { display: flex; gap: 6px; align-items: center; }
@media (max-width: 720px) {
.amp-form { grid-template-columns: 1fr 1fr; }
.amp-actions { grid-column: 1 / -1; }
}
/* --- strategy compare --- */
#page-compare .toolbar {
padding: 12px 16px;
margin-bottom: 14px;
}
.cmp-form { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
.cmp-form .card,
.cmp-common-card,
.cmp-sum-card,
.cmp-rec-card {
padding: 18px 20px;
}
.cmp-common-card h2,
.cmp-form .card h2 {
margin: 0 0 14px;
font-size: 15px;
}
.cmp-subhead {
margin: 16px 0 10px;
font-size: 13px;
color: var(--muted);
font-weight: 600;
}
.cmp-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 16px;
}
.cmp-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.cmp-field input,
.cmp-field select {
background: var(--inset-surface);
border: 1px solid var(--border-soft);
border-radius: 8px;
color: var(--text);
padding: 9px 12px;
font-size: 13px;
}
.cmp-input-cols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.cmp-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.cmp-sum-card h3 { margin: 0 0 12px; font-size: 14px; }
.cmp-sum-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
margin: 6px 0;
color: var(--muted);
}
.cmp-sum-row strong { color: var(--text); font-weight: 600; }
.cmp-muted { color: var(--muted); font-size: 12px; margin: 0; }
.cmp-table-wrap { margin-bottom: 16px; }
.cmp-table-scroll { overflow-x: auto; }
.cmp-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.cmp-table th,
.cmp-table td {
border-bottom: 1px solid var(--border-soft);
padding: 14px 16px;
vertical-align: top;
text-align: left;
}
.cmp-table th:first-child,
.cmp-table td:first-child { width: 22%; }
.cmp-table tr:last-child td { border-bottom: none; }
.cmp-cell-note {
margin-top: 6px;
font-size: 11px;
color: var(--muted);
line-height: 1.4;
}
.cmp-pnl-pos { color: var(--green); font-weight: 600; }
.cmp-pnl-neg { color: var(--red); font-weight: 600; }
.cmp-rec-head { font-size: 16px; margin-bottom: 8px; }
.cmp-rec-reason { margin: 0 0 10px; color: var(--muted); font-size: 13px; }
.cmp-rec-list { margin: 0; padding-left: 20px; font-size: 13px; line-height: 1.55; }
.cmp-warn { margin-top: 12px; font-size: 12px; color: var(--warn, #e6a23c); }
.cmp-foot-note { margin: 12px 0 0; font-size: 11px; color: var(--muted); }
@media (max-width: 900px) {
.cmp-form .card,
.cmp-common-card,
.cmp-sum-card,
.cmp-rec-card {
padding: 16px;
}
.cmp-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cmp-input-cols,
.cmp-summary { grid-template-columns: 1fr; }
.cmp-table th,
.cmp-table td { padding: 12px 14px; }
}
+127 -39
View File
@@ -13,6 +13,8 @@
return displayPref("show_account_pnl", true);
}
window.hubShowAccountPnlPref = showAccountPnlPref;
function showNavFundsPref() {
return displayPref("show_nav_funds", true);
}
@@ -41,10 +43,18 @@
return displayPref("show_nav_calculator", true);
}
function showNavComparePref() {
return displayPref("show_nav_compare", true);
}
function showNavStrategyPref() {
return displayPref("show_nav_strategy", true);
}
function showNavAmpStatsPref() {
return displayPref("show_nav_amp_stats", true);
}
function showNavHelpPref() {
return displayPref("show_nav_help", true);
}
@@ -63,7 +73,9 @@
["nav-quotes", "m-nav-quotes", d.show_nav_quotes === false],
["nav-ai", "m-tab-ai", d.show_nav_ai === false],
["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false],
["nav-compare", "m-nav-compare", d.show_nav_compare === false],
["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false],
["nav-amp-stats", "m-nav-amp-stats", d.show_nav_amp_stats === false],
["nav-help", "m-nav-help", d.show_nav_help === false],
["nav-logs", "m-nav-logs", d.show_nav_logs === false],
];
@@ -135,7 +147,9 @@
if (page === "quotes") return showNavQuotesPref();
if (page === "ai") return showNavAiPref();
if (page === "calculator") return showNavCalculatorPref();
if (page === "compare") return showNavComparePref();
if (page === "strategy") return showNavStrategyPref();
if (page === "amp-stats") return showNavAmpStatsPref();
if (page === "help") return showNavHelpPref();
if (page === "logs") return showNavLogsPref();
return true;
@@ -151,7 +165,9 @@
const quotesCb = document.getElementById("pref-show-nav-quotes");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const compareCb = document.getElementById("pref-show-nav-compare");
const strategyCb = document.getElementById("pref-show-nav-strategy");
const ampCb = document.getElementById("pref-show-nav-amp-stats");
const helpCb = document.getElementById("pref-show-nav-help");
const logsCb = document.getElementById("pref-show-nav-logs");
if (pnlCb) pnlCb.checked = d.show_account_pnl !== false;
@@ -162,7 +178,9 @@
if (quotesCb) quotesCb.checked = d.show_nav_quotes !== false;
if (aiCb) aiCb.checked = d.show_nav_ai !== false;
if (calcCb) calcCb.checked = d.show_nav_calculator !== false;
if (compareCb) compareCb.checked = d.show_nav_compare !== false;
if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false;
if (ampCb) ampCb.checked = d.show_nav_amp_stats !== false;
if (helpCb) helpCb.checked = d.show_nav_help !== false;
if (logsCb) logsCb.checked = d.show_nav_logs !== false;
syncNavVisibility(data);
@@ -1277,7 +1295,9 @@
if (p.includes("funds")) return "funds";
if (p.includes("plan")) return "plan";
if (p.includes("calculator")) return "calculator";
if (p.includes("compare")) return "compare";
if (p.includes("help")) return "help";
if (p.includes("amp-stats")) return "amp-stats";
if (p.includes("strategy")) return "strategy";
if (p.includes("logs")) return "logs";
if (p.includes("market")) return "market";
@@ -1293,8 +1313,10 @@
if (page === "funds") return "page-funds";
if (page === "plan") return "page-plan";
if (page === "calculator") return "page-calculator";
if (page === "compare") return "page-compare";
if (page === "help") return "page-help";
if (page === "strategy") return "page-strategy";
if (page === "amp-stats") return "page-amp-stats";
if (page === "logs") return "page-logs";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
@@ -1324,11 +1346,13 @@
document.body.classList.toggle("hub-page-monitor", page === "monitor");
document.body.classList.toggle("hub-page-market", page === "market");
document.body.classList.toggle("hub-page-calculator", page === "calculator");
document.body.classList.toggle("hub-page-compare", page === "compare");
document.body.classList.toggle("hub-page-settings", page === "settings");
document.body.classList.toggle("hub-page-archive", page === "archive");
document.body.classList.toggle("hub-page-quotes", page === "quotes");
document.body.classList.toggle("hub-page-plan", page === "plan");
document.body.classList.toggle("hub-page-strategy", page === "strategy");
document.body.classList.toggle("hub-page-amp-stats", page === "amp-stats");
document.body.classList.toggle("hub-page-logs", page === "logs");
document.body.classList.toggle("hub-page-help", page === "help");
syncHubPhoneShellClass();
@@ -1363,6 +1387,11 @@
if (page === "calculator" && window.hubCalculatorPage) {
window.hubCalculatorPage.init();
}
if (page === "compare" && window.hubComparePage) {
window.hubComparePage.init();
} else if (window.hubComparePage && window.hubComparePage.destroy) {
window.hubComparePage.destroy();
}
if (page === "funds" && window.hubFundsPage) {
window.hubFundsPage.init();
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
@@ -1373,6 +1402,9 @@
} else if (window.hubStrategyPage && window.hubStrategyPage.destroy) {
window.hubStrategyPage.destroy();
}
if (page === "amp-stats" && window.hubAmpStatsPage) {
window.hubAmpStatsPage.init();
}
if (page === "help" && window.hubHelpPage) {
window.hubHelpPage.init();
} else if (window.hubHelpPage && window.hubHelpPage.destroy) {
@@ -2412,10 +2444,20 @@
lossN > 0 && Number.isFinite(Number(t.loss_pnl_u))
? `<span class="${pnlCls(t.loss_pnl_u)}">${esc(pnlSigned(t.loss_pnl_u, 2))}U</span>`
: "—";
const showFloat = showAccountPnlPref();
const floatMain = esc(pnlSigned(floatVal, 2)) + "U";
const floatCls = Math.abs(floatVal) > 1e-9 ? pnlCls(floatVal) : "";
const foldLabel = collapsed ? "展开明细" : "收起";
return `<div class="card card-online monitor-stats-card${collapsed ? " is-collapsed" : ""}" data-monitor-stats="1">
const floatSummary = showFloat
? `<div class="monitor-stats-float-summary">
<div class="monitor-stat-label">总浮盈亏</div>
<div class="monitor-stat-value monitor-stats-float-value ${floatCls}">${floatMain}</div>
</div>`
: "";
const floatCell = showFloat ? cell("总浮盈亏", floatMain, "", floatCls) : "";
return `<div class="card card-online monitor-stats-card${collapsed ? " is-collapsed" : ""}${
showFloat ? "" : " hide-float-pnl"
}" data-monitor-stats="1">
<div class="card-head monitor-stats-head">
<div class="monitor-stats-head-main">
<div class="card-title-row">
@@ -2424,10 +2466,7 @@
</div>
<div class="card-sub">交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日</div>
</div>
<div class="monitor-stats-float-summary">
<div class="monitor-stat-label">总浮盈亏</div>
<div class="monitor-stat-value monitor-stats-float-value ${floatCls}">${floatMain}</div>
</div>
${floatSummary}
</div>
<div class="card-body monitor-stats-detail">
<div class="monitor-stats-grid">
@@ -2436,7 +2475,7 @@
${cell("持有仓位", String(Number(t.open_position_count) || 0), "", "")}
${cell("盈利", String(winN), winSub, winN > 0 ? "pnl-pos" : "")}
${cell("亏损", String(lossN), lossSub, lossN > 0 ? "pnl-neg" : "")}
${cell("总浮盈亏", floatMain, "", floatCls)}
${floatCell}
</div>
</div>
</div>`;
@@ -2528,6 +2567,17 @@
renderMonitorGrid(lastMonitorRows);
};
});
fsInner.querySelectorAll(".btn-expand-dashboard").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeExchangeFullscreen();
if (window.hubNavigateTo) window.hubNavigateTo("/dashboard");
else {
history.pushState({}, "", "/dashboard");
setActiveNav();
}
};
});
} catch (err) {
console.error("renderFullscreenExchange", err);
closeExchangeFullscreen();
@@ -3730,22 +3780,39 @@
}
function optionsBalanceFields(opt) {
const bal = (opt && opt.balances) || opt || {};
if (!opt || typeof opt !== "object") {
return { funding: null, trading: null, upl: null };
}
const bal =
opt.balances && typeof opt.balances === "object" ? opt.balances : {};
const pick = (a, b) => (a != null && a !== "" ? a : b);
return {
funding: sumUsdtEquiv(bal.funding_usdt, bal.funding_usdc),
trading: sumUsdtEquiv(bal.trading_usdt, bal.trading_usdc),
upl: opt && opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
? Number(opt.upl_total_usdc)
: null,
funding: sumUsdtEquiv(
pick(bal.funding_usdt, opt.funding_usdt),
pick(bal.funding_usdc, opt.funding_usdc)
),
trading: sumUsdtEquiv(
pick(bal.trading_usdt, opt.trading_usdt),
pick(bal.trading_usdc, opt.trading_usdc)
),
upl:
opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
? Number(opt.upl_total_usdc)
: null,
};
}
function renderStatRow(funding, trading, upnl) {
function renderStatRow(funding, trading, upnl, kind) {
if (!showAccountPnlPref()) return "";
return `<div class="stat-row">
<div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">交易账户</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
const isOpt = kind === "options";
const fundLabel = isOpt ? "期权资金账户" : "资金账户";
const tradeLabel = isOpt ? "期权交易账户" : "交易账户";
const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计";
const rowCls = isOpt ? "stat-row stat-row-options" : "stat-row";
return `<div class="${rowCls}">
<div class="stat-box"><div class="stat-label">${fundLabel}</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">${tradeLabel}</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">${pnlLabel}</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
</div>`;
}
@@ -3753,6 +3820,11 @@
return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl);
}
function renderOptionsAccountStatRow(opt) {
const bal = optionsBalanceFields(opt);
return renderStatRow(bal.funding, bal.trading, bal.upl, "options");
}
function shortOptionsInst(instId) {
const s = String(instId || "");
if (s.length <= 22) return s;
@@ -3788,9 +3860,10 @@
function renderOptionsPositionsTable(pos, targets) {
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
const showPnl = showAccountPnlPref();
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
html +=
"<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th><th>净盈亏</th><th>收益率</th>";
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>";
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
html += "</tr></thead><tbody>";
pos.forEach((p) => {
const optType =
@@ -3814,10 +3887,12 @@
<td>${esc(optType)}</td>
<td>${esc(p.pos)}</td>
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
${renderOptionsTargetCell(target)}
<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>
</tr>`;
${renderOptionsTargetCell(target)}`;
if (showPnl) {
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
}
html += "</tr>";
});
html += "</tbody></table></div>";
return html;
@@ -3845,8 +3920,9 @@
}
const cls = hubPosListCountClass(pos.length);
let html = `<div class="hub-pos-list hub-opt-pos-list ${cls}" data-pos-count="${pos.length}">`;
const hidePnl = !showAccountPnlPref();
pos.forEach((p) => {
html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true });
html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true, hidePnl });
});
html += "</div>";
return html;
@@ -3858,18 +3934,17 @@
const opt = row.options || {};
let html = "";
if (opt.enabled === false) {
html += renderStatRow(null, null, null);
html += renderOptionsAccountStatRow(opt);
html += '<div class="section-title hub-options-title">期权持仓</div>';
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
} else if (opt.ok === false) {
html += renderStatRow(null, null, null);
html += renderOptionsAccountStatRow(opt);
html += '<div class="section-title hub-options-title">期权持仓</div>';
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
} else {
const pos = Array.isArray(opt.positions) ? opt.positions : [];
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
const bal = optionsBalanceFields(opt);
html += renderStatRow(bal.funding, bal.trading, bal.upl);
html += renderOptionsAccountStatRow(opt);
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
html +=
layout === "cards"
@@ -4000,6 +4075,7 @@
</div>
<div class="fs-head-actions">
<button type="button" class="ghost btn-expand-back">返回监控</button>
<button type="button" class="ghost btn-expand-dashboard">返回数据看板</button>
${flaskOpen ? `<a class="btn-link btn-open-instance btn-open-trade" href="#" data-ex-id="${esc(row.id)}" data-next="/trade" data-new-tab="1">打开实例</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""}
@@ -4300,17 +4376,22 @@
opt.position_count != null ? opt.position_count : (opt.positions || []).length
);
const n = Number.isFinite(optCount) ? optCount : 0;
const bal = typeof optionsBalanceFields === "function" ? optionsBalanceFields(opt) : {};
const optUpl = bal && bal.upl != null ? bal.upl : null;
optLine = n > 0 ? `期权 ${n}` : "期权 空仓";
if (optUpl != null && Number.isFinite(Number(optUpl))) {
optLine += ` · 浮盈 ${fmt(optUpl, 2)}U`;
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
if (openCount === 0) {
const bal = optionsBalanceFields(opt);
const optUpl = bal.upl != null ? bal.upl : null;
const parts = [n > 0 ? `期权 ${n}` : "期权 空仓"];
if (showAccountPnlPref()) {
if (bal.funding != null) parts.push(`资金 ${fmt(bal.funding, 2)}U`);
if (bal.trading != null) parts.push(`交易 ${fmt(bal.trading, 2)}U`);
if (optUpl != null && Number.isFinite(Number(optUpl))) {
parts.push(`浮盈 ${fmt(optUpl, 2)}U`);
}
if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) {
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
pnlShow = optUpl;
pnlSuffix = "期权";
}
}
optLine = parts.join(" · ");
}
}
const hm = row.hub_monitor || {};
@@ -5009,12 +5090,12 @@
<div class="settings-card-head">
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
${envOff}
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" />
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</div>
<div class="settings-grid">
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></div>
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></div>
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder=" /records" /></div>
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></div>
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></div>
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder=" /records" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></div>
</div>
<div class="cap-chips">
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
@@ -5039,7 +5120,9 @@
const quotesCb = document.getElementById("pref-show-nav-quotes");
const aiCb = document.getElementById("pref-show-nav-ai");
const calcCb = document.getElementById("pref-show-nav-calculator");
const compareCb = document.getElementById("pref-show-nav-compare");
const strategyCb = document.getElementById("pref-show-nav-strategy");
const ampCb = document.getElementById("pref-show-nav-amp-stats");
const helpCb = document.getElementById("pref-show-nav-help");
const logsCb = document.getElementById("pref-show-nav-logs");
const supEnabled = document.getElementById("supervisor-enabled");
@@ -5062,7 +5145,9 @@
show_nav_quotes: quotesCb ? !!quotesCb.checked : true,
show_nav_ai: aiCb ? !!aiCb.checked : true,
show_nav_calculator: calcCb ? !!calcCb.checked : true,
show_nav_compare: compareCb ? !!compareCb.checked : true,
show_nav_strategy: strategyCb ? !!strategyCb.checked : true,
show_nav_amp_stats: ampCb ? !!ampCb.checked : true,
show_nav_help: helpCb ? !!helpCb.checked : true,
show_nav_logs: logsCb ? !!logsCb.checked : true,
},
@@ -5122,6 +5207,9 @@
loadSettingsMetaLine();
}
if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows);
if (window.hubDashboardPage && window.hubDashboardPage.refresh) {
window.hubDashboardPage.refresh();
}
if (!pageNavAllowed(currentPage())) {
history.replaceState({}, "", "/monitor");
setActiveNav();
+30
View File
@@ -197,9 +197,11 @@
const elPrevCloseLine = document.getElementById("market-prev-close-line");
const elPrevHlLines = document.getElementById("market-prev-hl-lines");
const elDaySplit = document.getElementById("market-day-split");
const elOptionExpirySplit = document.getElementById("market-option-expiry-split");
const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line";
const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines";
const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split";
const OPTION_EXPIRY_SPLIT_STORAGE_KEY = "hub-market-option-expiry-split";
const BJ_OFFSET_SEC = 8 * 60 * 60;
const elFsToolbar = document.getElementById("market-fs-toolbar");
const elFsExchange = document.getElementById("market-fs-exchange");
@@ -342,6 +344,14 @@
saveBoolPref(DAY_SPLIT_STORAGE_KEY, on);
}
function loadOptionExpirySplitPref() {
return loadBoolPref(OPTION_EXPIRY_SPLIT_STORAGE_KEY, false);
}
function saveOptionExpirySplitPref(on) {
saveBoolPref(OPTION_EXPIRY_SPLIT_STORAGE_KEY, on);
}
function loadPrevCloseLinePref() {
return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false);
}
@@ -456,6 +466,18 @@
applyTradingDaySplit(on);
}
function applyOptionExpirySplit(enabled) {
if (window.HubChartDraw && typeof window.HubChartDraw.setOptionExpirySplit === "function") {
window.HubChartDraw.setOptionExpirySplit(enabled);
}
}
function syncOptionExpirySplitUi() {
const on = !!(elOptionExpirySplit && elOptionExpirySplit.checked);
saveOptionExpirySplitPref(on);
applyOptionExpirySplit(on);
}
function ensureDrawLayer() {
if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return;
window.HubChartDraw.attach({
@@ -471,6 +493,9 @@
});
window.HubChartDraw.setViewKey(currentChartViewKey());
applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref());
applyOptionExpirySplit(
elOptionExpirySplit ? elOptionExpirySplit.checked : loadOptionExpirySplitPref()
);
drawAttached = true;
}
@@ -3481,6 +3506,11 @@
elDaySplit.addEventListener("change", syncTradingDaySplitUi);
applyTradingDaySplit(elDaySplit.checked);
}
if (elOptionExpirySplit) {
elOptionExpirySplit.checked = loadOptionExpirySplitPref();
elOptionExpirySplit.addEventListener("change", syncOptionExpirySplitUi);
applyOptionExpirySplit(elOptionExpirySplit.checked);
}
const pageMarket = document.getElementById("page-market");
const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean);
fsKeyTargets.forEach(function (el) {
+52
View File
@@ -68,6 +68,7 @@
let unsubClick = null;
let mainBound = false;
let tradingDaySplitEnabled = false;
let optionExpirySplitEnabled = false;
const BJ_OFFSET_SEC = 8 * 60 * 60;
function uid() {
@@ -409,6 +410,50 @@
ctx.restore();
}
/** OKX 期权到期切日:每天 08:00 UTC(=北京 16:00),对应日期权 1/2/3 日间隔 */
function collectOptionExpiryBoundaries(candles) {
if (!candles.length) return [];
const minT = Number(candles[0].time);
const maxT = Number(candles[candles.length - 1].time);
if (!Number.isFinite(minT) || !Number.isFinite(maxT)) return [];
// 北京日历日 → UTC 当天 08:00(=北京 16:00)
const minP = utcSecToBjParts(minT);
const maxP = utcSecToBjParts(maxT);
const out = [];
let curMs = Date.UTC(minP.y, minP.m, minP.d, 8, 0, 0) - 86400000;
const endMs = Date.UTC(maxP.y, maxP.m, maxP.d, 8, 0, 0) + 2 * 86400000;
while (curMs <= endMs) {
const boundary = Math.floor(curMs / 1000);
if (boundary >= minT - 3600 && boundary <= maxT + 3600) {
if (!out.length || out[out.length - 1] !== boundary) out.push(boundary);
}
curMs += 86400000;
}
return out;
}
function drawOptionExpirySplits(ctx, w, h) {
if (!optionExpirySplitEnabled || !chart) return;
const candles = getCandles();
if (!candles.length) return;
const boundaries = collectOptionExpiryBoundaries(candles);
if (!boundaries.length) return;
ctx.save();
ctx.strokeStyle = "#eab308";
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
boundaries.forEach(function (t) {
const x = timeToX(t);
if (x == null || !Number.isFinite(x) || x < -2 || x > w + 2) return;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
});
ctx.setLineDash([]);
ctx.restore();
}
function drawRect(ctx, x1, y1, x2, y2, selected) {
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
const l = Math.min(x1, x2);
@@ -743,6 +788,7 @@
const h = hostEl.clientHeight;
ctx.clearRect(0, 0, w, h);
drawTradingDaySplits(ctx, w, h);
drawOptionExpirySplits(ctx, w, h);
drawings.forEach(function (d) {
if (d.hidden) ctx.globalAlpha = 0.14;
renderDrawing(ctx, d, w, h, d.id === selectedId);
@@ -1451,10 +1497,16 @@
scheduleRedraw();
}
function setOptionExpirySplit(enabled) {
optionExpirySplitEnabled = !!enabled;
scheduleRedraw();
}
window.HubChartDraw = {
attach: attach,
setViewKey: setViewKey,
setTradingDaySplit: setTradingDaySplit,
setOptionExpirySplit: setOptionExpirySplit,
resize: scheduleRedraw,
redraw: scheduleRedraw,
destroy: destroy,
+296
View File
@@ -0,0 +1,296 @@
/**
* 中控策略对比:同风险额 R 合约 / 单期权 / 期期7:3
*/
(function () {
const page = document.getElementById("page-compare");
if (!page) return;
let inited = false;
let calcTimer = null;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function num(id) {
const el = $(id);
if (!el) return null;
const n = Number(el.value);
return Number.isFinite(n) ? n : null;
}
function text(id) {
const el = $(id);
return el ? String(el.value || "").trim() : "";
}
function fmtU(v) {
if (v == null || !Number.isFinite(Number(v))) return "—";
const n = Number(v);
const abs = Math.abs(n).toFixed(2);
if (Math.abs(n) < 1e-9) return "0.00U";
return (n > 0 ? "+" : "-") + abs + "U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
return n > 0 ? "cmp-pnl-pos" : "cmp-pnl-neg";
}
function setStatus(msg, isErr) {
const el = $("cmp-status");
if (!el) return;
el.textContent = msg || "";
el.className = "toolbar-meta" + (isErr ? " err" : "");
}
function syncDirectionDefaults() {
const dir = text("cmp-direction") || "long";
const isLong = dir === "long";
const optType = $("cmp-opt-type");
const mainType = $("cmp-hedge-main-type");
const sideType = $("cmp-hedge-side-type");
if (optType && !optType.dataset.touched) optType.value = isLong ? "C" : "P";
if (mainType && !mainType.dataset.touched) mainType.value = isLong ? "C" : "P";
if (sideType && !sideType.dataset.touched) sideType.value = isLong ? "P" : "C";
}
function collectPayload() {
const tp = num("cmp-tp");
return {
base: text("cmp-base") || "ETH",
direction: text("cmp-direction") || "long",
entry: num("cmp-entry"),
sl: num("cmp-sl"),
tp: tp,
risk_u: num("cmp-risk"),
tp_opt: num("cmp-tp-opt") != null ? num("cmp-tp-opt") : tp,
tp_hedge: num("cmp-tp-hedge") != null ? num("cmp-tp-hedge") : tp,
option: {
opt_type: text("cmp-opt-type") || "C",
strike: num("cmp-opt-strike"),
ask: num("cmp-opt-ask"),
},
hedge: {
main: {
opt_type: text("cmp-hedge-main-type") || "C",
strike: num("cmp-hedge-main-strike"),
ask: num("cmp-hedge-main-ask"),
},
side: {
opt_type: text("cmp-hedge-side-type") || "P",
strike: num("cmp-hedge-side-strike"),
ask: num("cmp-hedge-side-ask"),
},
},
};
}
function renderSummaryCards(data) {
const box = $("cmp-summary");
if (!box) return;
const perp = data.perp || {};
const opt = data.option || {};
const hedge = data.hedge || {};
const cards = [];
cards.push(`<article class="cmp-sum-card card">
<h3>单独合约</h3>
<div class="cmp-sum-row"><span>张数</span><strong>${esc(perp.sheets)}</strong></div>
<div class="cmp-sum-row"><span>止损占用</span><strong>${fmtU(perp.risk_used_u)}</strong></div>
<div class="cmp-sum-row"><span>面值</span><strong>${esc(perp.contract_size)} /</strong></div>
</article>`);
if (opt.ok) {
cards.push(`<article class="cmp-sum-card card">
<h3>单独期权 · ${esc(opt.opt_type)} ${esc(opt.strike)}</h3>
<div class="cmp-sum-row"><span>张数</span><strong>${esc(opt.sheets)}</strong></div>
<div class="cmp-sum-row"><span>权利金</span><strong>${fmtU(opt.premium_u)}</strong></div>
<div class="cmp-sum-row"><span>单张成本</span><strong>${fmtU(opt.unit_cost_u)}</strong></div>
</article>`);
} else {
cards.push(`<article class="cmp-sum-card card">
<h3>单独期权</h3>
<p class="cmp-muted">${esc(opt.msg || "输入不完整")}</p>
</article>`);
}
if (hedge.ok) {
const m = hedge.main || {};
const s = hedge.side || {};
cards.push(`<article class="cmp-sum-card card">
<h3>期期对冲 7:3</h3>
<div class="cmp-sum-row"><span>主腿 ${esc(m.opt_type)} ${esc(m.strike)}</span><strong>${esc(m.sheets)} · ${fmtU(m.premium_u)}</strong></div>
<div class="cmp-sum-row"><span>次腿 ${esc(s.opt_type)} ${esc(s.strike)}</span><strong>${esc(s.sheets)} · ${fmtU(s.premium_u)}</strong></div>
<div class="cmp-sum-row"><span>总权利金</span><strong>${fmtU(hedge.premium_u)}</strong></div>
</article>`);
} else {
cards.push(`<article class="cmp-sum-card card">
<h3>期期对冲</h3>
<p class="cmp-muted">${esc(hedge.msg || "输入不完整")}</p>
</article>`);
}
box.innerHTML = cards.join("");
}
function cell(v, note) {
const main = `<span class="${pnlClass(v)}">${fmtU(v)}</span>`;
if (!note) return main;
return `${main}<div class="cmp-cell-note">${esc(note)}</div>`;
}
function renderTable(data) {
const box = $("cmp-table-wrap");
if (!box) return;
const perp = data.perp || {};
const opt = data.option && data.option.ok ? data.option : null;
const hedge = data.hedge && data.hedge.ok ? data.hedge : null;
const dash = "—";
box.innerHTML = `<div class="cmp-table-scroll"><table class="cmp-table">
<thead>
<tr>
<th>路径</th>
<th>单独合约</th>
<th>单独期权</th>
<th>期期对冲</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>A 干净止盈</strong><div class="cmp-cell-note"></div></td>
<td>${cell(perp.path_a_tp)}</td>
<td>${opt ? cell(opt.path_a_tp) : dash}</td>
<td>${hedge ? cell(hedge.path_a_tp) : dash}</td>
</tr>
<tr>
<td><strong>B 打止损</strong><div class="cmp-cell-note">;</div></td>
<td>${cell(perp.path_b_sl)}</td>
<td>${
opt
? cell(opt.path_b_sl, "最坏到期亏满权利金 " + fmtU(opt.path_b_worst))
: dash
}</td>
<td>${
hedge
? cell(hedge.path_b_sl, "最坏双腿归零 " + fmtU(hedge.path_b_worst))
: dash
}</td>
</tr>
<tr>
<td><strong>C 先止损再去止盈</strong><div class="cmp-cell-note"></div></td>
<td>${cell(
perp.path_c_realized,
"踏空未拿到 " + fmtU(perp.path_c_missed)
)}</td>
<td>${opt ? cell(opt.path_c_hold_to_tp, opt.path_c_note || "") : dash}</td>
<td>${hedge ? cell(hedge.path_c_hold_to_tp, hedge.path_c_note || "") : dash}</td>
</tr>
</tbody>
</table></div>`;
}
function renderRecommend(data) {
const box = $("cmp-recommend");
if (!box) return;
const rec = data.recommend || {};
const bullets = Array.isArray(rec.bullets) ? rec.bullets : [];
const warns = Array.isArray(data.warnings) ? data.warnings : [];
box.innerHTML = `<div class="cmp-rec-card card">
<div class="cmp-rec-head">推荐:<strong>${esc(rec.choice || "—")}</strong></div>
<p class="cmp-rec-reason">${esc(rec.reason || "")}</p>
<ul class="cmp-rec-list">${bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>
${
warns.length
? `<div class="cmp-warn">${warns.map((w) => esc(w)).join(" · ")}</div>`
: ""
}
<p class="cmp-foot-note">${(data.notes || []).map(esc).join(" · ")}</p>
</div>`;
}
async function runCalc() {
const payload = collectPayload();
if (
payload.entry == null ||
payload.sl == null ||
payload.tp == null ||
payload.risk_u == null
) {
setStatus("请填写入场 / 止损 / 止盈 / 风险额", true);
return;
}
setStatus("计算中…");
try {
const r = await fetch("/api/compare/calc", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!data.ok) {
setStatus(data.msg || "计算失败", true);
return;
}
renderSummaryCards(data);
renderTable(data);
renderRecommend(data);
setStatus("已更新");
} catch (e) {
setStatus(String(e.message || e), true);
}
}
function scheduleCalc() {
if (calcTimer) clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
void runCalc();
}, 280);
}
function bind() {
const form = $("cmp-form");
if (!form || form.dataset.bound === "1") return;
form.dataset.bound = "1";
form.addEventListener("submit", (ev) => {
ev.preventDefault();
void runCalc();
});
form.querySelectorAll("input, select").forEach((el) => {
el.addEventListener("change", () => {
if (el.id === "cmp-direction") syncDirectionDefaults();
if (
el.id === "cmp-opt-type" ||
el.id === "cmp-hedge-main-type" ||
el.id === "cmp-hedge-side-type"
) {
el.dataset.touched = "1";
}
scheduleCalc();
});
el.addEventListener("input", scheduleCalc);
});
const btn = $("cmp-btn-run");
if (btn) btn.addEventListener("click", () => void runCalc());
}
window.hubComparePage = {
init() {
if (!inited) {
bind();
syncDirectionDefaults();
inited = true;
}
scheduleCalc();
},
destroy() {
/* keep form state */
},
};
})();
+64 -9
View File
@@ -124,7 +124,7 @@ body.hub-page-dashboard .page#page-dashboard {
justify-content: space-between;
gap: 0;
width: 100%;
padding: 10px 4px;
padding: 14px 6px;
border-radius: 12px;
background: var(--dash-card-bg);
border: 1px solid var(--dash-card-border);
@@ -136,7 +136,7 @@ body.hub-page-dashboard .page#page-dashboard {
flex: 1 1 0;
min-width: 0;
max-width: none;
padding: 4px 8px;
padding: 6px 10px;
position: relative;
text-align: center;
}
@@ -145,8 +145,8 @@ body.hub-page-dashboard .page#page-dashboard {
content: "";
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
top: 16%;
bottom: 16%;
width: 1px;
background: color-mix(in srgb, var(--dash-card-border) 85%, transparent);
}
@@ -167,10 +167,10 @@ body.hub-page-dashboard .page#page-dashboard {
}
.dash-kpi-label {
font-size: 0.65rem;
font-size: 0.78rem;
color: var(--dash-muted);
letter-spacing: 0.04em;
margin-bottom: 4px;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -178,8 +178,8 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-kpi-value {
font-family: JetBrains Mono, monospace;
font-size: 0.92rem;
font-weight: 600;
font-size: 1.18rem;
font-weight: 650;
line-height: 1.25;
color: var(--dash-text);
white-space: nowrap;
@@ -247,11 +247,66 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-ac-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
grid-template-columns: 1fr;
gap: 12px;
padding: 14px;
}
.dash-pos-unified-card {
width: 100%;
}
.dash-ex-link {
appearance: none;
border: 0;
background: transparent;
color: var(--dash-accent);
font: inherit;
font-weight: 600;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.dash-ex-link:hover {
color: color-mix(in srgb, var(--dash-accent) 80%, #fff);
}
.dash-tp-profit {
color: var(--dash-accent);
font-variant-numeric: tabular-nums;
}
.dash-tp-program {
color: var(--dash-accent);
font-size: 0.72rem;
font-weight: 600;
}
.dash-side {
font-weight: 700;
}
.dash-side-long {
color: #3dd68c;
}
.dash-side-short {
color: #ff6b7a;
}
.dash-empty-inline {
padding: 8px 0 4px;
font-size: 0.78rem;
}
.dash-pos-alert-note {
margin-top: 4px;
font-size: 0.75rem;
color: var(--dash-warn);
}
.dash-ac-card {
position: relative;
padding: 14px 16px;

Some files were not shown because too many files have changed in this diff Show More