81 Commits

Author SHA1 Message Date
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
108 changed files with 8672 additions and 1033 deletions
+2
View File
@@ -158,6 +158,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4 RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1 RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2 RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒) # 资金与仓位刷新周期(秒)
+20
View File
@@ -2751,6 +2751,17 @@ def insert_trade_record(
opened_at_ms=open_ts_ms, opened_at_ms=open_ts_ms,
closed_at_ms=close_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 return tid
@@ -7482,6 +7493,15 @@ def risk_policy_page():
return render_main_page("risk_policy") 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") @app.route("/env_config")
@login_required @login_required
def env_config_page(): def env_config_page():
+2
View File
@@ -160,6 +160,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4 RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1 RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2 RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒) # 资金与仓位刷新周期(秒)
+30
View File
@@ -2440,6 +2440,22 @@ def insert_trade_record(
opened_at_ms=open_ts_ms, opened_at_ms=open_ts_ms,
closed_at_ms=close_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 return tid
@@ -6942,6 +6958,10 @@ def sync_trade_records_from_exchange(conn, force=False):
matched += 1 matched += 1
stats["matched"] = matched stats["matched"] = matched
stats["ok"] = True stats["ok"] = True
# 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」
if matched < stats["pending"] and hist:
_LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0
else:
_LAST_EXCHANGE_PNL_SYNC_AT = now _LAST_EXCHANGE_PNL_SYNC_AT = now
try: try:
conn.commit() conn.commit()
@@ -7269,6 +7289,15 @@ def risk_policy_page():
return render_main_page("risk_policy") 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") @app.route("/env_config")
@login_required @login_required
def env_config_page(): def env_config_page():
@@ -9369,6 +9398,7 @@ register_trade_records_api(
filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, filter_trade_records_excluding_miss=filter_trade_records_excluding_miss,
app_tz=APP_TZ, app_tz=APP_TZ,
format_price_fn=format_price_for_symbol, 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): 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_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_BUDGET_BUFFER=0.95
OKX_OPTIONS_DEFAULT_UNDERLY=ETH OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
OKX_OPTIONS_MAX_DTE_DAYS=2 OKX_OPTIONS_MAX_DTE_DAYS=2
OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14
OKX_SUB_ACCOUNT_NAME= OKX_SUB_ACCOUNT_NAME=
@@ -120,19 +122,35 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# ============================================================================= # =============================================================================
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md) # 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
# ============================================================================= # =============================================================================
HEDGE_PLAN_ENABLED=false HEDGE_PLAN_ENABLED=false
# 页面 Tab 显示(默认全部显示,可单独关闭;不影响已有进行中/历史计划)
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false HEDGE_PLAN_LIVE_ORDER=false
HEDGE_PLAN_OPEN_ORDER=options_first HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true 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 MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15 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) # 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)
@@ -201,6 +219,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4 RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1 RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2 RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒) # 资金与仓位刷新周期(秒)
+43
View File
@@ -2359,6 +2359,22 @@ def insert_trade_record(
opened_at_ms=open_ts_ms, opened_at_ms=open_ts_ms,
closed_at_ms=close_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 return tid
@@ -4090,6 +4106,10 @@ def sync_trade_records_from_exchange(conn, force=False):
matched += 1 matched += 1
stats["matched"] = matched stats["matched"] = matched
stats["ok"] = True stats["ok"] = True
# 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」
if matched < stats["pending"] and hist:
_LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0
else:
_LAST_EXCHANGE_PNL_SYNC_AT = now _LAST_EXCHANGE_PNL_SYNC_AT = now
try: try:
conn.commit() conn.commit()
@@ -6785,8 +6805,20 @@ def render_main_page(page="trade", embed_mode=None):
options_nav_visible=True, options_nav_visible=True,
hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), 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_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_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_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, risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, 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, risk_status=risk_status,
trade_policy=TRADE_POLICY, trade_policy=TRADE_POLICY,
data_export_version=3, data_export_version=3,
open_guard_enabled=open_guard_enabled,
), ),
**force_close_template_context( **force_close_template_context(
FORCE_CLOSE_ENABLED, FORCE_CLOSE_ENABLED,
@@ -6893,6 +6926,15 @@ def risk_policy_page():
return render_main_page("risk_policy") 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") @app.route("/env_config")
@login_required @login_required
def env_config_page(): def env_config_page():
@@ -9044,6 +9086,7 @@ register_trade_records_api(
filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, filter_trade_records_excluding_miss=filter_trade_records_excluding_miss,
app_tz=APP_TZ, app_tz=APP_TZ,
format_price_fn=format_price_for_symbol, 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** 冷静期 | | 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
| 第 2 次用户主动平仓(同一交易日) | **日冻结** | | 第 2 次用户主动平仓(同一交易日) | **日冻结** |
| 平仓亏损达 `RISK_DAILY_LOSS_LIMIT` 次(同一交易日) | **日冻结**(默认 2 次;`0`=不启用) |
| 复盘勾选任意情绪标签 | **日冻结** | | 复盘勾选任意情绪标签 | **日冻结** |
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) | | 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
@@ -77,11 +78,15 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4 RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1 RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2 RISK_MANUAL_CLOSE_DAILY_LIMIT=2
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true RISK_MOOD_ISSUES_DAILY_FREEZE=true
TRADING_DAY_RESET_HOUR=8 TRADING_DAY_RESET_HOUR=8
APP_TIMEZONE=Asia/Shanghai APP_TIMEZONE=Asia/Shanghai
``` ```
- `RISK_DAILY_LOSS_LIMIT`:任意已平仓交易若盈亏 < 0 计 1 次(含止损/止盈后仍亏损等);达上限当日冻结开仓;`0` 表示不因亏损次数冻结.
- `RISK_MANUAL_CLOSE_DAILY_LIMIT`:仅计**用户主动平仓**次数(与亏损次数独立).
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控). `RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控).
## API 与 `risk_status` 字段 ## API 与 `risk_status` 字段
@@ -102,6 +107,7 @@ APP_TIMEZONE=Asia/Shanghai
| `can_trade` | 是否允许新开仓(仅风控维度) | | `can_trade` | 是否允许新开仓(仅风控维度) |
| `reason` | 悬停提示文案 | | `reason` | 悬停提示文案 |
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` | | `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` |
| `daily_loss_count` / `daily_loss_limit` | 当日亏损笔数与上限(`0` 上限表示未启用) |
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) | | `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) | | `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) | | `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` - `hub_bridge.py``/api/hub/account-risk/user-close`
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close - `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
- `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控 - `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). 详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
+1
View File
@@ -4,6 +4,7 @@
| 文档 | 实例 | 状态 | | 文档 | 实例 | 状态 |
|------|------|------| |------|------|------|
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册 | 个人开单纪律 |
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 | | [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 |
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 | | [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 |
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 | | [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
+145
View File
@@ -0,0 +1,145 @@
# 交易执行手册(期权为主 · Gate 为辅)
> 个人开单纪律与仓位规则(2026-07 起)。
> 目标:少而精、可控回撤、样本干净;**不保证收益**。
> 工具:OKX 期权(主)+ Gate 合约(辅);其它账户暂不做。
---
## 1. 总原则
1. **主做期权,合约为辅**;同一时段尽量只让一边「说话」。
2. **看不懂不做**;过滤比频率重要。
3. 开仓前先过三关:**方向 → 空间 → 值不值得**。不够格 → 空仓。
4. 期权离场只认:**止盈(规则触发)** 与 **到期**;**不手动平仓**(紧急例外单不算策略样本)。
5. 过程可控、结果随缘:用规则管仓位与次数,不追求每天打满理想上限。
---
## 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 为辅、仓位与日停手规则) |
+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 型样式/数据结构). - **T 型报价链**(复用期权页 T 型样式/数据结构).
- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). - 用户选 **腿 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 目标价 ### 4.2 目标价
@@ -141,16 +148,17 @@
### 5.2 期期对冲 ### 5.2 期期对冲
| 事件 | 盈利方 | 亏损方 | 计划是否结束 | | 事件 | 盈利方 | 另一腿(残腿) | 计划是否结束 |
|------|--------|--------|--------------| |------|--------|--------------|--------------|
| **标的价到达用户目标价 S\*** | **自动平仓** | **不平**,持有至到期 | 平盈利腿后计划可标 `closing`;**全部腿终态后结束**(亏损腿到期后结账) | | **标的价到达目标 + 平仓模式=到期平** | **自动平仓** | **不平**,持有至到期(`hold_expiry`) | 平盈利腿后仍 `active`;残腿到期后结账 |
| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 &lt; 0 的合计) | | **标的价到达目标 + 平仓模式=全平**(默认) | **自动平仓** | **随即买一清残腿**(无 2×门控,失败则每轮重试) | 两腿都平完后 `closed` |
| 到期时组合合计仍盈利 | — | 到期结算 | **算结束**;按实际结算盈亏入账 | | **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损** |
| 未达 S\* 至到期 | 两腿均到期 | | 同上,按结算合计结束 | | **到期时组合合计仍盈利** | — | 到期结算 | **算结束**;按实际结算盈亏入账 |
判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). - 界面「平仓模式」仅控制**盈利腿已平之后**另一腿的处理;须 `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true`(默认开)才显示,页面默认选 **全平**.
- 关闭方案C开关时行为固定为 **到期平**.
盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期). - 判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损).
- 盈利方判定:触达上破/下破时按浮盈较大一侧平仓;皆亏则等到期.
### 5.2.1 期权腿实盘平仓执行(与期权页共用) ### 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_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_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 |
| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 | | `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_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** |
| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** | | `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** |
| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 | | `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 |
| `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED` | 期期平仓模式(方案C) | true | bool | 热更 | 开:页面可选到期平/全平;关:固定到期平 |
| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 | | `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 |
| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 | | `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 |
| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 | | `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 |
@@ -577,11 +588,14 @@ realized_pnl_total = pnl_option_close - abs(pnl_perp_sl)
```env ```env
# --- 对冲计划(仅 OKX;前端 env「对冲计划」) --- # --- 对冲计划(仅 OKX;前端 env「对冲计划」) ---
HEDGE_PLAN_ENABLED=false HEDGE_PLAN_ENABLED=false
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false HEDGE_PLAN_LIVE_ORDER=false
HEDGE_PLAN_OPEN_ORDER=options_first HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
MAX_ACTIVE_HEDGE_PLANS=1 MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15 HEDGE_PLAN_MONITOR_POLL_SECONDS=15
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
+36
View File
@@ -0,0 +1,36 @@
# Git 快照标签
代码级快照用 annotated tag 打在 `main` 上,便于回看某日仓库状态(不含 `.env` / 数据库)。
## 当前快照
| 标签 | 指向提交 | 说明 |
|------|----------|------|
| `snapshot/20260723-pre-amp-stats` | 4ccfb83 | 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-pre-amp-stats
# 回到主线
git checkout main
```
数据备份(SQLite / 中控 JSON)走中控备份或各所 `scripts/backup_data.sh`**不要**把含密钥的 `.env` 与库文件提交进 Git。
+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 部署依赖安装失败 ## 2026-07-17 · 修复 pip>=26 部署依赖安装失败
### 修改原因 ### 修改原因
+2
View File
@@ -83,6 +83,7 @@
5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. 5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期.
6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. 6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」.
7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. 7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节.
8. **期期自动张数**:对冲计划页为「同张数 / 做多 / 做空」(已无均分);做多/做空按 Call·Put 比例拆,口径与比例见 env `HEDGE_PLAN_OO_BIAS_SPLIT_BY``HEDGE_PLAN_OO_BIAS_RATIO`.
--- ---
@@ -91,3 +92,4 @@
- [期权用法.md](./期权用法.md) — 资金兑划与页面操作 - [期权用法.md](./期权用法.md) — 资金兑划与页面操作
- [期权方案.md](./期权方案.md) — env 与架构 - [期权方案.md](./期权方案.md) — env 与架构
- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿 - [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿
- [系统说明.md](./系统说明.md) — 实例操作与门禁总手册
+14 -2
View File
@@ -78,16 +78,26 @@ OKX_OPTIONS_API_PASSPHRASE=...
需已配置 `WECHAT_WEBHOOK`. 需已配置 `WECHAT_WEBHOOK`.
## 6. 与永续的关系 ## 6. 与永续 / 对冲计划的关系
| | 永续(子账户) | 期权(主账户) | | | 永续(子账户) | 期权(主账户) |
|--|----------------|----------------| |--|----------------|----------------|
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` | | API | `OKX_API_*` | `OKX_OPTIONS_API_*` |
| 页面 | 实盘下单 / 关键位 | 期权 | | 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 |
| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 | | 资金顶栏 | 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. 配置说明 ## 7. 配置说明
| 变量 | 默认 | 含义 | | 变量 | 默认 | 含义 |
@@ -97,6 +107,8 @@ OKX_OPTIONS_API_PASSPHRASE=...
| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 | | `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 |
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD | | `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 | | `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
| `HEDGE_PLAN_OO_BIAS_SPLIT_BY` | budget | 期期做多/做空:按预算或按张数拆 |
| `HEDGE_PLAN_OO_BIAS_RATIO` | 0.7 | 期期做多/做空主腿占比 |
## 8. 期权复盘(含对冲) ## 8. 期权复盘(含对冲)
+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", records: "/records",
stats: "/stats", stats: "/stats",
risk_policy: "/risk_policy", risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config", env_config: "/env_config",
settings: "/settings", settings: "/settings",
}; };
@@ -296,7 +297,8 @@
} }
function syncShellChrome(tab) { 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) => { document.querySelectorAll(".instance-top-bar").forEach((el) => {
el.hidden = hideTopBar; 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{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px} .mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px} .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} .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{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px} .detail-modal .panel{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 */ /** 默认关闭的导航开关:缺失时按 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) { function navPrefShow(display, key) {
if (!key) return true; if (!key) return true;
@@ -41,6 +41,7 @@
"hedge-plan": "show_nav_hedge_plan", "hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan", hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy", risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config", env_config: "show_nav_env_config",
}; };
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => { document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
@@ -68,6 +69,7 @@
"hedge-plan": "show_nav_hedge_plan", "hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan", hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy", risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config", env_config: "show_nav_env_config",
}; };
const key = map[tab]; 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 { .hedge-plan-page-wrap .hp-head-card {
margin-bottom: 12px; 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 { .hedge-plan-page-wrap .hp-head-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -3249,15 +3276,276 @@ html[data-theme="light"] .opt-be-dist-down {
color: #7ee787; color: #7ee787;
font-weight: 700; 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 { .hedge-plan-page-wrap .hp-pnl-neg {
color: #ff8a8a; color: #ff8a8a;
} }
.hedge-plan-page-wrap .hp-oo-legs { .hedge-plan-page-wrap .hp-oo-legs {
margin-top: 8px; margin-top: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; 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 { .hedge-plan-page-wrap .hp-oo-leg-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -3394,15 +3682,24 @@ html[data-theme="light"] .opt-be-dist-down {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.hedge-plan-page-wrap .hp-preview-card {
margin-top: 0;
clear: both;
}
.hedge-plan-page-wrap .hp-action-row { .hedge-plan-page-wrap .hp-action-row {
margin-top: 10px; margin-top: 10px;
gap: 8px; gap: 8px;
justify-content: flex-end; 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 .hp-pick.active,
.hedge-plan-page-wrap .opt-row-selected td { .hedge-plan-page-wrap .opt-row-selected td {
background: rgba(90, 140, 255, 0.18); 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); background: rgba(255, 152, 0, 0.08);
} }
.opt-order-inline-row td { .opt-order-inline-row td {
padding: 14px 16px !important; padding: 0 !important;
background: rgba(74, 124, 255, 0.07); border: none !important;
border-top: 1px solid rgba(74, 124, 255, 0.25); background: transparent !important;
border-bottom: 1px solid rgba(74, 124, 255, 0.25);
} }
.opt-order-panel-inner { .opt-order-backdrop {
border-radius: 8px; 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 { .opt-order-layout {
display: flex; display: block;
align-items: stretch;
gap: 14px;
} }
.opt-order-main { .opt-order-main {
flex: 1 1 auto;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
} }
.opt-order-pending { .opt-order-dialog #opt-order-inst,
flex: 0 0 280px; .opt-order-dialog .options-order-inst {
max-width: 320px; font-size: 0.95rem;
padding: 10px 12px; font-weight: 600;
border-radius: 8px; letter-spacing: 0.01em;
border: 1px solid rgba(158, 192, 255, 0.2); line-height: 1.4;
background: rgba(0, 0, 0, 0.18); word-break: break-all;
margin: 0;
} }
.opt-order-pending-head { .opt-order-pending-head {
display: flex; display: flex;
@@ -3703,6 +4021,12 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
max-height: 220px; max-height: 220px;
overflow: auto; overflow: auto;
} }
.opt-pending-list--tab {
max-height: min(52vh, 420px);
}
.opt-pos-pending-pane {
padding: 4px 2px 8px;
}
.opt-pending-empty { .opt-pending-empty {
font-size: 0.72rem; 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; font-size: 0.68rem;
padding: 2px 8px; 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 { html[data-theme="light"] .opt-pending-item {
background: #fff; background: #fff;
border-color: rgba(0, 0, 0, 0.08); border-color: rgba(0, 0, 0, 0.08);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.opt-order-layout { .opt-pending-list--tab {
flex-direction: column; max-height: min(46vh, 360px);
}
.opt-order-pending {
flex: 1 1 auto;
max-width: none;
} }
} }
.opt-order-panel-inner .opt-order-title { .opt-order-dialog-head {
margin: 0 0 8px; display: flex;
font-size: 0.85rem; 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; color: #9ec0ff;
font-weight: 650;
} }
.options-page-wrap .opt-order-panel-inner .opt-order-title { .opt-order-dialog-actions {
font-size: 0.82rem; display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
} }
.opt-order-panel-host:not([hidden]) { .opt-order-dialog-actions .btn-primary,
display: block; .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] { .opt-order-dialog #opt-order-msg {
display: none !important; margin-top: 2px;
min-height: 1.2em;
line-height: 1.4;
} }
.options-order-grid { .options-order-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 10px; gap: 12px 14px;
margin: 10px 0; 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 { .options-order-grid .k {
display: block; display: block;
font-size: 0.68rem; font-size: 0.7rem;
color: #8892b0; color: #8892b0;
margin-bottom: 3px;
} }
.options-order-grid .v,
.options-page-wrap .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 { .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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px 12px; gap: 10px 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;
} }
.options-estimate-row .opt-est-label { .opt-order-chip {
color: #8892b0; 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 { .options-estimate-row .opt-target-idx {
width: 120px; width: 140px;
font-size: 0.74rem; font-size: 0.8rem;
padding: 3px 6px; padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
} }
.options-estimate-row .k { .options-estimate-row .k {
color: #8892b0; color: #8892b0;
} }
.options-estimate-row .v { .options-estimate-row .v {
font-size: 0.82rem; font-size: 0.86rem;
font-weight: 600; font-weight: 600;
} }
.options-estimate-row .opt-est-note { .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); 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 { .options-hint {
font-size: 0.75rem; font-size: 0.75rem;
margin-bottom: 6px; margin-bottom: 6px;
} }
.options-page-wrap .options-order-mode-row { .options-page-wrap .options-order-mode-row {
font-size: 0.74rem; font-size: 0.78rem;
gap: 6px; gap: 10px;
} }
.options-page-wrap .options-order-mode-row input[type="number"], .options-page-wrap .options-order-mode-row input[type="number"],
.options-page-wrap .options-order-mode-row input[type="text"] { .options-page-wrap .options-order-mode-row input[type="text"] {
font-size: 0.74rem; font-size: 0.8rem;
padding: 3px 6px; padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
} }
.options-page-wrap .options-order-mode-row .btn-primary { .options-page-wrap .options-order-mode-row .btn-primary {
font-size: 0.74rem; font-size: 0.8rem;
padding: 4px 10px; padding: 6px 12px;
} }
.options-page-wrap .opt-row-actions .btn-primary, .options-page-wrap .opt-row-actions .btn-primary,
.options-page-wrap .opt-row-actions .btn-secondary { .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); background: rgba(255, 209, 102, 0.12);
} }
.options-order-mode-row { .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; flex-wrap: wrap;
gap: 8px; align-items: center;
gap: 10px;
} }
.options-order-mode-row input[type="number"] { .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 { .options-dual-grid {
display: grid; display: grid;
@@ -4170,6 +4585,52 @@ html[data-theme="light"] .options-stats-pnl-summary .options-stat-item {
font-size: 0.62rem; font-size: 0.62rem;
line-height: 1; 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 { .opt-pos-bar-meta {
font-size: 0.66rem; font-size: 0.66rem;
color: #8b95b0; color: #8b95b0;
@@ -4357,14 +4818,19 @@ html[data-theme="light"] .options-stats-pnl-summary .options-stat-item {
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; 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-top: 8px;
margin-bottom: 10px;
padding: 0; padding: 0;
border: 1px solid rgba(67, 82, 118, 0.55); border: 1px solid rgba(67, 82, 118, 0.55);
border-radius: 10px; border-radius: 10px;
background: rgba(14, 19, 30, 0.58); background: rgba(14, 19, 30, 0.58);
overflow: hidden; overflow: hidden;
} }
.options-page-wrap .opt-open-rule {
margin-top: 4px;
}
.options-page-wrap .opt-close-rule summary { .options-page-wrap .opt-close-rule summary {
display: flex; display: flex;
align-items: center; 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-uly-btn.active,
html[data-theme="light"] .opt-type-btn.active, html[data-theme="light"] .opt-type-btn.active,
html[data-theme="light"] .opt-money-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; border-color: rgba(0, 95, 140, 0.45) !important;
color: #004d6e !important; color: #004d6e !important;
background: rgba(0, 110, 154, 0.14) !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); 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 { .pos-pnl-profit {
color: #7ee787; 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) { function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return; if (!input || input.dataset.journalSlotBound === "1") return;
// 期权复盘槽位由 options_review.js 处理,勿被合约复盘上传抢走
if (isOptionsReviewSlot(input)) return;
input.dataset.journalSlotBound = "1"; input.dataset.journalSlotBound = "1";
input.addEventListener("change", function () { input.addEventListener("change", function () {
var file = input.files && input.files[0]; var file = input.files && input.files[0];
+199 -40
View File
@@ -14,6 +14,13 @@
moneyFilter: "all", moneyFilter: "all",
chainView: "list", chainView: "list",
strikeExpandAll: false, 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, chain: panelCache.chain || null,
selectedInst: null, selectedInst: null,
orderQuote: null, orderQuote: null,
@@ -91,16 +98,17 @@
} }
function parkOrderPanel() { function parkOrderPanel() {
stopPendingOrdersPoll();
const panel = orderPanel(); const panel = orderPanel();
const host = orderPanelHost(); const host = orderPanelHost();
// 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM // 弹窗挂到 body;关闭后收回原位,绝不插入期权链表格
if (host && orderPanelHome && host.parentElement !== orderPanelHome) { 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); orderPanelHome.appendChild(host);
} else if (panel && host && panel.parentElement !== host) {
host.appendChild(panel);
} }
if (host) host.hidden = true; }
if (panel) panel.style.display = "none"; if (panel) panel.style.display = "none";
const inline = document.querySelector(".opt-order-inline-row"); const inline = document.querySelector(".opt-order-inline-row");
if (inline) inline.remove(); 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-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-call-inst="' + CSS.escape(instId) + '"]') ||
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-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) { 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); syncPickButtons(instId);
const oldInline = document.querySelector(".opt-order-inline-row"); const oldInline = document.querySelector(".opt-order-inline-row");
if (oldInline) oldInline.remove(); if (oldInline) oldInline.remove();
if (panel.parentElement !== host) host.appendChild(panel); if (panel.parentElement !== host) host.appendChild(panel);
const tr = document.createElement("tr"); // 挂到 body,避免被卡片 overflow 裁成「行内展开」
tr.className = "opt-order-inline-row"; if (host.parentElement !== document.body) document.body.appendChild(host);
const td = document.createElement("td");
td.colSpan = strikeTableColspan();
td.appendChild(host);
tr.appendChild(td);
row.after(tr);
host.hidden = false; host.hidden = false;
host.setAttribute("aria-hidden", "false");
panel.style.display = ""; panel.style.display = "";
tr.scrollIntoView({ behavior: "smooth", block: "nearest" });
refreshPendingOrders();
startPendingOrdersPoll();
return true; return true;
} }
function closeOrderDialog() {
state.selectedInst = null;
state.orderQuote = null;
parkOrderPanel();
}
function fmtPendingAge(sec) { function fmtPendingAge(sec) {
if (sec == null || Number.isNaN(Number(sec))) return "—"; if (sec == null || Number.isNaN(Number(sec))) return "—";
let s = Math.max(0, Math.round(Number(sec))); let s = Math.max(0, Math.round(Number(sec)));
@@ -279,6 +282,42 @@
const ethEl = document.getElementById("opt-eth-amount"); const ethEl = document.getElementById("opt-eth-amount");
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none"; if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none"; if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
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) { function quoteUrl(instId) {
@@ -341,21 +380,41 @@
return ""; return "";
} }
function askLiqFilterOn() {
return !!state.askLiqFilter;
}
function hasAskLiquidity(c) {
if (!c) return false;
if (c.ask_estimated) return false;
const a = Number(c.ask);
const s = Number(c.ask_sz);
return Number.isFinite(a) && a > 0 && Number.isFinite(s) && s >= 1;
}
function syncAskLiqFilterFromChain(d) {
if (!d || d.ask_liq_filter_enabled == null) return;
state.askLiqFilter = !!d.ask_liq_filter_enabled;
root.dataset.askLiqFilter = state.askLiqFilter ? "1" : "0";
}
function countContractsForType(contracts) { function countContractsForType(contracts) {
if (state.chainView === "t") { if (state.chainView === "t") {
return countStraddleStrikes(contracts); return countStraddleStrikes(contracts);
} }
return (contracts || []).filter(function (c) { 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; }).length;
} }
function countStraddleStrikes(contracts) { function countStraddleStrikes(contracts) {
const strikes = new Set(); const rows = buildStraddleRows(contracts).filter(function (row) {
(contracts || []).forEach(function (c) { if (!askLiqFilterOn()) return true;
if (c.strike != null) strikes.add(String(c.strike)); return hasAskLiquidity(row.call) || hasAskLiquidity(row.put);
}); });
return strikes.size; return rows.length;
} }
function buildStraddleRows(contracts) { function buildStraddleRows(contracts) {
@@ -398,7 +457,11 @@
function filterStraddleRows(rows, indexPx) { function filterStraddleRows(rows, indexPx) {
const atmStrike = findAtmStrike(rows, indexPx); const atmStrike = findAtmStrike(rows, indexPx);
return rows.filter(function (row) { 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) { function filterChainContracts(contracts) {
return (contracts || []).filter(function (c) { 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"; 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) { function expLabel(ms) {
try { try {
const dt = new Date(Number(ms)); 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() { function renderIndexLine() {
const idx = state.chain && state.chain.index_px; const idx = state.chain && state.chain.index_px;
const dte = state.chain && state.chain.chain_max_dte_days; const dte = state.chain && state.chain.chain_max_dte_days;
@@ -492,13 +589,37 @@
const el = document.getElementById("opt-chain-dte"); const el = document.getElementById("opt-chain-dte");
if (el) el.textContent = String(Math.round(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"); const line = document.getElementById("opt-index-line");
if (line) { if (line) {
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
line.textContent = 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) { function renderExpiryOptions(preserveSelection) {
const sel = document.getElementById("opt-exp-select"); const sel = document.getElementById("opt-exp-select");
if (!sel) return; if (!sel) return;
@@ -513,6 +634,8 @@
}); });
if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) { if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) {
sel.value = prev; sel.value = prev;
} else if (exps.length) {
sel.value = pickNearestExpiry(exps);
} }
} }
@@ -810,7 +933,8 @@
if (!list.length) { if (!list.length) {
const label = moneyFilterLabel(); const label = moneyFilterLabel();
const suffix = label ? label : optTypeLabel(state.optType); 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; state.selectedInst = null;
return; return;
} }
@@ -869,8 +993,10 @@
const atmStrike = findAtmStrike(rows, indexPx); const atmStrike = findAtmStrike(rows, indexPx);
let matchedSelected = false; let matchedSelected = false;
rows.forEach(function (row) { rows.forEach(function (row) {
const call = row.call; const callRaw = row.call;
const put = row.put; 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 combined = straddleAskPerUnit(call && call.ask, put && put.ask);
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "opt-strike-row opt-strike-row-t"; tr.className = "opt-strike-row opt-strike-row-t";
@@ -1048,6 +1174,7 @@
panelCache.chain = d; panelCache.chain = d;
panelCache.underlying = uly; panelCache.underlying = uly;
panelCache.optType = state.optType; panelCache.optType = state.optType;
syncAskLiqFilterFromChain(d);
if (!soft) { if (!soft) {
state.selectedInst = null; state.selectedInst = null;
resetMoneyFilterToAll(); resetMoneyFilterToAll();
@@ -1084,16 +1211,16 @@
async function openPosition() { async function openPosition() {
if (!state.selectedInst) { if (!state.selectedInst) {
alert("请先选择合约"); alert("请先选择合约");
return; return false;
} }
const q = state.orderQuote; const q = state.orderQuote;
if (!q || !q.ok || !q.can_open) { if (!q || !q.ok || !q.can_open) {
alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓"); alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓");
return; return false;
} }
if (q.sizing && q.sizing.ok === false) { if (q.sizing && q.sizing.ok === false) {
alert(q.sizing.msg || "张数无效"); alert(q.sizing.msg || "张数无效");
return; return false;
} }
const btn = document.getElementById("opt-open-btn"); const btn = document.getElementById("opt-open-btn");
btn.disabled = true; btn.disabled = true;
@@ -1114,7 +1241,7 @@
const tgt = parseFloat(tgtRaw); const tgt = parseFloat(tgtRaw);
if (!Number.isFinite(tgt) || tgt <= 0) { if (!Number.isFinite(tgt) || tgt <= 0) {
alert("目标位无效"); alert("目标位无效");
return; return false;
} }
body.target_index = tgt; body.target_index = tgt;
} }
@@ -1124,16 +1251,19 @@
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const msgEl = document.getElementById("opt-order-msg"); 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); msgEl.classList.toggle("opt-error", !d.ok);
if (d.ok) { if (d.ok) {
refreshPendingOrders(); refreshPendingOrders();
startPendingOrdersPoll(); startPendingOrdersPoll();
refreshAllPositions(); refreshAllPositions();
if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot();
} else { closeOrderDialog();
alert(d.msg || "下单失败"); setOptionsPosTab("pending");
return true;
} }
alert(d.msg || "下单失败");
return false;
} finally { } finally {
const latest = state.orderQuote; const latest = state.orderQuote;
btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false));
@@ -1158,11 +1288,14 @@
return ( return (
'<div class="pos-card-head">' + '<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' + '<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">' + '<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">买一平仓</button>' + '<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div></div>" + "</div></div>" +
'<div class="pos-meta">' + '<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.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" + '<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr (expAttr
@@ -1320,6 +1453,7 @@
'<span class="opt-pos-bar-id-group">' + '<span class="opt-pos-bar-id-group">' +
'<strong class="opt-pos-bar-title" title="' + inst + '">' + inst + "</strong>" + '<strong class="opt-pos-bar-title" title="' + inst + '">' + inst + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" + '<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</span>" + "</span>" +
'<span class="opt-pos-bar-meta">行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张</span>" + '<span class="opt-pos-bar-meta">行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张</span>" +
"</span>" + "</span>" +
@@ -1561,6 +1695,10 @@
if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer(); OptionsExpiryCountdown.ensureTimer();
} }
if (tab === "pending") {
refreshPendingOrders();
startPendingOrdersPoll();
}
} }
function bindOptionsPosTabs() { function bindOptionsPosTabs() {
@@ -1934,6 +2072,7 @@
} }
function bootOptionsPanel() { function bootOptionsPanel() {
applyBudgetBuffer(state.budgetBuffer);
updateSizeInputs(); updateSizeInputs();
syncMoneyFilterButtons(); syncMoneyFilterButtons();
syncChainViewUI(); syncChainViewUI();
@@ -2021,6 +2160,7 @@
}); });
} }
bindOptionsPosTabs(); bindOptionsPosTabs();
hardenOrderAutofill();
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
r.addEventListener("change", function () { 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) { ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
+26 -1
View File
@@ -38,6 +38,28 @@
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; 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) { function pnlCls(upl, hub) {
if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit"; if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss"; if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
@@ -147,10 +169,13 @@
return ( return (
'<div class="pos-card-head">' + '<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" + '<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 + headActions +
"</div>" + "</div>" +
'<div class="pos-meta">' + '<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.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" + '<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr (expAttr
+334 -98
View File
@@ -67,23 +67,74 @@
return s; 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) { function tradeTitle(t) {
if (!t) return "—"; if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || "—"; if (t.source_type === "option_spot") return t.inst_id || "—";
return ( return (
(t.underlying || "") + (t.underlying || "") +
(t.direction ? " " + t.direction : "") + (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); var n = Number(v);
if (n > 0) return "color:#3dd68c"; if (n > 0) return "pos-pnl-profit";
if (n < 0) return "color:#f07178"; if (n < 0) return "pos-pnl-loss";
return ""; 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() { function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") { if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, ""); return global.crypto.randomUUID().replace(/-/g, "");
@@ -98,12 +149,12 @@
p.set("source_type", activeSource); p.set("source_type", activeSource);
var uly = ($("or-filter-uly") || {}).value || ""; var uly = ($("or-filter-uly") || {}).value || "";
var opt = ($("or-filter-opt") || {}).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 from = ($("or-filter-from") || {}).value || "";
var to = ($("or-filter-to") || {}).value || ""; var to = ($("or-filter-to") || {}).value || "";
if (uly) p.set("underlying", uly); if (uly) p.set("underlying", uly);
if (opt) p.set("opt_type", opt); 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 (from) p.set("closed_from", from.replace("T", " ") + ":00");
if (to) p.set("closed_to", to.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"); if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1");
@@ -285,10 +336,10 @@
if (!tbody) return; if (!tbody) return;
var wrap = beginListLoad("or-trades-wrap", soft); var wrap = beginListLoad("or-trades-wrap", soft);
if (!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(); var p = baseQs();
p.set("reviewed", "0"); // 交易记录保留已复盘条目,不再只显示待复盘
p.set("limit", String(PAGE_SIZE)); p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE)); p.set("offset", String(tradesPage * PAGE_SIZE));
if (!doSync) p.set("sync", "0"); if (!doSync) p.set("sync", "0");
@@ -299,7 +350,7 @@
.then(function (data) { .then(function (data) {
if (doSync) setSyncStatus("本地记录已加载"); if (doSync) setSyncStatus("本地记录已加载");
if (!data.ok) { 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); endListLoad(wrap);
return; return;
} }
@@ -311,7 +362,7 @@
tradesCache = {}; tradesCache = {};
if (!rows.length) { if (!rows.length) {
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="6" class="muted">暂无待复盘记录</td></tr>'; '<tr><td colspan="7" class="muted">暂无交易记录</td></tr>';
endListLoad(wrap); endListLoad(wrap);
return; return;
} }
@@ -319,6 +370,17 @@
.map(function (t) { .map(function (t) {
tradesCache[t.id] = t; tradesCache[t.id] = t;
var active = currentTradeId === t.id ? " or-row-active" : ""; 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 ( return (
'<tr class="or-trade-row' + '<tr class="or-trade-row' +
active + active +
@@ -327,26 +389,29 @@
'">' + '">' +
"<td><span class=\"or-badge\">" + "<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) + escapeHtml(t.source_label || t.source_type) +
"</span></td>" + "</span>" +
badgeExtra +
"</td>" +
"<td>" + "<td>" +
escapeHtml(tradeTitle(t)) + escapeHtml(tradeTitle(t)) +
"</td>" + "</td>" +
'<td style="' + '<td class="' +
pnlStyle(t.realized_pnl_total) + pnlClass(t.realized_pnl_total) +
'">' + '">' +
fmtPnl(t.realized_pnl_total) + fmtPnl(t.realized_pnl_total) +
"</td>" + "</td>" +
'<td class="muted" style="font-size:12px">' + '<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") + escapeHtml(t.opened_at || "—") +
"<br>" + "</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") + escapeHtml(t.closed_at || "—") +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmtHold(t.hold_seconds) + fmtHold(t.hold_seconds) +
"</td>" + "</td>" +
'<td><button type="button" class="btn or-review-btn" data-id="' + "<td>" +
t.id + actionBtn +
'" style="font-size:.72rem;padding:2px 8px">复盘</button> ' + " " +
'<button type="button" class="btn-secondary or-hide-btn" data-id="' + '<button type="button" class="btn-secondary or-hide-btn" data-id="' +
t.id + t.id +
'" style="font-size:.72rem;padding:2px 8px">删除</button></td>' + '" style="font-size:.72rem;padding:2px 8px">删除</button></td>' +
@@ -371,7 +436,7 @@
endListLoad(wrap); endListLoad(wrap);
}) })
.catch(function () { .catch(function () {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap); endListLoad(wrap);
}); });
} }
@@ -384,7 +449,7 @@
if (!tbody) return; if (!tbody) return;
var wrap = beginListLoad("or-reviewed-wrap", soft); var wrap = beginListLoad("or-reviewed-wrap", soft);
if (!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(); var p = baseQs();
p.set("reviewed", "1"); p.set("reviewed", "1");
@@ -397,7 +462,7 @@
}) })
.then(function (data) { .then(function (data) {
if (!data.ok) { 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); endListLoad(wrap);
return; return;
} }
@@ -408,13 +473,16 @@
var rows = data.trades || []; var rows = data.trades || [];
reviewedCache = {}; reviewedCache = {};
if (!rows.length) { 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); endListLoad(wrap);
return; return;
} }
tbody.innerHTML = rows tbody.innerHTML = rows
.map(function (t) { .map(function (t) {
reviewedCache[t.id] = 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 ( return (
'<tr class="or-reviewed-row" data-id="' + '<tr class="or-reviewed-row" data-id="' +
t.id + t.id +
@@ -423,20 +491,37 @@
escapeHtml(t.source_label || t.source_type) + escapeHtml(t.source_label || t.source_type) +
"</span></td>" + "</span></td>" +
"<td>" + "<td>" +
escapeHtml(tradeTitle(t)) + escapeHtml(tradeContractLabel(t)) +
"</td>" + "</td>" +
'<td style="' + "<td>" +
pnlStyle(t.realized_pnl_total) + escapeHtml(direction || "—") +
"</td>" +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' + '">' +
fmtPnl(t.realized_pnl_total) + fmtPnl(t.realized_pnl_total) +
"</td>" + "</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>" + "<td>" +
escapeHtml(t.strategy_tag || "—") + escapeHtml(t.strategy_tag || "—") +
"</td>" + "</td>" +
"<td>" + "<td>" +
escapeHtml(entryLogic || "—") +
"</td>" +
'<td class="' +
resultClass(t.result_tag) +
'">' +
escapeHtml(t.result_tag || "—") + escapeHtml(t.result_tag || "—") +
"</td>" + "</td>" +
'<td class="muted" style="font-size:12px">' + '<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.reviewed_at || "—") + escapeHtml(t.reviewed_at || "—") +
"</td>" + "</td>" +
"</tr>" "</tr>"
@@ -451,25 +536,51 @@
endListLoad(wrap); endListLoad(wrap);
}) })
.catch(function () { .catch(function () {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>'; tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
endListLoad(wrap); 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() { function hideDetail() {
var panel = $("or-detail-panel"); hideLightbox();
if (panel) panel.classList.add("hidden"); var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true;
} }
function openDetail(tradeId) { function openDetail(tradeId) {
var panel = $("or-detail-panel"); var backdrop = $("or-detail-backdrop");
if (!panel) return; if (!backdrop) return;
panel.classList.remove("hidden"); backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…"; ($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = ""; ($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = ""; ($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = ""; ($("or-detail-images") || {}).innerHTML = "";
panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) { .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) { function renderDetail(t) {
var e = t.entry || {}; var e = t.entry || {};
reviewedCache[t.id] = t; reviewedCache[t.id] = t;
@@ -502,8 +698,8 @@
["合约/计划", tradeTitle(t)], ["合约/计划", tradeTitle(t)],
["盈亏", fmtPnl(t.realized_pnl_total)], ["盈亏", fmtPnl(t.realized_pnl_total)],
["持有", fmtHold(t.hold_seconds)], ["持有", fmtHold(t.hold_seconds)],
["开仓", t.opened_at || "—"], ["开仓时间", t.opened_at || "—"],
["平仓", t.closed_at || "—"], ["平仓时间", t.closed_at || "—"],
["策略", e.strategy_tag || "—"], ["策略", e.strategy_tag || "—"],
["方向", e.direction_view || "—"], ["方向", e.direction_view || "—"],
["结果", e.result_tag || "—"], ["结果", e.result_tag || "—"],
@@ -517,10 +713,20 @@
} }
meta.innerHTML = cells meta.innerHTML = cells
.map(function (pair) { .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 ( return (
"<div><div class=\"muted\" style=\"font-size:11px\">" + "<div><div class=\"muted\" style=\"font-size:11px\">" +
escapeHtml(pair[0]) + escapeHtml(pair[0]) +
"</div><div>" + '</div><div class="' +
cls +
'">' +
escapeHtml(pair[1]) + escapeHtml(pair[1]) +
"</div></div>" "</div></div>"
); );
@@ -540,13 +746,15 @@
.map(function (leg) { .map(function (leg) {
return ( return (
"<tr><td>" + "<tr><td>" +
escapeHtml(leg.leg_role || "") + escapeHtml(legRoleLabel(leg.leg_role)) +
"</td><td>" + "</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") + escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" + "</td><td class=\"" +
pnlClass(leg.realized_pnl) +
"\">" +
fmtPnl(leg.realized_pnl) + fmtPnl(leg.realized_pnl) +
"</td><td>" + "</td><td>" +
escapeHtml(leg.close_reason || "") + escapeHtml(closeReasonLabel(leg.close_reason)) +
"</td></tr>" "</td></tr>"
); );
}) })
@@ -559,64 +767,25 @@
var imagesHost = $("or-detail-images"); var imagesHost = $("or-detail-images");
if (imagesHost) { if (imagesHost) {
var images = e.images || []; renderDetailImages(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");
}
});
});
}
} }
} }
function renderGroup(title, items) { function renderGroup(title, items) {
if (!items || !items.length) { if (!items || !items.length) return "";
return (
'<div class="or-stat-card"><div class="muted">' +
title +
'</div><div class="muted">无数据</div></div>'
);
}
var lines = items var lines = items
.slice(0, 8) .slice(0, 8)
.map(function (g) { .map(function (g) {
var keyLabel =
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
return ( return (
'<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px">' + '<div class="or-stat-row">' +
"<span>" + '<span class="or-stat-key">' +
escapeHtml(g.key) + escapeHtml(keyLabel) +
" · " + " · " +
g.count + g.count +
"笔</span>" + "笔</span>" +
"<span>" + '<span class="or-stat-val">' +
fmtPnl(g.pnl_sum) + fmtPnl(g.pnl_sum) +
" / 胜" + " / 胜" +
(g.win_rate || 0) + (g.win_rate || 0) +
@@ -626,7 +795,7 @@
}) })
.join(""); .join("");
return ( 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 + title +
"</div>" + "</div>" +
lines + lines +
@@ -654,23 +823,31 @@
["平均持有", fmtHold(k.avg_hold_sec)], ["平均持有", fmtHold(k.avg_hold_sec)],
] ]
.map(function (pair) { .map(function (pair) {
var cls = "";
if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum);
if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl);
return ( return (
'<div><div class="muted" style="font-size:12px">' + '<div class="or-kpi-tile"><div class="or-kpi-label">' +
pair[0] + pair[0] +
'</div><div style="font-weight:600">' + '</div><div class="or-kpi-value' +
(cls ? " " + cls : "") +
'">' +
pair[1] + pair[1] +
"</div></div>" "</div></div>"
); );
}) })
.join(""); .join("");
groups.innerHTML = [ var html = [
renderGroup("按类型", data.by_source_type), renderGroup("按类型", data.by_source_type),
renderGroup("按标的", data.by_underlying), renderGroup("按标的", data.by_underlying),
renderGroup("按策略", data.by_strategy), renderGroup("按策略", data.by_strategy),
renderGroup("对冲结束原因", data.by_close_reason), renderGroup("对冲结束原因", data.by_close_reason),
renderGroup("持有周期", data.by_hold_bucket), renderGroup("持有周期", data.by_hold_bucket),
renderGroup("Call/Put", data.by_opt_type), renderGroup("Call/Put", data.by_opt_type),
].join(""); ]
.filter(Boolean)
.join("");
groups.innerHTML = html || '<div class="muted" style="font-size:.76rem">暂无分组数据</div>';
}) })
.catch(function () {}); .catch(function () {});
} }
@@ -849,12 +1026,13 @@
($("or-f-inst") || {}).value = ($("or-f-inst") || {}).value =
t.source_type === "option_spot" t.source_type === "option_spot"
? t.inst_id || "" ? 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-pnl") || {}).value = fmtPnl(t.realized_pnl_total);
($("or-f-hold") || {}).value = fmtHold(t.hold_seconds); ($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
setSelectValue($("or-f-strategy"), e.strategy_tag || ""); setSelectValue($("or-f-strategy"), e.strategy_tag || "");
setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t)); 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 || ""; ($("or-f-followed") || {}).value = e.followed_plan || "";
setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total)); setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total));
setSelectValue($("or-f-entry"), e.entry_logic || ""); setSelectValue($("or-f-entry"), e.entry_logic || "");
@@ -883,7 +1061,21 @@
); );
if (hidden && img.file) { if (hidden && img.file) {
hidden.value = 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) { .map(function (leg) {
return ( return (
"<tr><td>" + "<tr><td>" +
escapeHtml(leg.leg_role || "") + escapeHtml(legRoleLabel(leg.leg_role)) +
"</td><td>" + "</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") + escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" + "</td><td>" +
fmtPnl(leg.realized_pnl) + fmtPnl(leg.realized_pnl) +
"</td><td>" + "</td><td>" +
escapeHtml(leg.close_reason || "") + escapeHtml(closeReasonLabel(leg.close_reason)) +
"</td></tr>" "</td></tr>"
); );
}) })
@@ -1068,9 +1260,34 @@
if (detailEdit) { if (detailEdit) {
detailEdit.addEventListener("click", function () { detailEdit.addEventListener("click", function () {
var id = Number(detailEdit.getAttribute("data-id") || 0); 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) { ["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) {
var el = $(id); var el = $(id);
if (el) { 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); var el = $(id);
if (el) { if (el) {
el.addEventListener("change", function () { el.addEventListener("change", function () {
@@ -1094,9 +1311,28 @@
bindUploadSlots(); bindUploadSlots();
hideJournalForm(); hideJournalForm();
hideDetail(); hideDetail();
hardenSearchAutofill();
setActiveTab("option_spot"); 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 = { global.OptionsReview = {
init: init, init: init,
openJournalForm: openJournalForm, 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",
"RISK_COOLING_HOURS_MANUAL_JOURNAL", "RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT", "RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE", "RISK_MOOD_ISSUES_DAILY_FREEZE",
"KEY_AUTO_ORDER_ENABLED", "KEY_AUTO_ORDER_ENABLED",
"TRADE_DIRECTION_RESTRICT_ENABLED", "TRADE_DIRECTION_RESTRICT_ENABLED",
@@ -83,11 +84,20 @@ HOT_RELOAD_EXACT = frozenset({
"APP_AUTH_DISABLED", "APP_AUTH_DISABLED",
"WECHAT_WEBHOOK", "WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED", "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_LIVE_ORDER",
"HEDGE_PLAN_OPEN_ORDER", "HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "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", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS", "HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
@@ -116,6 +126,10 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("long_only", "仅做多"), ("long_only", "仅做多"),
("short_only", "仅做空"), ("short_only", "仅做空"),
), ),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
} }
_SELECT_ALIASES: dict[str, dict[str, str]] = { _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_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"), ("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"), ("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""), (
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"切点前禁止新开仓",
"默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""), ("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"), ("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"), ("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"),
@@ -90,6 +94,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""), ("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""), ("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""), ("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""), ("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
], ],
}, },
@@ -126,6 +131,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), ("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ("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"}), "exchanges": frozenset({"okx"}),
"fields": [ "fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"), ("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_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), ("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"), ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), ("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": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1", "RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2", "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true", "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, "") 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]]: def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {} out: dict[str, dict[str, Any]] = {}
for group in parse_env_example_schema(example_path): for group in parse_env_example_schema(example_path):
@@ -185,6 +245,13 @@ def _build_field(
meta = schema.get(key) or {} meta = schema.get(key) or {}
schema_default = meta.get("default") or "" schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default) 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) masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default) ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key) options = select_options_for(key)
@@ -296,3 +363,26 @@ def validate_env_ui_updates(
) )
groups.append({"title": sec["title"], "fields": fields}) groups.append({"title": sec["title"], "fields": fields})
return validate_env_updates(groups, updates) 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)} 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( def place_option_limit_order(
ex: ccxt.okx, ex: ccxt.okx,
*, *,
@@ -973,12 +1086,16 @@ def place_option_limit_order(
tick_sz: Any = None, tick_sz: Any = None,
reduce_only: bool = False, reduce_only: bool = False,
pos_side: str | None = None, pos_side: str | None = None,
ord_type: str = "limit",
) -> dict[str, Any]: ) -> dict[str, Any]:
side_l = (side or "").lower() side_l = (side or "").lower()
if side_l not in ("buy", "sell"): if side_l not in ("buy", "sell"):
return {"ok": False, "msg": "side 必须为 buy 或 sell"} return {"ok": False, "msg": "side 必须为 buy 或 sell"}
if sheets < 1: if sheets < 1:
return {"ok": False, "msg": "张数至少为 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) px = round_option_px(float(price), tick_sz, side_l)
if px <= 0: if px <= 0:
return {"ok": False, "msg": "价格无效"} return {"ok": False, "msg": "价格无效"}
@@ -986,7 +1103,7 @@ def place_option_limit_order(
"instId": inst_id, "instId": inst_id,
"tdMode": td_mode, "tdMode": td_mode,
"side": side_l, "side": side_l,
"ordType": "limit", "ordType": ot,
"px": format_option_px(px, tick_sz), "px": format_option_px(px, tick_sz),
"sz": str(int(sheets)), "sz": str(int(sheets)),
} }
@@ -998,7 +1115,7 @@ def place_option_limit_order(
resp = ex.private_post_trade_order(body) resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or [] data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0": 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} return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px}
except Exception as e: except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e), "px": px} 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 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( def build_perp_options_preview(
*, *,
direction: str, direction: str,
@@ -320,6 +541,10 @@ def build_options_options_preview(
"expiry_flat_total": round(expiry_loss, 4), "expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6), "premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0, "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, live_trading: bool = False,
active_count: int = 0, active_count: int = 0,
max_active: int = 1, 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]: ) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode from lib.trade.position_sizing_lib import is_full_margin_mode
@@ -349,12 +578,23 @@ def gate_status(
can_preview = False can_preview = False
can_start = False can_start = False
reasons.append("期权模块未启用") 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: if not live_order:
can_start = False can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)") reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)): if active_count >= max(1, int(max_active or 1)):
can_start = False can_start = False
reasons.append(f"活跃计划已达上限({max_active})") reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options": if pt == "perp_options":
if not full: if not full:
can_start = False can_start = False
@@ -379,6 +619,10 @@ def gate_status(
"live_trading": live_trading, "live_trading": live_trading,
"active_count": active_count, "active_count": active_count,
"max_active": max_active, "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_preview": can_preview,
"can_start": can_start, "can_start": can_start,
"reasons": reasons, "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_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "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: 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]) 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( def list_plans(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
@@ -182,15 +200,22 @@ def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = [] parts: list[str] = []
for leg in legs: for leg in legs:
role = str(leg.get("leg_role") or "") 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": if role == "perp":
name = str(leg.get("symbol") or "永续") name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}") parts.append(f"永续 {name}{suffix}")
else: else:
inst = str(leg.get("inst_id") or "") inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper() ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike") strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role) 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 "" 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 = dict(p)
row["legs"] = legs row["legs"] = legs
row["contracts_summary"] = legs_contract_summary(legs) row["contracts_summary"] = legs_contract_summary(legs)
row["missing_leg"] = missing_leg_role(legs)
out.append(row) out.append(row)
return out 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_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_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_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: 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"} return {"ok": False, "msg": "get_db missing"}
conn = get_db() conn = get_db()
acted: list[dict[str, Any]] = [] acted: list[dict[str, Any]] = []
backfill_stats: dict[str, int] = {}
try: try:
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables 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) acted.append(r)
orphaned = _settle_orphaned_after_tp(cfg, conn) orphaned = _settle_orphaned_after_tp(cfg, conn)
acted.extend(orphaned) 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() conn.commit()
finally: finally:
conn.close() 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: 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) 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]]: def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]:
pt = plan.get("plan_type") pt = plan.get("plan_type")
legs = get_plan_legs(conn, int(plan["id"])) 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 return r
if pt == "options_options": if pt == "options_options":
r = _tick_oo_expiry(cfg, conn, plan, legs) r = _tick_oo_expiry(cfg, conn, plan, legs)
if r:
return r
r = _tick_oo_close_rest(cfg, conn, plan, legs)
if r: if r:
return r return r
return _tick_oo_target(cfg, conn, plan, legs) 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")) ask_open = _sf(opt.get("avg_open"))
if bid is not None and ask_open is not None: if bid is not None and ask_open is not None:
ct = float(opt.get("ct_mult") or 0.01) 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: else:
opt_pnl = -premium est = -premium
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), opt_pnl, opt["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} 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( def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""期期:触及上破或下破目标价时平盈利腿.""" """期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None: if idx is None:
return None return None
@@ -261,7 +422,7 @@ def _tick_oo_target(
return None return None
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None 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: if len(open_legs) < 2:
return None return None
winners = [] winners = []
@@ -288,21 +449,51 @@ def _tick_oo_target(
) )
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" 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( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "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) update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan) mid = dict(plan)
mid["close_reason"] = reason mid["close_reason"] = reason
mid["status"] = "active" mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid) 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 { return {
"plan_id": plan["id"], "plan_id": plan["id"],
"close_reason": reason, "close_reason": reason,
"hit_side": hit_side, "hit_side": hit_side,
"closed_leg": best.get("id"), "closed_leg": best.get("id"),
"index": idx, "index": idx,
"oo_close_mode": rest_mode,
} }
@@ -347,7 +538,8 @@ def _tick_oo_expiry(
settled_sum = 0.0 settled_sum = 0.0
for leg in pending: 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 settled_sum += pnl
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "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")) spot = _index_px(cfg, str(leg.get("underlying") or "ETH"))
if spot is None: if spot is None:
continue 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( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "expiry", _now(), round(pnl, 4), leg["leg_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_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿", "target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿",
"oo_rest_closing": "期期全平·清残腿中",
"oo_rest_closed": "期期全平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利", "oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口", "expiry": "到期收口",
@@ -150,14 +152,20 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg", "target_win_leg",
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed": ) and (plan.get("status") or "") != "closed":
side = "上破" if "up" in str(plan.get("close_reason")) else ( side = "上破" if "up" in str(plan.get("close_reason")) else (
"下破" if "down" 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( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title=f"期期{side}已平盈利腿,亏损腿继续持有至到期", title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=( detail=(
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}" 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" 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]]: def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易).""" """永期下单路径清单(不交易)."""
mode = open_order_mode() 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( def _buy_option(
cfg: dict[str, Any], cfg: dict[str, Any],
*, *,
@@ -80,6 +99,7 @@ def _buy_option(
from lib.exchange.okx_options_lib import ( from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth, cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok, option_buy_liquidity_ok,
wait_option_order_full_fill,
) )
ex = cfg.get("exchange_options") ex = cfg.get("exchange_options")
@@ -134,6 +154,7 @@ def _buy_option(
td = "isolated" td = "isolated"
if callable(td_buy): if callable(td_buy):
td = td_buy(cfg.get("options_td_mode") or "isolated") td = td_buy(cfg.get("options_td_mode") or "isolated")
# IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交
order = place_fn( order = place_fn(
ex, ex,
inst_id=inst_id, inst_id=inst_id,
@@ -142,14 +163,42 @@ def _buy_option(
price=float(ask), price=float(ask),
td_mode=td, td_mode=td,
tick_sz=q.get("tick_sz"), tick_sz=q.get("tick_sz"),
ord_type="ioc",
) )
if not order.get("ok"): if not order.get("ok"):
return order 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 { return {
"ok": True, "ok": False,
"msg": fill.get("msg") or "未完全成交,开仓失败",
"inst_id": inst_id, "inst_id": inst_id,
"sheets": sheets_i, "sheets": sheets_i,
"ask": float(ask), "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": filled_n,
"ask": fill_px,
"ask_sz": float(ask_sz), "ask_sz": float(ask_sz),
"premium": premium, "premium": premium,
"ct_mult": ct_mult, "ct_mult": ct_mult,
@@ -158,8 +207,9 @@ def _buy_option(
"strike": q.get("strike"), "strike": q.get("strike"),
"exp_time": q.get("exp_time"), "exp_time": q.get("exp_time"),
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), "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, "order": order,
"fill": fill,
"can_open": True, "can_open": True,
} }
@@ -257,6 +307,244 @@ def _sell_option(
return order if order.get("ok") else order 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( def execute_perp_options_start(
cfg: dict[str, Any], cfg: dict[str, Any],
body: dict[str, Any], body: dict[str, Any],
@@ -264,6 +552,9 @@ def execute_perp_options_start(
dry_run: bool = False, dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None, persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]: ) -> 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) path = build_po_path_plan(body)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
opt_res: Optional[dict[str, Any]] = None opt_res: Optional[dict[str, Any]] = None
@@ -278,6 +569,27 @@ def execute_perp_options_start(
) )
results.append({"step": step["step"], **opt_res}) results.append({"step": step["step"], **opt_res})
if not opt_res.get("ok"): 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} return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else: else:
perp_res = _open_perp( perp_res = _open_perp(
@@ -292,8 +604,7 @@ def execute_perp_options_start(
) )
results.append({"step": step["step"], **perp_res}) results.append({"step": step["step"], **perp_res})
if not perp_res.get("ok"): if not perp_res.get("ok"):
# 半腿补偿:期权已成 + 配置允许则平期权 if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled():
if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
close_r = _sell_option( close_r = _sell_option(
cfg, cfg,
inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""),
@@ -301,15 +612,37 @@ def execute_perp_options_start(
) )
results.append({"step": "options_auto_close_on_perp_fail", **close_r}) results.append({"step": "options_auto_close_on_perp_fail", **close_r})
msg = perp_res.get("msg") or "永续开仓失败" msg = perp_res.get("msg") or "永续开仓失败"
if not dry_run: _notify_partial(cfg, "perp_options", msg, results)
try: return {
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail "ok": False,
"msg": msg,
notify_partial_fail( "path": path,
cfg, plan_type="perp_options", msg=msg, results=results "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,
) )
except Exception: msg = perp_res.get("msg") or "永续开仓失败"
pass if not dry_run:
_notify_partial(cfg, "perp_options", msg, results)
return { return {
"ok": False, "ok": False,
"msg": msg, "msg": msg,
@@ -326,6 +659,7 @@ def execute_perp_options_start(
"results": results, "results": results,
"option": opt_res, "option": opt_res,
"perp": perp_res, "perp": perp_res,
"refresh": refresh,
"opened_at": _now(), "opened_at": _now(),
} }
if persist and not dry_run: if persist and not dry_run:
@@ -340,6 +674,9 @@ def execute_options_options_start(
dry_run: bool = False, dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None, persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]: ) -> 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) path = build_oo_path_plan(body)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
leg_a = body.get("leg_a") or {} 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) 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}) results.append({"step": "options_buy_limit", "leg": "a", **a_res})
if not a_res.get("ok"): 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) 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}) results.append({"step": "options_buy_limit", "leg": "b", **b_res})
if not b_res.get("ok"): 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)) 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}) results.append({"step": "options_auto_close_leg_a", **close_r})
msg = b_res.get("msg") or "腿B开仓失败" msg = b_res.get("msg") or "腿B开仓失败"
if not dry_run: _notify_partial(cfg, "options_options", msg, results)
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
return { return {
"ok": False, "ok": False,
"msg": msg, "msg": msg,
"path": path, "path": path,
"results": results, "results": results,
"partial": True, "partial": True,
"refresh": refresh,
}
if not dry_run and manual_complete_on_partial() and persist:
out_p = _park_partial(
cfg,
plan_type="options_options",
body=body,
missing_leg="option_b",
msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
leg_a=a_res,
leg_b=None,
)
out_p["refresh"] = refresh
return out_p
msg = b_res.get("msg") or "腿B开仓失败"
if not dry_run:
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
} }
out = { out = {
"ok": True, "ok": True,
@@ -377,6 +742,7 @@ def execute_options_options_start(
"results": results, "results": results,
"leg_a": a_res, "leg_a": a_res,
"leg_b": b_res, "leg_b": b_res,
"refresh": refresh,
"opened_at": _now(), "opened_at": _now(),
} }
if persist and not dry_run: if persist and not dry_run:
@@ -384,6 +750,73 @@ def execute_options_options_start(
return out 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]: def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower() pt = (plan_type or "").strip().lower()
if pt == "perp_options": if pt == "perp_options":
@@ -427,3 +860,142 @@ def dump_preview(preview: Any) -> str:
return json.dumps(preview, ensure_ascii=False)[:8000] return json.dumps(preview, ensure_ascii=False)[:8000]
except Exception: except Exception:
return "" 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,
}
+281 -31
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any from typing import Any, Optional
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from jinja2 import ChoiceLoader, FileSystemLoader 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"), "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(), "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(),
"options_account_label": (os.getenv("OKX_OPTIONS_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), "live_trading": _env_bool("LIVE_TRADING_ENABLED", False),
"send_wechat": getattr(app_module, "send_wechat_msg", None), "send_wechat": getattr(app_module, "send_wechat_msg", None),
} }
@@ -106,6 +111,40 @@ def _hedge_enabled() -> bool:
return _env_bool("HEDGE_PLAN_ENABLED", False) 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: def _live_order() -> bool:
return _env_bool("HEDGE_PLAN_LIVE_ORDER", False) 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]: def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
active = 0 active = 0
has_standalone = False
mutual = True
try: 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 from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
mutual = mutual_exclusive_enabled()
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_hedge_plan_tables(conn) init_hedge_plan_tables(conn)
active = count_active_plans(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() conn.commit()
finally: finally:
conn.close() conn.close()
except Exception: except Exception:
active = 0 active = 0
has_standalone = False
return gate_status( return gate_status(
hedge_enabled=_hedge_enabled(), hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(), 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), live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False),
active_count=active, active_count=active,
max_active=_max_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: def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
if not _hedge_enabled(): if not _hedge_enabled():
return return
@@ -171,6 +241,18 @@ def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
cfg["hedge_monitor_thread"] = t 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: def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
from lib.hedge_plan.hedge_plan_db import ( from lib.hedge_plan.hedge_plan_db import (
get_plan, 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"]() conn = cfg["get_db"]()
try: try:
init_hedge_plan_tables(conn) 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 {} opt = result.get("option") or {}
perp = result.get("perp") 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( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "perp_options", "plan_type": "perp_options",
"status": "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"direction": str(body.get("direction") or "long"), "direction": str(body.get("direction") or "long"),
"entry_mark": float(body.get("entry") or 0), "entry_mark": float(body.get("entry") or 0),
"tp": float(body.get("tp") or 0), "tp": float(body.get("tp") or 0),
"sl": float(body.get("sl") or 0), "sl": float(body.get("sl") or 0),
"sizing_mode_at_open": load_position_sizing_mode(), "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"), "margin": body.get("margin"),
"leverage": float(body.get("leverage") or 10), "leverage": float(body.get("leverage") or 10),
"premium_total": premium, "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"), "opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
}, },
) )
insert_leg( insert_leg(
@@ -212,11 +305,11 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"leg_role": "perp", "leg_role": "perp",
"symbol": str(body.get("exchange_symbol") or ""), "symbol": str(body.get("exchange_symbol") or ""),
"side": str(body.get("direction") or "long"), "side": str(body.get("direction") or "long"),
"size": float(perp.get("contracts") or body.get("contracts") or 0), "size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0), "avg_open": float(body.get("entry") or 0) if perp_ok else None,
"status": "open", "status": "open" if perp_ok else "pending",
"exchange_ord_id": str(perp.get("exchange_ord_id") or ""), "exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"), "opened_at": result.get("opened_at") if perp_ok else None,
}, },
) )
insert_leg( insert_leg(
@@ -224,19 +317,20 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
{ {
"plan_id": plan_id, "plan_id": plan_id,
"leg_role": "option_hedge", "leg_role": "option_hedge",
"inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""), "inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""), "opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
"strike": opt.get("strike") or body.get("strike"), "strike": (opt or {}).get("strike") or body.get("strike"),
"side": "buy", "side": "buy",
"size": float(opt.get("sheets") or body.get("sheets") or 1), "size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
"avg_open": float(opt.get("ask") or 0), "avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None,
"premium": premium, "premium": premium if opt_ok else 0,
"status": "open", "status": "open" if opt_ok else "pending",
"exchange_ord_id": str(opt.get("exchange_ord_id") or ""), "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"), "opened_at": result.get("opened_at") if opt_ok else None,
}, },
) )
conn.commit() conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id) plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id) legs = get_plan_legs(conn, plan_id)
if plan: if plan:
@@ -260,14 +354,20 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_hedge_plan_tables(conn) 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 {} a = result.get("leg_a") or {}
b = result.get("leg_b") 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( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "options_options", "plan_type": "options_options",
"status": "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float( "target_price": float(
body.get("target_price_up") body.get("target_price_up")
@@ -286,28 +386,36 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
), ),
"sizing_mode_at_open": load_position_sizing_mode(), "sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium, "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"), "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( insert_leg(
conn, conn,
{ {
"plan_id": plan_id, "plan_id": plan_id,
"leg_role": role, "leg_role": role,
"inst_id": str(res.get("inst_id") or src.get("inst_id") or ""), "inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""),
"opt_type": str(res.get("opt_type") or src.get("opt_type") or ""), "opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""),
"strike": res.get("strike") or src.get("strike"), "strike": (res or {}).get("strike") or src.get("strike"),
"side": "buy", "side": "buy",
"size": float(res.get("sheets") or src.get("sheets") or 1), "size": float((res or {}).get("sheets") or src.get("sheets") or 1),
"avg_open": float(res.get("ask") or 0), "avg_open": float((res or {}).get("ask") or 0) if ok else None,
"premium": float(res.get("premium") or 0), "premium": float((res or {}).get("premium") or 0) if ok else 0,
"status": "open", "status": "open" if ok else "pending",
"exchange_ord_id": str(res.get("exchange_ord_id") or ""), "exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"), "opened_at": result.get("opened_at") if ok else None,
}, },
) )
conn.commit() conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id) plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id) legs = get_plan_legs(conn, plan_id)
if plan: if plan:
@@ -335,7 +443,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
@lr @lr
def api_hedge_gates(): def api_hedge_gates():
plan_type = (request.args.get("plan_type") or "perp_options").strip() 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") @app.route("/api/hedge-plan/market")
@lr @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_label": cfg.get("options_account_label") or "期权账户",
"account_note": "期权腿使用期权账户(交易 USDC)", "account_note": "期权腿使用期权账户(交易 USDC)",
"options_account": opt_acct, "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 out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400) 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") @app.route("/api/hedge-plan/list")
@lr @lr
def api_hedge_list(): def api_hedge_list():
@@ -530,6 +764,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables, init_hedge_plan_tables,
list_plans, list_plans,
) )
from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
@@ -538,6 +773,16 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
for status in ("opening", "active", "partial"): for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80)) rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) 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) plans = attach_legs_to_plans(conn, rows)
conn.commit() conn.commit()
finally: finally:
@@ -567,6 +812,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables, init_hedge_plan_tables,
legs_contract_summary, legs_contract_summary,
) )
from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
@@ -574,6 +820,10 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
plan = get_plan(conn, plan_id) plan = get_plan(conn, plan_id)
if not plan: if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404 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) legs = get_plan_legs(conn, plan_id)
conn.commit() conn.commit()
finally: finally:
+153 -2
View File
@@ -1,12 +1,17 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口.""" """对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations from __future__ import annotations
import os
import time 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 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]: def _sf(v: Any) -> Optional[float]:
try: try:
@@ -60,3 +65,149 @@ def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int]
if not opts: if not opts:
return False return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts) 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-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}" data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_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-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}"> data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %} {% if not hedge_plan_enabled %}
@@ -10,6 +14,9 @@
{% if not options_enabled %} {% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div> <div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %} {% 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="card hp-head-card">
<div class="hp-head-row"> <div class="hp-head-row">
@@ -19,8 +26,12 @@
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button> <button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div> </div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类"> <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> <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="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="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</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 id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout"> <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> <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"> <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 active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</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>
<div id="hp-perp-quote" class="muted hp-quote-line">加载中…</div> <div class="hp-po-top">
<p class="muted hp-unit-hint">单位说明:价格=USDT · 张数=交易所<strong>永续合约张</strong>(精度与 OKX 下单一致) · 盈亏=USDT</p> <div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<div class="form-row" style="flex-wrap:wrap"> <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>
<label>开仓价 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-entry" /></label> <button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空永续"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
<label>止盈 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-tp" /></label> </div>
<label>止损 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-sl" /></label> <span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
<label>张数 <span class="hp-unit">合约张</span> <input type="number" step="any" id="hp-contracts" /></label> </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> </div>
<p class="muted" id="hp-perp-pnl-line"></p>
<p class="muted" id="hp-sizing-line"></p>
</div> </div>
<div class="card hp-opt-card"> <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> <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"> <div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select> <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 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="itm">实值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</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> <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>
<div id="hp-index-line" class="muted hp-quote-line"></div>
<div class="options-strike-table-wrap hp-strike-table-wrap--5"> <div class="options-strike-table-wrap hp-strike-table-wrap--5">
<table class="options-strike-table" id="hp-strike-table"> <table class="options-strike-table" id="hp-strike-table">
<thead> <thead>
@@ -80,66 +115,67 @@
</div> </div>
<div class="form-row hp-pick-row"> <div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label> <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> <span class="muted" id="hp-premium-line"></span>
</div> </div>
<p class="muted hp-unit-hint">单位说明:权利金结算币=<strong>USDC</strong> · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度&gt;0;无深度不可开仓(链上~为参考估算).</p> <div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
<div id="hp-opt-bal-line" class="muted hp-quote-line hp-opt-bal-line"></div>
<div class="form-row hp-action-row"> <div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn">计算</button> <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>
</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>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden> <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="options-dual-grid" id="hp-oo-layout">
<div class="card"> <div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2> <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"> <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 active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div> </div>
<div class="form-row hp-target-row"> <div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" /></label> <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="向下突破" /></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>
<div id="hp-oo-index" class="muted hp-quote-line"></div> <div class="hp-oo-controls">
<div id="hp-oo-bal-line" class="muted hp-quote-line"></div> <div class="hp-oo-ctrl">
<p class="muted hp-unit-hint">震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=<strong>期权张</strong> · 权利金=USDC</p> <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 id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a"> <div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div> <div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span> <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> </label>
</div> </div>
<div class="hp-oo-leg-row" data-leg="b"> <div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div> <div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span> <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> </label>
</div> </div>
<p class="muted" id="hp-oo-prem-line"></p> <p class="muted" id="hp-oo-prem-line"></p>
@@ -170,33 +206,33 @@
</tbody> </tbody>
</table> </table>
</div> </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"> <div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button> <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>
</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>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden> <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 id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div> </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> </div>
<script src="/static/hedge_plan.js?v=13"></script> <script src="/static/hedge_plan.js?v=33"></script>
+1
View File
@@ -61,6 +61,7 @@ def install_instance_theme_static(app) -> None:
"records_review_page.js": "application/javascript; charset=utf-8", "records_review_page.js": "application/javascript; charset=utf-8",
"ai_review_render.js": "application/javascript; charset=utf-8", "ai_review_render.js": "application/javascript; charset=utf-8",
"form_submit_guard.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", "key_monitor_form.js": "application/javascript; charset=utf-8",
"time_close_ui.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", "manual_order_rr_preview.js": "application/javascript; charset=utf-8",
+10 -1
View File
@@ -10,9 +10,15 @@ from typing import Any
from lib.paths import REPO_ROOT from lib.paths import REPO_ROOT
STRATEGY_EXCHANGES: tuple[str, ...] = ("binance", "okx", "gate") STRATEGY_EXCHANGES: tuple[str, ...] = ("playbook", "binance", "okx", "gate")
STRATEGY_META: dict[str, dict[str, str]] = { STRATEGY_META: dict[str, dict[str, str]] = {
"playbook": {
"label": "执行手册",
"title": "交易执行手册(期权为主 · Gate 为辅)",
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
"md_rel": "docs/交易执行手册-期权与Gate.md",
},
"binance": { "binance": {
"label": "币安", "label": "币安",
"title": "币安·山寨多头趋势", "title": "币安·山寨多头趋势",
@@ -43,6 +49,9 @@ def _md_path(exchange_key: str) -> Path:
meta = STRATEGY_META.get((exchange_key or "").strip().lower()) meta = STRATEGY_META.get((exchange_key or "").strip().lower())
if not meta: if not meta:
raise KeyError(exchange_key) 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"] return _strategy_dir() / meta["md_file"]
+21 -13
View File
@@ -87,14 +87,15 @@ OPTIONS_SOURCE_LABELS = {
HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"}) 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"): 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: try:
row = conn.execute( row = conn.execute(
""" """
SELECT p.plan_type SELECT p.plan_type, p.id
FROM hedge_plans p FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.status IN ('opening', 'active', 'partial') WHERE p.status IN ('opening', 'active', 'partial')
@@ -106,13 +107,18 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]:
(inst_id,), (inst_id,),
).fetchone() ).fetchone()
except Exception: except Exception:
return "option", OPTIONS_SOURCE_LABELS["option"] return default
if not row: if not row:
return "option", OPTIONS_SOURCE_LABELS["option"] return default
pt = str((_row_dict(row).get("plan_type") if isinstance(row, dict) else row[0]) or "").strip() d = _row_dict(row)
if pt in OPTIONS_SOURCE_LABELS: pt = str(d.get("plan_type") or "").strip()
return pt, OPTIONS_SOURCE_LABELS[pt] try:
return "option", OPTIONS_SOURCE_LABELS["option"] 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: 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 exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None
except (TypeError, ValueError): except (TypeError, ValueError):
exp_ms = None exp_ms = None
source_key, source_label = ( if conn is not None:
_resolve_options_source(conn, inst) if conn is not None else ("option", OPTIONS_SOURCE_LABELS["option"]) 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 { return {
"id": inst, "id": inst,
"kind": "options", "kind": "options",
@@ -166,6 +173,7 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
"opt_type_label": label, "opt_type_label": label,
"source": source_key, "source": source_key,
"source_label": source_label, "source_label": source_label,
"source_plan_id": source_plan_id,
"pos": pos, "pos": pos,
"exp_time_ms": exp_ms, "exp_time_ms": exp_ms,
"target_monitor": _format_options_target(p), "target_monitor": _format_options_target(p),
@@ -14,6 +14,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_records": True, "show_nav_records": True,
"show_nav_stats": True, "show_nav_stats": True,
"show_nav_risk_policy": True, "show_nav_risk_policy": True,
"show_nav_system_guide": False,
"show_nav_env_config": True, "show_nav_env_config": True,
"show_nav_options": True, "show_nav_options": True,
"show_nav_options_review": True, "show_nav_options_review": True,
@@ -32,6 +33,7 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_records": "交易记录与复盘", "show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析", "show_nav_stats": "统计分析",
"show_nav_risk_policy": "风控说明", "show_nav_risk_policy": "风控说明",
"show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置", "show_nav_env_config": "env配置",
"show_nav_options": "期权", "show_nav_options": "期权",
"show_nav_options_review": "期权复盘", "show_nav_options_review": "期权复盘",
@@ -50,6 +52,7 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"records": "show_nav_records", "records": "show_nav_records",
"stats": "show_nav_stats", "stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy", "risk_policy": "show_nav_risk_policy",
"system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config", "env_config": "show_nav_env_config",
"options": "show_nav_options", "options": "show_nav_options",
"options_review": "show_nav_options_review", "options_review": "show_nav_options_review",
@@ -112,6 +115,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_records", "show_nav_records",
"show_nav_stats", "show_nav_stats",
"show_nav_risk_policy", "show_nav_risk_policy",
"show_nav_system_guide",
"show_nav_env_config", "show_nav_env_config",
"show_nav_options", "show_nav_options",
"show_nav_options_review", "show_nav_options_review",
+2
View File
@@ -22,6 +22,7 @@ EMBED_TABS: tuple[str, ...] = (
"records", "records",
"stats", "stats",
"risk_policy", "risk_policy",
"system_guide",
"env_config", "env_config",
"settings", "settings",
) )
@@ -41,6 +42,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/records": "records", "/records": "records",
"/stats": "stats", "/stats": "stats",
"/risk_policy": "risk_policy", "/risk_policy": "risk_policy",
"/system_guide": "system_guide",
"/env_config": "env_config", "/env_config": "env_config",
"/settings": "settings", "/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 ( from lib.trade.account_risk_lib import (
cooling_hours_manual, cooling_hours_manual,
cooling_hours_manual_journal, cooling_hours_manual_journal,
daily_loss_limit,
manual_close_daily_limit, manual_close_daily_limit,
max_active_positions_from_env, max_active_positions_from_env,
mood_issues_daily_freeze_enabled, mood_issues_daily_freeze_enabled,
@@ -53,6 +54,7 @@ def build_instance_settings_view(
risk_status: Optional[dict[str, Any]] = None, risk_status: Optional[dict[str, Any]] = None,
trade_policy: Optional[TradePolicy] = None, trade_policy: Optional[TradePolicy] = None,
data_export_version: int = 3, data_export_version: int = 3,
open_guard_enabled: Optional[bool] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
rs = risk_status or {} rs = risk_status or {}
sizing_mode = load_position_sizing_mode() 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_on = _env_bool("FORCE_CLOSE_ENABLED", False)
force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0) force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0)
auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False) auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False)
guard_on = (
bool(open_guard_enabled)
if open_guard_enabled is not None
else _env_bool("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", True)
)
sections: list[dict[str, Any]] = [] sections: list[dict[str, Any]] = []
@@ -79,6 +86,12 @@ def build_instance_settings_view(
f"北京时间 {reset_hour}:00", f"北京时间 {reset_hour}:00",
"新交易日统计与部分开仓限制以此为准", "新交易日统计与部分开仓限制以此为准",
), ),
_row(
"允许北京时间切点前开仓",
"已放开(允许开仓)" if not guard_on else "已限制(禁止开仓)",
f"关闭限制后,{reset_hour}:00 前也可斐波成交登记与人工下单;"
"环境配置「切点前禁止新开仓」(TRADING_DAY_RESET_OPEN_GUARD_ENABLED)",
),
_row( _row(
"单日开仓提醒", "单日开仓提醒",
f"{alert_threshold}", f"{alert_threshold}",
@@ -101,6 +114,15 @@ def build_instance_settings_view(
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"), _row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"), _row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
_row("日手动平仓上限", f"{manual_close_daily_limit()}", "超限当日冻结"), _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( _row(
"复盘情绪日冻结", "复盘情绪日冻结",
_on_off(mood_issues_daily_freeze_enabled()), _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]: def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]:
p = (page or "").strip() 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"): if p not in ("settings", "risk_policy", "env_config"):
return {} return {}
display = kwargs.pop("display", None) 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 ( from lib.env.env_ui_manifest import (
build_env_ui_payload, build_env_ui_payload,
filter_updates_for_ui, filter_updates_for_ui,
coerce_hedge_partial_close_with_manual,
validate_env_ui_updates, validate_env_ui_updates,
) )
from lib.env.env_schema import parse_env_example_schema 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) clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
if errors: if errors:
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400 return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean: if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
changed = apply_env_updates(env_path, clean) 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], filter_trade_records_excluding_miss: Callable[[list], list],
app_tz: Any, app_tz: Any,
format_price_fn: Callable[[Any, Any], str] | None = None, format_price_fn: Callable[[Any, Any], str] | None = None,
sync_exchange_pnl_fn: Callable[[Any], Any] | None = None,
) -> None: ) -> None:
"""
sync_exchange_pnl_fn(conn): 可选,列表前节流回填交易所已实现盈亏.
中控只走本 API,不经实例整页渲染,必须在此触发,否则盈亏U会一直显示.
"""
from lib.instance.records_list_lib import list_trade_records_page from lib.instance.records_list_lib import list_trade_records_page
@app.route("/api/trade_records") @app.route("/api/trade_records")
@@ -40,6 +45,11 @@ def register_trade_records_api(
offset = 0 offset = 0
conn = get_db() conn = get_db()
try: try:
if sync_exchange_pnl_fn is not None:
try:
sync_exchange_pnl_fn(conn)
except Exception:
pass
payload = list_trade_records_page( payload = list_trade_records_page(
conn, conn,
start_bj, start_bj,
@@ -10,7 +10,7 @@
<div class="display-prefs-checks"> <div class="display-prefs-checks">
{% for item in group.entries %} {% for item in group.entries %}
<label class="chk-label"> <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 }} {{ item.label }}
</label> </label>
{% endfor %} {% endfor %}
@@ -314,6 +314,10 @@
{% include 'risk_policy_panel.html' %} {% include 'risk_policy_panel.html' %}
{% endif %} {% endif %}
{% if page == 'system_guide' %}
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %} {% if page == 'settings' %}
{% include 'settings_panel.html' %} {% include 'settings_panel.html' %}
{% endif %} {% endif %}
+7 -5
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <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/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/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=10"> <link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=97"> <link rel="stylesheet" href="/static/instance_theme.css?v=105">
<script src="/static/account_risk_badge.js?v=4"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
@@ -56,6 +57,7 @@
{% if display.show_nav_risk_policy %} {% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a> <a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %} {% 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 %} {% 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> <a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
{% endif %} {% endif %}
@@ -64,7 +66,7 @@
<div id="embed-flash" class="flash" style="display:none" role="status"></div> <div id="embed-flash" class="flash" style="display:none" role="status"></div>
{% include 'instance_header_panel.html' %} {% 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' %} {% include 'instance_top_bar.html' %}
{% endif %} {% endif %}
@@ -92,7 +94,7 @@
</div> </div>
<script src="/static/instance_ui.js?v=10"></script> <script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=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/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script> <script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></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> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </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_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=27"></script> <script src="/static/instance_embed.js?v=27"></script>
</body> </body>
+15 -1
View File
@@ -64,7 +64,13 @@
type="password" type="password"
data-env-key="{{ field.key }}" data-env-key="{{ field.key }}"
placeholder="{% if field.has_value %}修改时填写新值,留空不修改{% else %}请输入{% endif %}" 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 %} {% else %}
<input <input
@@ -73,6 +79,14 @@
type="text" type="text"
data-env-key="{{ field.key }}" data-env-key="{{ field.key }}"
value="{{ field.current or field.default or '' }}" 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 %} {% endif %}
</div> </div>
+11 -5
View File
@@ -5,6 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <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/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/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=4"></script> <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="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=10"> <link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=97"> <link rel="stylesheet" href="/static/instance_theme.css?v=105">
</head> </head>
<body <body
@@ -143,6 +144,7 @@
{% if display.show_nav_risk_policy %} {% if display.show_nav_risk_policy %}
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a> <a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %} {% 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 %} {% if display.show_nav_env_config %}
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a> <a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
{% endif %} {% endif %}
@@ -151,7 +153,7 @@
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %} {% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %} {% 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' %} {% include 'instance_top_bar.html' %}
{% endif %} {% endif %}
@@ -390,6 +392,10 @@
{% include 'risk_policy_panel.html' %} {% include 'risk_policy_panel.html' %}
{% endif %} {% endif %}
{% if page == 'system_guide' %}
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %} {% if page == 'settings' %}
{% include 'settings_panel.html' %} {% include 'settings_panel.html' %}
{% endif %} {% endif %}
@@ -451,7 +457,7 @@
</div> </div>
<script src="/static/instance_ui.js?v=10"></script> <script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=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/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script> <script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script> <script src="/static/ai_review_render.js?v=2"></script>
@@ -2014,6 +2020,6 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
{% endif %} {% endif %}
</script> </script>
<script src="/static/instance_settings_prefs.js?v=14"></script> <script src="/static/instance_settings_prefs.js?v=15"></script>
</body> </body>
</html> </html>
+1 -12
View File
@@ -1,15 +1,4 @@
{# 三所统一顶栏:实时价 + 可选整点前开仓开关(划转已移至系统设置) #} {# 三所统一顶栏:实时价(划转已移至系统设置;切点前开仓说明见风控说明·交易执行) #}
<div class="rule-tip instance-price-bar"> <div class="rule-tip instance-price-bar">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8) 实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
</div> </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>; 不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
<strong>持仓中不划转</strong>并微信通知. <strong>持仓中不划转</strong>并微信通知.
</p> </p>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form"> <form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form" autocomplete="off">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required> <input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
<select name="from_account" aria-label="划出账户"> 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="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="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option> <option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select> </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="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="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option> <option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
@@ -1,7 +1,7 @@
{# 系统设置 · 账户密码(外层 card 由 settings_panel 提供) #} {# 系统设置 · 账户密码(外层 card 由 settings_panel 提供) #}
<h2>账户密码修改</h2> <h2>账户密码修改</h2>
<p class="settings-subcard-desc">修改网页登录账号密码,写入 <code>.env</code> 后需重启实例生效.</p> <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="password" id="pwd-old" autocomplete="current-password"></label>
<label>新用户名(可选) <input type="text" id="pwd-new-username" autocomplete="username"></label> <label>新用户名(可选) <input type="text" id="pwd-new-username" autocomplete="username"></label>
<label>新密码 <input type="password" id="pwd-new" autocomplete="new-password"></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>
<label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label> <label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label>
</div> </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> <button type="submit" style="margin-top:8px">保存复盘记录</button>
</form> </form>
</div> </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 "") 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"] = source_key
p["source_label"] = source_label p["source_label"] = source_label
p["source_plan_id"] = source_plan_id
p["target_monitor_text"] = _format_options_target(p) p["target_monitor_text"] = _format_options_target(p)
except Exception: except Exception:
p.setdefault("source_label", "") p.setdefault("source_label", "")
p.setdefault("source_plan_id", None)
p.setdefault("target_monitor_text", "") p.setdefault("target_monitor_text", "")
finally: finally:
conn.close() conn.close()
+99 -2
View File
@@ -1,13 +1,17 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" """期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable 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 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: def _safe_float(v: Any) -> float | None:
if v is None: if v is None:
@@ -121,20 +125,113 @@ def run_options_profit_alerts(
def _created_at_ms(created_at: Any) -> int | None: def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if not created_at: if not created_at:
return None return None
raw = str(created_at).strip() raw = str(created_at).strip()
if not raw: if not raw:
return None 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: 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) return int(dt.timestamp() * 1000)
except ValueError: except ValueError:
continue continue
return None 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( def sync_open_options_trades(
conn: sqlite3.Connection, 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(), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "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_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), "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
# 市价平仓已硬关闭(忽略 env),仅买一限价 # 市价平仓已硬关闭(忽略 env),仅买一限价
@@ -262,8 +263,12 @@ def _sync_options_trades(
if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
return return
_OPTIONS_SYNC_LAST_AT = now _OPTIONS_SYNC_LAST_AT = now
from lib.exchange.okx_options_lib import fetch_option_position_history from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history
from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades 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: if raw_positions is None:
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
@@ -281,6 +286,11 @@ def _sync_options_trades(
init_options_tables(conn) init_options_tables(conn)
reconcile_live_open_trades(conn, live_inst_ids=live_ids) reconcile_live_open_trades(conn, live_inst_ids=live_ids)
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist) 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() conn.commit()
finally: finally:
conn.close() conn.close()
@@ -370,6 +380,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
expiries = chain.get("expiries") or [] expiries = chain.get("expiries") or []
chain_err = chain.get("chain_error") 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: if not expiries:
return jsonify( return jsonify(
{ {
@@ -377,9 +390,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"msg": chain_err or "暂无到期日,请稍后点「刷新链」", "msg": chain_err or "暂无到期日,请稍后点「刷新链」",
**chain, **chain,
"chain_max_dte_days": cfg["chain_max_dte_days"], "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"],
"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"]})
@app.route("/api/options/quote") @app.route("/api/options/quote")
@lr @lr
@@ -509,6 +534,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg) ex, err = _require_options_ex(cfg)
if ex is None: if ex is None:
return jsonify({"ok": False, "msg": err}) 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 {} data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip() inst_id = (data.get("inst_id") or "").strip()
mode = (data.get("mode") or "budget_full").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) hedge_target = hedge_target_map.get(inst)
if hedge_target: if hedge_target:
row["hedge_plan_target"] = 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) rows.append(row)
finally: finally:
conn.close() conn.close()
+8 -2
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]: 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] = [] paths: List[str] = []
seen: set[str] = set() seen: set[str] = set()
def _add(name: Optional[str]) -> None: def _add(name: Optional[str]) -> None:
if not name: if not name:
return return
p = os.path.abspath(os.path.join(upload_folder, str(name).strip())) 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: if os.path.isfile(p) and p not in seen:
seen.add(p) seen.add(p)
paths.append(p) paths.append(p)
return
try: try:
keys = row.keys() if hasattr(row, "keys") else () keys = row.keys() if hasattr(row, "keys") else ()
+66 -4
View File
@@ -556,8 +556,28 @@ def sync_all_review_sources(
return out 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) 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"] = dict(entry)
out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json")) out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json"))
out["strategy_tag"] = entry.get("strategy_tag") 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["result_tag"] = entry.get("result_tag")
out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at") out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at")
else: else:
out["entry"] = None out["entry"] = None
out["strategy_tag"] = None out["strategy_tag"] = None
out["direction_view"] = None
out["entry_logic"] = None
out["result_tag"] = None out["result_tag"] = None
out["reviewed_at"] = None out["reviewed_at"] = None
return out 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( def _review_trades_filters(
*, *,
source_type: str | None = None, source_type: str | None = None,
underlying: str | None = None, underlying: str | None = None,
opt_type: str | None = None, opt_type: str | None = None,
strategy_tag: str | None = None, strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None, reviewed: str | None = None,
include_hedge_legs: bool = False, include_hedge_legs: bool = False,
closed_from: str | None = None, closed_from: str | None = None,
@@ -635,9 +676,26 @@ def _review_trades_filters(
if closed_to: if closed_to:
wheres.append("COALESCE(t.closed_at,'')<=?") wheres.append("COALESCE(t.closed_at,'')<=?")
args.append(closed_to) args.append(closed_to)
if strategy_tag: # 兼容旧参数:精确策略标签;前端已改用 q 模糊搜索
wheres.append("e.strategy_tag=?") if strategy_tag and not q:
wheres.append("UPPER(COALESCE(e.strategy_tag,''))=UPPER(?)")
args.append(strategy_tag) 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": if reviewed == "1" or reviewed == "yes":
wheres.append("e.id IS NOT NULL") wheres.append("e.id IS NOT NULL")
elif reviewed == "0" or reviewed == "no": elif reviewed == "0" or reviewed == "no":
@@ -653,6 +711,7 @@ def count_review_trades(
underlying: str | None = None, underlying: str | None = None,
opt_type: str | None = None, opt_type: str | None = None,
strategy_tag: str | None = None, strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None, reviewed: str | None = None,
include_hedge_legs: bool = False, include_hedge_legs: bool = False,
closed_from: str | None = None, closed_from: str | None = None,
@@ -664,6 +723,7 @@ def count_review_trades(
underlying=underlying, underlying=underlying,
opt_type=opt_type, opt_type=opt_type,
strategy_tag=strategy_tag, strategy_tag=strategy_tag,
q=q,
reviewed=reviewed, reviewed=reviewed,
include_hedge_legs=include_hedge_legs, include_hedge_legs=include_hedge_legs,
closed_from=closed_from, closed_from=closed_from,
@@ -688,6 +748,7 @@ def list_review_trades(
underlying: str | None = None, underlying: str | None = None,
opt_type: str | None = None, opt_type: str | None = None,
strategy_tag: str | None = None, strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None, reviewed: str | None = None,
include_hedge_legs: bool = False, include_hedge_legs: bool = False,
closed_from: str | None = None, closed_from: str | None = None,
@@ -701,6 +762,7 @@ def list_review_trades(
underlying=underlying, underlying=underlying,
opt_type=opt_type, opt_type=opt_type,
strategy_tag=strategy_tag, strategy_tag=strategy_tag,
q=q,
reviewed=reviewed, reviewed=reviewed,
include_hedge_legs=include_hedge_legs, include_hedge_legs=include_hedge_legs,
closed_from=closed_from, closed_from=closed_from,
+15 -6
View File
@@ -26,7 +26,6 @@ from lib.options.options_review_lib import (
hide_review_trade, hide_review_trade,
list_review_trades, list_review_trades,
save_review_entry, 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") return send_file(path, mimetype="application/javascript; charset=utf-8")
@app.route("/static/images/options_journal/<path:filename>") @app.route("/static/images/options_journal/<path:filename>")
@lr
def static_options_review_image(filename: str): def static_options_review_image(filename: str):
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
folder = options_review_upload_dir(cfg["upload_folder"]) folder = options_review_upload_dir(cfg["upload_folder"])
safe = os.path.basename(filename or "") safe = os.path.basename(filename or "")
path = os.path.join(folder, safe) path = os.path.join(folder, safe)
if not os.path.isfile(path): if not os.path.isfile(path):
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
root = os.path.abspath(cfg["upload_folder"] or "")
alt = os.path.join(root, safe)
if os.path.isfile(alt):
path = alt
else:
return ("not found", 404) return ("not found", 404)
return send_file(path) return send_file(path)
@app.route("/api/options/review/sync", methods=["POST"]) @app.route("/api/options/review/sync", methods=["POST"])
@lr @lr
def api_options_review_sync(): def api_options_review_sync():
"""刷新本地 options_trades + 已结束对冲计划(不访问交易所).""" """刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_options_review_tables(conn) 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() conn.commit()
return jsonify(result) return jsonify(result)
finally: finally:
@@ -128,13 +134,15 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
"no", "no",
) )
if do_sync: 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() conn.commit()
filt = dict( filt = dict(
source_type=(request.args.get("source_type") or "").strip() or None, source_type=(request.args.get("source_type") or "").strip() or None,
underlying=(request.args.get("underlying") or "").strip() or None, underlying=(request.args.get("underlying") or "").strip() or None,
opt_type=(request.args.get("opt_type") 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, 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, reviewed=(request.args.get("reviewed") or "").strip() or None,
include_hedge_legs=(request.args.get("include_hedge_legs") or "") include_hedge_legs=(request.args.get("include_hedge_legs") or "")
.strip() .strip()
@@ -259,7 +267,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
def api_options_review_stats(): def api_options_review_stats():
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: 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() conn.commit()
stats = compute_review_stats( stats = compute_review_stats(
conn, conn,
+67 -25
View File
@@ -1,13 +1,29 @@
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root" <div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"> data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% if not options_enabled %} {% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div> <div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %} {% endif %}
<div class="options-dual-grid"> <div class="options-dual-grid">
<div class="card options-order-card"> <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> <h2>期权下单</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> <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"> <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 active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
@@ -64,11 +80,14 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="opt-order-panel-host" class="opt-order-panel-host" hidden aria-hidden="true"> <div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
<div id="opt-order-panel" class="opt-order-panel-inner" style="display:none"> <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-layout">
<div class="opt-order-main"> <div class="opt-order-main">
<h3 class="opt-order-title">下单</h3>
<div id="opt-order-inst" class="options-order-inst"></div> <div id="opt-order-inst" class="options-order-inst"></div>
<div class="options-order-grid"> <div class="options-order-grid">
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v"></span></div> <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><span class="k">距平衡</span><span id="opt-order-dist-be" class="v"></span></div>
</div> </div>
<div class="options-estimate-row"> <div class="options-estimate-row">
<label class="opt-est-label" for="opt-target-idx">目标位(指数)</label> <div class="opt-est-main">
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓"> <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 class="k">预计价值</span>
<span id="opt-est-value" class="v"></span> <span id="opt-est-value" class="v"></span>
<span class="k">盈利</span> <span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span> <span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span> <span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span> <span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
</div>
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span> <span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
</div> </div>
<div class="form-row options-order-mode-row"> <div class="form-row options-order-mode-row">
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label> <div class="opt-size-mode-bar">
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"> <label class="btn-secondary opt-order-chip opt-size-mode-chip">
<label><input type="radio" name="opt-size-mode" value="budget_full"> 按可用余额打满</label> <input type="radio" name="opt-size-mode" value="sheets" checked>
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label> <span>指定张数</span>
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"> </label>
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)"> <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-primary" id="opt-open-btn">限价买入 @ 卖一</button>
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
</div> </div>
<div id="opt-order-msg" class="muted"></div> <div id="opt-order-msg" class="muted"></div>
</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> </div>
</div> </div>
@@ -125,6 +155,7 @@
</div> </div>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板"> <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 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="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> <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> </div>
@@ -152,6 +183,17 @@
</div> </div>
</details> </details>
</div> </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-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-panel">
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary"> <div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
@@ -274,4 +316,4 @@
</div> </div>
</div> </div>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=39"></script> <script src="/static/options_panel.js?v=49"></script>
+251 -67
View File
@@ -1,20 +1,110 @@
{# OKX 期权复盘:交易记录(5行)复盘表单 → 复盘记录 → 统计 #} {# OKX 期权复盘:交易记录 → 复盘表单 → 复盘记录 → 统计 #}
<div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1"> <div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1">
{% if not options_enabled %} {% if not options_enabled %}
<div class="flash" style="margin-bottom:12px;font-size:.82rem">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.</div> <div class="flash" style="margin-bottom:12px;font-size:.82rem">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.</div>
{% endif %} {% endif %}
<style> <style>
.options-review-wrap{font-size:.82rem} .options-review-wrap{font-size:.82rem;display:flex;flex-direction:column;gap:14px}
.options-review-wrap h2{font-size:1rem;margin:0 0 8px} .options-review-wrap h2,.options-review-wrap h3{margin:0}
.options-review-wrap h3{font-size:.9rem;margin:0 0 8px} .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-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-toolbar{display:flex;flex-direction:column;gap:0;margin:0}
.or-tab.active{background:rgba(59,130,246,.25);border-color:rgba(59,130,246,.55)} .or-tab{
.or-badge{display:inline-block;padding:1px 6px;border-radius:999px;background:rgba(127,127,127,.2);font-size:.7rem} border:1px solid var(--or-border, rgba(127,127,127,.35));background:transparent;color:inherit;
.or-stat-card{border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:8px;font-size:.78rem} padding:6px 12px;border-radius:8px;cursor:pointer;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-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{font-size:.78rem}
.or-journal-card h2{font-size:.92rem} .or-journal-card h2{font-size:.92rem}
.or-journal-card input, .or-journal-card input,
@@ -22,56 +112,117 @@
.or-journal-card textarea, .or-journal-card textarea,
.or-journal-card button{font-size:.76rem} .or-journal-card button{font-size:.76rem}
.or-journal-card .or-form-grid, .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 .or-mood-grid{display:flex;flex-wrap:wrap;gap:6px 12px;margin:8px 0;font-size:.74rem}
.or-journal-card .muted, .or-journal-card .muted,.or-journal-card .sub{font-size:.7rem}
.or-journal-card .sub{font-size:.7rem}
.or-journal-card.hidden{display:none!important} .or-journal-card.hidden{display:none!important}
.or-reviewed-table tbody tr{cursor:pointer} .or-detail-backdrop{
.or-detail-panel{margin-top:10px;padding-top:10px;border-top:1px solid rgba(127,127,127,.25)} position:fixed;inset:0;z-index:1300;
.or-detail-panel.hidden{display:none!important} 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-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-images{
.or-detail-img-cell{border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:6px;text-align:center} display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
.or-detail-img-label{display:block;font-size:.7rem;margin-bottom:4px;opacity:.8} gap:10px;margin:10px 0 4px;
.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-detail-img-cell{
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease} min-width:0;border:1px solid var(--or-border-soft, rgba(127,127,127,.25));border-radius:8px;
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem} 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> </style>
{# 1. 交易记录(含 Tab/筛选,固定约5行) #} <div class="or-page-head">
<div class="card" style="margin-bottom:10px"> <h2>期权复盘</h2>
<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> <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> <button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div> </div>
{# Tab + 筛选:放在各内容卡片上方,全局作用于下方列表/统计 #}
<div class="or-toolbar">
<div class="or-tabs" role="tablist" aria-label="复盘分类"> <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 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="options_options" role="tab">期期对冲记录</button>
<button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button> <button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button>
</div> </div>
<p class="muted" style="margin:0 0 8px;font-size:.72rem">待复盘交易(每页5条).点「复盘」填写表单;保存后进入下方复盘记录.</p> <div class="or-filters">
<div class="form-row" style="flex-wrap:wrap;gap:6px;margin-bottom:8px"> <select id="or-filter-uly" autocomplete="off">
<select id="or-filter-uly" style="font-size:.76rem">
<option value="">标的:全部</option> <option value="">标的:全部</option>
<option value="ETH">ETH</option> <option value="ETH">ETH</option>
<option value="BTC">BTC</option> <option value="BTC">BTC</option>
</select> </select>
<select id="or-filter-opt" style="font-size:.76rem"> <select id="or-filter-opt" autocomplete="off">
<option value="">Call/Put:全部</option> <option value="">Call/Put:全部</option>
<option value="C">Call</option> <option value="C">Call</option>
<option value="P">Put</option> <option value="P">Put</option>
</select> </select>
<input type="text" id="or-filter-strategy" placeholder="策略标签" style="max-width:110px;font-size:.76rem"> <input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
<input type="datetime-local" id="or-filter-from" title="平仓起" style="font-size:.76rem"> style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input type="datetime-local" id="or-filter-to" title="平仓止" style="font-size:.76rem"> <input type="search" id="or-filter-q" name="or_filter_q" placeholder="搜索标的/合约/策略"
<label class="muted" style="display:flex;align-items:center;gap:4px;font-size:.72rem"> 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"> 含已归属对冲的期权腿 <input type="checkbox" id="or-include-hedge-legs"> 含已归属对冲的期权腿
</label> </label>
</div> </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"> <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"> <table class="options-strike-table or-trades-table" id="or-trades-table">
<thead> <thead>
@@ -79,13 +230,14 @@
<th>类型</th> <th>类型</th>
<th>标的/合约</th> <th>标的/合约</th>
<th>盈亏</th> <th>盈亏</th>
<th>/平</th> <th>仓时间</th>
<th>平仓时间</th>
<th>持有</th> <th>持有</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="or-trades-tbody"> <tbody id="or-trades-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr> <tr><td colspan="7" class="muted">加载中…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -94,23 +246,33 @@
<span class="muted" id="or-trades-page-label">第 1 / 1 页</span> <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> <button type="button" class="btn-secondary" id="or-trades-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div> </div>
</div> </section>
{# 2. 复盘上传(默认隐藏,点交易「复盘」后显示) #} {# 2. 复盘上传(默认隐藏) #}
<div class="card journal-card or-journal-card hidden" id="or-journal-card" style="margin-bottom:10px"> <section class="or-section journal-card or-journal-card hidden" id="or-journal-card">
<h2>复盘记录上传(含截图)</h2> <div class="or-section-head">
<p class="muted" id="or-journal-summary" style="margin-top:0">截图槽位与合约复盘相同(5m / 15m / 1h / 4h).</p> <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"> <div class="or-journal-body">
<form id="or-journal-form" onsubmit="return false;"> <form id="or-journal-form" onsubmit="return false;">
<input type="hidden" id="or-trade-id" value=""> <input type="hidden" id="or-trade-id" value="">
<input type="hidden" id="or-draft-id" value=""> <input type="hidden" id="or-draft-id" value="">
<div class="or-form-grid"> <div class="or-form-grid">
<input type="datetime-local" id="or-f-open" title="开仓时间"> <input type="datetime-local" id="or-f-open" title="开仓时间" autocomplete="off">
<input type="datetime-local" id="or-f-close" title="平仓时间"> <input type="datetime-local" id="or-f-close" title="平仓时间" autocomplete="off">
<input type="text" id="or-f-coin" placeholder="标的(如 ETH)"> <input type="text" id="or-f-coin" name="or_f_coin" placeholder="标的(如 ETH)"
<input type="text" id="or-f-inst" placeholder="合约/计划"> autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
<input type="text" id="or-f-pnl" placeholder="盈亏(U)"> data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-hold" placeholder="持有时长" readonly> <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>
<div class="or-form-grid2"> <div class="or-form-grid2">
<select id="or-f-strategy" title="策略标签" required> <select id="or-f-strategy" title="策略标签" required>
@@ -145,13 +307,12 @@
<option value="">入场逻辑</option> <option value="">入场逻辑</option>
</select> </select>
<input type="hidden" id="journal-draft-id" value="">
<div class="journal-upload-slots" id="or-upload-slots"> <div class="journal-upload-slots" id="or-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %} {% for tf in ['5m', '15m', '1h', '4h'] %}
<div class="journal-upload-row" data-tf="{{ tf }}"> <div class="journal-upload-row" data-tf="{{ tf }}">
<span class="journal-upload-slot-label">{{ tf }}</span> <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="file" accept="image/*" class="or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="journal-upload-hidden-file or-upload-hidden" data-tf="{{ tf }}" value=""> <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> <span class="journal-upload-status or-upload-status" data-tf="{{ tf }}" aria-live="polite"></span>
</div> </div>
{% endfor %} {% endfor %}
@@ -176,26 +337,36 @@
<div id="or-legs-host" style="margin-top:10px;font-size:.74rem"></div> <div id="or-legs-host" style="margin-top:10px;font-size:.74rem"></div>
</form> </form>
</div> </div>
</div> </section>
{# 3. 已复盘记录 + 详情 #} {# 3. 已复盘记录 #}
<div class="card" style="margin-bottom:10px"> <section class="or-section" aria-labelledby="or-reviewed-heading">
<h3>复盘记录</h3> <div class="or-section-head">
<p class="muted" style="margin:0 0 8px;font-size:.72rem">已保存的复盘(每页5条).点一行查看详情.</p> <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"> <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"> <table class="options-strike-table or-reviewed-table" id="or-reviewed-table">
<thead> <thead>
<tr> <tr>
<th>类型</th> <th>类型</th>
<th>标的/合约</th> <th>标的/合约</th>
<th>方向</th>
<th>盈亏</th> <th>盈亏</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持仓时长</th>
<th>策略</th> <th>策略</th>
<th>入场逻辑</th>
<th>结果</th> <th>结果</th>
<th>复盘时间</th> <th>复盘时间</th>
</tr> </tr>
</thead> </thead>
<tbody id="or-reviewed-tbody"> <tbody id="or-reviewed-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr> <tr><td colspan="11" class="muted">加载中…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -204,24 +375,37 @@
<span class="muted" id="or-reviewed-page-label">第 1 / 1 页</span> <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> <button type="button" class="btn-secondary" id="or-reviewed-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div> </div>
<div class="or-detail-panel hidden" id="or-detail-panel"> </section>
<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> {# 详情 / 放大 #}
<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-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>
<div class="or-detail-grid" id="or-detail-meta"></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 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 class="or-detail-images" id="or-detail-images"></div>
</div> </div>
</div> </div>
<div id="or-img-lightbox" class="or-img-lightbox" hidden>
<img id="or-img-lightbox-img" src="" alt="截图放大">
</div>
{# 4. 统计 #} {# 4. 统计 #}
<div class="card" style="margin-bottom:10px"> <section class="or-section" aria-labelledby="or-stats-heading">
<h3>统计</h3> <div class="or-section-head">
<div id="or-kpi" class="form-row" style="flex-wrap:wrap;gap:10px"></div> <span class="or-step" aria-hidden="true">3</span>
<div id="or-stats-groups" style="margin-top:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px"></div> <div>
<div class="or-section-title" id="or-stats-heading">统计</div>
<p class="or-section-desc">跟随上方 Tab 与筛选条件汇总。</p>
</div> </div>
</div>
<div id="or-kpi" class="or-kpi-row"></div>
<div id="or-stats-groups" class="or-stats-grid"></div>
</section>
</div> </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 中拆分) #} {# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #}
<div id="options-settings-root" hidden <div id="options-settings-root" hidden
data-sub-account="{{ instance_settings.options_sub_account | default('', true) }}"></div> 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"> <div class="options-settings-section">
<p class="options-settings-hint">主账户资金账户:USDT ↔ USDC 现货市价单.</p> <p class="options-settings-hint">主账户资金账户:USDT ↔ USDC 现货市价单.</p>
<div class="form-row settings-transfer-form options-settings-row"> <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="usdt_to_usdc" selected>USDT → USDC</option>
<option value="usdc_to_usdt">USDC → USDT</option> <option value="usdc_to_usdt">USDC → USDT</option>
</select> </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-secondary btn-sm" id="opt-set-swap-all-btn">全部兑换</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-swap-btn">市价兑换</button> <button type="button" class="btn-primary btn-sm" id="opt-set-swap-btn">市价兑换</button>
</div> </div>
@@ -1,7 +1,9 @@
<div class="options-settings-section"> <div class="options-settings-section">
<div class="options-settings-subtitle">主账户内</div> <div class="options-settings-subtitle">主账户内</div>
<div class="form-row settings-transfer-form options-settings-row"> <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="USDC" selected>USDC</option>
<option value="USDT">USDT</option> <option value="USDT">USDT</option>
</select> </select>
@@ -13,7 +15,8 @@
<option value="trading" selected>to: 交易</option> <option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option> <option value="funding">to: 资金</option>
</select> </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-secondary btn-sm" id="opt-set-int-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-int-btn">划转</button> <button type="button" class="btn-primary btn-sm" id="opt-set-int-btn">划转</button>
</div> </div>
@@ -26,7 +29,9 @@
<span class="muted">({{ instance_settings.options_sub_account or '未配置' }})</span> <span class="muted">({{ instance_settings.options_sub_account or '未配置' }})</span>
</div> </div>
<div class="form-row settings-transfer-form options-settings-row"> <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="main_to_sub" selected>主 → 子</option>
<option value="sub_to_main">子 → 主</option> <option value="sub_to_main">子 → 主</option>
</select> </select>
@@ -42,7 +47,8 @@
<option value="trading" selected>to: 交易</option> <option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option> <option value="funding">to: 资金</option>
</select> </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-secondary btn-sm" id="opt-set-cross-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-cross-btn">划转</button> <button type="button" class="btn-primary btn-sm" id="opt-set-cross-btn">划转</button>
</div> </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>并微信通知) 划转:自动划转 {{ '开启' 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> </div>
</details> </details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form"> <form action="/manual_transfer" method="post" class="form-row gate-transfer-form" autocomplete="off">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required> <input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
<select name="from_account"> 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="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="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option> <option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select> </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="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="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</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) -%} {% macro journal_form_fields(entry_reason_options, order_type_options) -%}
<div class="form-grid journal-form-row1"> <div class="form-grid journal-form-row1">
<input type="datetime-local" name="open_datetime" class="journal-field-datetime" required> <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> <input type="datetime-local" name="close_datetime" class="journal-field-datetime" required autocomplete="off">
<input name="coin" class="journal-field-coin" placeholder="BTC" required> <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> <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> <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"> <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"> <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>
<div class="form-grid journal-form-row2"> <div class="form-grid journal-form-row2">
<select name="direction" id="journal-direction" class="journal-field-direction" required title="做多/做空"> <select name="direction" id="journal-direction" class="journal-field-direction" required title="做多/做空">
@@ -38,7 +38,7 @@
<option value="止损">止损</option> <option value="止损">止损</option>
<option value="其他">其他</option> <option value="其他">其他</option>
</select> </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> <select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
</div> </div>
{%- endmacro %} {%- endmacro %}
@@ -11,7 +11,7 @@
{% endfor %} {% endfor %}
</select> </select>
{% else %} {% 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 %} {% endif %}
{%- endmacro %} {%- endmacro %}
+69 -2
View File
@@ -86,6 +86,14 @@ def manual_close_daily_limit() -> int:
return 2 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: def max_active_positions_from_env(default: int = 1) -> int:
try: try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default)))) 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), id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT, trading_day TEXT,
manual_close_count INTEGER DEFAULT 0, manual_close_count INTEGER DEFAULT 0,
daily_loss_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER, cooloff_until_ms INTEGER,
cooloff_hours INTEGER, cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0, daily_frozen INTEGER DEFAULT 0,
@@ -124,10 +133,18 @@ def ensure_account_risk_schema(conn) -> None:
updated_at TEXT 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() row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone()
if not row: if not row:
conn.execute( 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 """UPDATE account_risk_state SET
trading_day=?, trading_day=?,
manual_close_count=0, manual_close_count=0,
daily_loss_count=0,
daily_frozen=0, daily_frozen=0,
cooloff_until_ms=?, cooloff_until_ms=?,
cooloff_hours=?, 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( def on_journal_saved(
conn, conn,
*, *,
@@ -762,6 +817,7 @@ def compute_account_risk_status(
"cooloff_until_ms": None, "cooloff_until_ms": None,
"cooloff_until": None, "cooloff_until": None,
"manual_close_count": 0, "manual_close_count": 0,
"daily_loss_count": 0,
"daily_frozen": False, "daily_frozen": False,
} }
row = _sync_trading_day(conn, trading_day, now=now) row = _sync_trading_day(conn, trading_day, now=now)
@@ -784,12 +840,21 @@ def compute_account_risk_status(
row = _load_state(conn) row = _load_state(conn)
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms) cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
manual_close_count = int(_row_get(row, "manual_close_count") or 0) 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 status = STATUS_NORMAL
reason = "" reason = ""
if daily_frozen: if daily_frozen:
status = STATUS_DAILY 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: elif cooloff_until_ms is not None:
remaining_ms = cooloff_until_ms - now_ms remaining_ms = cooloff_until_ms - now_ms
hours = _cooloff_hours_value(row) hours = _cooloff_hours_value(row)
@@ -818,6 +883,8 @@ def compute_account_risk_status(
if fmt_local_ms and cooloff_until_ms if fmt_local_ms and cooloff_until_ms
else None, else None,
"manual_close_count": manual_close_count, "manual_close_count": manual_close_count,
"daily_loss_count": daily_loss_count,
"daily_loss_limit": loss_limit,
"daily_frozen": daily_frozen, "daily_frozen": daily_frozen,
"pending_journal_trade_id": pending, "pending_journal_trade_id": pending,
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0, "freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
+1 -1
View File
@@ -6,7 +6,7 @@
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) | | **交易教练** | 口语化陪聊;注入三户监控快照(**含 OKX 期权持仓明细**)、执行手册短摘要与今日总结摘要(后台自动生成,不在页面展示) |
| **普通聊天** | 不绑交易数据,适合闲聊,答疑 | | **普通聊天** | 不绑交易数据,适合闲聊,答疑 |
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) | | **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
| **会话历史** | 右侧列表:切换,删除;消息一键复制 | | **会话历史** | 右侧列表:切换,删除;消息一键复制 |
+1 -1
View File
@@ -7,7 +7,7 @@
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 | | **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 | | **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 | | **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) | | **策略说明** | 执行手册 + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **使用说明** | 本页:中控与实例怎么用 | | **使用说明** | 本页:中控与实例怎么用 |
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 | | **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) | | **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
+11
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" _ACCOUNT_RISK_BADGE_JS = _REPO_STATIC / "account_risk_badge.js"
_OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js" _OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js"
_OPTIONS_POSITION_CARDS_JS = _REPO_STATIC / "options_position_cards.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") @app.get("/assets/account_risk_badge.css")
+6
View File
@@ -25,6 +25,7 @@ from hub_ai.context import (
format_chat_context_for_chat, format_chat_context_for_chat,
format_chat_position_overview, format_chat_position_overview,
) )
from hub_ai.playbook_brief import format_playbook_brief_for_chat
from hub_ai.prompts import ( from hub_ai.prompts import (
CHAT_GENERAL_SYSTEM, CHAT_GENERAL_SYSTEM,
CHAT_SYSTEM, CHAT_SYSTEM,
@@ -217,6 +218,10 @@ def send_chat_message(
ctx = build_chat_context(exchanges, trading_day=day) ctx = build_chat_context(exchanges, trading_day=day)
day = ctx["trading_day"] day = ctx["trading_day"]
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count) 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( user_prompt = build_chat_user_prompt(
context_text=brief_ctx, context_text=brief_ctx,
trading_day=day, trading_day=day,
@@ -225,6 +230,7 @@ def send_chat_message(
history_lines=history_tail, history_lines=history_tail,
user_message=user_for_prompt, user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""), attachment_note=str(parsed.get("attachment_note") or ""),
playbook_brief=playbook,
) )
if parsed.get("text_append"): if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000) 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: 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]: def _monitor_counts(ac: dict) -> dict[str, int]:
@@ -291,25 +365,45 @@ def _find_plan_tpsl_for_position(
if not isinstance(hub_mon, dict): if not isinstance(hub_mon, dict):
return None, None, False return None, None, False
side_l = (side or "").lower() 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 []: for o in hub_mon.get("orders") or []:
if not isinstance(o, dict): if not isinstance(o, dict):
continue continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or "" o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(symbol, o_sym): if not _symbols_match(symbol, o_sym):
continue 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 continue
return ( return (
_safe_float(o.get("stop_loss")), _safe_float(o.get("stop_loss")),
_safe_float(o.get("take_profit")), _safe_float(o.get("take_profit")),
False, 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 []: for t in hub_mon.get("trends") or []:
if not isinstance(t, dict): if not isinstance(t, dict):
continue continue
if not _symbols_match(symbol, t.get("symbol") or ""): if not _symbols_match(symbol, t.get("symbol") or ""):
continue 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 continue
plan_tp = t.get("take_profit") plan_tp = t.get("take_profit")
tp = _safe_float(plan_tp) if plan_tp not in (None, "") else None 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( lines.append(
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| " 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"浮盈亏 {totals.get('float_pnl_u')}U | "
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | " f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
f"交易账户合计 {_fmt_fund(totals.get('total_trading_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): if not isinstance(p, dict):
continue continue
lines.append(_format_position_detail_line(p, hub_mon)) lines.append(_format_position_detail_line(p, hub_mon))
_append_options_position_lines(lines, ac, limit=8)
lines.append( lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" 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( lines.append(
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| " 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"浮盈亏 {totals.get('float_pnl_u')}U | "
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | " f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
f"交易账户合计 {_fmt_fund(totals.get('total_trading_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): if not isinstance(p, dict):
continue continue
lines.append(_format_position_detail_line(p, hub_mon)) lines.append(_format_position_detail_line(p, hub_mon))
_append_options_position_lines(lines, ac, limit=8)
lines.append( lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" 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] 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: def _options_source_label(p: dict) -> str:
"""看板期权来源:期期/永期对冲,其余为纯期权.""" """看板期权来源:期期/永期对冲,其余为纯期权."""
source = str(p.get("source") or "").strip() 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") contracts = p.get("size")
upnl = _position_float_pnl(p) upnl = _position_float_pnl(p)
source = resolve_position_monitor_source(p, hub_mon) 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( position_lines.append(
{ {
"kind": "position", "kind": "position",
@@ -1107,6 +1284,15 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
"symbol": sym, "symbol": sym,
"side": side, "side": side,
"contracts": contracts, "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}", "text": f"{sym} {side}",
"pnl": round(upnl, 4), "pnl": round(upnl, 4),
} }
@@ -1183,21 +1369,30 @@ def collect_closed_trades_snapshot(
def format_chat_position_overview(payload: dict) -> str: def format_chat_position_overview(payload: dict) -> str:
totals = payload.get("totals") or {} totals = payload.get("totals") or {}
total_open = int(totals.get("open_position_count") or 0) 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: if total_open <= 0:
head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓).浮盈亏 0U 表示无持仓,不是「有仓但不动」." head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓).浮盈亏 0U 表示无持仓,不是「有仓但不动」."
else: else:
head = ( head = (
f"【实盘持仓总览】监控户合计 {total_open}," f"【实盘持仓总览】监控户合计 {total_open}"
f"(永续{perp_total}/期权{opt_total}),"
f"浮盈亏合计 {totals.get('float_pnl_u')}U." f"浮盈亏合计 {totals.get('float_pnl_u')}U."
) )
lines = [ lines = [
head, head,
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.", "【区分】只有带「持仓明细/交易所实盘/期权持仓」字样的才是已开仓;趋势回调,关键位,下单监控,顺势加仓是本地计划/监控,不算持仓.持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位.",
] ]
for ac in payload.get("accounts") or []: for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控": if ac.get("status") == "未监控":
continue continue
n = int(ac.get("open_position_count") or _account_open_position_count(ac)) 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) mc = _monitor_counts(ac)
mon_parts = [] mon_parts = []
if mc["trends"]: if mc["trends"]:
@@ -1213,8 +1408,11 @@ def format_chat_position_overview(payload: dict) -> str:
lines.append(f"- {ac.get('name')}:空仓{mon_txt}") lines.append(f"- {ac.get('name')}:空仓{mon_txt}")
else: else:
lines.append( 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) return "\n".join(lines)
@@ -1222,11 +1420,19 @@ def format_chat_context_slim(payload: dict) -> str:
"""聊天专用:不含 180 日资金曲线与昨日平仓明细,避免挤占对话上下文.""" """聊天专用:不含 180 日资金曲线与昨日平仓明细,避免挤占对话上下文."""
totals = payload.get("totals") or {} totals = payload.get("totals") or {}
day = totals.get("trading_day") 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 = [ lines = [
f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| " 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 []: for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控": if ac.get("status") == "未监控":
@@ -1234,7 +1440,12 @@ def format_chat_context_slim(payload: dict) -> str:
continue continue
st = ac.get("trade_stats") or {} st = ac.get("trade_stats") or {}
open_n = int(ac.get("open_position_count") or _account_open_position_count(ac)) 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) mc = _monitor_counts(ac)
mon = [] mon = []
if mc["trends"]: if mc["trends"]:
@@ -1263,6 +1474,7 @@ def format_chat_context_slim(payload: dict) -> str:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}") lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}")
_append_options_position_lines(lines, ac, limit=6, indent=" · ")
return "\n".join(lines) return "\n".join(lines)
@@ -0,0 +1,28 @@
"""交易教练用的执行手册短摘要(来源 docs/交易执行手册-期权与Gate.md)."""
from __future__ import annotations
from pathlib import Path
from lib.paths import REPO_ROOT
# 控制 token:保持简短;手册大改时同步修订本摘要.
_PLAYBOOK_BRIEF = """【用户策略执行手册·摘要】(来源:docs/交易执行手册-期权与Gate.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点你应该;不要作为你的教练我必须. - 不要第1点第2点你应该;不要作为你的教练我必须.
- 不预测涨跌,不保证收益,不替用户做决定. - 不预测涨跌,不保证收益,不替用户做决定.
- 只能依据提供的监控与交易数据说话;看不到的就说我这边看不到,你可以去 xx 实例页确认. - 只能依据提供的监控与交易数据说话;看不到的就说我这边看不到,你可以去 xx 实例页确认.
- **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动. - **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘 / 期权持仓才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动.
- **期权持仓**:快照中期权 行与永续同样是实盘;须分开提及.期权净盈亏/目标位以快照为准.
- **监控单 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓. - **监控单 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓.
- 用户口述与快照冲突时,以快照为准并口语说明我这边看到是空仓/有N仓. - 用户口述与快照冲突时,以快照为准并口语说明我这边看到是空仓/有N仓.
- 若附带今日总结摘要,那是较早生成的缓存,**实盘持仓以当前多账户快照里的实盘持仓总览为准**,摘要里若提到持仓可能已过时. - 若附带今日总结摘要,那是较早生成的缓存,**实盘持仓以当前多账户快照里的实盘持仓总览为准**,摘要里若提到持仓可能已过时.
- 若附带用户策略执行手册·摘要,须按该纪律理解账户分工与离场规则(如期权通常不手平Gate 一位置两次等);勿另造策略或鼓励违反摘要纪律.
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清. - 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清.
- **优先接住用户现在说对话核心摘要**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字. - **优先接住用户现在说对话核心摘要**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字.
- **接续对话**:对话核心摘要时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行. - **接续对话**:对话核心摘要时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行.
@@ -143,12 +145,20 @@ def build_chat_user_prompt(
history_lines: str = "", history_lines: str = "",
user_message: str, user_message: str,
attachment_note: str = "", attachment_note: str = "",
playbook_brief: str = "",
) -> str: ) -> str:
parts = [f"【交易日】{trading_day}"] parts = [f"【交易日】{trading_day}"]
if rolling_summary.strip(): if rolling_summary.strip():
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()]) parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
elif history_lines.strip(): elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()]) parts.extend(["【最近对话】", history_lines.strip()])
if playbook_brief.strip():
parts.extend(
[
"【用户策略执行手册·摘要(须知悉分工与纪律)】",
playbook_brief.strip(),
]
)
parts.extend([ parts.extend([
"【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】", "【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】",
context_text.strip() or "(无监控数据)", context_text.strip() or "(无监控数据)",
+376 -21
View File
@@ -392,6 +392,11 @@ button.ghost:hover:not(:disabled) {
display: none; display: none;
} }
/* 通用隐藏:策略正文/执行清单等 Tab 面板依赖此类互斥显示 */
.hidden {
display: none !important;
}
.page-head { .page-head {
margin: 24px 0 16px; margin: 24px 0 16px;
} }
@@ -4296,6 +4301,70 @@ body.login-page {
gap: 6px; 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 { body.hub-phone .monitor-stat-cell {
padding: 8px 6px; padding: 8px 6px;
} }
@@ -4633,7 +4702,16 @@ body.login-page {
display: none; 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) { @media (orientation: portrait) {
body.hub-phone.market-chart-fs-open .market-chart-wrap.is-fullscreen::before { body.hub-phone.market-chart-fs-open .market-chart-wrap.is-fullscreen::before {
content: "全屏看图请横持手机"; content: "全屏看图请横持手机";
@@ -9564,32 +9642,56 @@ body.funds-fullscreen-open {
} }
} }
/* 电脑端计算器改为单页 Tab;手机继续使用原有紧凑 Tab 样式 */ /* 电脑端计算器:上方 Tab + 左输入 / 右结果双卡;手机端保持原布局 */
.calc-tab-label-desktop { .calc-tab-label-desktop {
display: none; 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 { body:not(.hub-phone) #page-calculator .calc-workspace {
display: grid; display: flex;
grid-template-columns: 180px minmax(0, 1fr); flex-direction: column;
gap: 14px; gap: 14px;
align-items: start; align-items: stretch;
} }
body:not(.hub-phone) #page-calculator .calc-mobile-tabs { body:not(.hub-phone) #page-calculator .calc-mobile-tabs {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr); flex-direction: row;
flex-wrap: wrap;
gap: 8px; gap: 8px;
max-width: none; max-width: none;
margin: 0; margin: 0;
padding: 5px; padding: 6px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: 12px; border-radius: 12px;
background: var(--nav-bg); background: var(--nav-bg);
} }
body:not(.hub-phone) #page-calculator .calc-m-tab { body:not(.hub-phone) #page-calculator .calc-m-tab {
min-height: 42px; min-height: 40px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 9px; border-radius: 9px;
background: transparent; background: transparent;
@@ -9598,8 +9700,8 @@ body:not(.hub-phone) #page-calculator .calc-m-tab {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
text-align: left; text-align: center;
padding: 9px 12px; padding: 8px 16px;
} }
body:not(.hub-phone) #page-calculator .calc-m-tab:hover { body:not(.hub-phone) #page-calculator .calc-m-tab:hover {
@@ -9632,9 +9734,61 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
display: none; 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) { @media (min-width: 1200px) {
body:not(.hub-phone) #page-calculator .calc-form-grid { 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 +9813,26 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 12px; 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 { .strategy-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -9678,6 +9852,127 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
border-color: var(--accent); border-color: var(--accent);
color: var(--text); 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 { .strategy-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
@@ -9717,26 +10012,69 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
.strategy-doc-body { .strategy-doc-body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
max-height: 100%; max-height: none;
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.55; line-height: 1.55;
color: var(--text); color: var(--text);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 2px 10px 2px 4px; padding: 8px 22px 20px 18px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.strategy-doc-body h2 { .strategy-doc-body h2 {
font-size: 1rem; display: flex;
margin: 0.6em 0 0.5em; align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 1.05rem;
margin: 1.1em 0 0.55em;
color: var(--text); color: var(--text);
scroll-margin-top: 12px;
} }
.strategy-doc-body h2:first-child { .strategy-doc-body h2:first-child {
margin-top: 0; 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 { .strategy-doc-body h3 {
font-size: 0.92rem; font-size: 0.94rem;
margin: 1em 0 0.4em; margin: 1em 0 0.4em;
scroll-margin-top: 12px;
} }
.strategy-doc-body table { .strategy-doc-body table {
width: 100%; width: 100%;
@@ -9769,8 +10107,9 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
color: var(--muted); color: var(--muted);
} }
.strategy-checklist-body { .strategy-checklist-body {
flex: 0 0 auto; flex: 1 1 auto;
overflow: visible; min-height: 0;
overflow: auto;
padding: 2px 10px 2px 4px; padding: 2px 10px 2px 4px;
} }
.strategy-checklist-body ul { .strategy-checklist-body ul {
@@ -9818,12 +10157,28 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
.strategy-layout { .strategy-layout {
grid-template-columns: 1fr; 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-doc-card,
.strategy-checklist-card { .strategy-checklist-card {
align-self: stretch; align-self: stretch;
} }
.strategy-doc-body { .strategy-doc-body,
max-height: min(50vh, 520px); .strategy-checklist-body {
max-height: min(58vh, 560px);
} }
} }
+16 -4
View File
@@ -2528,6 +2528,17 @@
renderMonitorGrid(lastMonitorRows); 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) { } catch (err) {
console.error("renderFullscreenExchange", err); console.error("renderFullscreenExchange", err);
closeExchangeFullscreen(); closeExchangeFullscreen();
@@ -4000,6 +4011,7 @@
</div> </div>
<div class="fs-head-actions"> <div class="fs-head-actions">
<button type="button" class="ghost btn-expand-back">返回监控</button> <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 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="/trade">下单</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""} ${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""}
@@ -5009,12 +5021,12 @@
<div class="settings-card-head"> <div class="settings-card-head">
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label> <label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
${envOff} ${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>
<div class="settings-grid"> <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>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 || "")}" /></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" /></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>
<div class="cap-chips"> <div class="cap-chips">
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label> <label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
+30
View File
@@ -197,9 +197,11 @@
const elPrevCloseLine = document.getElementById("market-prev-close-line"); const elPrevCloseLine = document.getElementById("market-prev-close-line");
const elPrevHlLines = document.getElementById("market-prev-hl-lines"); const elPrevHlLines = document.getElementById("market-prev-hl-lines");
const elDaySplit = document.getElementById("market-day-split"); 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_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line";
const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines"; const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines";
const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split"; 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 BJ_OFFSET_SEC = 8 * 60 * 60;
const elFsToolbar = document.getElementById("market-fs-toolbar"); const elFsToolbar = document.getElementById("market-fs-toolbar");
const elFsExchange = document.getElementById("market-fs-exchange"); const elFsExchange = document.getElementById("market-fs-exchange");
@@ -342,6 +344,14 @@
saveBoolPref(DAY_SPLIT_STORAGE_KEY, on); 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() { function loadPrevCloseLinePref() {
return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false); return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false);
} }
@@ -456,6 +466,18 @@
applyTradingDaySplit(on); 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() { function ensureDrawLayer() {
if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return; if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return;
window.HubChartDraw.attach({ window.HubChartDraw.attach({
@@ -471,6 +493,9 @@
}); });
window.HubChartDraw.setViewKey(currentChartViewKey()); window.HubChartDraw.setViewKey(currentChartViewKey());
applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref()); applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref());
applyOptionExpirySplit(
elOptionExpirySplit ? elOptionExpirySplit.checked : loadOptionExpirySplitPref()
);
drawAttached = true; drawAttached = true;
} }
@@ -3481,6 +3506,11 @@
elDaySplit.addEventListener("change", syncTradingDaySplitUi); elDaySplit.addEventListener("change", syncTradingDaySplitUi);
applyTradingDaySplit(elDaySplit.checked); applyTradingDaySplit(elDaySplit.checked);
} }
if (elOptionExpirySplit) {
elOptionExpirySplit.checked = loadOptionExpirySplitPref();
elOptionExpirySplit.addEventListener("change", syncOptionExpirySplitUi);
applyOptionExpirySplit(elOptionExpirySplit.checked);
}
const pageMarket = document.getElementById("page-market"); const pageMarket = document.getElementById("page-market");
const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean); const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean);
fsKeyTargets.forEach(function (el) { fsKeyTargets.forEach(function (el) {
+52
View File
@@ -68,6 +68,7 @@
let unsubClick = null; let unsubClick = null;
let mainBound = false; let mainBound = false;
let tradingDaySplitEnabled = false; let tradingDaySplitEnabled = false;
let optionExpirySplitEnabled = false;
const BJ_OFFSET_SEC = 8 * 60 * 60; const BJ_OFFSET_SEC = 8 * 60 * 60;
function uid() { function uid() {
@@ -409,6 +410,50 @@
ctx.restore(); 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) { function drawRect(ctx, x1, y1, x2, y2, selected) {
if (x1 == null || y1 == null || x2 == null || y2 == null) return; if (x1 == null || y1 == null || x2 == null || y2 == null) return;
const l = Math.min(x1, x2); const l = Math.min(x1, x2);
@@ -743,6 +788,7 @@
const h = hostEl.clientHeight; const h = hostEl.clientHeight;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
drawTradingDaySplits(ctx, w, h); drawTradingDaySplits(ctx, w, h);
drawOptionExpirySplits(ctx, w, h);
drawings.forEach(function (d) { drawings.forEach(function (d) {
if (d.hidden) ctx.globalAlpha = 0.14; if (d.hidden) ctx.globalAlpha = 0.14;
renderDrawing(ctx, d, w, h, d.id === selectedId); renderDrawing(ctx, d, w, h, d.id === selectedId);
@@ -1451,10 +1497,16 @@
scheduleRedraw(); scheduleRedraw();
} }
function setOptionExpirySplit(enabled) {
optionExpirySplitEnabled = !!enabled;
scheduleRedraw();
}
window.HubChartDraw = { window.HubChartDraw = {
attach: attach, attach: attach,
setViewKey: setViewKey, setViewKey: setViewKey,
setTradingDaySplit: setTradingDaySplit, setTradingDaySplit: setTradingDaySplit,
setOptionExpirySplit: setOptionExpirySplit,
resize: scheduleRedraw, resize: scheduleRedraw,
redraw: scheduleRedraw, redraw: scheduleRedraw,
destroy: destroy, destroy: destroy,
+64 -9
View File
@@ -124,7 +124,7 @@ body.hub-page-dashboard .page#page-dashboard {
justify-content: space-between; justify-content: space-between;
gap: 0; gap: 0;
width: 100%; width: 100%;
padding: 10px 4px; padding: 14px 6px;
border-radius: 12px; border-radius: 12px;
background: var(--dash-card-bg); background: var(--dash-card-bg);
border: 1px solid var(--dash-card-border); border: 1px solid var(--dash-card-border);
@@ -136,7 +136,7 @@ body.hub-page-dashboard .page#page-dashboard {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
max-width: none; max-width: none;
padding: 4px 8px; padding: 6px 10px;
position: relative; position: relative;
text-align: center; text-align: center;
} }
@@ -145,8 +145,8 @@ body.hub-page-dashboard .page#page-dashboard {
content: ""; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
top: 18%; top: 16%;
bottom: 18%; bottom: 16%;
width: 1px; width: 1px;
background: color-mix(in srgb, var(--dash-card-border) 85%, transparent); 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 { .dash-kpi-label {
font-size: 0.65rem; font-size: 0.78rem;
color: var(--dash-muted); color: var(--dash-muted);
letter-spacing: 0.04em; letter-spacing: 0.04em;
margin-bottom: 4px; margin-bottom: 6px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -178,8 +178,8 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-kpi-value { .dash-kpi-value {
font-family: JetBrains Mono, monospace; font-family: JetBrains Mono, monospace;
font-size: 0.92rem; font-size: 1.18rem;
font-weight: 600; font-weight: 650;
line-height: 1.25; line-height: 1.25;
color: var(--dash-text); color: var(--dash-text);
white-space: nowrap; white-space: nowrap;
@@ -247,11 +247,66 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-ac-grid { .dash-ac-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); grid-template-columns: 1fr;
gap: 12px; gap: 12px;
padding: 14px; 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 { .dash-ac-card {
position: relative; position: relative;
padding: 14px 16px; padding: 14px 16px;
+169 -85
View File
@@ -88,22 +88,6 @@
</div>`; </div>`;
} }
function renderMonitorCountChips(counts) {
const mc = counts || {};
const chips = [];
const keys = Number(mc.keys) || 0;
const orders = Number(mc.orders) || 0;
const trends = Number(mc.trends) || 0;
const rolls = Number(mc.rolls) || 0;
if (keys > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-key">关键位 ${keys}</span>`);
if (orders > 0) {
chips.push(`<span class="dash-monitor-chip dash-monitor-order">下单监控 ${orders}</span>`);
}
if (trends > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-trend">趋势回调 ${trends}</span>`);
if (rolls > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-roll">顺势加仓 ${rolls}</span>`);
return chips;
}
function dashOptionsExpiryCd(expMs) { function dashOptionsExpiryCd(expMs) {
const ms = expMs != null && expMs !== "" ? String(expMs) : ""; const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—"; if (!ms) return "—";
@@ -130,6 +114,60 @@
return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0); return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0);
} }
function exchangeLinkCell(ac) {
const name = esc((ac && ac.name) || "—");
const exId = ac && ac.id != null ? String(ac.id) : "";
if (!exId) return name;
return (
`<button type="button" class="dash-ex-link" data-dash-ex-id="${esc(exId)}" ` +
`title="打开监控区放大查看">${name}</button>`
);
}
function priceCell(fmtVal, raw) {
if (fmtVal != null && String(fmtVal).trim() !== "") return esc(String(fmtVal));
if (raw != null && Number.isFinite(Number(raw))) return esc(fmt(raw, 4).replace(/\.?0+$/, ""));
return "—";
}
function floatPnlCell(ln) {
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
if (!Number.isFinite(pnl)) return "—";
return `<span class="${pnlClass(pnl)}">${fmt(pnl, 2)}</span>`;
}
function slTpCell(ln, kind) {
if (kind === "tp") {
const note = String((ln && ln.tp_note) || "").trim();
if (note) return `<span class="dash-tp-program">${esc(note)}</span>`;
}
const raw = kind === "sl" ? ln && ln.stop_loss : ln && ln.take_profit;
if (raw == null || raw === "") return "—";
if (Number.isFinite(Number(raw))) {
return esc(fmt(raw, 4).replace(/\.?0+$/, ""));
}
return esc(String(raw));
}
function collectUnifiedPositions(accounts) {
const perp = [];
const options = [];
(Array.isArray(accounts) ? accounts : []).forEach((ac) => {
if (!accountHasOpenPositions(ac)) return;
accountPerpLines(ac).forEach((ln) => {
if (!ln) return;
perp.push({ ac: ac, ln: ln });
});
if (ac && ac.options_layout) {
(Array.isArray(ac.options_positions) ? ac.options_positions : []).forEach((p) => {
if (!p) return;
options.push({ ac: ac, p: p });
});
}
});
return { perp: perp, options: options };
}
function sourceBadgeClass(source) { function sourceBadgeClass(source) {
const s = String(source || ""); const s = String(source || "");
if (s.indexOf("对冲") >= 0) return "is-hedge"; if (s.indexOf("对冲") >= 0) return "is-hedge";
@@ -141,23 +179,41 @@
return "is-none"; return "is-none";
} }
function renderDashboardPerpTable(lines) { function sourceTypeCell(source) {
const rows = Array.isArray(lines) ? lines : []; const s = String(source || "—");
return `<span class="dash-pos-source ${sourceBadgeClass(s)}">${esc(s)}</span>`;
}
function directionCell(side) {
const s = String(side || "").toLowerCase();
if (s === "long" || s === "buy") {
return `<span class="dash-side dash-side-long">做多</span>`;
}
if (s === "short" || s === "sell") {
return `<span class="dash-side dash-side-short">做空</span>`;
}
return esc(side || "—");
}
function renderUnifiedPerpTable(rows) {
if (!rows.length) return ""; if (!rows.length) return "";
const body = rows const body = rows
.map((ln) => { .map(({ ac, ln }) => {
const source = String((ln && ln.source) || "—"); const source = String((ln && ln.source) || "—");
const symbol = esc((ln && (ln.symbol || ln.text)) || "—"); const symbol = esc((ln && (ln.symbol || ln.text)) || "—");
const side = esc((ln && ln.side) || "—");
const contracts = const contracts =
ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—"; ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—";
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
return `<tr> return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td> <td>${exchangeLinkCell(ac)}</td>
<td>${sourceTypeCell(source)}</td>
<td>${symbol}</td> <td>${symbol}</td>
<td>${side}</td> <td>${directionCell(ln && ln.side)}</td>
<td>${priceCell(ln && ln.entry_price_fmt, ln && ln.entry_price)}</td>
<td>${priceCell(ln && ln.mark_price_fmt, ln && ln.mark_price)}</td>
<td>${contracts}</td> <td>${contracts}</td>
<td class="${pnlClass(pnl)}">${Number.isFinite(pnl) ? pnlSigned(pnl, 2) : "—"}</td> <td>${slTpCell(ln, "sl")}</td>
<td>${slTpCell(ln, "tp")}</td>
<td>${floatPnlCell(ln)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
@@ -166,7 +222,7 @@
<div class="dash-table-wrap"> <div class="dash-table-wrap">
<table class="dash-table dash-pos-table"> <table class="dash-table dash-pos-table">
<thead><tr> <thead><tr>
<th>来源</th><th></th><th></th><th></th><th></th> <th>交易所</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead> </tr></thead>
<tbody>${body}</tbody> <tbody>${body}</tbody>
</table> </table>
@@ -188,64 +244,110 @@
return null; return null;
} }
function renderDashboardOptionsTable(positions) { function optionsHedgePlanId(p) {
const pos = Array.isArray(positions) ? positions : []; if (!p || typeof p !== "object") return "";
if (!pos.length) return ""; const hedge = p.hedge_plan_target;
const rows = pos if (hedge && typeof hedge === "object" && hedge.plan_id != null && hedge.plan_id !== "") {
.map((p) => { return String(hedge.plan_id);
}
if (p.source_plan_id != null && p.source_plan_id !== "") return String(p.source_plan_id);
const target = String(p.target_monitor_text || "");
const m = target.match(/对冲\s*#\s*(\d+)/);
return m ? m[1] : "";
}
function optionsGroupKey(ac, p) {
const ex = ac && ac.id != null ? String(ac.id) : String((ac && ac.name) || "");
const planId = optionsHedgePlanId(p);
const source = String((p && (p.source_label || p.source)) || "");
if (planId && source.indexOf("对冲") >= 0) return "hedge:" + ex + ":" + planId;
if (planId && /对冲#/.test(String((p && p.target_monitor_text) || ""))) {
return "hedge:" + ex + ":" + planId;
}
return "solo:" + ex + ":" + String((p && p.inst_id) || Math.random());
}
function optionsTypeLabel(p) {
const source = String((p && (p.source_label || p.source)) || "纯期权").trim() || "纯期权";
const planId = optionsHedgePlanId(p);
if (planId && (source.indexOf("对冲") >= 0 || /对冲#/.test(String((p && p.target_monitor_text) || "")))) {
const base = source.indexOf("对冲") >= 0 ? source.replace(/\s*#\s*\d+\s*$/, "") : "对冲";
return base + "#" + planId;
}
return source;
}
function optionsRoiPct(p) {
if (!p || typeof p !== "object") return null;
const preview = p.close_preview || {};
if (preview.estimated_pnl_ratio_pct != null && Number.isFinite(Number(preview.estimated_pnl_ratio_pct))) {
return Number(preview.estimated_pnl_ratio_pct);
}
const net = optionsNetPnl(p);
const paid = Number(p.premium_paid);
if (net != null && Number.isFinite(paid) && paid > 0) {
return (net / paid) * 100;
}
return null;
}
function renderOptionsLegRow(ac, p) {
const optType = const optType =
(p.opt_type || "").toUpperCase() === "C" (p.opt_type || "").toUpperCase() === "C"
? "Call" ? "Call"
: (p.opt_type || "").toUpperCase() === "P" : (p.opt_type || "").toUpperCase() === "P"
? "Put" ? "Put"
: p.opt_type || "—"; : p.opt_type || "—";
const source = String(p.source_label || p.source || "纯期权"); const source = optionsTypeLabel(p);
const target = String(p.target_monitor_text || "—"); const target = String(p.target_monitor_text || "—");
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor"; const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
const net = optionsNetPnl(p); const net = optionsNetPnl(p);
const roi = optionsRoiPct(p);
return `<tr> return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td> <td>${exchangeLinkCell(ac)}</td>
<td>${sourceTypeCell(source)}</td>
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td> <td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
<td>${esc(optType)}</td> <td>${esc(optType)}</td>
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td> <td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td> <td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td> <td><span class="${targetCls}">${esc(target)}</span></td>
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td> <td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>
</tr>`; </tr>`;
}) }
.join("");
function renderUnifiedOptionsTable(rows) {
if (!rows.length) return "";
// 同所同计划相邻,Call 在前 Put 在后;不额外画分组框
const sorted = rows.slice().sort((a, b) => {
const ka = optionsGroupKey(a.ac, a.p);
const kb = optionsGroupKey(b.ac, b.p);
if (ka !== kb) return ka.localeCompare(kb);
const ta = String((a.p && a.p.opt_type) || "").toUpperCase();
const tb = String((b.p && b.p.opt_type) || "").toUpperCase();
if (ta === tb) return 0;
if (ta === "C") return -1;
if (tb === "C") return 1;
return ta.localeCompare(tb);
});
const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p)).join("");
return `<div class="dash-pos-block dash-options-block"> return `<div class="dash-pos-block dash-options-block">
<div class="dash-ac-section-label">期权持仓</div> <div class="dash-ac-section-label">期权持仓</div>
<div class="dash-table-wrap dash-options-table-wrap"> <div class="dash-table-wrap dash-options-table-wrap">
<table class="dash-table dash-options-table"> <table class="dash-table dash-options-table">
<thead><tr> <thead><tr>
<th>来源</th><th></th><th></th><th></th><th></th><th></th><th></th> <th>交易所</th><th></th><th></th><th>Call/Put</th><th></th><th></th><th></th><th></th><th></th>
</tr></thead> </tr></thead>
<tbody>${rows}</tbody> <tbody>${body}</tbody>
</table> </table>
</div> </div>
</div>`; </div>`;
} }
function renderAccountPositions(ac) {
const perpLines = accountPerpLines(ac);
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
const issues = Array.isArray(ac && ac.issues) ? ac.issues : [];
const chips = renderMonitorCountChips((ac && ac.monitor_counts) || {});
const monitorRow = chips.length
? `<div class="dash-ac-monitor-row">${chips.join("")}</div>`
: "";
const perpHtml = renderDashboardPerpTable(perpLines);
const optionsHtml = ac && ac.options_layout ? renderDashboardOptionsTable(optionsPositions) : "";
const issueHtml = issues
.map((text) => `<div class="dash-ac-remark-line dash-ac-remark-issue">${esc(text)}</div>`)
.join("");
return `${monitorRow}${perpHtml}${optionsHtml}${issueHtml}`;
}
function bindDashboardExpand() { function bindDashboardExpand() {
if (!elAccounts) return; if (!elAccounts) return;
elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => { elAccounts.querySelectorAll("[data-dash-ex-id]").forEach((btn) => {
btn.addEventListener("click", (ev) => { btn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -256,44 +358,26 @@
} }
function renderAccounts(accounts, threshold) { function renderAccounts(accounts, threshold) {
const rows = (Array.isArray(accounts) ? accounts : []).filter(accountHasOpenPositions); const unified = collectUnifiedPositions(accounts);
if (!rows.length) { const perpHtml = renderUnifiedPerpTable(unified.perp);
const optionsHtml = renderUnifiedOptionsTable(unified.options);
if (!perpHtml && !optionsHtml) {
elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>'; elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>';
return; return;
} }
elAccounts.innerHTML = rows const alertAccounts = (Array.isArray(accounts) ? accounts : []).filter((a) => a && a.loss_alert);
.map((ac) => { const alertNote = alertAccounts.length
const alert = !!ac.loss_alert; ? `<div class="dash-pos-alert-note">风险: ${esc(
const unmon = !ac.monitored; alertAccounts.map((a) => a.name || "").filter(Boolean).join("、")
const lossPct = Number(ac.daily_loss_pct); )} 单日亏损 ${threshold}%</div>`
const barW =
alert && Number.isFinite(lossPct)
? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100)
: 0;
const badge = alert
? `<span class="dash-ac-badge alert">单日亏损 ≥${threshold}%</span>`
: `<span class="dash-ac-badge ok">${esc(ac.status || "—")}</span>`;
const exId = ac && ac.id != null ? String(ac.id) : "";
const expandBtn = exId
? `<button type="button" class="dash-ac-expand-btn" data-dash-ex-id="${esc(exId)}" title="放大查看监控详情" aria-label="放大查看监控详情">` +
`<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M15 3h6v6h-2V6.41l-7.29 7.3-1.42-1.42 7.3-7.29H15V3zM3 9h2v10h10v2H3V9z"/></svg>` +
`</button>`
: ""; : "";
const lossBar = elAccounts.innerHTML = `<article class="dash-ac-card dash-pos-unified-card">
alert && barW > 0 <div class="dash-ac-pos-body">
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>` ${perpHtml}
: ""; ${optionsHtml}
const cardCls = ac.options_layout ? " dash-ac-card-options" : ""; ${alertNote}
return `<article class="dash-ac-card dash-ac-card-pos-only${cardCls}${alert ? " is-alert" : ""}${unmon ? " is-unmon" : ""}">
<div class="dash-ac-top">
<div class="dash-ac-name">${esc(ac.name || "—")}</div>
<div class="dash-ac-top-actions">${badge}${expandBtn}</div>
</div> </div>
${lossBar}
<div class="dash-ac-pos-body">${renderAccountPositions(ac)}</div>
</article>`; </article>`;
})
.join("");
bindDashboardExpand(); bindDashboardExpand();
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer(); OptionsExpiryCountdown.ensureTimer();
+56 -25
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<script src="/assets/theme.js?v=20260604-hub-inst-theme"></script> <script src="/assets/theme.js?v=20260604-hub-inst-theme"></script>
<script src="/assets/autofill_guard.js?v=1"></script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#0b0e18" /> <meta name="theme-color" content="#0b0e18" />
<meta name="apple-mobile-web-app-title" content="中控" /> <meta name="apple-mobile-web-app-title" content="中控" />
@@ -15,11 +16,11 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260717-archive-cal-chart" /> <link rel="stylesheet" href="/assets/app.css?v=20260722-monitor-m-stats" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" /> <link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" /> <link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script> <script src="/assets/account_risk_badge.js?v=4"></script>
<link rel="stylesheet" href="/assets/dashboard.css?v=20260717-dash-kpi-spread" /> <link rel="stylesheet" href="/assets/dashboard.css?v=20260720-dash-kpi-lg" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -113,15 +114,15 @@
</label> </label>
<label class="plan-field"> <label class="plan-field">
<span>目标位</span> <span>目标位</span>
<input id="plan-create-target" type="text" placeholder="如 68500" /> <input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label> </label>
<label class="plan-field"> <label class="plan-field">
<span>当前区间</span> <span>当前区间</span>
<input id="plan-create-range" type="text" placeholder="如 67000-68000" /> <input id="plan-create-range" type="text" placeholder="如 67000-68000" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label> </label>
<label class="plan-field plan-field-full"> <label class="plan-field plan-field-full">
<span>备注</span> <span>备注</span>
<textarea id="plan-create-note" rows="2" placeholder="计划说明…"></textarea> <textarea id="plan-create-note" rows="2" placeholder="计划说明…" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other"></textarea>
</label> </label>
</div> </div>
<button type="submit" class="primary plan-submit-btn">保存并进入进行中</button> <button type="submit" class="primary plan-submit-btn">保存并进入进行中</button>
@@ -323,6 +324,9 @@
<label class="market-day-split-opt" title="北京时间 8:00 交易切日竖线"> <label class="market-day-split-opt" title="北京时间 8:00 交易切日竖线">
<input type="checkbox" id="market-day-split" /> 交易间隔日 <input type="checkbox" id="market-day-split" /> 交易间隔日
</label> </label>
<label class="market-day-split-opt" title="OKX 期权到期切日:每天北京 16:00(UTC 08:00) 黄虚线,对应 1/2/3 日期权">
<input type="checkbox" id="market-option-expiry-split" /> 期权间隔日
</label>
<details class="market-ind-menu"> <details class="market-ind-menu">
<summary>技术指标</summary> <summary>技术指标</summary>
<div class="market-ind-options"> <div class="market-ind-options">
@@ -811,6 +815,8 @@
</div> </div>
<div class="calc-layout" data-calc-tab="trend"> <div class="calc-layout" data-calc-tab="trend">
<section class="calc-card card" data-calc-pane="trend"> <section class="calc-card card" data-calc-pane="trend">
<div class="calc-pane-split">
<div class="calc-input-panel">
<h2>趋势回调计算器</h2> <h2>趋势回调计算器</h2>
<p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%.</p> <p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%.</p>
<form id="calc-trend-form" class="calc-form"> <form id="calc-trend-form" class="calc-form">
@@ -870,10 +876,18 @@
<button type="submit" class="primary">计算</button> <button type="submit" class="primary">计算</button>
</div> </div>
</form> </form>
</div>
<aside class="calc-result-panel" aria-label="趋势回调结果推算">
<h3 class="calc-result-title">结果推算</h3>
<p class="calc-result-placeholder">填写左侧参数后点击「计算」</p>
<div id="calc-trend-result" class="calc-result hidden"></div> <div id="calc-trend-result" class="calc-result hidden"></div>
</aside>
</div>
</section> </section>
<section class="calc-card card" data-calc-pane="roll"> <section class="calc-card card" data-calc-pane="roll">
<div class="calc-pane-split">
<div class="calc-input-panel">
<h2>滚仓计算器</h2> <h2>滚仓计算器</h2>
<p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变.最多 3 次滚仓.</p> <p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变.最多 3 次滚仓.</p>
<form id="calc-roll-form" class="calc-form"> <form id="calc-roll-form" class="calc-form">
@@ -930,7 +944,13 @@
<button type="submit" class="primary">计算</button> <button type="submit" class="primary">计算</button>
</div> </div>
</form> </form>
</div>
<aside class="calc-result-panel" aria-label="滚仓结果推算">
<h3 class="calc-result-title">结果推算</h3>
<p class="calc-result-placeholder">填写左侧参数后点击「计算」</p>
<div id="calc-roll-result" class="calc-result hidden"></div> <div id="calc-roll-result" class="calc-result hidden"></div>
</aside>
</div>
</section> </section>
</div> </div>
</div> </div>
@@ -940,37 +960,48 @@
<div class="page-head strategy-page-head"> <div class="page-head strategy-page-head">
<div> <div>
<h1><span class="head-tag">STR</span> 策略说明</h1> <h1><span class="head-tag">STR</span> 策略说明</h1>
<p class="page-desc">三所策略文档 · 开仓前检查清单 · 可打印 / 下载</p> <p class="page-desc">执行手册 · 三所策略正文(带目录) · 执行清单(打印对照)</p>
</div> </div>
<div class="strategy-page-actions no-print"> <div class="strategy-page-actions no-print">
<button type="button" id="strategy-btn-download" class="primary">下载 HTML</button> <button type="button" id="strategy-btn-download" class="ghost">下载 HTML</button>
<button type="button" id="strategy-btn-print-doc" class="ghost">打印正文</button>
<button type="button" id="strategy-btn-print-checklist" class="primary">打印清单</button>
</div> </div>
</div> </div>
<div class="strategy-toolbar no-print"> <div class="strategy-toolbar no-print">
<div id="strategy-tabs" class="strategy-tabs" role="tablist" aria-label="交易所策略"></div> <div id="strategy-tabs" class="strategy-tabs" role="tablist" aria-label="交易所策略"></div>
<div id="strategy-view-tabs" class="strategy-view-tabs" role="tablist" aria-label="策略视图">
<button type="button" class="strategy-view-tab is-active" data-view="doc" role="tab" aria-selected="true">策略正文</button>
<button type="button" class="strategy-view-tab" data-view="checklist" role="tab" aria-selected="false">执行清单</button>
</div>
<span id="strategy-load-status" class="toolbar-meta"></span> <span id="strategy-load-status" class="toolbar-meta"></span>
</div> </div>
<div id="strategy-print-root" class="strategy-print-root"> <div id="strategy-print-root" class="strategy-print-root">
<div class="strategy-layout"> <section id="strategy-panel-doc" class="card strategy-doc-panel" data-strategy-view="doc">
<section class="card strategy-doc-card"> <div class="strategy-blog-layout">
<div class="strategy-blog-main">
<div class="strategy-col-head"> <div class="strategy-col-head">
<h3 class="strategy-col-title">策略说明</h3> <h3 class="strategy-col-title">策略正文</h3>
<button type="button" id="strategy-btn-print-doc" class="ghost strategy-col-print">打印</button>
</div> </div>
<div id="strategy-doc-body" class="strategy-doc-body prose"></div> <div id="strategy-doc-body" class="strategy-doc-body prose"></div>
<p id="strategy-doc-source" class="strategy-doc-source"></p> <p id="strategy-doc-source" class="strategy-doc-source"></p>
</div>
<aside class="strategy-blog-toc card strategy-toc-card no-print">
<h3 class="strategy-toc-title">目录</h3>
<nav id="strategy-doc-toc" class="strategy-toc-nav" aria-label="策略目录"></nav>
</aside>
</div>
</section> </section>
<section class="card strategy-checklist-card"> <section id="strategy-panel-checklist" class="card strategy-checklist-card hidden" data-strategy-view="checklist">
<div class="strategy-col-head"> <div class="strategy-col-head">
<h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3> <h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3>
<button type="button" id="strategy-btn-print-checklist" class="ghost strategy-col-print">打印</button> <button type="button" id="strategy-btn-print-checklist-inline" class="ghost strategy-col-print">打印</button>
</div> </div>
<div id="strategy-checklist-body" class="strategy-checklist-body"></div> <div id="strategy-checklist-body" class="strategy-checklist-body"></div>
<ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul> <ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul>
</section> </section>
</div> </div>
</div> </div>
</div>
<div id="page-help" class="page hidden"> <div id="page-help" class="page hidden">
<div class="page-head"> <div class="page-head">
@@ -1066,10 +1097,10 @@
</div> </div>
<p class="settings-display-hint">修改中控网页登录账号密码,写入 <code>manual_trading_hub/.env</code> 后需重启中控生效.当前用户:<code id="hub-pwd-current-user"></code></p> <p class="settings-display-hint">修改中控网页登录账号密码,写入 <code>manual_trading_hub/.env</code> 后需重启中控生效.当前用户:<code id="hub-pwd-current-user"></code></p>
<div class="settings-grid hub-password-grid"> <div class="settings-grid hub-password-grid">
<div class="field"><label>当前密码</label><input type="password" id="hub-pwd-old" autocomplete="current-password"></div> <div class="field" data-password-settings="1"><label>当前密码</label><input type="password" id="hub-pwd-old" autocomplete="current-password"></div>
<div class="field"><label>新用户名(可选)</label><input type="text" id="hub-pwd-new-username" autocomplete="username"></div> <div class="field" data-password-settings="1"><label>新用户名(可选)</label><input type="text" id="hub-pwd-new-username" autocomplete="username"></div>
<div class="field"><label>新密码</label><input type="password" id="hub-pwd-new" autocomplete="new-password"></div> <div class="field" data-password-settings="1"><label>新密码</label><input type="password" id="hub-pwd-new" autocomplete="new-password"></div>
<div class="field"><label>确认新密码</label><input type="password" id="hub-pwd-confirm" autocomplete="new-password"></div> <div class="field" data-password-settings="1"><label>确认新密码</label><input type="password" id="hub-pwd-confirm" autocomplete="new-password"></div>
</div> </div>
<div class="hub-settings-tab-actions"> <div class="hub-settings-tab-actions">
<button type="button" class="primary" id="hub-pwd-save-btn">保存密码</button> <button type="button" class="primary" id="hub-pwd-save-btn">保存密码</button>
@@ -1365,23 +1396,23 @@
<div id="toast"></div> <div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart_draw.js?v=20260609-market-day-split"></script> <script src="/assets/chart_draw.js?v=20260720-option-day-1600"></script>
<script src="/assets/chart.js?v=20260715-fs-landscape"></script> <script src="/assets/chart.js?v=20260720-option-day-1600"></script>
<script src="/assets/plan.js?v=20260614-plan-refresh"></script> <script src="/assets/plan.js?v=20260720-autofill"></script>
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script> <script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
<script src="/assets/trade_stats_calendar.js?v=3"></script> <script src="/assets/trade_stats_calendar.js?v=3"></script>
<script src="/assets/archive.js?v=20260717-archive-cal-chart"></script> <script src="/assets/archive.js?v=20260717-archive-cal-chart"></script>
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script> <script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script> <script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260717-dash-kpi-merge"></script> <script src="/assets/dashboard.js?v=20260720-dash-sl-tp"></script>
<script src="/assets/strategy.js?v=4"></script> <script src="/assets/strategy.js?v=9"></script>
<script src="/assets/help.js?v=1"></script> <script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.js?v=1"></script> <script src="/assets/logs.js?v=1"></script>
<script src="/assets/ai_review_render.js?v=3"></script> <script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script> <script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/options_expiry_countdown.js?v=1"></script> <script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=1"></script> <script src="/assets/options_position_cards.js?v=2"></script>
<script src="/assets/backup.js?v=1"></script> <script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260717-quotes-feed"></script> <script src="/assets/app.js?v=20260720-dash-back"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -481,7 +481,7 @@
"</select></label>" + "</select></label>" +
'<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' + '<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' +
esc(p.symbol) + esc(p.symbol) +
'" required /></label>' + '" required autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>' +
'<label class="plan-field"><span>类型</span><select name="plan_type" required>' + '<label class="plan-field"><span>类型</span><select name="plan_type" required>' +
opts(meta.plan_types, "plan_type", "value", "label") + opts(meta.plan_types, "plan_type", "value", "label") +
"</select></label>" + "</select></label>" +
@@ -496,10 +496,10 @@
"</span></label>" + "</span></label>" +
'<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' + '<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' +
esc(p.target_level || "") + esc(p.target_level || "") +
'" /></label>' + '" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>' +
'<label class="plan-field"><span>当前区间</span><input name="current_range" type="text" value="' + '<label class="plan-field"><span>当前区间</span><input name="current_range" type="text" value="' +
esc(p.current_range || "") + esc(p.current_range || "") +
'" /></label>' + '" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>' +
'<label class="plan-field plan-field-full"><span>入场方案</span><select name="entry_scheme" required>' + '<label class="plan-field plan-field-full"><span>入场方案</span><select name="entry_scheme" required>' +
opts(meta.entry_schemes, "entry_scheme", "value", "label") + opts(meta.entry_schemes, "entry_scheme", "value", "label") +
"</select></label>" + "</select></label>" +
+165 -25
View File
@@ -1,28 +1,32 @@
/** /**
* 策略说明:三所 MD + 开仓检查清单 JSON. * 策略说明:策略正文(目录 h2 + 章节标识) + 执行清单 Tab.
*/ */
(function () { (function () {
const page = document.getElementById("page-strategy"); const page = document.getElementById("page-strategy");
if (!page) return; if (!page) return;
const tabsEl = document.getElementById("strategy-tabs"); const tabsEl = document.getElementById("strategy-tabs");
const viewTabsEl = document.getElementById("strategy-view-tabs");
const statusEl = document.getElementById("strategy-load-status"); const statusEl = document.getElementById("strategy-load-status");
const docBody = document.getElementById("strategy-doc-body"); const docBody = document.getElementById("strategy-doc-body");
const docSource = document.getElementById("strategy-doc-source"); const docSource = document.getElementById("strategy-doc-source");
const docCard = page.querySelector(".strategy-doc-card"); const docToc = document.getElementById("strategy-doc-toc");
const checklistCard = page.querySelector(".strategy-checklist-card"); const panelDoc = document.getElementById("strategy-panel-doc");
const panelChecklist = document.getElementById("strategy-panel-checklist");
const checklistTitle = document.getElementById("strategy-checklist-title"); const checklistTitle = document.getElementById("strategy-checklist-title");
const checklistBody = document.getElementById("strategy-checklist-body"); const checklistBody = document.getElementById("strategy-checklist-body");
const footnotesEl = document.getElementById("strategy-checklist-footnotes"); const footnotesEl = document.getElementById("strategy-checklist-footnotes");
const btnPrintDoc = document.getElementById("strategy-btn-print-doc"); const btnPrintDoc = document.getElementById("strategy-btn-print-doc");
const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist"); const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist");
const btnPrintChecklistInline = document.getElementById("strategy-btn-print-checklist-inline");
const btnDownload = document.getElementById("strategy-btn-download"); const btnDownload = document.getElementById("strategy-btn-download");
let activeKey = "binance"; let activeKey = "playbook";
let activeView = "doc";
let tabsMeta = []; let tabsMeta = [];
let cache = {}; let cache = {};
let bound = false; let bound = false;
let heightSyncRaf = 0; let scrollSpyObs = null;
async function apiFetch(url, opts) { async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) }); const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
@@ -44,23 +48,31 @@
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function syncDocCardHeight() { function slugify(text) {
if (!docCard || !checklistCard || window.matchMedia("(max-width: 960px)").matches) { return String(text || "")
if (docCard) docCard.style.height = ""; .trim()
return; .toLowerCase()
} .replace(/\s+/g, "-")
docCard.style.height = `${checklistCard.offsetHeight}px`; .replace(/[^\w\u4e00-\u9fff\-]+/g, "")
.replace(/\-+/g, "-")
.replace(/^\-|\-$/g, "")
.slice(0, 48);
} }
function scheduleHeightSync() { function setView(view) {
if (heightSyncRaf) cancelAnimationFrame(heightSyncRaf); activeView = view === "checklist" ? "checklist" : "doc";
heightSyncRaf = requestAnimationFrame(() => { if (panelDoc) panelDoc.classList.toggle("hidden", activeView !== "doc");
heightSyncRaf = 0; if (panelChecklist) panelChecklist.classList.toggle("hidden", activeView !== "checklist");
syncDocCardHeight(); if (viewTabsEl) {
viewTabsEl.querySelectorAll(".strategy-view-tab").forEach((btn) => {
const on = btn.getAttribute("data-view") === activeView;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
}); });
} }
}
function renderTabs() { function renderExchangeTabs() {
if (!tabsEl) return; if (!tabsEl) return;
tabsEl.innerHTML = tabsMeta tabsEl.innerHTML = tabsMeta
.map( .map(
@@ -73,12 +85,133 @@
const key = btn.getAttribute("data-key"); const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return; if (!key || key === activeKey) return;
activeKey = key; activeKey = key;
renderTabs(); renderExchangeTabs();
void loadExchange(key); void loadExchange(key);
}); });
}); });
} }
function setActiveToc(id) {
if (!docToc) return;
docToc.querySelectorAll(".strategy-toc-item").forEach((a) => {
a.classList.toggle("is-active", a.getAttribute("data-id") === id);
});
}
function bindScrollSpy(heads) {
if (scrollSpyObs) {
scrollSpyObs.disconnect();
scrollSpyObs = null;
}
if (!docBody || !heads.length || typeof IntersectionObserver === "undefined") return;
const visible = new Map();
scrollSpyObs = new IntersectionObserver(
(entries) => {
entries.forEach((en) => {
if (en.isIntersecting) visible.set(en.target.id, en.intersectionRatio);
else visible.delete(en.target.id);
});
let bestId = "";
let bestRatio = -1;
visible.forEach((ratio, id) => {
if (ratio > bestRatio) {
bestRatio = ratio;
bestId = id;
}
});
if (bestId) setActiveToc(bestId);
},
{ root: docBody, rootMargin: "-8% 0px -70% 0px", threshold: [0, 0.25, 0.5, 1] }
);
heads.forEach((h) => scrollSpyObs.observe(h));
}
function sectionTag(title) {
const t = String(title || "");
if (/总原则|原则/.test(t)) return "原则";
if (/账户|定位|分工/.test(t)) return "账户";
if (/开仓类型|开仓|入场|反转|顺势|波段|假破|结构|对冲|方向单/.test(t)) return "入场";
if (/周期/.test(t)) return "周期";
if (/方向/.test(t)) return "方向";
if (/纪律|出场|笔数|节奏|止损|次数/.test(t)) return "纪律";
if (/持仓|离场|强平|到期/.test(t)) return "离场";
if (/资金|杠杆|计仓|仓位|预算/.test(t)) return "仓位";
if (/系统|字段|对接/.test(t)) return "系统";
if (/修订|记录/.test(t)) return "版本";
if (/边界|关系|行情状态/.test(t)) return "边界";
if (/选择|如何/.test(t)) return "选择";
return "章节";
}
function buildDocToc() {
if (!docBody || !docToc) return;
const heads = Array.from(docBody.querySelectorAll("h2"));
if (!heads.length) {
docToc.innerHTML = '<p class="strategy-empty">暂无目录</p>';
return;
}
const used = {};
const items = [];
heads.forEach((el, idx) => {
const num = String(idx + 1).padStart(2, "0");
const text = (el.textContent || "").trim();
const tag = sectionTag(text);
let base = "st-" + num + "-" + slugify(text);
if (!base || base === "st-" + num + "-") base = "st-" + num;
let id = base;
let n = 2;
while (used[id] || document.getElementById(id)) {
id = base + "-" + n;
n += 1;
}
used[id] = true;
el.id = id;
if (!el.querySelector(".strategy-sec-mark")) {
const mark = document.createElement("span");
mark.className = "strategy-sec-mark";
mark.setAttribute("aria-hidden", "true");
mark.innerHTML =
`<span class="strategy-sec-num">${esc(num)}</span>` +
`<span class="strategy-sec-tag">${esc(tag)}</span>`;
el.insertBefore(mark, el.firstChild);
}
items.push({ num, id, text, tag });
});
Array.from(docBody.querySelectorAll("h3")).forEach((el, idx) => {
if (el.id) return;
let base = "st-h3-" + (idx + 1) + "-" + slugify(el.textContent);
if (!base || base.endsWith("-")) base = "st-h3-" + (idx + 1);
let id = base;
let n = 2;
while (document.getElementById(id)) {
id = base + "-" + n;
n += 1;
}
el.id = id;
});
docToc.innerHTML = items
.map(
(it) =>
`<a href="#${esc(it.id)}" class="strategy-toc-item" data-id="${esc(it.id)}">` +
`<span class="strategy-toc-num">${esc(it.num)}</span>` +
`<span class="strategy-toc-tag">${esc(it.tag)}</span>` +
`<span class="strategy-toc-text">${esc(it.text)}</span></a>`
)
.join("");
docToc.querySelectorAll(".strategy-toc-item").forEach((a) => {
a.addEventListener("click", (ev) => {
ev.preventDefault();
const id = a.getAttribute("data-id");
const target = id ? document.getElementById(id) : null;
if (!target || !docBody.contains(target)) return;
setActiveToc(id);
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
if (items[0]) setActiveToc(items[0].id);
bindScrollSpy(heads);
}
function renderChecklist(checklist) { function renderChecklist(checklist) {
const cl = checklist || {}; const cl = checklist || {};
const title = cl.title || "开仓检查清单"; const title = cl.title || "开仓检查清单";
@@ -102,7 +235,6 @@
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join(""); footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
footnotesEl.classList.toggle("hidden", !notes.length); footnotesEl.classList.toggle("hidden", !notes.length);
} }
scheduleHeightSync();
} }
function renderPayload(data) { function renderPayload(data) {
@@ -111,8 +243,8 @@
const ver = data.version ? ` · ${data.version}` : ""; const ver = data.version ? ` · ${data.version}` : "";
docSource.textContent = `文档:${data.md_source || ""}${ver}`; docSource.textContent = `文档:${data.md_source || ""}${ver}`;
} }
buildDocToc();
renderChecklist(data.checklist); renderChecklist(data.checklist);
scheduleHeightSync();
} }
async function loadExchange(key) { async function loadExchange(key) {
@@ -128,8 +260,8 @@
} catch (e) { } catch (e) {
if (statusEl) statusEl.textContent = String(e); if (statusEl) statusEl.textContent = String(e);
if (docBody) docBody.innerHTML = ""; if (docBody) docBody.innerHTML = "";
if (docToc) docToc.innerHTML = "";
if (checklistBody) checklistBody.innerHTML = ""; if (checklistBody) checklistBody.innerHTML = "";
scheduleHeightSync();
} }
} }
@@ -139,13 +271,12 @@
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) { if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
activeKey = tabsMeta[0].key; activeKey = tabsMeta[0].key;
} }
renderTabs(); renderExchangeTabs();
} }
async function printSection(mode) { async function printSection(mode) {
const part = mode === "checklist" ? "checklist" : "doc"; const part = mode === "checklist" ? "checklist" : "doc";
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`; const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
// 同步打开空白页保留用户手势;再 fetch 写入,避免 noopener 丢句柄 / 短页误关窗
const w = window.open("about:blank", "_blank"); const w = window.open("about:blank", "_blank");
if (!w) { if (!w) {
if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览"; if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览";
@@ -183,18 +314,24 @@
function bindActions() { function bindActions() {
if (bound) return; if (bound) return;
bound = true; bound = true;
if (viewTabsEl) {
viewTabsEl.querySelectorAll(".strategy-view-tab").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.getAttribute("data-view")));
});
}
if (btnPrintDoc) btnPrintDoc.addEventListener("click", () => printSection("doc")); if (btnPrintDoc) btnPrintDoc.addEventListener("click", () => printSection("doc"));
if (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist")); if (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist"));
if (btnPrintChecklistInline) btnPrintChecklistInline.addEventListener("click", () => printSection("checklist"));
if (btnDownload) { if (btnDownload) {
btnDownload.addEventListener("click", () => { btnDownload.addEventListener("click", () => {
window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`; window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`;
}); });
} }
window.addEventListener("resize", scheduleHeightSync);
} }
async function init() { async function init() {
bindActions(); bindActions();
setView(activeView);
try { try {
await loadMeta(); await loadMeta();
await loadExchange(activeKey); await loadExchange(activeKey);
@@ -204,7 +341,10 @@
} }
function destroy() { function destroy() {
window.removeEventListener("resize", scheduleHeightSync); if (scrollSpyObs) {
scrollSpyObs.disconnect();
scrollSpyObs = null;
}
} }
window.hubStrategyPage = { init, destroy }; window.hubStrategyPage = { init, destroy };
+37
View File
@@ -20,6 +20,7 @@ from lib.trade.account_risk_lib import (
enrich_risk_status_countdown, enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
max_active_positions_from_env, max_active_positions_from_env,
on_closed_trade_pnl,
on_journal_saved, on_journal_saved,
on_manual_close, on_manual_close,
on_user_initiated_close, on_user_initiated_close,
@@ -58,6 +59,7 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4" os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1" os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2" os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
os.environ["RISK_DAILY_LOSS_LIMIT"] = "2"
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1" os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
os.environ["APP_TIMEZONE"] = "Asia/Shanghai" os.environ["APP_TIMEZONE"] = "Asia/Shanghai"
@@ -521,6 +523,41 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["MAX_ACTIVE_POSITIONS"] = "3" os.environ["MAX_ACTIVE_POSITIONS"] = "3"
self.assertEqual(max_active_positions_from_env(), 3) self.assertEqual(max_active_positions_from_env(), 3)
def test_daily_loss_limit_freezes_on_second_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-1.5, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 1)
self.assertEqual(st["status"], STATUS_NORMAL)
on_closed_trade_pnl(conn, pnl_amount=-0.2, trading_day="2026-06-14", now=now)
st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st2["daily_loss_count"], 2)
self.assertEqual(st2["status"], STATUS_DAILY)
self.assertFalse(st2["can_trade"])
self.assertIn("日亏损", st2["reason"])
def test_daily_loss_limit_zero_disables(self):
os.environ["RISK_DAILY_LOSS_LIMIT"] = "0"
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["daily_loss_limit"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
def test_profitable_close_does_not_count_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=3.2, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=0, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""对冲计划期权腿盈亏与交易所对齐."""
from __future__ import annotations
import sqlite3
import unittest
from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan
from lib.hedge_plan.hedge_plan_settle_lib import (
_parse_opened_ms,
backfill_hedge_option_legs_realized_pnl,
resolve_option_leg_realized_pnl,
)
class HedgeExchangePnlTest(unittest.TestCase):
def test_resolve_prefers_exchange(self):
leg = {
"inst_id": "ETH-USD_UM-260719-1850-P",
"opened_at": "2026-07-17 06:59:18",
"premium": 5.0,
}
hist = [
{
"instId": "ETH-USD_UM-260719-1850-P",
"uTime": "1784400000000",
"realizedPnl": "12.00",
"closeAvgPx": "30",
}
]
pnl, src = resolve_option_leg_realized_pnl(
leg=leg, hist_rows=hist, fallback=-5.0
)
self.assertEqual(src, "exchange")
self.assertEqual(pnl, 12.0)
def test_opened_at_beijing_wall_clock_not_utc(self):
# 北京 09:19 开仓 → UTC 01:19; 到期结算北京 16:00:31 = UTC 08:00:31
open_ms = _parse_opened_ms("2026-07-20 09:19:30")
self.assertEqual(open_ms, 1784510370000)
leg = {
"inst_id": "ETH-USD_UM-260720-1870-C",
"opened_at": "2026-07-20 09:19:30",
"premium": 6.9,
}
hist = [
{
"instId": "ETH-USD_UM-260720-1870-C",
"uTime": "1784534431512",
"realizedPnl": "-7.1812815",
"pnl": "-6.9",
"type": "2",
}
]
pnl, src = resolve_option_leg_realized_pnl(
leg=leg, hist_rows=hist, fallback=-6.9
)
self.assertEqual(src, "exchange")
self.assertAlmostEqual(pnl, -7.1813, places=4)
def test_backfill_updates_plan_total(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
pid = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "closed",
"underlying": "ETH",
"premium_total": 10,
"realized_pnl_options": 6.08,
"realized_pnl_total": 6.08,
"opened_at": "2026-07-17 06:59:18",
"closed_at": "2026-07-19 16:00:13",
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_a",
"inst_id": "ETH-USD_UM-260719-1890-C",
"opt_type": "C",
"strike": 1890,
"size": 10,
"premium": 5.92,
"status": "closed",
"realized_pnl": -5.92,
"opened_at": "2026-07-17 06:59:18",
"closed_at": "2026-07-19 16:00:13",
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_b",
"inst_id": "ETH-USD_UM-260719-1850-P",
"opt_type": "P",
"strike": 1850,
"size": 10,
"premium": 4.0,
"status": "closed",
"realized_pnl": 12.0,
"opened_at": "2026-07-17 06:59:18",
"closed_at": "2026-07-19 08:00:00",
},
)
hist = [
{
"instId": "ETH-USD_UM-260719-1890-C",
"uTime": "1784476813000",
"realizedPnl": "-5.50",
},
{
"instId": "ETH-USD_UM-260719-1850-P",
"uTime": "1784448000000",
"realizedPnl": "11.80",
},
]
out = backfill_hedge_option_legs_realized_pnl(conn, hist)
self.assertEqual(out["legs"], 2)
self.assertEqual(out["plans"], 1)
plan = get_plan(conn, pid)
self.assertAlmostEqual(float(plan["realized_pnl_options"]), 6.3, places=4)
self.assertAlmostEqual(float(plan["realized_pnl_total"]), 6.3, places=4)
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
"""对冲计划与单独期权互斥门控."""
from __future__ import annotations
import os
import sqlite3
import unittest
from unittest import mock
from lib.hedge_plan.hedge_options_exclusive_lib import (
block_hedge_plan_start_msg,
block_standalone_option_open_msg,
has_standalone_option_position,
mutual_exclusive_enabled,
)
from lib.hedge_plan.hedge_plan_calc_lib import gate_status
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
class HedgeOptionsExclusiveTests(unittest.TestCase):
def test_mutual_default_true(self):
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", None)
self.assertTrue(mutual_exclusive_enabled())
def test_mutual_can_disable(self):
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}):
self.assertFalse(mutual_exclusive_enabled())
def test_block_open_when_active_plan(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
conn.execute(
"INSERT INTO hedge_plans (plan_type, underlying, status) "
"VALUES ('options_options', 'ETH', 'active')"
)
conn.commit()
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}):
msg = block_standalone_option_open_msg(conn)
self.assertIsNotNone(msg)
self.assertIn("对冲计划", msg or "")
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}):
self.assertIsNone(block_standalone_option_open_msg(conn))
conn.close()
def test_standalone_position_detection(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "1"}]
with mock.patch(
"lib.instance.instance_dashboard_lib._resolve_options_source",
return_value=("option", "纯期权", None),
):
self.assertTrue(has_standalone_option_position(conn, raw))
with mock.patch(
"lib.instance.instance_dashboard_lib._resolve_options_source",
return_value=("options_options", "期期对冲", 2),
):
self.assertFalse(has_standalone_option_position(conn, raw))
conn.close()
def test_block_hedge_start_msg(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "2"}]
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}):
with mock.patch(
"lib.instance.instance_dashboard_lib._resolve_options_source",
return_value=("option", "纯期权", None),
):
msg = block_hedge_plan_start_msg(conn, raw_positions=raw)
self.assertIsNotNone(msg)
self.assertIn("单独期权", msg or "")
conn.close()
def test_gate_status_blocks_start(self):
g = gate_status(
hedge_enabled=True,
sizing_mode="full_margin",
plan_type="options_options",
options_enabled=True,
live_order=True,
mutual_exclusive=True,
has_standalone_option=True,
)
self.assertFalse(g["can_start"])
self.assertTrue(any("单独期权" in r for r in g["reasons"]))
g2 = gate_status(
hedge_enabled=True,
sizing_mode="full_margin",
plan_type="options_options",
options_enabled=True,
live_order=True,
mutual_exclusive=False,
has_standalone_option=True,
)
self.assertTrue(g2["can_start"])
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
"""半腿失败:手动补开 vs 自动平."""
from __future__ import annotations
import os
import unittest
from unittest import mock
from lib.hedge_plan.hedge_plan_orders_lib import (
manual_complete_on_partial,
partial_auto_close_enabled,
)
class PartialManualTests(unittest.TestCase):
def test_manual_default_forces_auto_close_off(self):
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", None)
os.environ["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "true"
self.assertTrue(manual_complete_on_partial())
self.assertFalse(partial_auto_close_enabled())
def test_manual_off_allows_auto_close(self):
with mock.patch.dict(
os.environ,
{
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "true",
},
):
self.assertFalse(manual_complete_on_partial())
self.assertTrue(partial_auto_close_enabled())
def test_both_off(self):
with mock.patch.dict(
os.environ,
{
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "false",
},
):
self.assertFalse(partial_auto_close_enabled())
if __name__ == "__main__":
unittest.main()
+125
View File
@@ -9,7 +9,10 @@ from lib.hedge_plan.hedge_plan_calc_lib import (
option_expiry_pnl, option_expiry_pnl,
option_premium_total, option_premium_total,
perp_pnl, perp_pnl,
resolve_oo_budget_usdc,
suggest_oo_sheets,
) )
from lib.hedge_plan.hedge_plan_monitor_lib import resolve_oo_rest_close_mode
class TestHedgePlanCalc(unittest.TestCase): class TestHedgePlanCalc(unittest.TestCase):
@@ -69,6 +72,32 @@ class TestHedgePlanCalc(unittest.TestCase):
self.assertFalse(g["can_start"]) self.assertFalse(g["can_start"])
self.assertTrue(any("全仓" in r for r in g["reasons"])) self.assertTrue(any("全仓" in r for r in g["reasons"]))
def test_gate_hidden_plan_type_blocks_preview(self):
g = gate_status(
hedge_enabled=True,
sizing_mode="full_margin",
plan_type="perp_options",
options_enabled=True,
live_order=True,
live_trading=True,
show_perp_options=False,
)
self.assertFalse(g["can_preview"])
self.assertFalse(g["can_start"])
self.assertTrue(any("隐藏" in r for r in g["reasons"]))
self.assertFalse(g["show_perp_options"])
self.assertTrue(g["show_options_options"])
g2 = gate_status(
hedge_enabled=True,
sizing_mode="risk",
plan_type="options_options",
options_enabled=True,
show_options_options=False,
)
self.assertFalse(g2["can_preview"])
self.assertTrue(any("期期" in r for r in g2["reasons"]))
def test_oo_expiry_loss_flag(self): def test_oo_expiry_loss_flag(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
@@ -81,6 +110,9 @@ class TestHedgePlanCalc(unittest.TestCase):
) )
self.assertEqual(p["summary"]["premium_paid"], 10) self.assertEqual(p["summary"]["premium_paid"], 10)
self.assertTrue(p["summary"]["expiry_is_loss"]) self.assertTrue(p["summary"]["expiry_is_loss"])
self.assertEqual(p["summary"]["rr_risk_premium"], 10)
self.assertIsNotNone(p["summary"]["rr_at_up"])
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4)
self.assertEqual(len(p["scenarios"]), 4) self.assertEqual(len(p["scenarios"]), 4)
self.assertEqual(p["scenarios"][0]["id"], "target_up") self.assertEqual(p["scenarios"][0]["id"], "target_up")
self.assertEqual(p["scenarios"][1]["id"], "target_down") self.assertEqual(p["scenarios"][1]["id"], "target_down")
@@ -103,6 +135,99 @@ class TestHedgePlanCalc(unittest.TestCase):
self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0) self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0)
self.assertEqual(floor_contracts_to_precision(0, 4), 0.0) self.assertEqual(floor_contracts_to_precision(0, 4), 0.0)
def test_oo_budget_min_trading_and_cap(self):
b = resolve_oo_budget_usdc(trading_usdc=11.07, trade_budget_usdc=10, buffer_ratio=0.95)
self.assertTrue(b["ok"])
self.assertAlmostEqual(b["trading_cap"], 11.07 * 0.95, places=4)
self.assertEqual(b["budget_usdc"], 10.0)
def test_suggest_oo_same_sheets_default(self):
# cost_a=1, cost_b=1 → pair=2; budget=10 → n=5
s = suggest_oo_sheets(
mode="same_sheets",
budget_usdc=10,
ask_a=100,
ct_mult_a=0.01,
ask_b=100,
ct_mult_b=0.01,
)
self.assertEqual(s["mode"], "same_sheets")
self.assertEqual(s["sheets_a"], 5)
self.assertEqual(s["sheets_b"], 5)
self.assertTrue(s["ok"])
def test_suggest_oo_long_bias_budget(self):
# cost_call=1, cost_put=1; budget 10 → call 7U / put 3U → 7 / 3
s = suggest_oo_sheets(
mode="long_bias",
budget_usdc=10,
ask_a=100,
ct_mult_a=0.01,
opt_type_a="C",
ask_b=100,
ct_mult_b=0.01,
opt_type_b="P",
bias_split_by="budget",
bias_ratio=0.7,
)
self.assertEqual(s["mode"], "long_bias")
self.assertEqual(s["sheets_a"], 7)
self.assertEqual(s["sheets_b"], 3)
self.assertTrue(s["ok"])
def test_suggest_oo_short_bias_sheets(self):
# 同张数 n=5 → 总张数 10; short → put 7 / call 3
s = suggest_oo_sheets(
mode="short_bias",
budget_usdc=10,
ask_a=100,
ct_mult_a=0.01,
opt_type_a="C",
ask_b=100,
ct_mult_b=0.01,
opt_type_b="P",
bias_split_by="sheets",
bias_ratio=0.7,
)
self.assertEqual(s["mode"], "short_bias")
self.assertEqual(s["sheets_a"], 3)
self.assertEqual(s["sheets_b"], 7)
def test_suggest_oo_bias_requires_call_put(self):
s = suggest_oo_sheets(
mode="long_bias",
budget_usdc=10,
ask_a=100,
ct_mult_a=0.01,
opt_type_a="C",
ask_b=100,
ct_mult_b=0.01,
opt_type_b="C",
bias_split_by="budget",
)
self.assertFalse(s["ok"])
self.assertIn("Call", s["msg"])
def test_suggest_oo_depth_cap(self):
s = suggest_oo_sheets(
mode="same_sheets",
budget_usdc=100,
ask_a=100,
ct_mult_a=0.01,
ask_sz_a=2,
ask_b=100,
ct_mult_b=0.01,
ask_sz_b=50,
)
self.assertEqual(s["sheets_a"], 2)
self.assertEqual(s["sheets_b"], 2)
def test_oo_rest_close_mode_default_close_all(self):
self.assertEqual(resolve_oo_rest_close_mode({"oo_close_mode": "close_all"}), "close_all")
self.assertEqual(resolve_oo_rest_close_mode({"oo_close_mode": "hold_expiry"}), "hold_expiry")
# 旧计划无字段:保持到期平,避免部署后误清残腿
self.assertEqual(resolve_oo_rest_close_mode({}), "hold_expiry")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+140
View File
@@ -0,0 +1,140 @@
"""对冲计划:人工结束 + 未成交不显示 open."""
from __future__ import annotations
import sqlite3
import unittest
from unittest import mock
from lib.hedge_plan.hedge_plan_db import (
get_plan,
get_plan_legs,
init_hedge_plan_tables,
insert_leg,
insert_plan,
legs_contract_summary,
)
from lib.hedge_plan.hedge_plan_orders_lib import (
execute_manual_end_plan,
reconcile_unfilled_option_legs,
)
def _mem():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_hedge_plan_tables(conn)
return conn
class TestHedgePlanEnd(unittest.TestCase):
def test_legs_summary_marks_unfilled(self):
s = legs_contract_summary(
[
{"leg_role": "option_a", "inst_id": "ETH-C", "status": "pending"},
{"leg_role": "option_b", "inst_id": "ETH-P", "status": "open"},
{"leg_role": "option_a", "inst_id": "X", "status": "cancelled"},
]
)
self.assertIn("(待补)", s)
self.assertIn("(未成交)", s)
def test_reconcile_ghost_open_to_pending(self):
conn = _mem()
pid = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "active",
"underlying": "ETH",
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_a",
"inst_id": "ETH-USD_UM-260720-1870-C",
"status": "open",
"exchange_ord_id": "1",
"avg_open": 0.01,
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_b",
"inst_id": "ETH-USD_UM-260720-1870-P",
"status": "open",
"exchange_ord_id": "2",
"avg_open": 0.02,
},
)
cfg = {"exchange_options": object()}
with mock.patch(
"lib.hedge_plan.hedge_plan_orders_lib._live_option_pos_sheets",
side_effect=lambda ex, inst: 0.0 if "C" in inst else 50.0,
), mock.patch(
"lib.exchange.okx_options_lib.fetch_option_order",
return_value={"ok": True, "state": "canceled", "acc_fill_sz": 0},
):
notes = reconcile_unfilled_option_legs(cfg, conn, pid)
self.assertTrue(notes)
legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)}
self.assertEqual(legs["option_a"]["status"], "pending")
self.assertEqual(legs["option_b"]["status"], "open")
self.assertEqual(get_plan(conn, pid)["status"], "partial")
def test_manual_end_no_flat(self):
conn = _mem()
pid = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "partial",
"underlying": "ETH",
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_a",
"inst_id": "ETH-C",
"status": "pending",
"exchange_ord_id": "9",
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_b",
"inst_id": "ETH-P",
"status": "open",
"exchange_ord_id": "8",
},
)
cfg = {"exchange_options": object()}
with mock.patch(
"lib.hedge_plan.hedge_plan_orders_lib.reconcile_unfilled_option_legs",
return_value=[],
), mock.patch(
"lib.exchange.okx_options_lib.cancel_option_order",
return_value={"ok": True},
) as cancel, mock.patch(
"lib.hedge_plan.hedge_plan_notify_lib.notify_plan_end",
return_value=True,
):
out = execute_manual_end_plan(cfg, conn, pid)
self.assertTrue(out.get("ok"))
self.assertEqual(get_plan(conn, pid)["status"], "closed")
self.assertEqual(get_plan(conn, pid)["close_reason"], "manual")
legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)}
self.assertEqual(legs["option_a"]["status"], "cancelled")
self.assertEqual(legs["option_b"]["status"], "open") # 已有持仓不动
cancel.assert_called()
if __name__ == "__main__":
unittest.main()
+16 -6
View File
@@ -133,15 +133,15 @@ class TestHedgePlanOrderPath(unittest.TestCase):
def test_dry_run_oo(self): def test_dry_run_oo(self):
quote = MagicMock( quote = MagicMock(
return_value={ side_effect=lambda _ex, inst_id: {
"ok": True, "ok": True,
"ask": 10, "ask": 10,
"ask_sz": 5, "ask_sz": 50,
"can_open": True, "can_open": True,
"ct_mult": 0.01, "ct_mult": 0.01,
"tick_sz": "0.1", "tick_sz": "0.1",
"strike": 1800, "strike": 1800,
"meta": {"optType": "C"}, "meta": {"optType": "C" if inst_id == "A" else "P"},
} }
) )
cfg = { cfg = {
@@ -149,17 +149,27 @@ class TestHedgePlanOrderPath(unittest.TestCase):
"quote_option_contract": quote, "quote_option_contract": quote,
"place_option_limit_order": MagicMock(), "place_option_limit_order": MagicMock(),
"td_mode_for_option_buy": lambda x: "isolated", "td_mode_for_option_buy": lambda x: "isolated",
"trade_budget_usdc": 10,
"budget_buffer": 0.95,
} }
body = { body = {
"target_price": 1900, "target_price": 1900,
"target_price_up": 1950, "target_price_up": 1950,
"target_price_down": 1750, "target_price_down": 1750,
"leg_a": {"inst_id": "A", "sheets": 1}, "oo_sheets_mode": "same_sheets",
"leg_b": {"inst_id": "B", "sheets": 1}, "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
} }
from unittest import mock
with mock.patch(
"lib.exchange.okx_options_lib.fetch_options_trading_usdc",
return_value=100.0,
):
out = execute_options_options_start(cfg, body, dry_run=True) out = execute_options_options_start(cfg, body, dry_run=True)
self.assertTrue(out["ok"]) self.assertTrue(out["ok"], out)
self.assertEqual(len(out["results"]), 2) self.assertEqual(len(out["results"]), 2)
self.assertTrue(out.get("refresh", {}).get("ok"))
def test_buy_rejects_without_ask_depth(self): def test_buy_rejects_without_ask_depth(self):
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
+125
View File
@@ -0,0 +1,125 @@
"""对冲启动:再拉卖一 + 对冲专用预算缓冲重算张数."""
from __future__ import annotations
import os
import unittest
from unittest import mock
from unittest.mock import MagicMock
from lib.hedge_plan.hedge_plan_orders_lib import (
_hedge_budget_buffer,
execute_options_options_start,
refresh_oo_sizing_before_start,
refresh_po_option_quote_before_start,
)
class TestHedgeStartRefresh(unittest.TestCase):
def test_hedge_budget_buffer_independent(self):
with mock.patch.dict(
os.environ,
{
"HEDGE_PLAN_BUDGET_BUFFER": "0.9",
"OKX_OPTIONS_BUDGET_BUFFER": "0.5",
},
):
self.assertAlmostEqual(_hedge_budget_buffer(None), 0.9)
self.assertAlmostEqual(_hedge_budget_buffer({"budget_buffer": 0.88}), 0.88)
def test_refresh_oo_resizes_from_fresh_ask(self):
def quote(_ex, inst_id):
if inst_id == "A":
return {
"ok": True,
"ask": 20,
"ask_sz": 100,
"ct_mult": 0.01,
"meta": {"optType": "C"},
}
return {
"ok": True,
"ask": 20,
"ask_sz": 100,
"ct_mult": 0.01,
"meta": {"optType": "P"},
}
cfg = {
"exchange_options": object(),
"quote_option_contract": quote,
"trade_budget_usdc": 10,
"budget_buffer": 0.95,
}
body = {
"oo_sheets_mode": "same_sheets",
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
}
# unit cost = 20*0.01=0.2 each → pair 0.4; budget min(100*0.95,10)=10 → n=25
with mock.patch(
"lib.exchange.okx_options_lib.fetch_options_trading_usdc",
return_value=100.0,
):
out = refresh_oo_sizing_before_start(cfg, body)
self.assertTrue(out.get("ok"), out)
self.assertEqual(body["leg_a"]["sheets"], 25)
self.assertEqual(body["leg_b"]["sheets"], 25)
self.assertEqual(body["leg_a"]["ask"], 20)
self.assertEqual(out["sheets_a"], 25)
def test_execute_oo_start_uses_refreshed_sheets(self):
quote = MagicMock(
side_effect=lambda _ex, inst_id: {
"ok": True,
"ask": 10,
"ask_sz": 50,
"ct_mult": 0.01,
"tick_sz": "0.1",
"meta": {"optType": "C" if inst_id == "A" else "P"},
}
)
cfg = {
"exchange_options": object(),
"quote_option_contract": quote,
"place_option_limit_order": MagicMock(),
"td_mode_for_option_buy": lambda x: "isolated",
"trade_budget_usdc": 4,
"budget_buffer": 0.95,
}
body = {
"oo_sheets_mode": "same_sheets",
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
}
# cost 0.1+0.1=0.2; budget 4 → 20 sheets each
with mock.patch(
"lib.exchange.okx_options_lib.fetch_options_trading_usdc",
return_value=100.0,
):
out = execute_options_options_start(cfg, body, dry_run=True)
self.assertTrue(out["ok"], out)
self.assertEqual(body["leg_a"]["sheets"], 20)
self.assertEqual(out["results"][0]["sheets"], 20)
self.assertIn("refresh", out)
self.assertTrue(out["refresh"]["ok"])
def test_refresh_po_keeps_sheets(self):
quote = MagicMock(
return_value={
"ok": True,
"ask": 12.5,
"ask_sz": 8,
"ct_mult": 0.01,
"meta": {"optType": "P"},
}
)
cfg = {"exchange_options": object(), "quote_option_contract": quote}
body = {"opt_inst_id": "X", "sheets": 3}
out = refresh_po_option_quote_before_start(cfg, body)
self.assertTrue(out["ok"])
self.assertEqual(body["ask"], 12.5)
self.assertEqual(body["sheets"], 3)
if __name__ == "__main__":
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
"""hub_ai:期权持仓进教练上下文 + 执行手册摘要."""
from __future__ import annotations
import unittest
from hub_ai.context import (
format_chat_context_for_chat,
format_chat_context_slim,
format_chat_position_overview,
)
from hub_ai.playbook_brief import format_playbook_brief_for_chat
from hub_ai.prompts import build_chat_user_prompt
def _sample_payload():
return {
"totals": {
"trading_day": "2026-07-22",
"total_pnl_u": 0,
"closed_count": 0,
"win_count": 0,
"loss_count": 0,
"float_pnl_u": 1.25,
"open_position_count": 1,
"options_open_position_count": 1,
"perpetual_open_position_count": 0,
},
"accounts": [
{
"name": "OKX_趋势",
"key": "okx",
"status": "已监控",
"open_position_count": 1,
"options_open_position_count": 1,
"float_pnl_u": 1.25,
"funding_usdt": 100,
"trading_usdt": 50,
"trade_stats": {"total_pnl_u": 0, "closed_count": 0, "win_count": 0, "loss_count": 0},
"positions": [],
"trades": [],
"monitor_lines": {},
"options_snapshot": {
"ok": True,
"enabled": True,
"positions": [
{
"inst_id": "ETH-USD-260723-3500-C",
"opt_type": "C",
"pos": 1,
"premium_paid": 8.5,
"source_label": "纯期权",
"source": "option",
"upl": 1.25,
"target_monitor_text": "目标 3600",
}
],
},
}
],
}
class HubAiOptionsPlaybookTests(unittest.TestCase):
def test_chat_slim_includes_options_line(self):
text = format_chat_context_slim(_sample_payload())
self.assertIn("期权 ETH-USD-260723-3500-C Call", text)
self.assertIn("永续0/期权1", text)
self.assertIn("权利金8.5U", text)
def test_overview_lists_options(self):
text = format_chat_position_overview(_sample_payload())
self.assertIn("期权1", text)
self.assertIn("ETH-USD-260723-3500-C", text)
def test_chat_bundle_keeps_options(self):
text = format_chat_context_for_chat(_sample_payload(), max_chars=8000)
self.assertIn("ETH-USD-260723-3500-C", text)
self.assertIn("期权", text)
def test_playbook_brief_injected(self):
brief = format_playbook_brief_for_chat()
self.assertIn("执行手册", brief)
self.assertIn("OKX", brief)
self.assertIn("不手动平仓", brief)
prompt = build_chat_user_prompt(
context_text="快照",
trading_day="2026-07-22",
summary_excerpt="",
user_message="今天怎么样",
playbook_brief=brief,
)
self.assertIn("用户策略执行手册", prompt)
self.assertIn("不手动平仓", prompt)
if __name__ == "__main__":
unittest.main()

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