76 Commits

Author SHA1 Message Date
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
102 changed files with 8088 additions and 1022 deletions
+2
View File
@@ -158,6 +158,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+20
View File
@@ -2751,6 +2751,17 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -7482,6 +7493,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
@app.route("/system_guide")
@login_required
def system_guide_page():
redir = redirect_to_embed_shell_if_enabled("system_guide")
if redir is not None:
return redir
return render_main_page("system_guide")
@app.route("/env_config")
@login_required
def env_config_page():
+2
View File
@@ -160,6 +160,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+31 -1
View File
@@ -2440,6 +2440,22 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
# 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流)
try:
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -6942,7 +6958,11 @@ def sync_trade_records_from_exchange(conn, force=False):
matched += 1
stats["matched"] = matched
stats["ok"] = True
_LAST_EXCHANGE_PNL_SYNC_AT = now
# 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」
if matched < stats["pending"] and hist:
_LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0
else:
_LAST_EXCHANGE_PNL_SYNC_AT = now
try:
conn.commit()
except Exception:
@@ -7269,6 +7289,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
@app.route("/system_guide")
@login_required
def system_guide_page():
redir = redirect_to_embed_shell_if_enabled("system_guide")
if redir is not None:
return redir
return render_main_page("system_guide")
@app.route("/env_config")
@login_required
def env_config_page():
@@ -9369,6 +9398,7 @@ register_trade_records_api(
filter_trade_records_excluding_miss=filter_trade_records_excluding_miss,
app_tz=APP_TZ,
format_price_fn=format_price_for_symbol,
sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False),
)
def _dashboard_enrich_orders(items):
+21 -1
View File
@@ -112,6 +112,8 @@ OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
OKX_OPTIONS_MAX_DTE_DAYS=2
OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14
OKX_SUB_ACCOUNT_NAME=
@@ -120,19 +122,35 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# =============================================================================
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md)
# =============================================================================
HEDGE_PLAN_ENABLED=false
# 页面 Tab 显示(默认全部显示,可单独关闭;不影响已有进行中/历史计划)
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false
HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆
HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget
# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put
HEDGE_PLAN_OO_BIAS_RATIO=0.7
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false
# =============================================================================
# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)
@@ -201,6 +219,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+44 -1
View File
@@ -2359,6 +2359,22 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
# 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流)
try:
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
@@ -4090,7 +4106,11 @@ def sync_trade_records_from_exchange(conn, force=False):
matched += 1
stats["matched"] = matched
stats["ok"] = True
_LAST_EXCHANGE_PNL_SYNC_AT = now
# 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」
if matched < stats["pending"] and hist:
_LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0
else:
_LAST_EXCHANGE_PNL_SYNC_AT = now
try:
conn.commit()
except Exception:
@@ -6785,8 +6805,20 @@ def render_main_page(page="trade", embed_mode=None):
options_nav_visible=True,
hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"),
hedge_plan_show_perp_options=os.getenv("HEDGE_PLAN_SHOW_PERP_OPTIONS", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_show_options_options=os.getenv("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
).lower()
in ("1", "true", "yes", "on"),
risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
@@ -6806,6 +6838,7 @@ def render_main_page(page="trade", embed_mode=None):
risk_status=risk_status,
trade_policy=TRADE_POLICY,
data_export_version=3,
open_guard_enabled=open_guard_enabled,
),
**force_close_template_context(
FORCE_CLOSE_ENABLED,
@@ -6893,6 +6926,15 @@ def risk_policy_page():
return render_main_page("risk_policy")
@app.route("/system_guide")
@login_required
def system_guide_page():
redir = redirect_to_embed_shell_if_enabled("system_guide")
if redir is not None:
return redir
return render_main_page("system_guide")
@app.route("/env_config")
@login_required
def env_config_page():
@@ -9044,6 +9086,7 @@ register_trade_records_api(
filter_trade_records_excluding_miss=filter_trade_records_excluding_miss,
app_tz=APP_TZ,
format_price_fn=format_price_for_symbol,
sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False),
)
+7 -1
View File
@@ -41,6 +41,7 @@
|------|------|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
| 平仓亏损达 `RISK_DAILY_LOSS_LIMIT` 次(同一交易日) | **日冻结**(默认 2 次;`0`=不启用) |
| 复盘勾选任意情绪标签 | **日冻结** |
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
@@ -77,11 +78,15 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
TRADING_DAY_RESET_HOUR=8
APP_TIMEZONE=Asia/Shanghai
```
- `RISK_DAILY_LOSS_LIMIT`:任意已平仓交易若盈亏 < 0 计 1 次(含止损/止盈后仍亏损等);达上限当日冻结开仓;`0` 表示不因亏损次数冻结.
- `RISK_MANUAL_CLOSE_DAILY_LIMIT`:仅计**用户主动平仓**次数(与亏损次数独立).
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控).
## API 与 `risk_status` 字段
@@ -102,6 +107,7 @@ APP_TIMEZONE=Asia/Shanghai
| `can_trade` | 是否允许新开仓(仅风控维度) |
| `reason` | 悬停提示文案 |
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` |
| `daily_loss_count` / `daily_loss_limit` | 当日亏损笔数与上限(`0` 上限表示未启用) |
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
@@ -123,7 +129,7 @@ APP_TIMEZONE=Asia/Shanghai
## 相关代码
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`,`on_closed_trade_pnl`
- `hub_bridge.py``/api/hub/account-risk/user-close`
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
- `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控
+1
View File
@@ -131,6 +131,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 手动平仓冷静(小时) | |
| 复盘情绪冷静(小时) | |
| 日手动平仓次数上限 | |
| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 |
| 情绪标签日冻结 | |
详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
+1
View File
@@ -4,6 +4,7 @@
| 文档 | 实例 | 状态 |
|------|------|------|
| [交易执行手册-期权与Gate.md](../交易执行手册-期权与Gate.md) | 中控「策略说明」·执行手册 | 个人开单纪律 |
| [binance-alt-trend-long.md](./binance-alt-trend-long.md) | 币安山寨·多头趋势 | v0.4 讨论稿 |
| [okx-trend-both.md](./okx-trend-both.md) | OKX·多空趋势 | v0.4 讨论稿 |
| [gate-intraday.md](./gate-intraday.md) | Gate·BTC 日内 | v0.2 |
+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 型样式/数据结构).
- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部).
- 预算`OKX_OPTIONS_TRADE_BUDGET_USDC` 等既有约束;可拆预算到两腿.
- 预算:`B = min(交易户 USDC × OKX_OPTIONS_BUDGET_BUFFER, OKX_OPTIONS_TRADE_BUDGET_USDC)`(默认 buffer=0.95).
- 自动张数(选齐两腿后写入,可手改):
- **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n`
- **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改)
- 做多:主腿=Call;做空:主腿=Put
- 拆分口径 `HEDGE_PLAN_OO_BIAS_SPLIT_BY`:`budget`(默认,按权利金预算拆) / `sheets`(先按同张数得每腿 `n`,总张数 `2n` 再按比例拆到 Call/Put)
- 另受各自卖一深度上限约束
- 已移除页面「均分」;后端仍兼容旧 `split_budget` 入参(预算对半)
### 4.2 目标价
@@ -141,16 +148,17 @@
### 5.2 期期对冲
| 事件 | 盈利方 | 亏损方 | 计划是否结束 |
|------|--------|--------|--------------|
| **标的价到达用户目标价 S\*** | **自动平仓** | **不平**,持有至到期 | 平盈利腿后计划可标 `closing`;**全部腿终态后结束**(亏损腿到期后结账) |
| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 &lt; 0 的合计) |
| 到期时组合合计仍盈利 | — | 到期结算 | **算结束**;按实际结算盈亏入账 |
| 未达 S\* 至到期 | 两腿均到期 | | 同上,按结算合计结束 |
| 事件 | 盈利方 | 另一腿(残腿) | 计划是否结束 |
|------|--------|--------------|--------------|
| **标的价到达目标 + 平仓模式=到期平** | **自动平仓** | **不平**,持有至到期(`hold_expiry`) | 平盈利腿后仍 `active`;残腿到期后结账 |
| **标的价到达目标 + 平仓模式=全平**(默认) | **自动平仓** | **随即买一清残腿**(无 2×门控,失败则每轮重试) | 两腿都平完后 `closed` |
| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损** |
| **到期时组合合计仍盈利** | — | 到期结算 | **算结束**;按实际结算盈亏入账 |
判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损).
盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期).
- 界面「平仓模式」仅控制**盈利腿已平之后**另一腿的处理;须 `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true`(默认开)才显示,页面默认选 **全平**.
- 关闭方案C开关时行为固定为 **到期平**.
- 判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损).
- 盈利方判定:触达上破/下破时按浮盈较大一侧平仓;皆亏则等到期.
### 5.2.1 期权腿实盘平仓执行(与期权页共用)
@@ -553,11 +561,14 @@ realized_pnl_total = pnl_option_close - abs(pnl_perp_sl)
| 变量 | 前端标签 | 默认 | 控件 | 热更新 | 说明 |
|------|----------|------|------|--------|------|
| `HEDGE_PLAN_ENABLED` | 启用对冲计划 | false | bool | 热更优先 | 总开关:导航 + API |
| `HEDGE_PLAN_SHOW_PERP_OPTIONS` | 显示永期对冲 | true | bool | 热更 | 关则隐藏永期 Tab,不可测算/开仓 |
| `HEDGE_PLAN_SHOW_OPTIONS_OPTIONS` | 显示期期对冲 | true | bool | 热更 | 关则隐藏期期 Tab,不可测算/开仓 |
| `HEDGE_PLAN_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 |
| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 |
| `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** |
| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** |
| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 |
| `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED` | 期期平仓模式(方案C) | true | bool | 热更 | 开:页面可选到期平/全平;关:固定到期平 |
| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 |
| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 |
| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 |
@@ -577,11 +588,14 @@ realized_pnl_total = pnl_option_close - abs(pnl_perp_sl)
```env
# --- 对冲计划(仅 OKX;前端 env「对冲计划」) ---
HEDGE_PLAN_ENABLED=false
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false
HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true
+36
View File
@@ -0,0 +1,36 @@
# Git 快照标签
代码级快照用 annotated tag 打在 `main` 上,便于回看某日仓库状态(不含 `.env` / 数据库)。
## 当前快照
| 标签 | 指向提交 | 说明 |
|------|----------|------|
| `snapshot/20260721-2` | `77f66bf` | 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/20260721-2
# 回到主线
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 部署依赖安装失败
### 修改原因
+2
View File
@@ -83,6 +83,7 @@
5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期.
6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」.
7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节.
8. **期期自动张数**:对冲计划页为「同张数 / 做多 / 做空」(已无均分);做多/做空按 Call·Put 比例拆,口径与比例见 env `HEDGE_PLAN_OO_BIAS_SPLIT_BY``HEDGE_PLAN_OO_BIAS_RATIO`.
---
@@ -91,3 +92,4 @@
- [期权用法.md](./期权用法.md) — 资金兑划与页面操作
- [期权方案.md](./期权方案.md) — env 与架构
- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿
- [系统说明.md](./系统说明.md) — 实例操作与门禁总手册
+14 -2
View File
@@ -78,16 +78,26 @@ OKX_OPTIONS_API_PASSPHRASE=...
需已配置 `WECHAT_WEBHOOK`.
## 6. 与永续的关系
## 6. 与永续 / 对冲计划的关系
| | 永续(子账户) | 期权(主账户) |
|--|----------------|----------------|
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` |
| 页面 | 实盘下单 / 关键位 | 期权 |
| 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 |
| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 |
两套资金 **不合并** 显示.
**期期对冲张数**(对冲计划页,与单独开期权共用预算算法):
| 模式 | 说明 |
|------|------|
| 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 |
| 做多 | Call:Put 按主腿占比(默认 7:3) |
| 做空 | Put:Call 按主腿占比(默认 7:3) |
拆分口径与比例见 env:`HEDGE_PLAN_OO_BIAS_SPLIT_BY`(`budget` 默认 / `sheets`=先算同张数总张数 `2n` 再拆)、`HEDGE_PLAN_OO_BIAS_RATIO`(默认 `0.7`)。细则见 [对冲计划开发方案.md](./对冲计划开发方案.md) §4.1、[系统说明.md](./系统说明.md)。
## 7. 配置说明
| 变量 | 默认 | 含义 |
@@ -97,6 +107,8 @@ OKX_OPTIONS_API_PASSPHRASE=...
| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 |
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
| `HEDGE_PLAN_OO_BIAS_SPLIT_BY` | budget | 期期做多/做空:按预算或按张数拆 |
| `HEDGE_PLAN_OO_BIAS_RATIO` | 0.7 | 期期做多/做空主腿占比 |
## 8. 期权复盘(含对冲)
+362
View File
@@ -0,0 +1,362 @@
# 系统说明(实例操作与逻辑手册)
本文是实例侧的**详细说明书**:既写「点哪里、先做什么」,也写「为什么这样设计、钱怎么算、门禁如何拦」。
默认不在顶栏显示;需要时到 **系统设置 → 导航显示** 打开「系统说明」。
覆盖:**总览 · 期权 · 对冲计划 · 实盘下单 · 策略交易 · 关键位监控**。复盘/统计字段级细则与风控参数表仍以对应专页为准。
---
## 一、总览:账户、资金与一天怎么用
### 1.1 两套账户(OKX
| 账户 | 典型用途 | 界面相关 |
|------|----------|----------|
| **合约账户** | 永续开仓、止盈止损 | 实盘下单、策略、关键位自动单、永期对冲的永续腿 |
| **期权账户** | 买期权、期期双腿、权利金结算(多为 USDC) | 期权页、对冲计划期权腿 |
逻辑要点:对冲计划里「永续腿 → 合约账户」「期权腿 → 期权账户」。资金不够时,要先划转,再开仓。Binance / Gate 实例主要是合约侧永续能力(无期权/对冲 Tab 时忽略期权相关章节即可)。
### 1.2 资金流(操作顺序)
1. 确认合约可用 USDT(及 OKX 期权交易账户 USDC)是否够用。
2. 期期 / 单独开期权:常在期权页或期期卡片做 **资金 ↔ 交易** USDC 划转。
3. 永期:合约侧按全仓建议张数;期权侧再买保险腿。
4. 实盘 / 策略 / 关键位自动单:只动合约账户,按计仓模式算张数。
5. 开仓后到对应页看持仓与监控状态;结束后看复盘 / 统计 / 策略记录。
### 1.3 推荐使用节奏
| 场景 | 建议路径 |
|------|----------|
| 人工永续单 + 监控 | **实盘下单** |
| 趋势分档 / 在已有仓上滚仓 | **策略交易** |
| 位到提醒或自动开仓 | **关键位监控** |
| 只做方向 + 保险 | **对冲计划 → 永期**(测算 → 启动) |
| 只做上下突破双买 | **对冲计划 → 期期** |
| 单独买一张期权并挂目标 | **期权** 页开仓 + 目标监控 |
| 看说明 / 改开关 | **系统说明** / **env 配置** / **系统设置** |
### 1.4 互斥与门禁(总原则)
- **实盘 ↔ 趋势**:有活跃下单监控或运行中趋势计划时,另一侧不能再开(预览/执行会被挡)。
- **滚仓 ↔ 趋势**:有运行中趋势计划时,顺势加仓不可用。
- **计仓模式**`risk`(以损定仓)才允许趋势与多数关键位自动单;`full_margin`(全仓)适合永期对冲与部分触价单,**禁止**趋势/滚仓。切换计仓须无仓后改 env 并重启。
- **对冲与期权互斥门控**(默认开):有进行中对冲计划时,不能再「单独开期权」;账户里已有「纯期权」持仓时,不能启动对冲计划。
- **半腿失败改手动补开**(默认开):对冲启动一腿成功、一腿失败 → 不自动平已成腿,挂「半腿待补」后在「进行中」补开。
- **顶栏可开仓状态**:实盘开关、持仓上限、单日开仓硬上限、冷静期/日冻结、切点前禁开等取交集;细则见 **风控说明**
---
## 二、期权模块
### 2.1 这块干什么
在期权账户上:**选合约 → 按卖一限价买入 → 持仓监控 → 买一平仓 / 目标到位平仓**。
也是对冲计划期权腿的共用能力。
### 2.2 操作:开仓
1. 打开 **期权**,选标的(ETH/BTC)、到期日、Call/Put。
2. 看清 **卖一价与深度**:无真实卖一深度时系统禁止开仓(链上带 `~` 的是参考估算,不能当真开仓价)。
3. 选张数 / 预算模式后下单。
4. 可选填写 **目标指数位**:到位后由目标监控按买一挂平(与对冲计划托管的目标不是同一套执行器)。
### 2.3 操作:平仓与目标
- **买一平仓**:按当前买一深度估算可回收金额与净盈亏;注意买卖价差,权利金一侧常见较大滑点。
- **目标监控**:手动委托的目标写在期权目标表;**期期对冲**的目标由对冲监控执行,持仓卡上会显示「由对冲计划监控」。
- 门控示例:可回收 < 权利金×2 时,目标平仓门控可能未过(保护「太亏别乱平」类规则,以页面提示为准)。
### 2.4 逻辑:持仓来源
持仓卡上的 **持仓来源** 表示这条仓和哪类计划绑定:
| 来源 | 含义 |
|------|------|
| 纯期权 | 未挂在进行中对冲计划腿上(含手动开、或计划已结束仍留着的仓) |
| 永期对冲 #N | 属于进行中永期计划的保险腿 |
| 期期对冲 #N | 属于进行中期期计划的腿 |
判定依据:数据库里进行中计划的 `open` 腿合约 ID。来源会影响互斥门控(「纯期权」会挡住新对冲启动)。
### 2.5 逻辑:盈亏怎么看
- **权利金**:买入成本(USDC)。
- **按买盘回收**:按当前买一深度卖掉大约能拿回多少。
- **净盈亏 ≈ 回收 − 权利金**(页面以买一回收为准,不是单纯看标记价浮动)。
- **到期平衡 / 平掉回本**:帮助判断「拿到到期」与「现在平掉」的盈亏分界,属于情景参考。
### 2.6 更多细则
期权开平仓字段级说明仍可打开独立页:[期权开平仓与监控说明](/options/guide)。
---
## 三、对冲计划
### 3.1 这块干什么
把「永续 + 期权」或「期权 + 期权」做成**可测算、可下单、可监控**的计划,与普通交易记录分开。
| 类型 | 组成 | 核心逻辑 |
|------|------|----------|
| **永期** | 合约账户永续 + 期权账户保险腿 | 全仓做方向;止盈/止损按规则处理期权 |
| **期期** | 期权账户两腿买方 | 上破/下破目标;盈利腿先平,残腿按模式处理 |
### 3.2 操作:永期
1. 选 ETH/BTC、做多/做空;看标记价与全仓建议张数。
2. 填开仓价、止盈、止损、张数;右侧选期权腿(列表)。
3.**计算** → 弹窗看情景测算 → **启动计划** 或取消。
4. 启动后在 **进行中的计划** 看状态;细节可点「成交细节」。
逻辑摘要:
- 永期开仓通常要求 **全仓计仓** + 实盘与对冲真实下单门禁。
- 止盈后是否强平期权、止损后是否强平期权,由 env 开关控制(止损强平默认开,止盈强平默认关)。
- 统计口径:止盈多为「永续盈利 − 权利金」;止损多为「期权盈亏 + 永续盈亏」有符号相加(以系统结案字段为准)。
- 启动前会校验:合约侧不宜再有「额外」永续仓与永期腿冲突(以页面提示为准)。
### 3.3 操作:期期
1. 填上破 / 下破目标;指数价作参考。
2. 张数模式:**同张数**(默认)、**做多**、**做空**;平仓模式:**全平**(默认)或 **到期平**(若 env 打开方案 C)。
3. T 型报价选用两腿(做多/做空须一 Call 一 Put);可先划转 USDC。
4. **计算** → 情景测算 → **启动计划**
逻辑摘要:
- **同张数**:最大 `n` 使两腿各 `n` 张且总权利金 ≤ 预算。
- **做多 / 做空**:主腿与次腿按 env 比例(默认 7:3)分配;做多主腿=Call,做空主腿=Put。拆分口径由 `HEDGE_PLAN_OO_BIAS_SPLIT_BY` 决定:`budget`(默认,按权利金预算拆)或 `sheets`(先按同张数算出每腿 `n`,总张数 `2n` 再按比例拆到 Call/Put)。
- 达目标价:通常只平盈利腿。
- **全平**:盈利腿平掉后立刻尝试清另一腿(无 2× 权利金门控,失败会重试)。
- **到期平**:残腿持有至到期再结。
- 旧计划若无平仓模式字段,按「到期平」更安全的口径处理。
### 3.4 半腿失败与手动补开
启动时两腿要连续下单。若一腿成功、一腿失败:
| 配置 | 行为 |
|------|------|
| **半腿失败改手动补开 = 开**(默认) | 已成腿留下;计划状态 **半腿待补**;「进行中」出现 **补开永续 / 补开腿B / 补开期权****不会**自动买一平已成腿 |
| 手动补开 = 关,且自动平 = 开 | 尝试自动平掉已成期权腿(会吃买卖价差,几乎必亏一笔) |
操作建议:半腿出现后,先看失败原因(深度、余额、权限),再点补开;确认补开会真实下单。
### 3.5 情景测算弹窗
测算不再占页面下方大块区域:点 **计算** 弹出结果,底部 **启动计划 / 取消**
取消只关窗;启动按当前参数真实下单(仍受门禁约束)。
期期弹窗:合计盈亏绿/红配色;摘要显示盈亏比(盈利÷全额保费,亏损按权利金全亏计)。
### 3.6 进行中 / 历史 / 统计
- **进行中**:含 `opening` / `active` / `partial`。半腿待补可补开。
- **历史**:已结束计划与成交细节。
- **统计**:按永期 / 期期分别看胜率、盈亏比、最大盈亏与回撤等(按结束时间累积)。
- 对冲成交 **不进** 普通「交易记录与复盘」/「策略交易记录」。
---
## 四、实盘下单
### 4.1 这块干什么
合约账户上的 **人工永续开仓 + 下单监控**:提交后进入监控列表,轮询标记价与交易所止盈止损,支持改委托、手动平仓、移动保本、时间平等;平仓后进 **交易记录与复盘**
### 4.2 操作
1. 打开 **实盘下单**,选币种、方向。
2. 选止盈止损模式(固定盈亏比 / 价格 / 百分比等,以页面选项为准)。
3. 趋势类账户可再选开仓类型(反转 / 顺势 / 波段等);Gate 日内类账户选项更窄,且可能无移动保本 / 时间平。
4. 填止损与止盈(或 RR),看 **预估盈亏比** 与计划预览。
5. 确认开仓(按钮文案随「是否实盘」变化;关实盘时不会发交易所单)。
6. 右侧 **实时持仓**:看浮盈亏、交易所 TP/SL;用 **委托** 改止盈止损,或 **平仓** 全平。
7. 需要时点 **放大 K 线**;若交易所已有仓但本地无监控,可用 **恢复监控**(孤儿仓恢复)。
8. 结束后到 **交易记录与复盘** / **统计分析** 查看。
### 4.3 逻辑与门禁
| 项 | 说明 |
|----|------|
| `LIVE_TRADING_ENABLED` | 关则不发真单,仅本地流程 |
| `MANUAL_MIN_PLANNED_RR` | 人工开仓计划 RR 下限(表单 + 服务端) |
| `POSITION_SIZING_MODE` | `risk` 以损定仓 / `full_margin` 全仓;须无仓切换并重启 |
| `can_trade` 交集 | 持仓上限、单日开仓硬上限、冷静期、切点前禁开等 |
| 方向 / 币种白名单 | 账户策略限制时,不符合的单会被拒 |
三所核心流程一致;折叠区「开仓规则说明」文案按交易所模板略有不同。
### 4.4 与策略 / 期权 / 对冲的关系
-**趋势回调** 互斥(见 1.4);**顺势加仓** 必须先有本页同向活跃监控单。
- 期权 / 对冲互斥门控 **不拦** 本页永续单。
- 永期计划 active 时,合约侧不宜再挂「额外」永续仓(启动对冲前会校验)。
- 关键位自动开仓成交后,也会进入同一套 **下单监控**
### 4.5 常见问题
**Q:预估 RR 已经够绿,仍开不了?**
A:看顶栏 / 返回文案:满仓、日上限、冷静期、方向白名单、实盘关、服务端 RR 口径等。
**Q:交易所有仓,本页没有监控?**
A:用孤儿仓 **恢复监控**;不要另开一笔同向重复仓。
---
## 五、策略交易
### 5.1 这块干什么
自动化永续策略页:**趋势回调**(预览 → 分档补仓计划)与 **顺势加仓**(在已有同向监控持仓上滚仓)。执行历史在 **策略交易记录**
部分「日内纪律」类账户整 Tab 隐藏,以导航是否出现为准。
### 5.2 操作:趋势回调
1. 填币种、方向、杠杆、风险%、止损、补仓边界(多=上沿 / 空=下沿)、止盈。
2. **生成预览**(有短时效;用快照余额算张数)。
3. 核对预览表后 **确认执行(实盘)**
4. 运行中可看补仓档与浮盈亏;可 **手动保本**,或 **保本移交下单监控**(计划结束,仓交给实盘监控继续管)。
5. **结束计划** 或止盈止损自动结束后,写入策略记录与交易记录(类型「趋势回调」)。
### 5.3 操作:顺势加仓
1. 先在 **实盘下单** 有一条 **同向** 活跃监控单。
2. 选持仓、加仓模式(市价 / 斐波 / 突破等)、新止损 → **执行滚仓**(无预览步;同时通常只允许一条监控中滚仓腿)。
3. 注意次数上限(如做多/做空各最多若干次已成交腿)与首仓 TP 锁定规则,以页面提示为准。
### 5.4 逻辑与门禁
| 项 | 说明 |
|----|------|
| 实盘 + 计仓 | 须 `LIVE_TRADING_ENABLED=true``POSITION_SIZING_MODE=risk`;全仓模式禁止趋势与滚仓 |
| 与下单监控互斥 | 有活跃监控单或运行中趋势时,不能开另一侧预览/执行 |
| 与滚仓互斥 | 运行中趋势时滚仓按钮禁用 |
| 余额漂移 | 预览后余额变化过大(约 5%)须重新预览 |
| 单日开仓上限 | 与人工开仓共用计数,同样可拦预览/执行 |
### 5.5 与期权 / 对冲
独立模块:不走对冲计划状态机;记录进策略库 / 普通交易记录,**不进** 对冲历史与对冲统计。
OKX 上可与期权/对冲并存,但仍须遵守合约侧「永期不得另挂额外永续仓」等规则。
### 5.6 常见问题
**Q:触价到了却没补仓?**
A:看页面 `block_reason`:实盘关、余额漂移、最小张数减档、日上限等。
**Q:中控「策略说明」是不是本页手册?**
A:不是。中控策略说明是玩法 playbook;本说明书讲本系统如何操作与门禁。
---
## 六、关键位监控
### 6.1 这块干什么
配置 **关键价位**(常见 5m 门控):支撑/阻力可 **微信提醒**;箱体/收敛/触价等类型可在开关打开后 **程序自动开仓**,成交后进入 **实盘下单监控**
### 6.2 操作
1. 打开 **关键位监控**,选类型、币种、方向,填上下沿 / 触价 / E·SL·TP 等。
2. 箱体类可选 SL/TP 方案、移动保本、时间平等(以类型是否支持为准)。
3. **添加** 后在列表看现价、距沿距离、**门控** 状态;不需要则 **删除**
4. 右侧 **关键位历史** 看失效 / 成交 / 提醒完成等原因。
5. 可用 **放大 K 线** 辅助画位。
### 6.3 类型与开关(逻辑)
| 类型(概括) | 关键位自动单开关 | 全仓模式 |
|--------------|------------------|----------|
| 关键支撑阻力 | 不需要(仅提醒) | 可用 |
| 箱体 / 收敛 / 斐波 / 假突破等 | 需要开启,且一般为 `risk` 计仓 | **不可用**(添加会拒;已有位在全仓下可能被撤销并通知) |
| 回调 / 突破触价开仓 | 需要开启 | **可用**(全仓下常见的自动单路径) |
其它要点:
- `KEY_AUTO_MIN_PLANNED_RR`:自动单计划 RR 须严格大于该值(默认约 1.5)。
- 箱体类门控常含双 K 确认、突破幅度、量能、24h 成交额排名等(阈值见 env,改后多需重启)。
- 自动成交计入 **单日开仓次数**,并受 `can_trade`、持仓上限、冷静期约束。
- 假突破等类型可能仅限 BTC/ETH,且同币种条数有限,以页面校验为准。
### 6.4 与期权 / 对冲
无直接耦合。自动开仓写入下单监控后,与期权/对冲并行存在;若同时做永期,注意合约侧持仓冲突。
### 6.5 常见问题
**Q:开了「关键位自动单」仍只有支撑阻力可选?**
A:检查是否 **全仓模式**,或自动单开关实际未生效(改后是否重启)。
**Q:微信提醒有了却没开仓?**
A:可能是仅提醒类型、门控未过、RR 不足、满仓/日上限,或实盘/可开仓状态未过。
---
## 七、env 与系统设置(和说明书相关的部分)
### 7.1 系统设置 → 导航显示
控制顶栏是否出现各板块。「系统说明」默认关闭,打开后顶栏才显示入口。
### 7.2 env → 交易与关键位(常用)
| 开关 | 作用 |
|------|------|
| 实盘交易 | 关则人工/策略/自动单都不发真单(对冲另有「允许真实下单」) |
| 计仓模式 | `risk` / `full_margin`;决定策略与多数关键位自动单能否用 |
| 人工最小计划 RR | 实盘下单 RR 下限 |
| 关键位自动单 | 关则箱体等不自动开仓;支撑阻力提醒仍可用 |
| 关键位自动单最小 RR | 自动开仓 RR 下限 |
### 7.3 env → 对冲计划(常用)
| 开关 | 作用 |
|------|------|
| 启用对冲计划 | 总开关;关则导航隐藏且不可开仓 |
| 显示永期 / 期期 | 单独隐藏某一 Tab |
| 允许对冲真实下单 | 与实盘开关一起才可启动永期 |
| 对冲与期权互斥门控 | 见 1.4 |
| 半腿失败改手动补开 | 见 3.4 |
| 半腿失败时自动平期权 | 手动补开开启时强制无效 |
| 期期平仓模式(方案 C) | 页面是否出现「全平 / 到期平」 |
| 期期做多做空拆分口径 | `budget` 预算金额(默认)/ `sheets` 张数 |
| 期期做多做空主腿占比 | 默认 `0.7`(即 7:3 |
含「需重启」标记的项保存后要用「保存并重启」;对冲多数开关可热更,以页面标注为准。
---
## 八、常见问题
**Q:为什么有对冲计划时单独开不了期权?**
A:互斥门控默认开启,避免计划仓与手开仓搅在一起。可在 env 关闭互斥。
**Q:为什么有一张「纯期权」就启动不了对冲?**
A:同上。先平掉或确认来源;若其实是对冲腿,看持仓来源是否显示计划编号。
**Q:半腿后为什么不自动平?**
A:默认改手动补开,避免买一平仓吃掉 ≥10% 量级价差。到「进行中」补开即可。
**Q:测算能过但启动按钮灰?**
A:看顶部门禁行:全仓、实盘、真实下单、活跃计划数、互斥、Tab 是否隐藏等。
**Q:为什么策略页不能预览 / 滚仓灰掉?**
A:常见原因:全仓模式、实盘关、已有活跃下单监控或运行中趋势、日上限。见第四、五章。
**Q:关键位只提醒不开仓?**
A:支撑阻力本就只提醒;其它类型看自动单开关、计仓模式、门控与 RR。见第六章。
**Q:说明书和「风控说明」什么关系?**
A:风控说明仍是独立页(冷却、当日次数等细则)。本说明书讲板块逻辑与操作;风控细则以风控说明 + env 为准。
---
## 九、版本与维护
- 文档路径:`docs/系统说明.md`
- 功能变更后应同步改本章(尤其门禁、半腿、互斥、计仓、关键位自动单、平仓模式)。
- 更偏开发/方案的材料仍在 `docs/对冲计划*.md``docs/期权对冲方案分析.md`、策略专项 md 等,不必与本说明书一一粘贴。
+148
View File
@@ -0,0 +1,148 @@
/**
* 全局防浏览器自动填充登录账号/密码进业务输入框.
* 跳过真正的登录/改密字段;对划转数量等易中招框用 readonly 到聚焦.
*/
(function () {
"use strict";
var GUARD_ATTRS = {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
spellcheck: "false",
"data-lpignore": "true",
"data-1p-ignore": "true",
"data-bwignore": "true",
"data-form-type": "other",
};
function looksLikeUsername(v) {
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
}
function isAuthField(el) {
if (!el || !el.getAttribute) return true;
var t = String(el.type || "").toLowerCase();
if (t === "hidden" || t === "checkbox" || t === "radio" || t === "file" || t === "submit" || t === "button") {
return true;
}
if (el.getAttribute("aria-hidden") === "true") return true;
if (el.tabIndex === -1 && String(el.getAttribute("autocomplete") || "").toLowerCase() === "username") {
return true; // 诱饵账号框
}
var idName = String(el.id || "") + " " + String(el.name || "");
if (/^(pwd-|hub-pwd-|login-)/i.test(String(el.id || ""))) return true;
if (el.closest) {
if (el.closest(".login-form, #login-form, form.login-form, .password-settings, [data-password-settings]")) {
return true;
}
}
// env API Key 等 type=password 仍要防登录密码灌入,不在此跳过
if (t === "password" && /^(username|password)$/i.test(String(el.name || ""))) {
if (el.closest && el.closest("form[method='post'], form[method='POST']")) return true;
}
return false;
}
function isAmountLike(el) {
var key = String(el.id || "") + " " + String(el.name || "") + " " + String(el.placeholder || "");
return /amount|xfer|transfer|划转|数量|金额/i.test(key);
}
function wipeBad(el) {
if (!el || isAuthField(el)) return;
var v = String(el.value || "").trim();
if (!looksLikeUsername(v)) return;
var t = String(el.type || "text").toLowerCase();
if (t === "number" || isAmountLike(el) || /price|sheets|qty|sl|tp|target|entry|strike/i.test(String(el.id || "") + String(el.name || ""))) {
el.value = "";
}
}
function harden(el) {
if (!el || el.nodeType !== 1) return;
if (isAuthField(el)) return;
if (el.getAttribute("aria-hidden") === "true") return;
if (el.dataset && el.dataset.autofillGuarded === "1") {
wipeBad(el);
return;
}
if (el.dataset) el.dataset.autofillGuarded = "1";
Object.keys(GUARD_ATTRS).forEach(function (k) {
var cur = el.getAttribute(k);
if (k === "autocomplete" && cur && /^(username|current-password)/i.test(cur)) {
return;
}
// env 密钥框用 new-password 更抗登录密码灌入
if (k === "autocomplete" && String(el.type || "").toLowerCase() === "password") {
el.setAttribute(k, "new-password");
return;
}
if (!cur || cur === "on") el.setAttribute(k, GUARD_ATTRS[k]);
});
if (String(el.type || "").toLowerCase() === "password" || isAmountLike(el)) {
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
}
wipeBad(el);
setTimeout(function () {
wipeBad(el);
}, 250);
setTimeout(function () {
wipeBad(el);
}, 900);
setTimeout(function () {
wipeBad(el);
}, 2000);
}
function scan(root) {
var scope = root && root.querySelectorAll ? root : document;
var list = scope.querySelectorAll(
'input[type="text"], input[type="number"], input[type="search"], input[type="url"], input[type="email"], input[type="tel"], input[type="password"], input:not([type]), textarea'
);
for (var i = 0; i < list.length; i++) harden(list[i]);
}
function boot() {
scan(document);
if (typeof MutationObserver === "undefined") return;
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.type === "childList") {
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.matches && n.matches("input, textarea")) harden(n);
else if (n.querySelectorAll) scan(n);
}
} else if (m.type === "attributes" && m.target) {
harden(m.target);
}
}
});
obs.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["value"],
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
window.cmAutofillGuardScan = scan;
})();
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -15,6 +15,7 @@
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
};
@@ -296,7 +297,8 @@
}
function syncShellChrome(tab) {
const hideTopBar = tab === "settings" || tab === "risk_policy" || tab === "env_config";
const hideTopBar =
tab === "settings" || tab === "risk_policy" || tab === "system_guide" || tab === "env_config";
document.querySelectorAll(".instance-top-bar").forEach((el) => {
el.hidden = hideTopBar;
});
+1 -1
View File
@@ -88,7 +88,7 @@
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:2100}
.modal img{max-width:90%;max-height:90%;border-radius:8px}
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
+3 -1
View File
@@ -20,7 +20,7 @@
}
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
const NAV_DEFAULT_OFF = { show_nav_dashboard: true };
const NAV_DEFAULT_OFF = { show_nav_dashboard: true, show_nav_system_guide: true };
function navPrefShow(display, key) {
if (!key) return true;
@@ -41,6 +41,7 @@
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => {
@@ -68,6 +69,7 @@
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
const key = map[tab];
+648 -72
View File
@@ -3198,6 +3198,33 @@ html[data-theme="light"] .opt-be-dist-down {
.hedge-plan-page-wrap .hp-head-card {
margin-bottom: 12px;
}
.hedge-plan-page-wrap .hp-rule-collapse {
margin: 0 0 10px;
border: none;
background: transparent;
box-shadow: none;
}
.hedge-plan-page-wrap .hp-rule-collapse > .tip-collapse-summary {
padding: 4px 0;
font-size: 0.78rem;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body {
padding: 6px 0 2px;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body.rule-tip {
margin: 0;
border: none;
background: transparent;
padding: 0;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body p {
margin: 0 0 6px;
font-size: 0.74rem;
line-height: 1.45;
}
.hedge-plan-page-wrap .hp-rule-collapse .tip-collapse-body p:last-child {
margin-bottom: 0;
}
.hedge-plan-page-wrap .hp-head-row {
display: flex;
flex-wrap: wrap;
@@ -3249,15 +3276,276 @@ html[data-theme="light"] .opt-be-dist-down {
color: #7ee787;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-plan-partial {
color: #ffb454;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-hist-actions .hp-btn-complete {
margin-right: 6px;
padding: 3px 8px;
font-size: 0.72rem;
min-height: 26px;
}
.hedge-plan-page-wrap .hp-pnl-neg {
color: #ff8a8a;
}
.hedge-plan-page-wrap .hp-oo-legs {
margin-top: 8px;
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hedge-plan-page-wrap .hp-oo-target-row {
align-items: flex-end;
flex-wrap: wrap;
gap: 10px 14px;
}
.hedge-plan-page-wrap .hp-oo-index {
display: inline-flex;
align-items: center;
margin: 0 0 2px;
padding: 4px 0;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #3dd68c;
white-space: nowrap;
}
.hedge-plan-page-wrap .hp-oo-transfer {
margin: 10px 0 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hedge-plan-page-wrap .hp-oo-transfer--compact {
margin: 12px 0 8px;
padding: 8px 10px;
}
.hedge-plan-page-wrap .hp-oo-transfer-bals {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 8px;
margin-bottom: 6px;
font-size: 0.76rem;
}
.hedge-plan-page-wrap .hp-oo-transfer-bals strong {
color: var(--text, #e6edf3);
font-variant-numeric: tabular-nums;
}
.hedge-plan-page-wrap .hp-oo-transfer-unit {
opacity: 0.75;
}
.hedge-plan-page-wrap .hp-oo-transfer-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
}
.hedge-plan-page-wrap .hp-oo-transfer-form select,
.hedge-plan-page-wrap .hp-oo-transfer-form input[type="number"] {
font-size: 0.74rem;
}
.hedge-plan-page-wrap .hp-oo-transfer-form input[type="number"] {
width: 96px;
max-width: 30vw;
}
.hedge-plan-page-wrap #hp-oo-xfer-msg {
margin-left: auto;
font-size: 0.72rem;
min-height: 1.1em;
}
.hedge-plan-page-wrap #hp-oo-xfer-msg.is-err {
color: #ff8a8a;
}
.hedge-plan-page-wrap #hp-oo-xfer-msg.is-ok {
color: #3dd68c;
}
.hedge-plan-page-wrap .hp-oo-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
margin: 10px 0 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hedge-plan-page-wrap .hp-oo-ctrl {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.hedge-plan-page-wrap .hp-oo-ctrl-lab {
font-size: 0.72rem;
color: var(--muted, #8b949e);
letter-spacing: 0.02em;
}
.hedge-plan-page-wrap .hp-oo-seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hedge-plan-page-wrap .hp-oo-seg .btn-secondary {
flex: 1 1 auto;
min-width: 4.5em;
justify-content: center;
padding: 5px 8px;
position: relative;
}
.hedge-plan-page-wrap .hp-oo-check {
display: none;
margin-right: 4px;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-size-mode.is-selected,
.hedge-plan-page-wrap .hp-oo-close-mode.is-selected,
.hedge-plan-page-wrap .hp-po-dir.is-selected,
.hedge-plan-page-wrap .hp-oo-size-mode.active,
.hedge-plan-page-wrap .hp-oo-close-mode.active,
.hedge-plan-page-wrap .hp-po-dir.active {
border-color: var(--accent, #00d4ff);
color: var(--text, #fff);
background: rgba(0, 212, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.35);
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-size-mode.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-close-mode.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-po-dir.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-size-mode.active .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-close-mode.active .hp-oo-check,
.hedge-plan-page-wrap .hp-po-dir.active .hp-oo-check {
display: inline;
color: var(--accent, #00d4ff);
}
.hedge-plan-page-wrap .hp-po-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin: 8px 0 4px;
}
.hedge-plan-page-wrap .hp-po-dir-seg {
flex: 1 1 180px;
max-width: 220px;
}
.hedge-plan-page-wrap .hp-po-mark {
font-size: 0.95rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #3dd68c;
white-space: nowrap;
}
.hedge-plan-page-wrap .hp-po-meta {
margin: 2px 0 8px;
font-size: 0.74rem;
line-height: 1.4;
}
.hedge-plan-page-wrap .hp-po-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
margin: 8px 0 6px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hedge-plan-page-wrap .hp-po-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
margin: 0;
font-size: 0.78rem;
}
.hedge-plan-page-wrap .hp-po-field-lab {
color: #9aa4b2;
font-size: 0.72rem;
}
.hedge-plan-page-wrap .hp-po-field-lab em {
font-style: normal;
color: #6b7388;
margin-left: 2px;
}
.hedge-plan-page-wrap .hp-po-field input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.hedge-plan-page-wrap .hp-po-field--tp input {
border-color: rgba(61, 214, 140, 0.45);
}
.hedge-plan-page-wrap .hp-po-field--sl input {
border-color: rgba(255, 138, 138, 0.45);
}
.hedge-plan-page-wrap .hp-po-summary {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.hedge-plan-page-wrap .hp-po-pnl {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hedge-plan-page-wrap .hp-po-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.76rem;
}
.hedge-plan-page-wrap .hp-po-sizing {
font-size: 0.74rem;
line-height: 1.4;
}
.hedge-plan-page-wrap .hp-po-opt-toolbar {
align-items: center;
}
.hedge-plan-page-wrap .hp-po-index {
margin-left: auto;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #3dd68c;
white-space: nowrap;
}
@media (max-width: 720px) {
.hedge-plan-page-wrap .hp-po-fields {
grid-template-columns: 1fr;
}
.hedge-plan-page-wrap .hp-po-index {
margin-left: 0;
}
}
.hedge-plan-page-wrap #hp-oo-close-mode-row.hidden {
display: none !important;
}
.hedge-plan-page-wrap .hp-oo-controls:has(#hp-oo-close-mode-row.hidden) {
grid-template-columns: 1fr;
}
.hedge-plan-page-wrap .hp-oo-meta {
margin: 4px 0 0;
font-size: 0.75rem;
line-height: 1.4;
}
.hedge-plan-page-wrap #hp-oo-budget-line.hp-oo-budget-warn {
color: #ff8a8a;
}
@media (max-width: 720px) {
.hedge-plan-page-wrap .hp-oo-controls {
grid-template-columns: 1fr;
}
}
.hedge-plan-page-wrap .hp-oo-leg-row {
display: flex;
flex-wrap: wrap;
@@ -3394,15 +3682,24 @@ html[data-theme="light"] .opt-be-dist-down {
display: flex;
flex-direction: column;
}
.hedge-plan-page-wrap .hp-preview-card {
margin-top: 0;
clear: both;
}
.hedge-plan-page-wrap .hp-action-row {
margin-top: 10px;
gap: 8px;
justify-content: flex-end;
}
.hedge-plan-page-wrap .hp-preview-modal {
width: min(96vw, 920px);
}
.hedge-plan-page-wrap .hp-preview-summary {
margin: 0 0 10px;
font-size: 0.84rem;
line-height: 1.45;
}
.hedge-plan-page-wrap .hp-preview-actions {
margin-top: 14px;
justify-content: flex-end;
gap: 10px;
}
.hedge-plan-page-wrap .hp-pick.active,
.hedge-plan-page-wrap .opt-row-selected td {
background: rgba(90, 140, 255, 0.18);
@@ -3649,30 +3946,51 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
background: rgba(255, 152, 0, 0.08);
}
.opt-order-inline-row td {
padding: 14px 16px !important;
background: rgba(74, 124, 255, 0.07);
border-top: 1px solid rgba(74, 124, 255, 0.25);
border-bottom: 1px solid rgba(74, 124, 255, 0.25);
padding: 0 !important;
border: none !important;
background: transparent !important;
}
.opt-order-panel-inner {
border-radius: 8px;
.opt-order-backdrop {
position: fixed;
inset: 0;
z-index: 2100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
}
.opt-order-backdrop[hidden] {
display: none !important;
}
.opt-order-dialog {
width: min(96vw, 760px);
max-height: 92vh;
overflow: auto;
background: var(--card-bg, #121726);
color: inherit;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 14px;
padding: 18px 20px 20px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.opt-order-layout {
display: flex;
align-items: stretch;
gap: 14px;
display: block;
}
.opt-order-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.opt-order-pending {
flex: 0 0 280px;
max-width: 320px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(158, 192, 255, 0.2);
background: rgba(0, 0, 0, 0.18);
.opt-order-dialog #opt-order-inst,
.opt-order-dialog .options-order-inst {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.4;
word-break: break-all;
margin: 0;
}
.opt-order-pending-head {
display: flex;
@@ -3703,6 +4021,12 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
max-height: 220px;
overflow: auto;
}
.opt-pending-list--tab {
max-height: min(52vh, 420px);
}
.opt-pos-pending-pane {
padding: 4px 2px 8px;
}
.opt-pending-empty {
font-size: 0.72rem;
}
@@ -3740,101 +4064,174 @@ html[data-theme="light"] .options-strike-table--t .opt-strike-row-atm td {
font-size: 0.68rem;
padding: 2px 8px;
}
html[data-theme="light"] .opt-order-pending {
background: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.08);
}
html[data-theme="light"] .opt-pending-item {
background: #fff;
border-color: rgba(0, 0, 0, 0.08);
}
@media (max-width: 900px) {
.opt-order-layout {
flex-direction: column;
}
.opt-order-pending {
flex: 1 1 auto;
max-width: none;
.opt-pending-list--tab {
max-height: min(46vh, 360px);
}
}
.opt-order-panel-inner .opt-order-title {
margin: 0 0 8px;
font-size: 0.85rem;
.opt-order-dialog-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.opt-order-dialog-head .opt-order-title {
margin: 0;
margin-right: auto;
font-size: 1.02rem;
color: #9ec0ff;
font-weight: 650;
}
.options-page-wrap .opt-order-panel-inner .opt-order-title {
font-size: 0.82rem;
.opt-order-dialog-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.opt-order-panel-host:not([hidden]) {
display: block;
.opt-order-dialog-actions .btn-primary,
.opt-order-dialog-actions .btn-secondary {
flex: 1 1 140px;
min-height: 38px;
font-size: 0.86rem;
padding: 8px 14px;
}
.opt-order-panel-host[hidden] {
display: none !important;
.opt-order-dialog #opt-order-msg {
margin-top: 2px;
min-height: 1.2em;
line-height: 1.4;
}
.options-order-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin: 10px 0;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 12px 14px;
margin: 0;
padding: 12px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.options-order-grid .k {
display: block;
font-size: 0.68rem;
font-size: 0.7rem;
color: #8892b0;
margin-bottom: 3px;
}
.options-order-grid .v,
.options-page-wrap .options-order-grid .v {
font-size: 0.8rem;
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
}
.options-estimate-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12);
font-size: 0.78rem;
}
.opt-est-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin: 8px 0 10px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.08);
font-size: 0.74rem;
gap: 10px 12px;
}
.options-estimate-row .opt-est-label {
color: #8892b0;
.opt-order-chip {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
cursor: pointer;
user-select: none;
font-size: 0.76rem;
padding: 6px 12px;
min-height: 32px;
line-height: 1.2;
white-space: nowrap;
border: 1px solid rgba(140, 160, 200, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: inherit;
box-sizing: border-box;
}
.opt-order-chip:hover {
border-color: rgba(140, 170, 230, 0.55);
background: rgba(255, 255, 255, 0.07);
}
.opt-size-mode-chip {
position: relative;
}
.opt-size-mode-chip input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
pointer-events: none;
}
.opt-size-mode-chip:has(input:checked),
.opt-size-mode-chip.is-selected,
.opt-size-mode-chip.active {
border-color: #5b8cff;
color: #cfe0ff;
background: rgba(74, 124, 255, 0.28);
box-shadow: inset 0 0 0 1px rgba(120, 160, 255, 0.45);
}
.options-estimate-row .opt-target-idx {
width: 120px;
font-size: 0.74rem;
padding: 3px 6px;
width: 140px;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-estimate-row .k {
color: #8892b0;
}
.options-estimate-row .v {
font-size: 0.82rem;
font-size: 0.86rem;
font-weight: 600;
}
.options-estimate-row .opt-est-note {
font-size: 0.66rem;
display: block;
font-size: 0.7rem;
line-height: 1.45;
opacity: 0.85;
}
html[data-theme="light"] .options-estimate-row {
html[data-theme="light"] .options-estimate-row,
html[data-theme="light"] .options-order-grid {
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.1);
}
html[data-theme="light"] .opt-order-chip {
border-color: rgba(0, 0, 0, 0.16);
background: #fff;
}
.options-hint {
font-size: 0.75rem;
margin-bottom: 6px;
}
.options-page-wrap .options-order-mode-row {
font-size: 0.74rem;
gap: 6px;
font-size: 0.78rem;
gap: 10px;
}
.options-page-wrap .options-order-mode-row input[type="number"],
.options-page-wrap .options-order-mode-row input[type="text"] {
font-size: 0.74rem;
padding: 3px 6px;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-page-wrap .options-order-mode-row .btn-primary {
font-size: 0.74rem;
padding: 4px 10px;
font-size: 0.8rem;
padding: 6px 12px;
}
.options-page-wrap .opt-row-actions .btn-primary,
.options-page-wrap .opt-row-actions .btn-secondary {
@@ -3876,11 +4273,29 @@ html[data-theme="light"] .options-estimate-row {
background: rgba(255, 209, 102, 0.12);
}
.options-order-mode-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
margin: 0;
}
.opt-size-mode-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
gap: 10px;
}
.options-order-mode-row input[type="number"] {
width: 88px;
width: 96px;
}
.options-order-mode-row .opt-signal-note,
.options-order-mode-row #opt-signal-note {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.options-order-mode-row .opt-order-chip {
flex: 0 0 auto;
}
.options-dual-grid {
display: grid;
@@ -4170,6 +4585,52 @@ html[data-theme="light"] .options-stats-pnl-summary .options-stat-item {
font-size: 0.62rem;
line-height: 1;
}
.opt-source-badge {
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
padding: 2px 7px;
border-radius: 6px;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.2;
border: 1px solid transparent;
}
.opt-source-badge--plain {
background: rgba(255, 255, 255, 0.06);
color: #9aa4b2;
border-color: rgba(255, 255, 255, 0.1);
}
.opt-source-badge--po {
background: rgba(100, 160, 255, 0.16);
color: #8ec0ff;
border-color: rgba(100, 160, 255, 0.35);
}
.opt-source-badge--oo {
background: rgba(0, 212, 255, 0.14);
color: #5ee4ff;
border-color: rgba(0, 212, 255, 0.35);
}
.opt-pos-bar .opt-source-badge {
padding: 2px 6px;
font-size: 0.6rem;
}
html[data-theme="light"] .opt-source-badge--plain {
background: rgba(15, 23, 42, 0.06);
color: #5a6578;
border-color: rgba(15, 23, 42, 0.12);
}
html[data-theme="light"] .opt-source-badge--po {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.25);
}
html[data-theme="light"] .opt-source-badge--oo {
background: rgba(8, 145, 178, 0.1);
color: #0e7490;
border-color: rgba(8, 145, 178, 0.28);
}
.opt-pos-bar-meta {
font-size: 0.66rem;
color: #8b95b0;
@@ -4357,14 +4818,19 @@ html[data-theme="light"] .options-stats-pnl-summary .options-stat-item {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.options-page-wrap .opt-close-rule {
.options-page-wrap .opt-close-rule,
.options-page-wrap .opt-open-rule {
margin-top: 8px;
margin-bottom: 10px;
padding: 0;
border: 1px solid rgba(67, 82, 118, 0.55);
border-radius: 10px;
background: rgba(14, 19, 30, 0.58);
overflow: hidden;
}
.options-page-wrap .opt-open-rule {
margin-top: 4px;
}
.options-page-wrap .opt-close-rule summary {
display: flex;
align-items: center;
@@ -4691,7 +5157,10 @@ html[data-theme="light"] .options-chain-toolbar .btn-secondary.active,
html[data-theme="light"] .opt-uly-btn.active,
html[data-theme="light"] .opt-type-btn.active,
html[data-theme="light"] .opt-money-btn.active,
html[data-theme="light"] .opt-pos-tab.active {
html[data-theme="light"] .opt-pos-tab.active,
html[data-theme="light"] .opt-size-mode-chip.is-selected,
html[data-theme="light"] .opt-size-mode-chip.active,
html[data-theme="light"] .opt-size-mode-chip:has(input:checked) {
border-color: rgba(0, 95, 140, 0.45) !important;
color: #004d6e !important;
background: rgba(0, 110, 154, 0.14) !important;
@@ -4943,6 +5412,113 @@ html[data-theme="light"] .settings-account-summary {
box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06);
}
/* 期权复盘 · 亮色主题(覆盖面板内暗色默认变量) */
html[data-theme="light"] .options-review-wrap {
--or-section-bg: #fff;
--or-section-shadow: 0 1px 3px rgba(20, 34, 50, 0.06);
--or-border: #9eb0c4;
--or-border-soft: #c8d4e0;
--or-border-faint: #dce4ec;
--or-text: #142232;
--or-title: #142232;
--or-muted: #3a5068;
--or-filters-bg: #eef3f8;
--or-tile-bg: #f6f9fc;
--or-badge-bg: rgba(0, 110, 154, 0.1);
--or-accent-bg: rgba(0, 110, 154, 0.12);
--or-accent-fg: #004d6e;
--or-accent-border: rgba(0, 95, 140, 0.28);
--or-row-active-bg: rgba(0, 110, 154, 0.08);
--or-row-hover-bg: rgba(0, 110, 154, 0.06);
--or-modal-bg: #fff;
--or-modal-shadow: 0 12px 40px rgba(20, 34, 50, 0.18);
--or-backdrop: rgba(20, 34, 50, 0.45);
--or-img-bg: #eef3f8;
color: #142232;
}
html[data-theme="light"] .options-review-wrap .or-section {
background: #fff !important;
border-color: #9eb0c4 !important;
box-shadow: 0 1px 3px rgba(20, 34, 50, 0.06);
color: #142232 !important;
}
html[data-theme="light"] .options-review-wrap .or-section-title,
html[data-theme="light"] .options-review-wrap .or-detail-modal-head h3,
html[data-theme="light"] .options-review-wrap .or-page-head h2 {
color: #142232 !important;
}
html[data-theme="light"] .options-review-wrap .or-section-desc,
html[data-theme="light"] .options-review-wrap .muted,
html[data-theme="light"] .options-review-wrap .sub {
color: #3a5068 !important;
}
html[data-theme="light"] .options-review-wrap .or-filters {
background: #eef3f8 !important;
border-color: #c8d4e0 !important;
}
html[data-theme="light"] .options-review-wrap .or-kpi-tile,
html[data-theme="light"] .options-review-wrap .or-stat-card,
html[data-theme="light"] .options-review-wrap .or-detail-img-cell {
background: #f6f9fc !important;
border-color: #c8d4e0 !important;
}
html[data-theme="light"] .options-review-wrap .or-tab {
background: #fff !important;
color: #006e9a !important;
border-color: rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .options-review-wrap .or-tab.active {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border-color: rgba(0, 95, 140, 0.28) !important;
}
html[data-theme="light"] .options-review-wrap .or-step {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border-color: rgba(0, 95, 140, 0.28) !important;
}
html[data-theme="light"] .options-review-wrap .or-badge {
background: rgba(0, 110, 154, 0.1) !important;
color: #004d6e !important;
}
html[data-theme="light"] .options-review-wrap .or-detail-modal {
background: #fff !important;
color: #142232 !important;
border-color: #9eb0c4 !important;
box-shadow: 0 12px 40px rgba(20, 34, 50, 0.18);
}
html[data-theme="light"] .options-review-wrap .or-detail-backdrop {
background: rgba(20, 34, 50, 0.45) !important;
}
html[data-theme="light"] .options-review-wrap .options-strike-table thead th {
background: #eef3f8 !important;
color: #334155 !important;
border-bottom: 1px solid #c8d4e0 !important;
}
html[data-theme="light"] .options-review-wrap .options-strike-table th,
html[data-theme="light"] .options-review-wrap .options-strike-table td {
color: #142232 !important;
border-bottom-color: #d0dae4 !important;
}
html[data-theme="light"] .options-review-wrap .or-trades-table tr.or-row-active,
html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover {
background: rgba(0, 110, 154, 0.08) !important;
}
.pos-pnl-profit {
color: #7ee787;
}
@@ -93,8 +93,16 @@
});
}
function isOptionsReviewSlot(input) {
if (!input) return false;
if (input.classList && input.classList.contains("or-upload-input")) return true;
return !!(input.closest && input.closest("#or-upload-slots, #options-review-root"));
}
function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return;
// 期权复盘槽位由 options_review.js 处理,勿被合约复盘上传抢走
if (isOptionsReviewSlot(input)) return;
input.dataset.journalSlotBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
+200 -41
View File
@@ -14,6 +14,13 @@
moneyFilter: "all",
chainView: "list",
strikeExpandAll: false,
/** 环境 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED;链接口可热更新 */
askLiqFilter: root.dataset.askLiqFilter !== "0",
budgetBuffer: (function () {
const raw = root.dataset.budgetBuffer;
const n = raw != null && raw !== "" ? Number(raw) : NaN;
return !Number.isNaN(n) && n > 0 ? n : 0.95;
})(),
chain: panelCache.chain || null,
selectedInst: null,
orderQuote: null,
@@ -91,16 +98,17 @@
}
function parkOrderPanel() {
stopPendingOrdersPoll();
const panel = orderPanel();
const host = orderPanelHost();
// 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM
if (host && orderPanelHome && host.parentElement !== orderPanelHome) {
orderPanelHome.appendChild(host);
} else if (panel && host && panel.parentElement !== host) {
host.appendChild(panel);
// 弹窗挂到 body;关闭后收回原位,绝不插入期权链表格
if (panel && host && panel.parentElement !== host) host.appendChild(panel);
if (host) {
host.hidden = true;
host.setAttribute("aria-hidden", "true");
if (orderPanelHome && host.parentElement !== orderPanelHome) {
orderPanelHome.appendChild(host);
}
}
if (host) host.hidden = true;
if (panel) panel.style.display = "none";
const inline = document.querySelector(".opt-order-inline-row");
if (inline) inline.remove();
@@ -121,32 +129,27 @@
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') ||
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') ||
document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]');
if (!row) {
syncPickButtons(null);
return false;
}
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
r.classList.toggle("opt-row-selected", r === row);
r.classList.toggle("opt-row-selected", !!row && r === row);
});
syncPickButtons(instId);
const oldInline = document.querySelector(".opt-order-inline-row");
if (oldInline) oldInline.remove();
if (panel.parentElement !== host) host.appendChild(panel);
const tr = document.createElement("tr");
tr.className = "opt-order-inline-row";
const td = document.createElement("td");
td.colSpan = strikeTableColspan();
td.appendChild(host);
tr.appendChild(td);
row.after(tr);
// 挂到 body,避免被卡片 overflow 裁成「行内展开」
if (host.parentElement !== document.body) document.body.appendChild(host);
host.hidden = false;
host.setAttribute("aria-hidden", "false");
panel.style.display = "";
tr.scrollIntoView({ behavior: "smooth", block: "nearest" });
refreshPendingOrders();
startPendingOrdersPoll();
return true;
}
function closeOrderDialog() {
state.selectedInst = null;
state.orderQuote = null;
parkOrderPanel();
}
function fmtPendingAge(sec) {
if (sec == null || Number.isNaN(Number(sec))) return "—";
let s = Math.max(0, Math.round(Number(sec)));
@@ -279,6 +282,42 @@
const ethEl = document.getElementById("opt-eth-amount");
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
const radio = chip.querySelector('input[name="opt-size-mode"]');
chip.classList.toggle("is-selected", !!(radio && radio.checked));
chip.classList.toggle("active", !!(radio && radio.checked));
});
}
function hardenOrderAutofill() {
function looksLikeUsername(v) {
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
}
function harden(el) {
if (!el) return;
function wipe() {
if (looksLikeUsername(el.value)) el.value = "";
}
wipe();
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
setTimeout(wipe, 200);
setTimeout(wipe, 800);
setTimeout(wipe, 2000);
}
const note = document.getElementById("opt-signal-note");
harden(note);
[
"opt-sheets-amount",
"opt-eth-amount",
"opt-target-idx",
].forEach(function (id) {
harden(document.getElementById(id));
});
}
function quoteUrl(instId) {
@@ -341,21 +380,41 @@
return "";
}
function askLiqFilterOn() {
return !!state.askLiqFilter;
}
function hasAskLiquidity(c) {
if (!c) return false;
if (c.ask_estimated) return false;
const a = Number(c.ask);
const s = Number(c.ask_sz);
return Number.isFinite(a) && a > 0 && Number.isFinite(s) && s >= 1;
}
function syncAskLiqFilterFromChain(d) {
if (!d || d.ask_liq_filter_enabled == null) return;
state.askLiqFilter = !!d.ask_liq_filter_enabled;
root.dataset.askLiqFilter = state.askLiqFilter ? "1" : "0";
}
function countContractsForType(contracts) {
if (state.chainView === "t") {
return countStraddleStrikes(contracts);
}
return (contracts || []).filter(function (c) {
return c.opt_type === state.optType;
if (c.opt_type !== state.optType) return false;
if (askLiqFilterOn() && !hasAskLiquidity(c)) return false;
return true;
}).length;
}
function countStraddleStrikes(contracts) {
const strikes = new Set();
(contracts || []).forEach(function (c) {
if (c.strike != null) strikes.add(String(c.strike));
const rows = buildStraddleRows(contracts).filter(function (row) {
if (!askLiqFilterOn()) return true;
return hasAskLiquidity(row.call) || hasAskLiquidity(row.put);
});
return strikes.size;
return rows.length;
}
function buildStraddleRows(contracts) {
@@ -398,7 +457,11 @@
function filterStraddleRows(rows, indexPx) {
const atmStrike = findAtmStrike(rows, indexPx);
return rows.filter(function (row) {
return matchesStrikeRowFilter(row.strike, indexPx, atmStrike);
if (!matchesStrikeRowFilter(row.strike, indexPx, atmStrike)) return false;
if (askLiqFilterOn() && !hasAskLiquidity(row.call) && !hasAskLiquidity(row.put)) {
return false;
}
return true;
});
}
@@ -459,7 +522,10 @@
function filterChainContracts(contracts) {
return (contracts || []).filter(function (c) {
return c.opt_type === state.optType && matchesMoneyFilter(c.moneyness);
if (c.opt_type !== state.optType) return false;
if (!matchesMoneyFilter(c.moneyness)) return false;
if (askLiqFilterOn() && !hasAskLiquidity(c)) return false;
return true;
});
}
@@ -473,6 +539,28 @@
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function sourceText(p) {
const lab = (p && p.source_label) || "纯期权";
const src = (p && p.source) || "option";
let pid = p && p.source_plan_id;
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
pid = p.hedge_plan_target.plan_id;
}
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
return lab;
}
function sourceBadgeHtml(p) {
const src = (p && p.source) || "option";
const cls =
src === "options_options"
? "opt-source-badge opt-source-badge--oo"
: src === "perp_options"
? "opt-source-badge opt-source-badge--po"
: "opt-source-badge opt-source-badge--plain";
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
}
function expLabel(ms) {
try {
const dt = new Date(Number(ms));
@@ -485,6 +573,15 @@
}
}
function applyBudgetBuffer(raw) {
if (raw == null || raw === "") return;
const buf = Number(raw);
if (Number.isNaN(buf) || buf <= 0) return;
state.budgetBuffer = buf;
const el = document.getElementById("opt-budget-buf");
if (el) el.textContent = fmt(buf, 2);
}
function renderIndexLine() {
const idx = state.chain && state.chain.index_px;
const dte = state.chain && state.chain.chain_max_dte_days;
@@ -492,13 +589,37 @@
const el = document.getElementById("opt-chain-dte");
if (el) el.textContent = String(Math.round(dte));
}
if (state.chain && state.chain.budget_buffer != null) {
applyBudgetBuffer(state.chain.budget_buffer);
}
const line = document.getElementById("opt-index-line");
if (line) {
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
line.textContent =
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 默认显示全部 · 实值含平值 · 虚值=价外";
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外";
}
}
function pickNearestExpiry(exps) {
if (!exps || !exps.length) return "";
const now = Date.now();
let best = null;
let bestDelta = Infinity;
exps.forEach(function (e) {
const t = Number(e.exp_time);
if (!Number.isFinite(t)) return;
const delta = t - now;
if (delta < -60000) return;
if (delta < bestDelta) {
bestDelta = delta;
best = e;
}
});
if (best) return String(best.exp_time);
return String(exps[0].exp_time);
}
function renderExpiryOptions(preserveSelection) {
const sel = document.getElementById("opt-exp-select");
if (!sel) return;
@@ -513,6 +634,8 @@
});
if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) {
sel.value = prev;
} else if (exps.length) {
sel.value = pickNearestExpiry(exps);
}
}
@@ -810,7 +933,8 @@
if (!list.length) {
const label = moneyFilterLabel();
const suffix = label ? label : optTypeLabel(state.optType);
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "合约</td></tr>";
const liqTip = askLiqFilterOn() ? "(卖一深度≥1 时才显示,可在环境配置关闭筛选)" : "";
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "合约" + liqTip + "</td></tr>";
state.selectedInst = null;
return;
}
@@ -869,8 +993,10 @@
const atmStrike = findAtmStrike(rows, indexPx);
let matchedSelected = false;
rows.forEach(function (row) {
const call = row.call;
const put = row.put;
const callRaw = row.call;
const putRaw = row.put;
const call = callRaw && (!askLiqFilterOn() || hasAskLiquidity(callRaw)) ? callRaw : null;
const put = putRaw && (!askLiqFilterOn() || hasAskLiquidity(putRaw)) ? putRaw : null;
const combined = straddleAskPerUnit(call && call.ask, put && put.ask);
const tr = document.createElement("tr");
tr.className = "opt-strike-row opt-strike-row-t";
@@ -1048,6 +1174,7 @@
panelCache.chain = d;
panelCache.underlying = uly;
panelCache.optType = state.optType;
syncAskLiqFilterFromChain(d);
if (!soft) {
state.selectedInst = null;
resetMoneyFilterToAll();
@@ -1084,16 +1211,16 @@
async function openPosition() {
if (!state.selectedInst) {
alert("请先选择合约");
return;
return false;
}
const q = state.orderQuote;
if (!q || !q.ok || !q.can_open) {
alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓");
return;
return false;
}
if (q.sizing && q.sizing.ok === false) {
alert(q.sizing.msg || "张数无效");
return;
return false;
}
const btn = document.getElementById("opt-open-btn");
btn.disabled = true;
@@ -1114,7 +1241,7 @@
const tgt = parseFloat(tgtRaw);
if (!Number.isFinite(tgt) || tgt <= 0) {
alert("目标位无效");
return;
return false;
}
body.target_index = tgt;
}
@@ -1124,16 +1251,19 @@
body: JSON.stringify(body),
});
const msgEl = document.getElementById("opt-order-msg");
msgEl.textContent = d.ok ? "下单已提交,右侧可查看/撤销未成交委托" : (d.msg || "失败");
msgEl.textContent = d.ok ? "下单已提交,可在「当前委托」查看/撤销" : (d.msg || "失败");
msgEl.classList.toggle("opt-error", !d.ok);
if (d.ok) {
refreshPendingOrders();
startPendingOrdersPoll();
refreshAllPositions();
if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot();
} else {
alert(d.msg || "下单失败");
closeOrderDialog();
setOptionsPosTab("pending");
return true;
}
alert(d.msg || "下单失败");
return false;
} finally {
const latest = state.orderQuote;
btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false));
@@ -1158,11 +1288,14 @@
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</div>" +
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div></div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
@@ -1320,6 +1453,7 @@
'<span class="opt-pos-bar-id-group">' +
'<strong class="opt-pos-bar-title" title="' + inst + '">' + inst + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</span>" +
'<span class="opt-pos-bar-meta">行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张</span>" +
"</span>" +
@@ -1561,6 +1695,10 @@
if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer();
}
if (tab === "pending") {
refreshPendingOrders();
startPendingOrdersPoll();
}
}
function bindOptionsPosTabs() {
@@ -1934,6 +2072,7 @@
}
function bootOptionsPanel() {
applyBudgetBuffer(state.budgetBuffer);
updateSizeInputs();
syncMoneyFilterButtons();
syncChainViewUI();
@@ -2021,6 +2160,7 @@
});
}
bindOptionsPosTabs();
hardenOrderAutofill();
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
r.addEventListener("change", function () {
@@ -2029,6 +2169,25 @@
});
});
function bindOrderDialogChrome() {
const host = orderPanelHost();
const closeBtn = document.getElementById("opt-order-close-btn");
const cancelBtn = document.getElementById("opt-order-cancel-btn");
if (closeBtn) closeBtn.addEventListener("click", closeOrderDialog);
if (cancelBtn) cancelBtn.addEventListener("click", closeOrderDialog);
if (host) {
host.addEventListener("click", function (ev) {
if (ev.target === host) closeOrderDialog();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
const h = orderPanelHost();
if (h && !h.hidden) closeOrderDialog();
});
}
bindOrderDialogChrome();
["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
+26 -1
View File
@@ -38,6 +38,28 @@
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function sourceText(p) {
const lab = (p && p.source_label) || "纯期权";
const src = (p && p.source) || "option";
let pid = p && p.source_plan_id;
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
pid = p.hedge_plan_target.plan_id;
}
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
return lab;
}
function sourceBadgeHtml(p) {
const src = (p && p.source) || "option";
const cls =
src === "options_options"
? "opt-source-badge opt-source-badge--oo"
: src === "perp_options"
? "opt-source-badge opt-source-badge--po"
: "opt-source-badge opt-source-badge--plain";
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
}
function pnlCls(upl, hub) {
if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
@@ -147,10 +169,13 @@
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</div>" +
headActions +
"</div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
+334 -98
View File
@@ -67,23 +67,74 @@
return s;
}
function closeReasonLabel(r) {
var map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期",
expiry: "到期",
manual: "人工结束",
partial_fail: "半腿失败",
cancelled: "已取消",
tp: "止盈",
sl: "止损",
};
var key = String(r || "").trim();
if (!key) return "—";
return map[key] || key;
}
function legRoleLabel(role) {
var map = {
perp: "永续腿",
option_hedge: "保险期权",
option_a: "期期腿A",
option_b: "期期腿B",
};
var key = String(role || "").trim();
if (!key) return "—";
return map[key] || key;
}
function tradeTitle(t) {
if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || "—";
return (
(t.underlying || "") +
(t.direction ? " " + t.direction : "") +
(t.plan_close_reason ? " · " + t.plan_close_reason : "")
(t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "")
);
}
function pnlStyle(v) {
function pnlClass(v) {
var n = Number(v);
if (n > 0) return "color:#3dd68c";
if (n < 0) return "color:#f07178";
if (n > 0) return "pos-pnl-profit";
if (n < 0) return "pos-pnl-loss";
return "";
}
function resultClass(tag) {
var t = String(tag || "").trim();
if (t === "盈利") return "pos-pnl-profit";
if (t === "亏损") return "pos-pnl-loss";
return "";
}
function tradeContractLabel(t) {
if (!t) return "—";
if (t.source_type === "option_spot") return t.inst_id || t.underlying || "—";
return t.underlying || "—";
}
function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, "");
@@ -98,12 +149,12 @@
p.set("source_type", activeSource);
var uly = ($("or-filter-uly") || {}).value || "";
var opt = ($("or-filter-opt") || {}).value || "";
var strategy = (($("or-filter-strategy") || {}).value || "").trim();
var q = (($("or-filter-q") || $("or-filter-strategy") || {}).value || "").trim();
var from = ($("or-filter-from") || {}).value || "";
var to = ($("or-filter-to") || {}).value || "";
if (uly) p.set("underlying", uly);
if (opt) p.set("opt_type", opt);
if (strategy) p.set("strategy_tag", strategy);
if (q) p.set("q", q);
if (from) p.set("closed_from", from.replace("T", " ") + ":00");
if (to) p.set("closed_to", to.replace("T", " ") + ":00");
if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1");
@@ -285,10 +336,10 @@
if (!tbody) return;
var wrap = beginListLoad("or-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载中…</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载中…</td></tr>';
}
var p = baseQs();
p.set("reviewed", "0");
// 交易记录保留已复盘条目,不再只显示待复盘
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE));
if (!doSync) p.set("sync", "0");
@@ -299,7 +350,7 @@
.then(function (data) {
if (doSync) setSyncStatus("本地记录已加载");
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
@@ -311,7 +362,7 @@
tradesCache = {};
if (!rows.length) {
tbody.innerHTML =
'<tr><td colspan="6" class="muted">暂无待复盘记录</td></tr>';
'<tr><td colspan="7" class="muted">暂无交易记录</td></tr>';
endListLoad(wrap);
return;
}
@@ -319,6 +370,17 @@
.map(function (t) {
tradesCache[t.id] = t;
var active = currentTradeId === t.id ? " or-row-active" : "";
var reviewed = !!t.reviewed;
var actionBtn = reviewed
? '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">编辑</button>'
: '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">复盘</button>';
var badgeExtra = reviewed
? ' <span class="or-badge" style="background:rgba(61,214,140,.2)">已复盘</span>'
: "";
return (
'<tr class="or-trade-row' +
active +
@@ -327,26 +389,29 @@
'">' +
"<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) +
"</span></td>" +
"</span>" +
badgeExtra +
"</td>" +
"<td>" +
escapeHtml(tradeTitle(t)) +
"</td>" +
'<td style="' +
pnlStyle(t.realized_pnl_total) +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px">' +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
"<br>" +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") +
"</td>" +
"<td>" +
fmtHold(t.hold_seconds) +
"</td>" +
'<td><button type="button" class="btn or-review-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">复盘</button> ' +
"<td>" +
actionBtn +
" " +
'<button type="button" class="btn-secondary or-hide-btn" data-id="' +
t.id +
'" style="font-size:.72rem;padding:2px 8px">删除</button></td>' +
@@ -371,7 +436,7 @@
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
@@ -384,7 +449,7 @@
if (!tbody) return;
var wrap = beginListLoad("or-reviewed-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载中…</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载中…</td></tr>';
}
var p = baseQs();
p.set("reviewed", "1");
@@ -397,7 +462,7 @@
})
.then(function (data) {
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
@@ -408,13 +473,16 @@
var rows = data.trades || [];
reviewedCache = {};
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">暂无复盘记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">暂无复盘记录</td></tr>';
endListLoad(wrap);
return;
}
tbody.innerHTML = rows
.map(function (t) {
reviewedCache[t.id] = t;
var entry = t.entry || {};
var direction = t.direction_view || entry.direction_view || "";
var entryLogic = t.entry_logic || entry.entry_logic || "";
return (
'<tr class="or-reviewed-row" data-id="' +
t.id +
@@ -423,20 +491,37 @@
escapeHtml(t.source_label || t.source_type) +
"</span></td>" +
"<td>" +
escapeHtml(tradeTitle(t)) +
escapeHtml(tradeContractLabel(t)) +
"</td>" +
'<td style="' +
pnlStyle(t.realized_pnl_total) +
"<td>" +
escapeHtml(direction || "—") +
"</td>" +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.closed_at || "—") +
"</td>" +
"<td>" +
escapeHtml(fmtHold(t.hold_seconds)) +
"</td>" +
"<td>" +
escapeHtml(t.strategy_tag || "—") +
"</td>" +
"<td>" +
escapeHtml(entryLogic || "—") +
"</td>" +
'<td class="' +
resultClass(t.result_tag) +
'">' +
escapeHtml(t.result_tag || "—") +
"</td>" +
'<td class="muted" style="font-size:12px">' +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.reviewed_at || "—") +
"</td>" +
"</tr>"
@@ -451,25 +536,51 @@
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="6" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
function hideLightbox() {
var box = $("or-img-lightbox");
if (box) box.hidden = true;
var img = $("or-img-lightbox-img");
if (img) img.src = "";
}
function showLightbox(src) {
var url = String(src || "").trim();
if (!url) return;
var box = $("or-img-lightbox");
var img = $("or-img-lightbox-img");
if (box && img) {
img.src = url;
box.hidden = false;
return;
}
if (typeof global.showImage === "function") {
global.showImage(url);
} else if (typeof window.showImage === "function") {
window.showImage(url);
} else {
global.open(url, "_blank");
}
}
function hideDetail() {
var panel = $("or-detail-panel");
if (panel) panel.classList.add("hidden");
hideLightbox();
var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true;
}
function openDetail(tradeId) {
var panel = $("or-detail-panel");
if (!panel) return;
panel.classList.remove("hidden");
var backdrop = $("or-detail-backdrop");
if (!backdrop) return;
backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = "";
panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
@@ -487,6 +598,91 @@
});
}
function optionsJournalImgSrc(file) {
var name = String(file || "").trim().replace(/\\/g, "/");
var slash = name.lastIndexOf("/");
if (slash >= 0) name = name.slice(slash + 1);
if (!name) return "";
// options_journal_* 在子目录;误走合约上传的 journal_* 在 static/images 根目录
var base =
name.toLowerCase().indexOf("options_journal_") === 0
? "/static/images/options_journal/"
: "/static/images/";
return base + encodeURIComponent(name);
}
function renderDetailImages(images) {
var imagesHost = $("or-detail-images");
if (!imagesHost) return;
var byTf = {};
(images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
var file = String((img && img.file) || "").trim();
if (!file) return;
var key = tf || "_";
byTf[key] = file;
});
var order = ["5m", "15m", "1h", "4h"];
var keys = order.slice();
Object.keys(byTf).forEach(function (k) {
if (keys.indexOf(k) < 0) keys.push(k);
});
var cells = keys
.map(function (tf) {
var file = byTf[tf];
if (!file) {
if (order.indexOf(tf) < 0) return "";
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
escapeHtml(tf) +
"</span>" +
'<div class="or-detail-img-miss">未上传</div>' +
"</div>"
);
}
var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf);
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
label +
"</span>" +
'<img class="or-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" data-src="' +
src +
'" loading="lazy">' +
"</div>"
);
})
.filter(Boolean);
if (!cells.length) {
imagesHost.innerHTML = '<div class="muted">无截图</div>';
return;
}
imagesHost.innerHTML = cells.join("");
imagesHost.querySelectorAll("img").forEach(function (img) {
img.addEventListener("error", function () {
var cell = img.closest(".or-detail-img-cell");
if (!cell) return;
var label = cell.querySelector(".or-detail-img-label");
var tf = label ? label.textContent : "截图";
cell.innerHTML =
'<span class="or-detail-img-label">' +
escapeHtml(tf) +
"</span>" +
'<div class="or-detail-img-miss">文件缺失或无法加载</div>';
});
img.addEventListener("click", function () {
var src = img.getAttribute("data-src") || img.src;
showLightbox(src);
});
});
}
function renderDetail(t) {
var e = t.entry || {};
reviewedCache[t.id] = t;
@@ -502,8 +698,8 @@
["合约/计划", tradeTitle(t)],
["盈亏", fmtPnl(t.realized_pnl_total)],
["持有", fmtHold(t.hold_seconds)],
["开仓", t.opened_at || "—"],
["平仓", t.closed_at || "—"],
["开仓时间", t.opened_at || "—"],
["平仓时间", t.closed_at || "—"],
["策略", e.strategy_tag || "—"],
["方向", e.direction_view || "—"],
["结果", e.result_tag || "—"],
@@ -517,10 +713,20 @@
}
meta.innerHTML = cells
.map(function (pair) {
var cls = "";
if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") {
cls = pnlClass(t.realized_pnl_total);
if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp);
if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options);
} else if (pair[0] === "结果") {
cls = resultClass(e.result_tag);
}
return (
"<div><div class=\"muted\" style=\"font-size:11px\">" +
escapeHtml(pair[0]) +
"</div><div>" +
'</div><div class="' +
cls +
'">' +
escapeHtml(pair[1]) +
"</div></div>"
);
@@ -540,13 +746,15 @@
.map(function (leg) {
return (
"<tr><td>" +
escapeHtml(leg.leg_role || "") +
escapeHtml(legRoleLabel(leg.leg_role)) +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
"</td><td class=\"" +
pnlClass(leg.realized_pnl) +
"\">" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(leg.close_reason || "") +
escapeHtml(closeReasonLabel(leg.close_reason)) +
"</td></tr>"
);
})
@@ -559,64 +767,25 @@
var imagesHost = $("or-detail-images");
if (imagesHost) {
var images = e.images || [];
if (!images.length) {
imagesHost.innerHTML = '<div class="muted">无截图</div>';
} else {
imagesHost.innerHTML = images
.map(function (img) {
var file = String(img.file || "").trim();
if (!file) return "";
var src = "/static/images/options_journal/" + encodeURIComponent(file).replace(/%2F/g, "/");
var label = escapeHtml(img.tf || "截图");
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
label +
"</span>" +
'<img class="or-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" data-src="' +
src +
'">' +
"</div>"
);
})
.join("");
imagesHost.querySelectorAll("img").forEach(function (img) {
img.addEventListener("click", function () {
if (typeof global.showImage === "function") {
global.showImage(img.getAttribute("data-src"));
} else {
global.open(img.getAttribute("data-src"), "_blank");
}
});
});
}
renderDetailImages(e.images || []);
}
}
function renderGroup(title, items) {
if (!items || !items.length) {
return (
'<div class="or-stat-card"><div class="muted">' +
title +
'</div><div class="muted">无数据</div></div>'
);
}
if (!items || !items.length) return "";
var lines = items
.slice(0, 8)
.map(function (g) {
var keyLabel =
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
return (
'<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px">' +
"<span>" +
escapeHtml(g.key) +
'<div class="or-stat-row">' +
'<span class="or-stat-key">' +
escapeHtml(keyLabel) +
" · " +
g.count +
"笔</span>" +
"<span>" +
'<span class="or-stat-val">' +
fmtPnl(g.pnl_sum) +
" / 胜" +
(g.win_rate || 0) +
@@ -626,7 +795,7 @@
})
.join("");
return (
'<div class="or-stat-card"><div style="font-weight:600;margin-bottom:6px">' +
'<div class="or-stat-card"><div class="or-stat-card-title">' +
title +
"</div>" +
lines +
@@ -654,23 +823,31 @@
["平均持有", fmtHold(k.avg_hold_sec)],
]
.map(function (pair) {
var cls = "";
if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum);
if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl);
return (
'<div><div class="muted" style="font-size:12px">' +
'<div class="or-kpi-tile"><div class="or-kpi-label">' +
pair[0] +
'</div><div style="font-weight:600">' +
'</div><div class="or-kpi-value' +
(cls ? " " + cls : "") +
'">' +
pair[1] +
"</div></div>"
);
})
.join("");
groups.innerHTML = [
var html = [
renderGroup("按类型", data.by_source_type),
renderGroup("按标的", data.by_underlying),
renderGroup("按策略", data.by_strategy),
renderGroup("对冲结束原因", data.by_close_reason),
renderGroup("持有周期", data.by_hold_bucket),
renderGroup("Call/Put", data.by_opt_type),
].join("");
]
.filter(Boolean)
.join("");
groups.innerHTML = html || '<div class="muted" style="font-size:.76rem">暂无分组数据</div>';
})
.catch(function () {});
}
@@ -849,12 +1026,13 @@
($("or-f-inst") || {}).value =
t.source_type === "option_spot"
? t.inst_id || ""
: (t.source_label || "") + (t.plan_close_reason ? " · " + t.plan_close_reason : "");
: (t.source_label || "") +
(t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "");
($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total);
($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
setSelectValue($("or-f-strategy"), e.strategy_tag || "");
setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t));
($("or-f-exit") || {}).value = e.exit_reason || t.plan_close_reason || "";
($("or-f-exit") || {}).value = e.exit_reason || closeReasonLabel(t.plan_close_reason) || "";
($("or-f-followed") || {}).value = e.followed_plan || "";
setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total));
setSelectValue($("or-f-entry"), e.entry_logic || "");
@@ -883,7 +1061,21 @@
);
if (hidden && img.file) {
hidden.value = img.file;
if (status) status.textContent = "已有 " + img.file;
if (status) {
var src = optionsJournalImgSrc(img.file);
status.innerHTML =
'已有 <a href="' +
src +
'" target="_blank" rel="noopener">' +
escapeHtml(img.file) +
'</a><br><img class="or-slot-thumb" src="' +
src +
'" alt="' +
escapeHtml(img.tf || "") +
'" loading="lazy">';
status.className =
"journal-upload-status or-upload-status journal-upload-status--ok";
}
}
});
@@ -896,13 +1088,13 @@
.map(function (leg) {
return (
"<tr><td>" +
escapeHtml(leg.leg_role || "") +
escapeHtml(legRoleLabel(leg.leg_role)) +
"</td><td>" +
escapeHtml(leg.inst_id || leg.symbol || "") +
"</td><td>" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
escapeHtml(leg.close_reason || "") +
escapeHtml(closeReasonLabel(leg.close_reason)) +
"</td></tr>"
);
})
@@ -1068,9 +1260,34 @@
if (detailEdit) {
detailEdit.addEventListener("click", function () {
var id = Number(detailEdit.getAttribute("data-id") || 0);
if (id) openJournalForm(id);
if (id) {
hideDetail();
openJournalForm(id);
}
});
}
var detailBackdrop = $("or-detail-backdrop");
if (detailBackdrop) {
detailBackdrop.addEventListener("click", function (ev) {
if (ev.target === detailBackdrop) hideDetail();
});
}
var lightbox = $("or-img-lightbox");
if (lightbox) {
lightbox.addEventListener("click", function () {
hideLightbox();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
var lb = $("or-img-lightbox");
if (lb && !lb.hidden) {
hideLightbox();
return;
}
var bd = $("or-detail-backdrop");
if (bd && !bd.hidden) hideDetail();
});
["or-filter-uly", "or-filter-opt", "or-include-hedge-legs"].forEach(function (id) {
var el = $(id);
if (el) {
@@ -1081,7 +1298,7 @@
});
}
});
["or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) {
["or-filter-q", "or-filter-strategy", "or-filter-from", "or-filter-to"].forEach(function (id) {
var el = $(id);
if (el) {
el.addEventListener("change", function () {
@@ -1094,9 +1311,28 @@
bindUploadSlots();
hideJournalForm();
hideDetail();
hardenSearchAutofill();
setActiveTab("option_spot");
}
function hardenSearchAutofill() {
var qEl = $("or-filter-q");
if (!qEl) return;
function wipe() {
qEl.value = "";
}
wipe();
qEl.addEventListener("focus", function () {
qEl.removeAttribute("readonly");
});
qEl.addEventListener("blur", function () {
if (!qEl.value) qEl.setAttribute("readonly", "readonly");
});
// 密码管理器常延后写入用户名,加载后再清两次
setTimeout(wipe, 200);
setTimeout(wipe, 800);
}
global.OptionsReview = {
init: init,
openJournalForm: openJournalForm,
+43
View File
@@ -342,4 +342,47 @@
}
});
}
function hardenAmountAutofill(ids) {
ids.forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
function wipe() {
const v = String(el.value || "").trim();
if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = "";
}
wipe();
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
setTimeout(wipe, 200);
setTimeout(wipe, 800);
setTimeout(wipe, 2000);
});
}
// 全部划转/兑换前去掉 readonly,避免写不进数量
["opt-set-swap-all-btn", "opt-set-int-all-btn", "opt-set-cross-all-btn"].forEach(function (btnId) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener(
"click",
function () {
const map = {
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
"opt-set-cross-all-btn": "opt-set-cross-amount",
};
const input = document.getElementById(map[btnId]);
if (input) input.removeAttribute("readonly");
},
true
);
});
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount", "opt-set-cross-amount"]);
})();
+14
View File
@@ -58,6 +58,7 @@ HOT_RELOAD_EXACT = frozenset({
"RISK_COOLING_HOURS_MANUAL",
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE",
"KEY_AUTO_ORDER_ENABLED",
"TRADE_DIRECTION_RESTRICT_ENABLED",
@@ -83,11 +84,20 @@ HOT_RELOAD_EXACT = frozenset({
"APP_AUTH_DISABLED",
"WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_OO_BIAS_RATIO",
"HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
@@ -116,6 +126,10 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("long_only", "仅做多"),
("short_only", "仅做空"),
),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
}
_SELECT_ALIASES: dict[str, dict[str, str]] = {
+92 -2
View File
@@ -67,7 +67,11 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""),
(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"切点前禁止新开仓",
"默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
("KEY_AUTO_ORDER_ENABLED", "关键位自动单", "关闭后箱体/收敛/斐波等不自动开仓;支撑阻力提醒仍可用"),
@@ -90,6 +94,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
@@ -126,6 +131,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
),
],
}
@@ -134,14 +144,50 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = {
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "默认 true;关闭后隐藏永期 Tab,不可测算/开仓"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "默认 true;关闭后隐藏期期 Tab,不可测算/开仓"),
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算按比例分两腿;sheets=先算同张数总张数(2n)再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划(期期可用预算=交易户×本比例);与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"对冲与期权互斥门控",
"默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平",
),
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效(不会自动平)",
),
],
}
@@ -152,7 +198,17 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_COOLING_HOURS_MANUAL": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
}
@@ -167,6 +223,10 @@ def _effective_env_value(key: str, file_values: dict[str, str], schema_default:
return _RUNTIME_ENV_DEFAULTS.get(key, "")
def _env_truthy(raw: str) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "yes", "on")
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for group in parse_env_example_schema(example_path):
@@ -185,6 +245,13 @@ def _build_field(
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default)
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
)
if _env_truthy(manual):
val = "false"
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key)
@@ -296,3 +363,26 @@ def validate_env_ui_updates(
)
groups.append({"title": sec["title"], "fields": fields})
return validate_env_updates(groups, updates)
def coerce_hedge_partial_close_with_manual(
clean: dict[str, str],
*,
env_path: str = "",
) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
out = dict(clean or {})
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
if manual is None and env_path:
try:
from lib.env.env_file_lib import env_get_all, read_env_lines
file_vals = env_get_all(read_env_lines(env_path))
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
)
except Exception:
manual = "true"
if _env_truthy(str(manual or "")):
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
return out
+119 -2
View File
@@ -962,6 +962,119 @@ def cancel_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
return {"ok": False, "msg": _okx_trade_error_message(e)}
def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str, Any]:
"""查询单笔期权订单状态."""
inst_id = (inst_id or "").strip()
ord_id = (ord_id or "").strip()
if not inst_id or not ord_id:
return {"ok": False, "msg": "缺少 inst_id 或 ord_id"}
try:
resp = ex.private_get_trade_order({"instId": inst_id, "ordId": ord_id})
data = (resp or {}).get("data") or []
if not data or not isinstance(data[0], dict):
return {"ok": False, "msg": "订单不存在或暂不可查", "raw": resp}
o = data[0]
sz = _safe_float(o.get("sz"))
acc = _safe_float(o.get("accFillSz"))
if acc is None:
acc = _safe_float(o.get("fillSz")) or 0.0
avg = _safe_float(o.get("avgPx"))
fill_px = _safe_float(o.get("fillPx"))
if avg is None or avg <= 0:
avg = fill_px
state = str(o.get("state") or "").strip().lower()
return {
"ok": True,
"ord_id": str(o.get("ordId") or ord_id),
"inst_id": str(o.get("instId") or inst_id),
"state": state,
"sz": int(sz) if sz is not None else None,
"acc_fill_sz": float(acc or 0),
"avg_px": avg,
"side": str(o.get("side") or "").lower(),
"ord_type": str(o.get("ordType") or ""),
"raw": o,
}
except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e)}
def wait_option_order_full_fill(
ex: ccxt.okx,
*,
inst_id: str,
ord_id: str,
need_sheets: int,
timeout_sec: float = 12.0,
poll_sec: float = 0.35,
cancel_on_timeout: bool = True,
) -> dict[str, Any]:
"""轮询至完全成交;超时则撤单.未完全成交返回 ok=False."""
need = max(1, int(need_sheets))
deadline = time.time() + max(0.5, float(timeout_sec))
last: dict[str, Any] = {}
while time.time() < deadline:
last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id)
if not last.get("ok"):
time.sleep(max(0.15, float(poll_sec)))
continue
acc = float(last.get("acc_fill_sz") or 0)
state = str(last.get("state") or "")
if acc + 1e-9 >= need or state == "filled":
if acc + 1e-9 < need:
return {
"ok": False,
"msg": f"订单已结束但成交不足 {need} 张(已成 {acc:g})",
"filled_sheets": acc,
"order": last,
}
return {
"ok": True,
"filled_sheets": int(round(acc)),
"avg_px": last.get("avg_px"),
"state": state,
"order": last,
}
if state in ("canceled", "cancelled", "mmp_canceled"):
if acc + 1e-9 >= need:
return {
"ok": True,
"filled_sheets": int(round(acc)),
"avg_px": last.get("avg_px"),
"state": state,
"order": last,
}
return {
"ok": False,
"msg": f"订单已撤销且未完全成交(已成 {acc:g}/{need})",
"filled_sheets": acc,
"order": last,
}
time.sleep(max(0.15, float(poll_sec)))
if cancel_on_timeout:
cancel_option_order(ex, inst_id=inst_id, ord_id=ord_id)
time.sleep(0.25)
last = fetch_option_order(ex, inst_id=inst_id, ord_id=ord_id)
acc = float((last or {}).get("acc_fill_sz") or 0) if (last or {}).get("ok") else 0.0
if acc + 1e-9 >= need:
return {
"ok": True,
"filled_sheets": int(round(acc)),
"avg_px": (last or {}).get("avg_px"),
"state": (last or {}).get("state"),
"order": last,
"timed_out": True,
}
return {
"ok": False,
"msg": f"等待成交超时({float(timeout_sec):g}s),已撤未成交部分;已成 {acc:g}/{need}",
"filled_sheets": acc,
"order": last,
"timed_out": True,
}
def place_option_limit_order(
ex: ccxt.okx,
*,
@@ -973,12 +1086,16 @@ def place_option_limit_order(
tick_sz: Any = None,
reduce_only: bool = False,
pos_side: str | None = None,
ord_type: str = "limit",
) -> dict[str, Any]:
side_l = (side or "").lower()
if side_l not in ("buy", "sell"):
return {"ok": False, "msg": "side 必须为 buy 或 sell"}
if sheets < 1:
return {"ok": False, "msg": "张数至少为 1"}
ot = (ord_type or "limit").strip().lower()
if ot not in ("limit", "ioc", "fok", "post_only"):
return {"ok": False, "msg": f"不支持的 ordType: {ord_type}"}
px = round_option_px(float(price), tick_sz, side_l)
if px <= 0:
return {"ok": False, "msg": "价格无效"}
@@ -986,7 +1103,7 @@ def place_option_limit_order(
"instId": inst_id,
"tdMode": td_mode,
"side": side_l,
"ordType": "limit",
"ordType": ot,
"px": format_option_px(px, tick_sz),
"sz": str(int(sheets)),
}
@@ -998,7 +1115,7 @@ def place_option_limit_order(
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {"ok": True, "data": data[0], "raw": resp, "px": px}
return {"ok": True, "data": data[0], "raw": resp, "px": px, "ord_type": ot}
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp, "px": px}
except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e), "px": px}
@@ -0,0 +1,86 @@
"""对冲计划与单独期权开仓互斥门控.
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
def _env_bool(key: str, default: bool = False) -> bool:
v = (os.getenv(key) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on")
def mutual_exclusive_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
"""若应拦截单独开期权,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
try:
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
init_hedge_plan_tables(conn)
if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return None
return None
def _pos_nonzero(raw: dict[str, Any]) -> bool:
try:
return abs(float(raw.get("pos") or 0)) > 1e-12
except (TypeError, ValueError):
return False
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
if not raw_positions:
return False
from lib.instance.instance_dashboard_lib import _resolve_options_source
for p in raw_positions:
if not isinstance(p, dict) or not _pos_nonzero(p):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
source, _, _ = _resolve_options_source(conn, inst)
if source == "option":
return True
return False
def block_hedge_plan_start_msg(
conn: Any,
*,
fetch_positions: Optional[Callable[[Any], Any]] = None,
exchange: Any = None,
raw_positions: list[dict[str, Any]] | None = None,
) -> Optional[str]:
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
rows = raw_positions
if rows is None:
if fetch_positions is None or exchange is None:
return None
try:
rows = fetch_positions(exchange) or []
except Exception:
return None
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return None
return None
+244
View File
@@ -86,6 +86,227 @@ def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
return math.floor(raw * scale + 1e-12) / scale
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
"""单张权利金(USDC) = 卖一价 × ct_mult."""
a = _f(ask)
if a is None or a <= 0:
return 0.0
return float(a) * float(ct_mult or 0.01)
def resolve_oo_budget_usdc(
*,
trading_usdc: Any,
trade_budget_usdc: Any,
buffer_ratio: Any = 0.95,
) -> dict[str, Any]:
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
import math
trading = _f(trading_usdc)
cap = _f(trade_budget_usdc)
buf = _f(buffer_ratio)
if buf is None or buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
trade_cap = None if cap is None else max(0.0, float(cap))
if trading_cap is None and trade_cap is None:
return {
"ok": False,
"budget_usdc": 0.0,
"trading_cap": None,
"trade_budget_cap": None,
"buffer_ratio": float(buf),
"msg": "缺少交易户余额与单笔预算",
}
if trading_cap is None:
budget = float(trade_cap or 0.0)
elif trade_cap is None:
budget = float(trading_cap)
else:
budget = min(float(trading_cap), float(trade_cap))
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
return {
"ok": budget > 0,
"budget_usdc": budget,
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
"buffer_ratio": float(buf),
"msg": "" if budget > 0 else "可用预算为 0",
}
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
import math
n = max(0, int(sheets))
depth = _f(ask_sz)
if depth is None:
return n
if depth <= 0:
return 0
return min(n, int(math.floor(float(depth) + 1e-12)))
def _normalize_oo_sheets_mode(mode: str) -> str:
m = (mode or "same_sheets").strip().lower()
if m in ("long_bias", "bias_long", "long", "做多"):
return "long_bias"
if m in ("short_bias", "bias_short", "short", "做空"):
return "short_bias"
# 旧「均分」兼容:按预算 50/50(页面已移除)
if m in ("split", "equal_budget", "split_budget", "均分"):
return "split_budget"
return "same_sheets"
def _normalize_oo_bias_split_by(raw: Any) -> str:
v = str(raw or "budget").strip().lower()
if v in ("sheets", "qty", "quantity", "张数"):
return "sheets"
return "budget"
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
try:
r = float(raw)
except (TypeError, ValueError):
r = float(default)
if r <= 0 or r >= 1:
r = float(default)
return r
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
a = (opt_type_a or "").strip().upper()
b = (opt_type_b or "").strip().upper()
if a.startswith("C"):
a = "C"
elif a.startswith("P"):
a = "P"
if b.startswith("C"):
b = "C"
elif b.startswith("P"):
b = "P"
if {a, b} != {"C", "P"}:
return None, None, "做多/做空需一腿 Call、一腿 Put"
call_side = "a" if a == "C" else "b"
put_side = "b" if call_side == "a" else "a"
return call_side, put_side, ""
def suggest_oo_sheets(
*,
mode: str,
budget_usdc: float,
ask_a: float,
ct_mult_a: float = 0.01,
ask_sz_a: Any = None,
opt_type_a: str = "",
ask_b: float,
ct_mult_b: float = 0.01,
ask_sz_b: Any = None,
opt_type_b: str = "",
bias_split_by: str = "budget",
bias_ratio: float = 0.7,
) -> dict[str, Any]:
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
import math
m = _normalize_oo_sheets_mode(mode)
split_by = _normalize_oo_bias_split_by(bias_split_by)
ratio = _clamp_oo_bias_ratio(bias_ratio)
budget = max(0.0, float(budget_usdc or 0.0))
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
"ok": False,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
if budget <= 0:
return _fail("可用预算为 0")
if cost_a <= 0 or cost_b <= 0:
return _fail("缺少有效卖一价,无法建议张数")
pair = cost_a + cost_b
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
n_same = min(
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
)
if m == "same_sheets":
n_a = n_same
n_b = n_same
elif m == "split_budget":
half = budget / 2.0
n_a = int(math.floor(half / cost_a + 1e-12))
n_b = int(math.floor(half / cost_b + 1e-12))
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
else:
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
if err:
return _fail(err)
major_is_call = m == "long_bias"
if split_by == "sheets":
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
total = int(n_same) * 2
if total < 2:
return _fail("同张数总规模不足 2,无法按比例拆分")
major_n = int(round(total * ratio))
major_n = max(1, min(major_n, total - 1))
minor_n = total - major_n
n_call = major_n if major_is_call else minor_n
n_put = minor_n if major_is_call else major_n
else:
maj_budget = budget * ratio
min_budget = budget * (1.0 - ratio)
cost_call = cost_a if call_side == "a" else cost_b
cost_put = cost_b if call_side == "a" else cost_a
if major_is_call:
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
else:
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_a = n_call if call_side == "a" else n_put
n_b = n_put if call_side == "a" else n_call
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
prem = cost_a * n_a + cost_b * n_b
ok = n_a >= 1 and n_b >= 1
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(prem, 6),
"ok": ok,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
def build_perp_options_preview(
*,
direction: str,
@@ -320,6 +541,10 @@ def build_options_options_preview(
"expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
},
}
@@ -334,6 +559,10 @@ def gate_status(
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
show_perp_options: bool = True,
show_options_options: bool = True,
mutual_exclusive: bool = True,
has_standalone_option: bool = False,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
@@ -349,12 +578,23 @@ def gate_status(
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if pt == "perp_options" and not show_perp_options:
can_preview = False
can_start = False
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
if pt == "options_options" and not show_options_options:
can_preview = False
can_start = False
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options":
if not full:
can_start = False
@@ -379,6 +619,10 @@ def gate_status(
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"show_perp_options": bool(show_perp_options),
"show_options_options": bool(show_options_options),
"mutual_exclusive": bool(mutual_exclusive),
"has_standalone_option": bool(has_standalone_option),
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
+28 -2
View File
@@ -72,6 +72,8 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
)
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
@@ -126,6 +128,22 @@ def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
for leg in legs or []:
if str(leg.get("status") or "").strip().lower() == "pending":
role = str(leg.get("leg_role") or "").strip()
if role:
return role
return None
def list_plans(
conn: sqlite3.Connection,
*,
@@ -182,15 +200,22 @@ def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = []
for leg in legs:
role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
if st == "pending":
suffix = "(待补)"
elif st in ("cancelled", "canceled"):
suffix = "(未成交)"
else:
suffix = ""
if role == "perp":
name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}")
parts.append(f"永续 {name}{suffix}")
else:
inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
parts.append(label)
parts.append(f"{label}{suffix}")
return " · ".join(parts) if parts else ""
@@ -201,6 +226,7 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
row = dict(p)
row["legs"] = legs
row["contracts_summary"] = legs_contract_summary(legs)
row["missing_leg"] = missing_leg_role(legs)
out.append(row)
return out
+205 -9
View File
@@ -8,7 +8,11 @@ from typing import Any, Optional
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, list_plans, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_hedge, notify_plan_end, build_hedge_alert_message
from lib.hedge_plan.hedge_plan_orders_lib import _sell_option
from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot
from lib.hedge_plan.hedge_plan_settle_lib import (
leg_is_expired,
resolve_option_leg_realized_pnl,
settle_option_leg_at_spot,
)
def _now() -> str:
@@ -69,6 +73,7 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "msg": "get_db missing"}
conn = get_db()
acted: list[dict[str, Any]] = []
backfill_stats: dict[str, int] = {}
try:
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
@@ -80,10 +85,22 @@ def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
acted.append(r)
orphaned = _settle_orphaned_after_tp(cfg, conn)
acted.extend(orphaned)
try:
ex = cfg.get("exchange_options")
if ex is not None:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_stats = backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
conn.commit()
finally:
conn.close()
return {"ok": True, "acted": acted}
return {"ok": True, "acted": acted, "pnl_backfill": backfill_stats}
def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
@@ -92,6 +109,55 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
notify_plan_end(cfg, conn, plan)
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(全平) / hold_expiry(到期平).
- 方案C关闭 强制到期平
- 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿
- 新开仓默认写入 close_all
"""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
return "hold_expiry"
raw = plan.get("oo_close_mode")
if raw is None or str(raw).strip() == "":
return "hold_expiry"
v = str(raw).strip().lower()
if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"):
return "hold_expiry"
return "close_all"
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
out = []
for x in legs:
if not str(x.get("leg_role") or "").startswith("option"):
continue
if str(x.get("status") or "") in statuses:
out.append(x)
return out
def _finalize_oo_all_closed(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
) -> dict[str, Any]:
closed_opts = _oo_option_legs(legs, statuses=("closed",))
total_opts = sum(float(x.get("realized_pnl") or 0) for x in closed_opts)
close_reason = reason or "oo_rest_closed"
bucket = "oo_target" if total_opts > 0 else "oo_expiry_loss"
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=close_reason,
realized_pnl_options=round(total_opts, 4),
realized_pnl_total=round(total_opts, 4),
stats_bucket=bucket,
closed_at=_now(),
)
_notify_end_reload(cfg, conn, int(plan["id"]))
return {"plan_id": plan["id"], "close_reason": close_reason, "total": total_opts}
def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]:
pt = plan.get("plan_type")
legs = get_plan_legs(conn, int(plan["id"]))
@@ -101,6 +167,9 @@ def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[
return r
if pt == "options_options":
r = _tick_oo_expiry(cfg, conn, plan, legs)
if r:
return r
r = _tick_oo_close_rest(cfg, conn, plan, legs)
if r:
return r
return _tick_oo_target(cfg, conn, plan, legs)
@@ -178,9 +247,10 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
ask_open = _sf(opt.get("avg_open"))
if bid is not None and ask_open is not None:
ct = float(opt.get("ct_mult") or 0.01)
opt_pnl = (bid - ask_open) * float(opt.get("size") or 1) * ct
est = (bid - ask_open) * float(opt.get("size") or 1) * ct
else:
opt_pnl = -premium
est = -premium
opt_pnl = _option_leg_pnl_after_close(cfg, opt, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), opt_pnl, opt["id"]),
@@ -232,10 +302,101 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di
return {"plan_id": plan["id"], "close_reason": reason, "total": total}
def _option_leg_pnl_after_close(
cfg: dict[str, Any],
leg: dict[str, Any],
*,
fallback: float,
) -> float:
"""平仓后写腿盈亏:优先交易所历史,否则用估算."""
ex = cfg.get("exchange_options")
pnl, _src = resolve_option_leg_realized_pnl(ex=ex, leg=leg, fallback=fallback)
return float(pnl if pnl is not None else fallback)
def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Optional[float]) -> float:
"""残腿平仓盈亏估算:优先买一回收 − 权利金;无买一则用内在价值."""
premium = float(leg.get("premium") or 0)
sheets = float(leg.get("size") or 1)
ct = float(leg.get("ct_mult") or 0.01)
if bid is not None and float(bid) > 0:
return float(bid) * sheets * ct - premium
if idx is None:
return -premium
strike = _sf(leg.get("strike")) or 0
o = (leg.get("opt_type") or "").upper()
intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx)
return intrinsic * sheets * ct - premium
def _tick_oo_close_rest(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""盈利腿已平后:全平模式清残腿(无2×门控,买一失败则下轮重试)."""
if resolve_oo_rest_close_mode(plan) != "close_all":
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
closed_legs = _oo_option_legs(legs, statuses=("closed",))
# 至少已平一条,且仍有残腿;避免双腿都还 open 时误清
if len(closed_legs) < 1 or len(open_legs) < 1:
return None
reason0 = str(plan.get("close_reason") or "")
allowed_reasons = (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"oo_rest_closing",
"",
)
if reason0 not in allowed_reasons and not (
len(closed_legs) >= 1 and len(open_legs) == 1
):
return None
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
acted = False
for leg in list(open_legs):
close_r = _sell_option(
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期全平·残腿平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
bid = _sf(close_r.get("bid"))
est = _estimate_leg_close_pnl(leg, idx, bid)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "oo_rest_close", _now(), round(pnl, 4), leg["id"]),
)
leg["status"] = "closed"
leg["realized_pnl"] = round(pnl, 4)
acted = True
if not acted:
return None
legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
if still_open:
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿部分已平,继续重试", "remaining": len(still_open)}
return _finalize_oo_all_closed(
cfg, conn, plan, legs2, reason="oo_rest_closed"
)
def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期期:触及上破或下破目标价时平盈利腿."""
"""期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None:
return None
@@ -261,7 +422,7 @@ def _tick_oo_target(
return None
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = [x for x in legs if x.get("status") == "open" and str(x.get("leg_role") or "").startswith("option")]
open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 2:
return None
winners = []
@@ -288,21 +449,51 @@ def _tick_oo_target(
)
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
# 选腿用内在估算;落库优先交易所已实现盈亏
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), best_pnl, best["id"]),
("closed", reason, _now(), closed_pnl, best["id"]),
)
rest_mode = resolve_oo_rest_close_mode(plan)
update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan)
mid["close_reason"] = reason
mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid)
# 全平:同轮尝试清残腿;失败则下轮 _tick_oo_close_rest 重试
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
out = {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
if rest:
out["rest"] = rest
return out
# 到期平:显式标记残腿 hold_to_expiry
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
)
return {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
@@ -347,7 +538,8 @@ def _tick_oo_expiry(
settled_sum = 0.0
for leg in pending:
pnl = settle_option_leg_at_spot(leg, float(spot))
est = settle_option_leg_at_spot(leg, float(spot))
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
settled_sum += pnl
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
@@ -396,7 +588,11 @@ def _settle_orphaned_after_tp(cfg: dict[str, Any], conn: Any) -> list[dict[str,
spot = _index_px(cfg, str(leg.get("underlying") or "ETH"))
if spot is None:
continue
pnl = settle_option_leg_at_spot(leg, float(spot))
pnl_est = settle_option_leg_at_spot(leg, float(spot))
# orphan row uses leg_id; map to id for resolver
leg_for_pnl = dict(leg)
leg_for_pnl["id"] = leg.get("leg_id")
pnl = _option_leg_pnl_after_close(cfg, leg_for_pnl, fallback=pnl_est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "expiry", _now(), round(pnl, 4), leg["leg_id"]),
+9 -1
View File
@@ -81,6 +81,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"oo_rest_closing": "期期全平·清残腿中",
"oo_rest_closed": "期期全平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
@@ -150,14 +152,20 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
side = "上破" if "up" in str(plan.get("close_reason")) else (
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
)
mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平"):
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)"
else:
rest_txt = "另一腿到期平(持有至到期结算)"
notify_hedge(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿,亏损腿继续持有至到期",
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=(
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
+593 -21
View File
@@ -23,6 +23,18 @@ def open_order_mode() -> str:
return v if v in ("options_first", "perp_first") else "options_first"
def manual_complete_on_partial() -> bool:
"""半腿失败后挂 partial 并手动补开(默认 true)."""
return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True)
def partial_auto_close_enabled() -> bool:
"""手动补开开启时强制关闭自动平,避免吃买卖价差."""
if manual_complete_on_partial():
return False
return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True)
def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易)."""
mode = open_order_mode()
@@ -70,6 +82,13 @@ def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
]
def _option_open_fill_timeout_sec() -> float:
try:
return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
return 12.0
def _buy_option(
cfg: dict[str, Any],
*,
@@ -80,6 +99,7 @@ def _buy_option(
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
wait_option_order_full_fill,
)
ex = cfg.get("exchange_options")
@@ -134,6 +154,7 @@ def _buy_option(
td = "isolated"
if callable(td_buy):
td = td_buy(cfg.get("options_td_mode") or "isolated")
# IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交
order = place_fn(
ex,
inst_id=inst_id,
@@ -142,14 +163,42 @@ def _buy_option(
price=float(ask),
td_mode=td,
tick_sz=q.get("tick_sz"),
ord_type="ioc",
)
if not order.get("ok"):
return order
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
return {"ok": False, "msg": "下单成功但未返回订单号", "order": order}
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=sheets_i,
timeout_sec=_option_open_fill_timeout_sec(),
cancel_on_timeout=True,
)
if not fill.get("ok"):
return {
"ok": False,
"msg": fill.get("msg") or "未完全成交,开仓失败",
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"exchange_ord_id": ord_id,
"filled_sheets": fill.get("filled_sheets"),
"order": order,
"fill": fill,
"can_open": False,
}
fill_px = float(fill.get("avg_px") or ask)
filled_n = int(fill.get("filled_sheets") or sheets_i)
premium = fill_px * filled_n * ct_mult
return {
"ok": True,
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"sheets": filled_n,
"ask": fill_px,
"ask_sz": float(ask_sz),
"premium": premium,
"ct_mult": ct_mult,
@@ -158,8 +207,9 @@ def _buy_option(
"strike": q.get("strike"),
"exp_time": q.get("exp_time"),
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
"exchange_ord_id": (order.get("data") or {}).get("ordId"),
"exchange_ord_id": ord_id,
"order": order,
"fill": fill,
"can_open": True,
}
@@ -257,6 +307,244 @@ def _sell_option(
return order if order.get("ok") else order
def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results)
except Exception:
pass
def _park_partial(
cfg: dict[str, Any],
*,
plan_type: str,
body: dict[str, Any],
missing_leg: str,
msg: str,
path: list[dict[str, Any]],
results: list[dict[str, Any]],
persist: Optional[Callable[..., Any]],
dry_run: bool,
**filled: Any,
) -> dict[str, Any]:
"""半腿失败:保留已成腿,挂 partial 供手动补开."""
if not dry_run:
_notify_partial(cfg, plan_type, msg, results)
out: dict[str, Any] = {
"ok": True,
"partial": True,
"status": "partial",
"dry_run": dry_run,
"plan_type": plan_type,
"missing_leg": missing_leg,
"msg": msg,
"path": path,
"results": results,
"opened_at": _now(),
**filled,
}
if persist and not dry_run:
out["plan_id"] = persist(out, body)
return out
def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float:
"""对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立."""
raw = None
if cfg is not None:
raw = cfg.get("budget_buffer")
if raw is None or raw == "":
raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"
try:
buf = float(raw)
except (TypeError, ValueError):
buf = 0.95
if buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
return float(buf)
def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]:
from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by
split = None
ratio = None
if cfg is not None:
split = cfg.get("oo_bias_split_by")
ratio = cfg.get("oo_bias_ratio")
if split in (None, ""):
split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget"
if ratio in (None, ""):
ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7"
return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio)
def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*.
方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额.
"""
from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok
from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets
leg_a = dict(body.get("leg_a") or {})
leg_b = dict(body.get("leg_b") or {})
inst_a = str(leg_a.get("inst_id") or "").strip()
inst_b = str(leg_b.get("inst_id") or "").strip()
if not inst_a or not inst_b:
return {"ok": False, "msg": "缺少期权合约"}
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
qa = quote_fn(ex, inst_a)
if not qa.get("ok"):
return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa}
qb = quote_fn(ex, inst_b)
if not qb.get("ok"):
return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb}
for tag, q in (("A", qa), ("B", qb)):
can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not can_open:
return {
"ok": False,
"msg": f"{tag}: {block_msg or '暂无卖一深度,无法买入'}",
"quote_a": qa,
"quote_b": qb,
}
trading = fetch_options_trading_usdc(ex)
buf = _hedge_budget_buffer(cfg)
budget_info = resolve_oo_budget_usdc(
trading_usdc=trading,
trade_budget_usdc=cfg.get("trade_budget_usdc"),
buffer_ratio=buf,
)
if not budget_info.get("ok"):
return {
"ok": False,
"msg": budget_info.get("msg") or "可用预算不足",
"budget": budget_info,
"quote_a": qa,
"quote_b": qb,
}
mode = str(body.get("oo_sheets_mode") or "same_sheets")
split_by, bias_ratio = _oo_bias_settings(cfg)
opt_a = str(
leg_a.get("opt_type")
or (qa.get("meta") or {}).get("optType")
or qa.get("opt_type")
or ""
)
opt_b = str(
leg_b.get("opt_type")
or (qb.get("meta") or {}).get("optType")
or qb.get("opt_type")
or ""
)
sug = suggest_oo_sheets(
mode=mode,
budget_usdc=float(budget_info["budget_usdc"]),
ask_a=float(qa["ask"]),
ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01),
ask_sz_a=qa.get("ask_sz"),
opt_type_a=opt_a,
ask_b=float(qb["ask"]),
ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01),
ask_sz_b=qb.get("ask_sz"),
opt_type_b=opt_b,
bias_split_by=split_by,
bias_ratio=bias_ratio,
)
if not sug.get("ok"):
return {
"ok": False,
"msg": sug.get("msg") or "按最新卖一无法建议张数",
"sizing": sug,
"budget": budget_info,
"quote_a": qa,
"quote_b": qb,
}
prev_a = leg_a.get("sheets")
prev_b = leg_b.get("sheets")
leg_a["sheets"] = int(sug["sheets_a"])
leg_a["ask"] = float(qa["ask"])
leg_a["ask_sz"] = qa.get("ask_sz")
leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01)
if opt_a:
leg_a["opt_type"] = opt_a
leg_b["sheets"] = int(sug["sheets_b"])
leg_b["ask"] = float(qb["ask"])
leg_b["ask_sz"] = qb.get("ask_sz")
leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01)
if opt_b:
leg_b["opt_type"] = opt_b
body["leg_a"] = leg_a
body["leg_b"] = leg_b
return {
"ok": True,
"buffer_ratio": buf,
"budget": budget_info,
"sizing": sug,
"quote_a": qa,
"quote_b": qb,
"prev_sheets_a": prev_a,
"prev_sheets_b": prev_b,
"sheets_a": int(sug["sheets_a"]),
"sheets_b": int(sug["sheets_b"]),
"ask_a": float(qa["ask"]),
"ask_b": float(qb["ask"]),
"premium_est": sug.get("premium_est"),
"msg": (
f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + "
f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U"
),
}
def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""永期启动前再拉保险腿卖一(张数沿用页面值,不按预算重算)."""
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
inst = str(body.get("opt_inst_id") or "").strip()
if not inst:
return {"ok": False, "msg": "缺少期权合约"}
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
q = quote_fn(ex, inst)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q}
can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"quote": q,
}
body["ask"] = float(q["ask"])
body["ask_sz"] = q.get("ask_sz")
if q.get("ct_mult") is not None:
body["ct_mult"] = float(q.get("ct_mult") or 0.01)
return {
"ok": True,
"ask": float(q["ask"]),
"ask_sz": q.get("ask_sz"),
"sheets": body.get("sheets"),
"quote": q,
"msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}",
}
def execute_perp_options_start(
cfg: dict[str, Any],
body: dict[str, Any],
@@ -264,6 +552,9 @@ def execute_perp_options_start(
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
refresh = refresh_po_option_quote_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh}
path = build_po_path_plan(body)
results: list[dict[str, Any]] = []
opt_res: Optional[dict[str, Any]] = None
@@ -278,6 +569,27 @@ def execute_perp_options_start(
)
results.append({"step": step["step"], **opt_res})
if not opt_res.get("ok"):
# 永续已成、期权失败 → 可挂 partial 等补开期权
if (
perp_res
and perp_res.get("ok")
and not dry_run
and manual_complete_on_partial()
and persist
):
return _park_partial(
cfg,
plan_type="perp_options",
body=body,
missing_leg="option_hedge",
msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
option=None,
perp=perp_res,
)
return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else:
perp_res = _open_perp(
@@ -292,24 +604,45 @@ def execute_perp_options_start(
)
results.append({"step": step["step"], **perp_res})
if not perp_res.get("ok"):
# 半腿补偿:期权已成 + 配置允许则平期权
if opt_res and opt_res.get("ok") and not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled():
close_r = _sell_option(
cfg,
inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""),
sheets=float(opt_res.get("sheets") or body.get("sheets") or 1),
)
results.append({"step": "options_auto_close_on_perp_fail", **close_r})
msg = perp_res.get("msg") or "永续开仓失败"
_notify_partial(cfg, "perp_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
}
if (
opt_res
and opt_res.get("ok")
and not dry_run
and manual_complete_on_partial()
and persist
):
return _park_partial(
cfg,
plan_type="perp_options",
body=body,
missing_leg="perp",
msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
option=opt_res,
perp=None,
)
msg = perp_res.get("msg") or "永续开仓失败"
if not dry_run:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(
cfg, plan_type="perp_options", msg=msg, results=results
)
except Exception:
pass
_notify_partial(cfg, "perp_options", msg, results)
return {
"ok": False,
"msg": msg,
@@ -326,6 +659,7 @@ def execute_perp_options_start(
"results": results,
"option": opt_res,
"perp": perp_res,
"refresh": refresh,
"opened_at": _now(),
}
if persist and not dry_run:
@@ -340,6 +674,9 @@ def execute_options_options_start(
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
refresh = refresh_oo_sizing_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh}
path = build_oo_path_plan(body)
results: list[dict[str, Any]] = []
leg_a = body.get("leg_a") or {}
@@ -347,27 +684,55 @@ def execute_options_options_start(
a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "a", **a_res})
if not a_res.get("ok"):
return {"ok": False, "msg": a_res.get("msg") or "腿A开仓失败", "path": path, "results": results}
return {
"ok": False,
"msg": a_res.get("msg") or "腿A开仓失败",
"path": path,
"results": results,
"refresh": refresh,
}
b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "b", **b_res})
if not b_res.get("ok"):
if not dry_run and _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
if not dry_run and partial_auto_close_enabled():
close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1))
results.append({"step": "options_auto_close_leg_a", **close_r})
msg = b_res.get("msg") or "腿B开仓失败"
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
}
if not dry_run and manual_complete_on_partial() and persist:
out_p = _park_partial(
cfg,
plan_type="options_options",
body=body,
missing_leg="option_b",
msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
leg_a=a_res,
leg_b=None,
)
out_p["refresh"] = refresh
return out_p
msg = b_res.get("msg") or "腿B开仓失败"
if not dry_run:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(cfg, plan_type="options_options", msg=msg, results=results)
except Exception:
pass
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
}
out = {
"ok": True,
@@ -377,6 +742,7 @@ def execute_options_options_start(
"results": results,
"leg_a": a_res,
"leg_b": b_res,
"refresh": refresh,
"opened_at": _now(),
}
if persist and not dry_run:
@@ -384,6 +750,73 @@ def execute_options_options_start(
return out
def execute_complete_missing_leg(
cfg: dict[str, Any],
plan: dict[str, Any],
legs: list[dict[str, Any]],
start_body: dict[str, Any],
*,
dry_run: bool = False,
) -> dict[str, Any]:
"""对 partial 计划补开缺失腿;成功后由调用方把计划升为 active."""
missing = None
for leg in legs:
if str(leg.get("status") or "").lower() == "pending":
missing = leg
break
if not missing:
return {"ok": False, "msg": "没有待补开的腿"}
role = str(missing.get("leg_role") or "")
results: list[dict[str, Any]] = []
if role == "perp":
res = _open_perp(
cfg,
symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""),
direction=str(start_body.get("direction") or "long"),
contracts=float(start_body.get("contracts") or missing.get("size") or 0),
leverage=int(start_body.get("leverage") or 10),
tp=float(start_body["tp"]),
sl=float(start_body["sl"]),
dry_run=dry_run,
)
results.append({"step": "perp_market_open", "complete": True, **res})
if not res.get("ok"):
return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role}
return {
"ok": True,
"leg_role": role,
"leg_id": missing.get("id"),
"results": results,
"fill": res,
"opened_at": _now(),
}
if role in ("option_hedge", "option_b", "option_a"):
if role == "option_b":
src = start_body.get("leg_b") or {}
inst = str(src.get("inst_id") or missing.get("inst_id") or "")
sheets = float(src.get("sheets") or missing.get("size") or 1)
elif role == "option_a":
src = start_body.get("leg_a") or {}
inst = str(src.get("inst_id") or missing.get("inst_id") or "")
sheets = float(src.get("sheets") or missing.get("size") or 1)
else:
inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "")
sheets = float(start_body.get("sheets") or missing.get("size") or 1)
res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run)
results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res})
if not res.get("ok"):
return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role}
return {
"ok": True,
"leg_role": role,
"leg_id": missing.get("id"),
"results": results,
"fill": res,
"opened_at": _now(),
}
return {"ok": False, "msg": f"未知待补腿: {role}"}
def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower()
if pt == "perp_options":
@@ -427,3 +860,142 @@ def dump_preview(preview: Any) -> str:
return json.dumps(preview, ensure_ascii=False)[:8000]
except Exception:
return ""
def _live_option_pos_sheets(ex: Any, inst_id: str) -> float:
from lib.exchange.okx_options_lib import fetch_option_positions
inst_id = (inst_id or "").strip()
if not inst_id or ex is None:
return 0.0
rows = fetch_option_positions(ex)
if rows is None:
return -1.0 # API 失败:未知
for r in rows:
if str(r.get("instId") or "").strip() != inst_id:
continue
try:
return abs(float(r.get("pos") or 0))
except (TypeError, ValueError):
return 0.0
return 0.0
def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None:
"""腿状态校正后:有 open + pending → partial."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
plan = get_plan(conn, int(plan_id))
if not plan:
return
pst = str(plan.get("status") or "")
if pst not in ("opening", "active", "partial"):
return
legs = get_plan_legs(conn, int(plan_id))
statuses = [str(l.get("status") or "").lower() for l in legs]
n_open = sum(1 for s in statuses if s == "open")
n_pending = sum(1 for s in statuses if s == "pending")
if n_pending and n_open:
update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail")
def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]:
"""未成交却标 open 的期权腿 → pending(可补开);不显示成持仓."""
from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg
from lib.exchange.okx_options_lib import fetch_option_order
ex = cfg.get("exchange_options")
notes: list[str] = []
legs = get_plan_legs(conn, int(plan_id))
for leg in legs:
role = str(leg.get("leg_role") or "")
if not role.startswith("option"):
continue
st = str(leg.get("status") or "").lower()
if st != "open":
continue
inst = str(leg.get("inst_id") or "").strip()
oid = str(leg.get("exchange_ord_id") or "").strip()
leg_id = int(leg["id"])
sheets = _live_option_pos_sheets(ex, inst)
if sheets < 0:
continue # 查仓失败不改
if sheets >= 1:
continue
# 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单)
if ex is not None and inst and oid:
od = fetch_option_order(ex, inst_id=inst, ord_id=oid)
if od.get("ok"):
acc = float(od.get("acc_fill_sz") or 0)
ostate = str(od.get("state") or "")
if acc >= 1 or ostate == "filled":
continue # 有成交但仓位暂未同步,暂不改
update_leg(
conn,
leg_id,
status="pending",
close_reason=None,
closed_at=None,
avg_open=None,
premium=0,
)
notes.append(f"{inst} 无成交却标open→pending")
if notes:
_sync_plan_status_after_leg_fix(conn, int(plan_id))
return notes
def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]:
"""人工结束进行中计划:不自动平仓;未成交腿标 cancelled."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end
from lib.exchange.okx_options_lib import cancel_option_order
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
if st not in ("opening", "active", "partial"):
return {"ok": False, "msg": f"当前状态 {st or ''} 不可结束"}
notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
ex = cfg.get("exchange_options")
legs = get_plan_legs(conn, int(plan_id))
for leg in legs:
lst = str(leg.get("status") or "").lower()
inst = str(leg.get("inst_id") or "").strip()
oid = str(leg.get("exchange_ord_id") or "").strip()
if lst == "pending":
if ex is not None and inst and oid:
cancel_option_order(ex, inst_id=inst, ord_id=oid)
update_leg(
conn,
int(leg["id"]),
status="cancelled",
close_reason="manual_end",
closed_at=_now(),
avg_open=None,
premium=0,
)
notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled")
update_plan(
conn,
int(plan_id),
status="closed",
close_reason="manual",
closed_at=_now(),
note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500],
)
plan2 = get_plan(conn, int(plan_id))
if plan2:
try:
notify_plan_end(cfg, conn, plan2)
except Exception:
pass
return {
"ok": True,
"plan_id": int(plan_id),
"msg": "计划已结束(未自动平仓;有持仓请自行平掉)",
"notes": notes,
}
+291 -41
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import os
from typing import Any
from typing import Any, Optional
from flask import Flask, jsonify, request
from jinja2 import ChoiceLoader, FileSystemLoader
@@ -97,6 +97,11 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"),
"perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(),
"options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(),
"trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"),
# 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立
"budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
"oo_bias_split_by": _oo_bias_split_by(),
"oo_bias_ratio": _oo_bias_ratio(),
"live_trading": _env_bool("LIVE_TRADING_ENABLED", False),
"send_wechat": getattr(app_module, "send_wechat_msg", None),
}
@@ -106,6 +111,40 @@ def _hedge_enabled() -> bool:
return _env_bool("HEDGE_PLAN_ENABLED", False)
def _show_perp_options() -> bool:
return _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
def _show_options_options() -> bool:
return _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
def _oo_close_mode_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True)
def _oo_bias_split_by() -> str:
from lib.hedge_plan.hedge_plan_calc_lib import _normalize_oo_bias_split_by
return _normalize_oo_bias_split_by(os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget")
def _oo_bias_ratio() -> float:
from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio
return _clamp_oo_bias_ratio(os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7")
def _normalize_oo_close_mode(raw: Any) -> str:
"""方案C关闭时强制 hold_expiry;开启时默认 close_all."""
if not _oo_close_mode_enabled():
return "hold_expiry"
v = str(raw or "close_all").strip().lower()
if v in ("hold_expiry", "hold_to_expiry", "expiry", "到期平"):
return "hold_expiry"
return "close_all"
def _live_order() -> bool:
return _env_bool("HEDGE_PLAN_LIVE_ORDER", False)
@@ -119,18 +158,35 @@ def _max_active() -> int:
def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
active = 0
has_standalone = False
mutual = True
try:
from lib.hedge_plan.hedge_options_exclusive_lib import (
has_standalone_option_position,
mutual_exclusive_enabled,
)
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
mutual = mutual_exclusive_enabled()
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
active = count_active_plans(conn)
if mutual:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
ex = cfg.get("exchange_options") or cfg.get("exchange")
raw = fetch_option_positions(ex) if ex is not None else []
has_standalone = has_standalone_option_position(conn, raw or [])
except Exception:
has_standalone = False
conn.commit()
finally:
conn.close()
except Exception:
active = 0
has_standalone = False
return gate_status(
hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(),
@@ -140,9 +196,23 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False),
active_count=active,
max_active=_max_active(),
show_perp_options=_show_perp_options(),
show_options_options=_show_options_options(),
mutual_exclusive=mutual,
has_standalone_option=has_standalone,
)
def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
g = _gates_dict(cfg, plan_type)
g["oo_close_mode_enabled"] = _oo_close_mode_enabled()
g["oo_close_mode_default"] = "close_all" if _oo_close_mode_enabled() else "hold_expiry"
g["oo_bias_split_by"] = _oo_bias_split_by()
g["oo_bias_ratio"] = _oo_bias_ratio()
g["budget_buffer"] = float(cfg.get("budget_buffer") or 0.95)
return g
def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
if not _hedge_enabled():
return
@@ -171,6 +241,18 @@ def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
cfg["hedge_monitor_thread"] = t
def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str:
import json
try:
return json.dumps(
{"start_body": body, "missing_leg": missing_leg},
ensure_ascii=False,
)[:8000]
except Exception:
return ""
def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
from lib.hedge_plan.hedge_plan_db import (
get_plan,
@@ -184,25 +266,36 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
is_partial = bool(result.get("partial"))
missing = str(result.get("missing_leg") or "") if is_partial else ""
opt = result.get("option") or {}
perp = result.get("perp") or {}
premium = float(opt.get("premium") or 0)
if is_partial:
opt_ok = missing != "option_hedge" and bool(result.get("option"))
perp_ok = missing != "perp" and bool(result.get("perp"))
else:
opt_ok = True
perp_ok = True
premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
plan_id = insert_plan(
conn,
{
"plan_type": "perp_options",
"status": "active",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"direction": str(body.get("direction") or "long"),
"entry_mark": float(body.get("entry") or 0),
"tp": float(body.get("tp") or 0),
"sl": float(body.get("sl") or 0),
"sizing_mode_at_open": load_position_sizing_mode(),
"perp_size": float(perp.get("contracts") or body.get("contracts") or 0),
"perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"margin": body.get("margin"),
"leverage": float(body.get("leverage") or 10),
"premium_total": premium,
"preview_json": _start_body_json(body, missing or None),
"close_reason": "partial_fail" if is_partial else None,
"opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
},
)
insert_leg(
@@ -212,11 +305,11 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"leg_role": "perp",
"symbol": str(body.get("exchange_symbol") or ""),
"side": str(body.get("direction") or "long"),
"size": float(perp.get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0),
"status": "open",
"exchange_ord_id": str(perp.get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"),
"size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0) if perp_ok else None,
"status": "open" if perp_ok else "pending",
"exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if perp_ok else None,
},
)
insert_leg(
@@ -224,24 +317,25 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
{
"plan_id": plan_id,
"leg_role": "option_hedge",
"inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""),
"strike": opt.get("strike") or body.get("strike"),
"inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
"strike": (opt or {}).get("strike") or body.get("strike"),
"side": "buy",
"size": float(opt.get("sheets") or body.get("sheets") or 1),
"avg_open": float(opt.get("ask") or 0),
"premium": premium,
"status": "open",
"exchange_ord_id": str(opt.get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"),
"size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
"avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None,
"premium": premium if opt_ok else 0,
"status": "open" if opt_ok else "pending",
"exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if opt_ok else None,
},
)
conn.commit()
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
return plan_id
finally:
conn.close()
@@ -260,14 +354,20 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
is_partial = bool(result.get("partial"))
missing = str(result.get("missing_leg") or "") if is_partial else ""
a = result.get("leg_a") or {}
b = result.get("leg_b") or {}
premium = float(a.get("premium") or 0) + float(b.get("premium") or 0)
a_ok = True if not is_partial else bool(result.get("leg_a"))
b_ok = True if not is_partial else (missing != "option_b" and bool(result.get("leg_b")))
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0
)
plan_id = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "active",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float(
body.get("target_price_up")
@@ -286,33 +386,41 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
),
"sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
"preview_json": _start_body_json(body, missing or None),
"close_reason": "partial_fail" if is_partial else None,
"opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
},
)
for role, res, src in (("option_a", a, body.get("leg_a") or {}), ("option_b", b, body.get("leg_b") or {})):
for role, res, src, ok in (
("option_a", a, body.get("leg_a") or {}, a_ok),
("option_b", b, body.get("leg_b") or {}, b_ok),
):
insert_leg(
conn,
{
"plan_id": plan_id,
"leg_role": role,
"inst_id": str(res.get("inst_id") or src.get("inst_id") or ""),
"opt_type": str(res.get("opt_type") or src.get("opt_type") or ""),
"strike": res.get("strike") or src.get("strike"),
"inst_id": str((res or {}).get("inst_id") or src.get("inst_id") or ""),
"opt_type": str((res or {}).get("opt_type") or src.get("opt_type") or ""),
"strike": (res or {}).get("strike") or src.get("strike"),
"side": "buy",
"size": float(res.get("sheets") or src.get("sheets") or 1),
"avg_open": float(res.get("ask") or 0),
"premium": float(res.get("premium") or 0),
"status": "open",
"exchange_ord_id": str(res.get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at"),
"size": float((res or {}).get("sheets") or src.get("sheets") or 1),
"avg_open": float((res or {}).get("ask") or 0) if ok else None,
"premium": float((res or {}).get("premium") or 0) if ok else 0,
"status": "open" if ok else "pending",
"exchange_ord_id": str((res or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if ok else None,
},
)
conn.commit()
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
if not is_partial:
plan = get_plan(conn, plan_id)
legs = get_plan_legs(conn, plan_id)
if plan:
notify_plan_start(cfg, conn, plan, legs)
conn.commit()
return plan_id
finally:
conn.close()
@@ -335,7 +443,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
@lr
def api_hedge_gates():
plan_type = (request.args.get("plan_type") or "perp_options").strip()
return jsonify({"ok": True, **_gates_dict(cfg, plan_type)})
return jsonify({"ok": True, **_gates_public(cfg, plan_type)})
@app.route("/api/hedge-plan/market")
@lr
@@ -395,6 +503,8 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
"account_label": cfg.get("options_account_label") or "期权账户",
"account_note": "期权腿使用期权账户(交易 USDC)",
"options_account": opt_acct,
"trade_budget_usdc": cfg.get("trade_budget_usdc"),
"budget_buffer": cfg.get("budget_buffer"),
}
)
@@ -482,6 +592,130 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400)
@app.route("/api/hedge-plan/<int:plan_id>/end", methods=["POST"])
@lr
def api_hedge_end_plan(plan_id: int):
"""人工结束进行中计划:不自动平仓;未成交腿改为 cancelled."""
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
from lib.hedge_plan.hedge_plan_orders_lib import execute_manual_end_plan
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
out = execute_manual_end_plan(cfg, conn, plan_id)
if not out.get("ok"):
return jsonify(out), 400
conn.commit()
finally:
conn.close()
return jsonify(out)
@app.route("/api/hedge-plan/<int:plan_id>/complete-leg", methods=["POST"])
@lr
def api_hedge_complete_leg(plan_id: int):
"""半腿待补:手动补开缺失腿,成功后升为 active."""
import json
from lib.hedge_plan.hedge_plan_db import (
get_plan,
get_plan_legs,
init_hedge_plan_tables,
update_leg,
update_plan,
)
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
from lib.hedge_plan.hedge_plan_orders_lib import execute_complete_missing_leg
body = request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
plan = get_plan(conn, plan_id)
if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404
if str(plan.get("status") or "") != "partial":
return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400
legs = get_plan_legs(conn, plan_id)
start_body: dict[str, Any] = {}
try:
meta = json.loads(plan.get("preview_json") or "{}")
if isinstance(meta, dict):
start_body = dict(meta.get("start_body") or {})
except Exception:
start_body = {}
if not start_body:
return jsonify({"ok": False, "msg": "缺少开仓参数,无法补开"}), 400
# 允许请求体覆盖少量字段
for k in ("contracts", "leverage", "sheets", "tp", "sl"):
if body.get(k) not in (None, ""):
start_body[k] = body.get(k)
out = execute_complete_missing_leg(
cfg, plan, legs, start_body, dry_run=dry_run
)
if not out.get("ok"):
return jsonify(out), 400
if dry_run:
return jsonify(out)
fill = out.get("fill") or {}
leg_id = out.get("leg_id")
role = str(out.get("leg_role") or "")
opened_at = out.get("opened_at")
if leg_id:
if role == "perp":
update_leg(
conn,
int(leg_id),
status="open",
size=float(fill.get("contracts") or start_body.get("contracts") or 0),
avg_open=float(start_body.get("entry") or plan.get("entry_mark") or 0),
exchange_ord_id=str(fill.get("exchange_ord_id") or ""),
opened_at=opened_at,
)
update_plan(
conn,
plan_id,
status="active",
close_reason=None,
note=None,
perp_size=float(fill.get("contracts") or start_body.get("contracts") or 0),
)
else:
prem = float(fill.get("premium") or 0)
update_leg(
conn,
int(leg_id),
status="open",
size=float(fill.get("sheets") or start_body.get("sheets") or 1),
avg_open=float(fill.get("ask") or 0),
premium=prem,
exchange_ord_id=str(fill.get("exchange_ord_id") or ""),
opened_at=opened_at,
inst_id=str(fill.get("inst_id") or ""),
)
old_prem = float(plan.get("premium_total") or 0)
update_plan(
conn,
plan_id,
status="active",
close_reason=None,
note=None,
premium_total=old_prem + prem,
)
conn.commit()
plan2 = get_plan(conn, plan_id)
legs2 = get_plan_legs(conn, plan_id)
if plan2:
notify_plan_start(cfg, conn, plan2, legs2)
conn.commit()
out["plan_id"] = plan_id
out["status"] = "active"
out["plan"] = plan2
out["legs"] = legs2
return jsonify(out)
finally:
conn.close()
@app.route("/api/hedge-plan/list")
@lr
def api_hedge_list():
@@ -530,6 +764,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables,
list_plans,
)
from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]()
try:
@@ -538,6 +773,16 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
for row in rows:
try:
reconcile_unfilled_option_legs(cfg, conn, int(row["id"]))
except Exception:
pass
# 校正后可能 status 变化,重新拉一遍
rows = []
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows)
conn.commit()
finally:
@@ -567,6 +812,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
init_hedge_plan_tables,
legs_contract_summary,
)
from lib.hedge_plan.hedge_plan_orders_lib import reconcile_unfilled_option_legs
conn = cfg["get_db"]()
try:
@@ -574,6 +820,10 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
plan = get_plan(conn, plan_id)
if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404
# 打开细节时校正:无成交却标 open → cancelled
if str(plan.get("status") or "") in ("opening", "active", "partial"):
reconcile_unfilled_option_legs(cfg, conn, plan_id)
plan = get_plan(conn, plan_id) or plan
legs = get_plan_legs(conn, plan_id)
conn.commit()
finally:
+153 -2
View File
@@ -1,12 +1,17 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations
import os
import time
from typing import Any, Optional
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _sf(v: Any) -> Optional[float]:
try:
@@ -60,3 +65,149 @@ def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int]
if not opts:
return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
def _parse_opened_ms(raw: Any) -> Optional[int]:
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if raw is None or raw == "":
return None
s = str(raw).strip()
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def resolve_option_leg_realized_pnl(
*,
ex: Any = None,
leg: dict[str, Any],
fallback: Optional[float] = None,
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
hist_rows: Optional[list[dict[str, Any]]] = None,
) -> tuple[Optional[float], str]:
"""
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
返回 (pnl, source) source=exchange|fallback|none.
"""
inst_id = str(leg.get("inst_id") or "").strip()
open_ms = _parse_opened_ms(leg.get("opened_at"))
rows = hist_rows
if rows is None and inst_id:
try:
if callable(fetch_history_fn):
rows = fetch_history_fn(inst_id)
elif ex is not None:
from lib.exchange.okx_options_lib import fetch_option_position_history
rows = fetch_option_position_history(ex, inst_id)
except Exception:
rows = None
if rows:
info = resolve_option_close_from_history(rows, open_ms=open_ms)
pnl = _sf((info or {}).get("realized_pnl")) if info else None
if pnl is not None:
return round(float(pnl), 4), "exchange"
if fallback is not None:
return round(float(fallback), 4), "fallback"
return None, "none"
def backfill_hedge_option_legs_realized_pnl(
conn: Any,
hist_rows: list[dict[str, Any]],
*,
update_plan_fn: Optional[Callable[..., Any]] = None,
) -> dict[str, int]:
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if inst:
by_inst.setdefault(inst, []).append(raw)
legs = conn.execute(
"""
SELECT * FROM hedge_plan_legs
WHERE status = 'closed'
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
ORDER BY id DESC
LIMIT 400
"""
).fetchall()
updated_legs = 0
touched_plans: set[int] = set()
for row in legs:
leg = dict(row)
inst = str(leg.get("inst_id") or "").strip()
if not inst or inst not in by_inst:
continue
pnl, src = resolve_option_leg_realized_pnl(
leg=leg,
hist_rows=by_inst[inst],
fallback=None,
)
if src != "exchange" or pnl is None:
continue
local = _sf(leg.get("realized_pnl"))
if local is not None and abs(local - pnl) < 1e-6:
continue
conn.execute(
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
(pnl, int(leg["id"])),
)
updated_legs += 1
touched_plans.add(int(leg["plan_id"]))
updated_plans = 0
updater = update_plan_fn or update_plan
for pid in touched_plans:
plan = get_plan(conn, pid)
if not plan or str(plan.get("status") or "") != "closed":
continue
plan_legs = get_plan_legs(conn, pid)
opt_sum = 0.0
for lg in plan_legs:
role = str(lg.get("leg_role") or "")
if not (role.startswith("option") or lg.get("opt_type")):
continue
if str(lg.get("status") or "") != "closed":
continue
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
ptype = str(plan.get("plan_type") or "")
if ptype == "options_options":
total = opt_sum
kwargs: dict[str, Any] = {
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
else:
total = perp + opt_sum
kwargs = {
"realized_pnl_perp": round(perp, 4),
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
old_total = _sf(plan.get("realized_pnl_total"))
old_opts = _sf(plan.get("realized_pnl_options"))
if (
old_total is not None
and abs(old_total - total) < 1e-6
and old_opts is not None
and abs(old_opts - opt_sum) < 1e-6
):
continue
updater(conn, pid, **kwargs)
updated_plans += 1
return {"legs": updated_legs, "plans": updated_plans}
+141 -74
View File
@@ -2,6 +2,10 @@
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options | default(true) else '0' }}"
data-show-oo="{{ '1' if hedge_plan_show_options_options | default(true) else '0' }}"
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled | default(true) else '0' }}"
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %}
@@ -10,6 +14,9 @@
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %}
{% if hedge_plan_enabled and not (hedge_plan_show_perp_options | default(true)) and not (hedge_plan_show_options_options | default(true)) %}
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:可在 <code>env配置 → 对冲计划</code> 打开显示开关;进行中/历史仍可查看.</div>
{% endif %}
<div class="card hp-head-card">
<div class="hp-head-row">
@@ -19,8 +26,12 @@
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
<button type="button" class="hp-tab active" role="tab" aria-selected="true" data-tab="perp_options">永期对冲</button>
{% if hedge_plan_show_perp_options | default(true) %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %}
{% if hedge_plan_show_options_options | default(true) %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% endif %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
@@ -31,37 +42,61 @@
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout">
<div class="card">
<div class="card hp-po-perp-card">
<h2>永续 · <span id="hp-perp-uly-label">ETH</span> <span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);保险期权走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>下单</strong>:先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁。</p>
<p><strong>板块</strong>:左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。止盈后保险腿默认可持有;止损会联动平期权。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
<select id="hp-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</div>
<div id="hp-perp-quote" class="muted hp-quote-line">加载中…</div>
<p class="muted hp-unit-hint">单位说明:价格=USDT · 张数=交易所<strong>永续合约张</strong>(精度与 OKX 下单一致) · 盈亏=USDT</p>
<div class="form-row" style="flex-wrap:wrap">
<label>开仓价 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-entry" /></label>
<label>止盈 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-tp" /></label>
<label>止损 <span class="hp-unit">USDT</span> <input type="number" step="any" id="hp-sl" /></label>
<label>张数 <span class="hp-unit">合约张</span> <input type="number" step="any" id="hp-contracts" /></label>
<div class="hp-po-top">
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多永续"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空永续"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<div class="hp-po-fields">
<label class="hp-po-field">
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--tp">
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--sl">
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-po-summary">
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div>
<p class="muted" id="hp-perp-pnl-line"></p>
<p class="muted" id="hp-sizing-line"></p>
</div>
<div class="card hp-opt-card">
<h2>期权(列表) · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar">
<h2>期权 · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-money-btn active" data-money="all">全部</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="itm">实值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</button>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
</div>
<div id="hp-index-line" class="muted hp-quote-line"></div>
<div class="options-strike-table-wrap hp-strike-table-wrap--5">
<table class="options-strike-table" id="hp-strike-table">
<thead>
@@ -80,66 +115,67 @@
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" /></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<p class="muted hp-unit-hint">单位说明:权利金结算币=<strong>USDC</strong> · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度&gt;0;无深度不可开仓(链上~为参考估算).</p>
<div id="hp-opt-bal-line" class="muted hp-quote-line hp-opt-bal-line"></div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn">计算</button>
<button type="button" class="btn-secondary" id="hp-start-btn" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
<div class="card hp-preview-card" id="hp-preview-card-po">
<h2 style="margin:0 0 8px">情景测算</h2>
<div id="hp-summary" class="muted" style="margin:8px 0"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th>永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">填写参数后点计算</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="options-dual-grid" id="hp-oo-layout">
<div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" /></label>
<div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div>
<div id="hp-oo-index" class="muted hp-quote-line"></div>
<div id="hp-oo-bal-line" class="muted hp-quote-line"></div>
<p class="muted hp-unit-hint">震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=<strong>期权张</strong> · 权利金=USDC</p>
<div class="hp-oo-controls">
<div class="hp-oo-ctrl">
<span class="hp-oo-ctrl-lab">张数</span>
<div class="hp-oo-seg" role="group" aria-label="自动张数">
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true"></span>同张数</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
</div>
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>全平</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div>
</div>
</div>
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="1" id="hp-oo-sheets-a" value="1" disabled />
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="1" id="hp-oo-sheets-b" value="1" disabled />
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<p class="muted" id="hp-oo-prem-line"></p>
@@ -170,33 +206,33 @@
</tbody>
</table>
</div>
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
<div class="hp-oo-transfer-bals muted">
<span>资金 <strong id="hp-oo-funding-usdc"></strong></span>
<span class="hp-oo-transfer-sep">·</span>
<span>交易 <strong id="hp-oo-trading-usdc"></strong></span>
<span class="hp-oo-transfer-unit">USDC</span>
<span class="muted" id="hp-oo-xfer-msg"></span>
</div>
<div class="form-row hp-oo-transfer-form" autocomplete="off">
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
<option value="funding_to_trading" selected>资金 → 交易</option>
<option value="trading_to_funding">交易 → 资金</option>
</select>
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
</div>
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
<button type="button" class="btn-secondary" id="hp-start-btn-oo" title="需开启 HEDGE_PLAN_LIVE_ORDER">启动计划</button>
</div>
</div>
</div>
<div class="card hp-preview-card">
<h2 style="margin:0 0 8px">情景测算</h2>
<div id="hp-summary-oo" class="muted" style="margin:8px 0"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th>腿盈亏</th>
<th>期权</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody-oo">
<tr><td colspan="6" class="muted">选用两腿并填上破/下破目标后点计算</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
@@ -254,5 +290,36 @@
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div>
</div>
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
<div class="hp-modal-head">
<h3 id="hp-preview-title">情景测算</h3>
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
</div>
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th id="hp-preview-mid-th">永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">计算中…</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=13"></script>
<script src="/static/hedge_plan.js?v=33"></script>
+1
View File
@@ -61,6 +61,7 @@ def install_instance_theme_static(app) -> None:
"records_review_page.js": "application/javascript; charset=utf-8",
"ai_review_render.js": "application/javascript; charset=utf-8",
"form_submit_guard.js": "application/javascript; charset=utf-8",
"autofill_guard.js": "application/javascript; charset=utf-8",
"key_monitor_form.js": "application/javascript; charset=utf-8",
"time_close_ui.js": "application/javascript; charset=utf-8",
"manual_order_rr_preview.js": "application/javascript; charset=utf-8",
+10 -1
View File
@@ -10,9 +10,15 @@ from typing import Any
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]] = {
"playbook": {
"label": "执行手册",
"title": "交易执行手册(期权为主 · Gate 为辅)",
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
"md_rel": "docs/交易执行手册-期权与Gate.md",
},
"binance": {
"label": "币安",
"title": "币安·山寨多头趋势",
@@ -43,6 +49,9 @@ def _md_path(exchange_key: str) -> Path:
meta = STRATEGY_META.get((exchange_key or "").strip().lower())
if not meta:
raise KeyError(exchange_key)
md_rel = (meta.get("md_rel") or "").strip()
if md_rel:
return REPO_ROOT / md_rel
return _strategy_dir() / meta["md_file"]
+21 -13
View File
@@ -87,14 +87,15 @@ OPTIONS_SOURCE_LABELS = {
HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"})
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]:
"""根据进行中对冲计划腿判定来源;默认纯期权."""
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
"""根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id)."""
default = ("option", OPTIONS_SOURCE_LABELS["option"], None)
if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"):
return "option", OPTIONS_SOURCE_LABELS["option"]
return default
try:
row = conn.execute(
"""
SELECT p.plan_type
SELECT p.plan_type, p.id
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.status IN ('opening', 'active', 'partial')
@@ -106,13 +107,18 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str]:
(inst_id,),
).fetchone()
except Exception:
return "option", OPTIONS_SOURCE_LABELS["option"]
return default
if not row:
return "option", OPTIONS_SOURCE_LABELS["option"]
pt = str((_row_dict(row).get("plan_type") if isinstance(row, dict) else row[0]) or "").strip()
if pt in OPTIONS_SOURCE_LABELS:
return pt, OPTIONS_SOURCE_LABELS[pt]
return "option", OPTIONS_SOURCE_LABELS["option"]
return default
d = _row_dict(row)
pt = str(d.get("plan_type") or "").strip()
try:
plan_id = int(d["id"]) if d.get("id") is not None else None
except (TypeError, ValueError):
plan_id = None
if pt in OPTIONS_SOURCE_LABELS and pt != "option":
return pt, OPTIONS_SOURCE_LABELS[pt], plan_id
return default
def _format_options_target(p: dict[str, Any]) -> str:
@@ -152,9 +158,10 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None
except (TypeError, ValueError):
exp_ms = None
source_key, source_label = (
_resolve_options_source(conn, inst) if conn is not None else ("option", OPTIONS_SOURCE_LABELS["option"])
)
if conn is not None:
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
else:
source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None
return {
"id": inst,
"kind": "options",
@@ -166,6 +173,7 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
"opt_type_label": label,
"source": source_key,
"source_label": source_label,
"source_plan_id": source_plan_id,
"pos": pos,
"exp_time_ms": exp_ms,
"target_monitor": _format_options_target(p),
@@ -14,6 +14,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_records": True,
"show_nav_stats": True,
"show_nav_risk_policy": True,
"show_nav_system_guide": False,
"show_nav_env_config": True,
"show_nav_options": True,
"show_nav_options_review": True,
@@ -32,6 +33,7 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析",
"show_nav_risk_policy": "风控说明",
"show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置",
"show_nav_options": "期权",
"show_nav_options_review": "期权复盘",
@@ -50,6 +52,7 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"records": "show_nav_records",
"stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy",
"system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config",
"options": "show_nav_options",
"options_review": "show_nav_options_review",
@@ -112,6 +115,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_records",
"show_nav_stats",
"show_nav_risk_policy",
"show_nav_system_guide",
"show_nav_env_config",
"show_nav_options",
"show_nav_options_review",
+2
View File
@@ -22,6 +22,7 @@ EMBED_TABS: tuple[str, ...] = (
"records",
"stats",
"risk_policy",
"system_guide",
"env_config",
"settings",
)
@@ -41,6 +42,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/records": "records",
"/stats": "stats",
"/risk_policy": "risk_policy",
"/system_guide": "system_guide",
"/env_config": "env_config",
"/settings": "settings",
}
+26
View File
@@ -8,6 +8,7 @@ from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled
from lib.trade.account_risk_lib import (
cooling_hours_manual,
cooling_hours_manual_journal,
daily_loss_limit,
manual_close_daily_limit,
max_active_positions_from_env,
mood_issues_daily_freeze_enabled,
@@ -53,6 +54,7 @@ def build_instance_settings_view(
risk_status: Optional[dict[str, Any]] = None,
trade_policy: Optional[TradePolicy] = None,
data_export_version: int = 3,
open_guard_enabled: Optional[bool] = None,
) -> dict[str, Any]:
rs = risk_status or {}
sizing_mode = load_position_sizing_mode()
@@ -63,6 +65,11 @@ def build_instance_settings_view(
force_close_on = _env_bool("FORCE_CLOSE_ENABLED", False)
force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0)
auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False)
guard_on = (
bool(open_guard_enabled)
if open_guard_enabled is not None
else _env_bool("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", True)
)
sections: list[dict[str, Any]] = []
@@ -79,6 +86,12 @@ def build_instance_settings_view(
f"北京时间 {reset_hour}:00",
"新交易日统计与部分开仓限制以此为准",
),
_row(
"允许北京时间切点前开仓",
"已放开(允许开仓)" if not guard_on else "已限制(禁止开仓)",
f"关闭限制后,{reset_hour}:00 前也可斐波成交登记与人工下单;"
"环境配置「切点前禁止新开仓」(TRADING_DAY_RESET_OPEN_GUARD_ENABLED)",
),
_row(
"单日开仓提醒",
f"{alert_threshold}",
@@ -101,6 +114,15 @@ def build_instance_settings_view(
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
_row("日手动平仓上限", f"{manual_close_daily_limit()}", "超限当日冻结"),
_row(
"日亏损次数上限",
(
f"{daily_loss_limit()}"
if daily_loss_limit() > 0
else "未启用"
),
"平仓亏损达限后当日冻结开仓;0=不启用" if daily_loss_limit() > 0 else "RISK_DAILY_LOSS_LIMIT=0",
),
_row(
"复盘情绪日冻结",
_on_off(mood_issues_daily_freeze_enabled()),
@@ -202,6 +224,10 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]:
p = (page or "").strip()
if p == "system_guide":
from lib.instance.instance_system_guide_lib import system_guide_template_context
return system_guide_template_context()
if p not in ("settings", "risk_policy", "env_config"):
return {}
display = kwargs.pop("display", None)
@@ -11,6 +11,7 @@ from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
from lib.env.env_ui_manifest import (
build_env_ui_payload,
filter_updates_for_ui,
coerce_hedge_partial_close_with_manual,
validate_env_ui_updates,
)
from lib.env.env_schema import parse_env_example_schema
@@ -102,6 +103,7 @@ def register_instance_settings_routes(
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
if errors:
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
changed = apply_env_updates(env_path, clean)
+69
View File
@@ -0,0 +1,69 @@
"""实例「系统说明」:加载 Markdown,生成 h2 目录与带锚点正文."""
from __future__ import annotations
import re
from functools import lru_cache
from html import escape
from pathlib import Path
from typing import Any
from lib.hub.hub_strategy_lib import render_markdown_html
from lib.paths import REPO_ROOT
def system_guide_md_path() -> Path:
return REPO_ROOT / "docs" / "系统说明.md"
def _slugify(text: str) -> str:
raw = re.sub(r"<[^>]+>", "", text or "")
raw = re.sub(r"\s+", "-", raw.strip())
raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw)
return raw[:80] or "section"
def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]:
"""为 h2 注入 id,并收集目录(仅 h2)."""
toc: list[dict[str, str]] = []
used: dict[str, int] = {}
def repl(m: re.Match[str]) -> str:
inner = m.group(1)
base = _slugify(inner)
n = used.get(base, 0) + 1
used[base] = n
hid = base if n == 1 else f"{base}-{n}"
toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()})
return f'<h2 id="{escape(hid)}">{inner}</h2>'
out = re.sub(r"<h2>(.*?)</h2>", repl, html, flags=re.I | re.S)
return out, toc
@lru_cache(maxsize=4)
def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]:
path = Path(path_str)
try:
md_text = path.read_text(encoding="utf-8")
except OSError:
md_text = "# 系统说明缺失\n\n未找到 `docs/系统说明.md`。"
body = render_markdown_html(md_text)
body, toc = _inject_h2_ids(body)
return {"html": body, "toc": toc, "mtime_ns": mtime_ns}
def load_system_guide_payload() -> dict[str, Any]:
path = system_guide_md_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
return dict(_load_payload_cached(mtime_ns, str(path)))
def system_guide_template_context() -> dict[str, Any]:
payload = load_system_guide_payload()
return {
"system_guide_html": payload.get("html") or "",
"system_guide_toc": payload.get("toc") or [],
}
+10
View File
@@ -19,7 +19,12 @@ def register_trade_records_api(
filter_trade_records_excluding_miss: Callable[[list], list],
app_tz: Any,
format_price_fn: Callable[[Any, Any], str] | None = None,
sync_exchange_pnl_fn: Callable[[Any], Any] | None = None,
) -> None:
"""
sync_exchange_pnl_fn(conn): 可选,列表前节流回填交易所已实现盈亏.
中控只走本 API,不经实例整页渲染,必须在此触发,否则盈亏U会一直显示.
"""
from lib.instance.records_list_lib import list_trade_records_page
@app.route("/api/trade_records")
@@ -40,6 +45,11 @@ def register_trade_records_api(
offset = 0
conn = get_db()
try:
if sync_exchange_pnl_fn is not None:
try:
sync_exchange_pnl_fn(conn)
except Exception:
pass
payload = list_trade_records_page(
conn,
start_bj,
@@ -10,7 +10,7 @@
<div class="display-prefs-checks">
{% for item in group.entries %}
<label class="chk-label">
<input type="checkbox" data-pref-key="{{ item.key }}"{% if display.get(item.key, true) %} checked{% endif %}>
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
{{ item.label }}
</label>
{% endfor %}
@@ -314,6 +314,10 @@
{% include 'risk_policy_panel.html' %}
{% endif %}
{% if page == 'system_guide' %}
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
+7 -5
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=50"></script>
<script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=10">
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title>
@@ -56,6 +57,7 @@
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide" class="{% if initial_tab == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
{% endif %}
@@ -64,7 +66,7 @@
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
{% include 'instance_header_panel.html' %}
{% if initial_tab not in ('settings', 'risk_policy', 'env_config') and include_transfer_block %}
{% if initial_tab not in ('settings', 'risk_policy', 'system_guide', 'env_config') and include_transfer_block %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -92,7 +94,7 @@
</div>
<script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
@@ -116,7 +118,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=14"></script>
<script src="/static/instance_settings_prefs.js?v=15"></script>
<script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=27"></script>
</body>
+15 -1
View File
@@ -64,7 +64,13 @@
type="password"
data-env-key="{{ field.key }}"
placeholder="{% if field.has_value %}修改时填写新值,留空不修改{% else %}请输入{% endif %}"
autocomplete="off"
autocomplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
readonly
onfocus="this.removeAttribute('readonly')"
>
{% else %}
<input
@@ -73,6 +79,14 @@
type="text"
data-env-key="{{ field.key }}"
value="{{ field.current or field.default or '' }}"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
>
{% endif %}
</div>
+11 -5
View File
@@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=50"></script>
<script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=4"></script>
@@ -16,8 +17,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=10">
<link rel="stylesheet" href="/static/instance_theme.css?v=97">
<link rel="stylesheet" href="/static/instance_page.css?v=11">
<link rel="stylesheet" href="/static/instance_theme.css?v=105">
</head>
<body
@@ -143,6 +144,7 @@
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
<a href="/system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
{% endif %}
@@ -151,7 +153,7 @@
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %}
{% if page not in ('settings', 'risk_policy', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -390,6 +392,10 @@
{% include 'risk_policy_panel.html' %}
{% endif %}
{% if page == 'system_guide' %}
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
@@ -451,7 +457,7 @@
</div>
<script src="/static/instance_ui.js?v=10"></script>
<script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/journal_upload_slots.js?v=4"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=3"></script>
<script src="/static/ai_review_render.js?v=2"></script>
@@ -2014,6 +2020,6 @@ document.addEventListener("DOMContentLoaded", function () {
});
{% endif %}
</script>
<script src="/static/instance_settings_prefs.js?v=14"></script>
<script src="/static/instance_settings_prefs.js?v=15"></script>
</body>
</html>
+1 -12
View File
@@ -1,15 +1,4 @@
{# 三所统一顶栏:实时价 + 可选整点前开仓开关(划转已移至系统设置) #}
{# 三所统一顶栏:实时价(划转已移至系统设置;切点前开仓说明见风控说明·交易执行) #}
<div class="rule-tip instance-price-bar">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
</div>
{% if ui_open_guard_enabled %}
<div class="rule-tip" id="open-guard-bar" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:#cfd3ef">
<input type="checkbox" id="allow-open-before-reset" {% if not open_guard_enabled %}checked{% endif %}>
允许北京时间 {{ reset_hour }}:00 前开仓(斐波成交登记,人工下单)
</label>
<span id="open-guard-status" style="color:#8892b0;font-size:.75rem">
{% if open_guard_enabled %}已限制:{{ reset_hour }}:00 前不可开仓{% else %}已放开:{{ reset_hour }}:00 前允许开仓{% endif %}
</span>
</div>
{% endif %}
@@ -8,14 +8,17 @@
不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
<strong>持仓中不划转</strong>并微信通知.
</p>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required>
<select name="from_account" aria-label="划出账户">
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" id="manual-xfer-amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" aria-label="划出账户" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account" aria-label="划入账户">
<select name="to_account" aria-label="划入账户" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
@@ -1,7 +1,7 @@
{# 系统设置 · 账户密码(外层 card 由 settings_panel 提供) #}
<h2>账户密码修改</h2>
<p class="settings-subcard-desc">修改网页登录账号密码,写入 <code>.env</code> 后需重启实例生效.</p>
<div class="settings-password-form">
<div class="settings-password-form password-settings" data-password-settings="1">
<label>当前密码 <input type="password" id="pwd-old" autocomplete="current-password"></label>
<label>新用户名(可选) <input type="text" id="pwd-new-username" autocomplete="username"></label>
<label>新密码 <input type="password" id="pwd-new" autocomplete="new-password"></label>
+1 -1
View File
@@ -92,7 +92,7 @@
<label><input type="checkbox" name="mood_issues" value="扛单">扛单</label>
<label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label>
</div>
<textarea name="note" rows="2" placeholder="备注"></textarea>
<textarea name="note" rows="2" placeholder="备注" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other"></textarea>
<button type="submit" style="margin-top:8px">保存复盘记录</button>
</form>
</div>
@@ -0,0 +1,87 @@
{# 系统说明: docs/系统说明.md + h2 目录 #}
<div class="system-guide-page full">
<div class="card system-guide-card">
<div class="system-guide-head">
<h2 style="margin:0">系统说明</h2>
<p class="muted" style="margin:6px 0 0;font-size:.85rem">操作与逻辑按章节混排。默认不在顶栏显示;可在系统设置 → 导航显示中打开。</p>
</div>
<div class="system-guide-layout">
{% if system_guide_toc %}
<aside class="system-guide-toc" aria-label="章节目录">
<div class="system-guide-toc-title">目录</div>
<nav>
{% for item in system_guide_toc %}
<a href="#{{ item.id }}">{{ item.title }}</a>
{% endfor %}
</nav>
</aside>
{% endif %}
<article class="system-guide-body prose">
{{ system_guide_html|safe }}
</article>
</div>
</div>
</div>
<style>
.system-guide-page { grid-column: 1 / -1; }
.system-guide-card { padding: 16px 18px 28px; }
.system-guide-layout {
display: grid;
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
gap: 18px;
margin-top: 14px;
align-items: start;
}
.system-guide-toc {
position: sticky;
top: 8px;
padding: 10px 12px;
border: 1px solid rgba(127,127,127,.25);
border-radius: 8px;
background: rgba(127,127,127,.06);
max-height: calc(100vh - 120px);
overflow: auto;
}
.system-guide-toc-title {
font-size: .78rem;
font-weight: 600;
opacity: .75;
margin-bottom: 8px;
}
.system-guide-toc a {
display: block;
font-size: .84rem;
line-height: 1.35;
padding: 5px 0;
text-decoration: none;
color: inherit;
opacity: .9;
}
.system-guide-toc a:hover { opacity: 1; text-decoration: underline; }
.system-guide-body { min-width: 0; line-height: 1.65; font-size: .92rem; }
.system-guide-body h1 { font-size: 1.35rem; margin: 0 0 12px; }
.system-guide-body h2 { font-size: 1.12rem; margin: 22px 0 10px; padding-top: 4px; scroll-margin-top: 12px; }
.system-guide-body h3 { font-size: 1rem; margin: 16px 0 8px; }
.system-guide-body p, .system-guide-body li { margin: 0 0 8px; }
.system-guide-body ul, .system-guide-body ol { padding-left: 1.35em; margin: 0 0 10px; }
.system-guide-body table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: .86rem; }
.system-guide-body th, .system-guide-body td {
border: 1px solid rgba(127,127,127,.35);
padding: 7px 9px;
text-align: left;
vertical-align: top;
}
.system-guide-body code {
font-family: ui-monospace, Consolas, monospace;
font-size: .86em;
padding: 1px 4px;
border-radius: 4px;
background: rgba(127,127,127,.12);
}
.system-guide-body hr { border: 0; border-top: 1px solid rgba(127,127,127,.28); margin: 18px 0; }
@media (max-width: 820px) {
.system-guide-layout { grid-template-columns: 1fr; }
.system-guide-toc { position: static; max-height: none; }
.system-guide-toc nav { display: flex; flex-wrap: wrap; gap: 4px 12px; }
}
</style>
+3 -1
View File
@@ -52,12 +52,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
)
inst = str(p.get("inst_id") or "")
source_key, source_label = _resolve_options_source(conn, inst)
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
p["source"] = source_key
p["source_label"] = source_label
p["source_plan_id"] = source_plan_id
p["target_monitor_text"] = _format_options_target(p)
except Exception:
p.setdefault("source_label", "")
p.setdefault("source_plan_id", None)
p.setdefault("target_monitor_text", "")
finally:
conn.close()
+99 -2
View File
@@ -1,13 +1,17 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime, timezone
from typing import Any, Callable
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _safe_float(v: Any) -> float | None:
if v is None:
@@ -121,20 +125,113 @@ def run_options_profit_alerts(
def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if not created_at:
return None
raw = str(created_at).strip()
if not raw:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%f"):
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(raw[:26], fmt).replace(tzinfo=timezone.utc)
dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def _group_key_for_closed_trade(row: Any) -> str:
inst = str(row["inst_id"] or "").strip()
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
if ord_id:
return f"{inst}|ord:{ord_id}"
closed = str(row["closed_at"] or "").strip()
return f"{inst}|close:{(closed[:16] if closed else '')}"
def backfill_closed_options_realized_pnl_from_history(
conn: sqlite3.Connection,
hist_rows: list[dict[str, Any]],
*,
trade_limit: int = 200,
) -> int:
"""
OKX positions-history realizedPnl 覆盖本地已平记录.
同一次平仓多笔本地 open(加仓)按权利金占比分摊交易所总盈亏.
"""
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if not inst:
continue
by_inst.setdefault(inst, []).append(raw)
rows = conn.execute(
"""
SELECT id, inst_id, sheets, premium_paid, realized_pnl, created_at, closed_at, close_ord_id
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT ?
""",
(int(trade_limit),),
).fetchall()
if not rows:
return 0
groups: dict[str, list[Any]] = {}
for row in rows:
inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst:
continue
groups.setdefault(_group_key_for_closed_trade(row), []).append(row)
updated = 0
for group in groups.values():
inst = str(group[0]["inst_id"] or "").strip()
open_candidates = [_created_at_ms(r["created_at"]) for r in group]
open_ms = min((x for x in open_candidates if x is not None), default=None)
close_info = resolve_option_close_from_history(by_inst.get(inst) or [], open_ms=open_ms)
if not close_info:
continue
ex_pnl = _safe_float(close_info.get("realized_pnl"))
if ex_pnl is None:
continue
close_quote = _safe_float(close_info.get("close_quote"))
total_paid = 0.0
for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
allocated = 0.0
for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0)
if i == len(group) - 1:
share = round(float(ex_pnl) - allocated, 4)
elif total_paid > 0:
share = round(float(ex_pnl) * (paid / total_paid), 4)
allocated += share
else:
share = round(float(ex_pnl) / len(group), 4)
allocated += share
local = _safe_float(r["realized_pnl"])
if local is not None and abs(local - share) < 1e-6:
continue
prem_recv = round(paid + share, 4)
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = ?,
premium_received = ?,
close_quote = COALESCE(?, close_quote)
WHERE id = ?
""",
(share, prem_recv, close_quote, int(r["id"])),
)
updated += 1
return updated
def sync_open_options_trades(
conn: sqlite3.Connection,
*,
+51 -3
View File
@@ -108,6 +108,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
"chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True),
"itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
# 市价平仓已硬关闭(忽略 env),仅买一限价
@@ -262,8 +263,12 @@ def _sync_options_trades(
if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC:
return
_OPTIONS_SYNC_LAST_AT = now
from lib.exchange.okx_options_lib import fetch_option_position_history
from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades
from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history
from lib.options.options_monitor_lib import (
backfill_closed_options_realized_pnl_from_history,
reconcile_live_open_trades,
sync_open_options_trades,
)
if raw_positions is None:
raw = cfg["fetch_option_positions"](ex)
@@ -281,6 +286,11 @@ def _sync_options_trades(
init_options_tables(conn)
reconcile_live_open_trades(conn, live_inst_ids=live_ids)
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
try:
hist_all = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist_all)
except Exception:
pass
conn.commit()
finally:
conn.close()
@@ -370,6 +380,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
expiries = chain.get("expiries") or []
chain_err = chain.get("chain_error")
# 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if not expiries:
return jsonify(
{
@@ -377,9 +390,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"msg": chain_err or "暂无到期日,请稍后点「刷新链」",
**chain,
"chain_max_dte_days": cfg["chain_max_dte_days"],
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
}
)
return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]})
return jsonify(
{
"ok": True,
**chain,
"chain_max_dte_days": cfg["chain_max_dte_days"],
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
}
)
@app.route("/api/options/quote")
@lr
@@ -509,6 +534,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
try:
from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg
conn_gate = cfg["get_db"]()
try:
block_msg = block_standalone_option_open_msg(conn_gate)
finally:
conn_gate.close()
if block_msg:
return jsonify({"ok": False, "msg": block_msg})
except Exception:
pass
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
mode = (data.get("mode") or "budget_full").strip()
@@ -763,6 +800,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
hedge_target = hedge_target_map.get(inst)
if hedge_target:
row["hedge_plan_target"] = hedge_target
try:
from lib.instance.instance_dashboard_lib import _resolve_options_source
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
row["source"] = source_key
row["source_label"] = source_label
row["source_plan_id"] = source_plan_id
except Exception:
row.setdefault("source", "option")
row.setdefault("source_label", "纯期权")
row.setdefault("source_plan_id", None)
rows.append(row)
finally:
conn.close()
+11 -5
View File
@@ -112,17 +112,23 @@ def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
def options_review_image_paths(row: Any, upload_folder: str) -> List[str]:
upload_folder = os.path.abspath(upload_folder or "")
upload_root = os.path.abspath(upload_folder or "")
options_dir = options_review_upload_dir(upload_root)
paths: List[str] = []
seen: set[str] = set()
def _add(name: Optional[str]) -> None:
if not name:
return
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
if os.path.isfile(p) and p not in seen:
seen.add(p)
paths.append(p)
base = os.path.basename(str(name).strip())
if not base:
return
for folder in (options_dir, upload_root):
p = os.path.abspath(os.path.join(folder, base))
if os.path.isfile(p) and p not in seen:
seen.add(p)
paths.append(p)
return
try:
keys = row.keys() if hasattr(row, "keys") else ()
+66 -4
View File
@@ -556,8 +556,28 @@ def sync_all_review_sources(
return out
def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源."""
def ensure_local_review_synced(
conn: sqlite3.Connection,
*,
ex: Any | None = None,
backfill_exchange_pnl: bool = True,
) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
if backfill_exchange_pnl and ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
from lib.options.options_monitor_lib import (
backfill_closed_options_realized_pnl_from_history,
)
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist)
backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
return sync_all_review_sources(conn, from_exchange=False)
@@ -581,22 +601,43 @@ def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -
out["entry"] = dict(entry)
out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json"))
out["strategy_tag"] = entry.get("strategy_tag")
out["direction_view"] = entry.get("direction_view")
out["entry_logic"] = entry.get("entry_logic")
out["result_tag"] = entry.get("result_tag")
out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at")
else:
out["entry"] = None
out["strategy_tag"] = None
out["direction_view"] = None
out["entry_logic"] = None
out["result_tag"] = None
out["reviewed_at"] = None
return out
def _review_search_tokens(q: str) -> list[str]:
"""自由搜索词:BTCUSDT 同时匹配 BTC / BTCUSDT."""
raw = str(q or "").strip()
if not raw:
return []
tokens = [raw]
u = raw.upper()
for suf in ("-USDT", "-USD", "-USDC", "USDT", "USD", "USDC"):
if u.endswith(suf) and len(u) > len(suf):
base = u[: -len(suf)].rstrip("-_")
if base and base not in {t.upper() for t in tokens}:
tokens.append(base)
break
return tokens
def _review_trades_filters(
*,
source_type: str | None = None,
underlying: str | None = None,
opt_type: str | None = None,
strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None,
include_hedge_legs: bool = False,
closed_from: str | None = None,
@@ -635,9 +676,26 @@ def _review_trades_filters(
if closed_to:
wheres.append("COALESCE(t.closed_at,'')<=?")
args.append(closed_to)
if strategy_tag:
wheres.append("e.strategy_tag=?")
# 兼容旧参数:精确策略标签;前端已改用 q 模糊搜索
if strategy_tag and not q:
wheres.append("UPPER(COALESCE(e.strategy_tag,''))=UPPER(?)")
args.append(strategy_tag)
search_tokens = _review_search_tokens(q or "")
if search_tokens:
token_ors: list[str] = []
for tok in search_tokens:
like = f"%{tok}%"
token_ors.append(
"""(
UPPER(COALESCE(t.underlying,'')) LIKE UPPER(?)
OR UPPER(COALESCE(t.inst_id,'')) LIKE UPPER(?)
OR UPPER(COALESCE(t.legs_json,'')) LIKE UPPER(?)
OR UPPER(COALESCE(e.strategy_tag,'')) LIKE UPPER(?)
OR UPPER(COALESCE(e.result_tag,'')) LIKE UPPER(?)
)"""
)
args.extend([like, like, like, like, like])
wheres.append("(" + " OR ".join(token_ors) + ")")
if reviewed == "1" or reviewed == "yes":
wheres.append("e.id IS NOT NULL")
elif reviewed == "0" or reviewed == "no":
@@ -653,6 +711,7 @@ def count_review_trades(
underlying: str | None = None,
opt_type: str | None = None,
strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None,
include_hedge_legs: bool = False,
closed_from: str | None = None,
@@ -664,6 +723,7 @@ def count_review_trades(
underlying=underlying,
opt_type=opt_type,
strategy_tag=strategy_tag,
q=q,
reviewed=reviewed,
include_hedge_legs=include_hedge_legs,
closed_from=closed_from,
@@ -688,6 +748,7 @@ def list_review_trades(
underlying: str | None = None,
opt_type: str | None = None,
strategy_tag: str | None = None,
q: str | None = None,
reviewed: str | None = None,
include_hedge_legs: bool = False,
closed_from: str | None = None,
@@ -701,6 +762,7 @@ def list_review_trades(
underlying=underlying,
opt_type=opt_type,
strategy_tag=strategy_tag,
q=q,
reviewed=reviewed,
include_hedge_legs=include_hedge_legs,
closed_from=closed_from,
+16 -7
View File
@@ -26,7 +26,6 @@ from lib.options.options_review_lib import (
hide_review_trade,
list_review_trades,
save_review_entry,
sync_all_review_sources,
)
@@ -94,23 +93,30 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
return send_file(path, mimetype="application/javascript; charset=utf-8")
@app.route("/static/images/options_journal/<path:filename>")
@lr
def static_options_review_image(filename: str):
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
folder = options_review_upload_dir(cfg["upload_folder"])
safe = os.path.basename(filename or "")
path = os.path.join(folder, safe)
if not os.path.isfile(path):
return ("not found", 404)
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
root = os.path.abspath(cfg["upload_folder"] or "")
alt = os.path.join(root, safe)
if os.path.isfile(alt):
path = alt
else:
return ("not found", 404)
return send_file(path)
@app.route("/api/options/review/sync", methods=["POST"])
@lr
def api_options_review_sync():
"""刷新本地 options_trades + 已结束对冲计划(不访问交易所)."""
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
conn = cfg["get_db"]()
try:
init_options_review_tables(conn)
result = sync_all_review_sources(conn, from_exchange=False)
ex, _err = _require_ex(cfg)
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
return jsonify(result)
finally:
@@ -128,13 +134,15 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
"no",
)
if do_sync:
ensure_local_review_synced(conn)
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
filt = dict(
source_type=(request.args.get("source_type") or "").strip() or None,
underlying=(request.args.get("underlying") or "").strip() or None,
opt_type=(request.args.get("opt_type") or "").strip() or None,
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
q=(request.args.get("q") or "").strip() or None,
reviewed=(request.args.get("reviewed") or "").strip() or None,
include_hedge_legs=(request.args.get("include_hedge_legs") or "")
.strip()
@@ -259,7 +267,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
def api_options_review_stats():
conn = cfg["get_db"]()
try:
ensure_local_review_synced(conn)
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
stats = compute_review_stats(
conn,
+73 -31
View File
@@ -1,13 +1,29 @@
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}">
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %}
<div class="options-dual-grid">
<div class="card options-order-card">
<h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 <strong>~</strong>(仅参考).<strong>开仓只认真实卖一价且卖一深度&gt;0</strong>;无深度时面板显示参考标记价并禁用买入.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
<h2>期权下单</h2>
<details class="opt-close-rule opt-open-rule">
<summary>开仓规则说明</summary>
<div class="opt-close-rule-body">
<p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
<ul>
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;<strong>T 型</strong>默认 ATM ±5 档,可展开全部。</li>
<li>「按可用余额打满」可用额度 = min(交易 USDC × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「预算缓冲比例」改。</li>
<li>平仓仅买一限价,详见说明文档。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
<div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
@@ -64,11 +80,14 @@
</tbody>
</table>
</div>
<div id="opt-order-panel-host" class="opt-order-panel-host" hidden aria-hidden="true">
<div id="opt-order-panel" class="opt-order-panel-inner" style="display:none">
<div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
<div id="opt-order-panel" class="opt-order-dialog" role="dialog" aria-modal="true" aria-labelledby="opt-order-dialog-title" style="display:none">
<div class="opt-order-dialog-head">
<h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3>
<button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button>
</div>
<div class="opt-order-layout">
<div class="opt-order-main">
<h3 class="opt-order-title">下单</h3>
<div id="opt-order-inst" class="options-order-inst"></div>
<div class="options-order-grid">
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v"></span></div>
@@ -82,37 +101,48 @@
<div><span class="k">距平衡</span><span id="opt-order-dist-be" class="v"></span></div>
</div>
<div class="options-estimate-row">
<label class="opt-est-label" for="opt-target-idx">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
</div>
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
</div>
<div class="form-row options-order-mode-row">
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数">
<label><input type="radio" name="opt-size-mode" value="budget_full"> 按可用余额打满</label>
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
<div class="opt-size-mode-bar">
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="sheets" checked>
<span>指定张数</span>
</label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="budget_full">
<span>按可用余额打满</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span>
</label>
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
</div>
<div class="opt-order-dialog-actions">
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
</div>
<div id="opt-order-msg" class="muted"></div>
</div>
<aside class="opt-order-pending" aria-label="未成交委托">
<div class="opt-order-pending-head">
<h4 class="opt-order-pending-title">委托</h4>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
</div>
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint">平仓限价超 10 分未成交将自动撤销</p>
<div id="opt-pending-list" class="opt-pending-list">
<div class="muted opt-pending-empty">暂无未成交委托</div>
</div>
</aside>
</div>
</div>
</div>
@@ -125,6 +155,7 @@
</div>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
<button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="history" role="tab" aria-selected="false" id="opt-pos-tab-history">期权历史</button>
</div>
@@ -152,6 +183,17 @@
</div>
</details>
</div>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
<div class="opt-pos-pending-pane">
<div class="opt-order-pending-head">
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
</div>
<div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab">
<div class="muted opt-pending-empty">暂无未成交委托</div>
</div>
</div>
</div>
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
<div class="options-stats-panel">
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
@@ -274,4 +316,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=39"></script>
<script src="/static/options_panel.js?v=49"></script>
+255 -71
View File
@@ -1,20 +1,110 @@
{# OKX 期权复盘:交易记录(5行)复盘表单 → 复盘记录 → 统计 #}
{# OKX 期权复盘:交易记录 → 复盘表单 → 复盘记录 → 统计 #}
<div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px;font-size:.82rem">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.</div>
{% endif %}
<style>
.options-review-wrap{font-size:.82rem}
.options-review-wrap h2{font-size:1rem;margin:0 0 8px}
.options-review-wrap h3{font-size:.9rem;margin:0 0 8px}
.options-review-wrap{font-size:.82rem;display:flex;flex-direction:column;gap:14px}
.options-review-wrap h2,.options-review-wrap h3{margin:0}
.or-page-head{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
padding:2px 2px 0;
}
.or-page-head h2{font-size:1.05rem;font-weight:650;margin-right:auto;letter-spacing:.02em}
.or-section{
margin:0;padding:14px 16px 16px;
border:1px solid var(--or-border, rgba(127,127,127,.28));
border-radius:12px;
background:var(--or-section-bg, rgba(18,23,38,.55));
box-shadow:var(--or-section-shadow, 0 1px 0 rgba(255,255,255,.03) inset);
color:var(--or-text, inherit);
}
.or-section-head{
display:flex;align-items:flex-start;gap:10px;flex-wrap:wrap;
margin-bottom:12px;padding-bottom:10px;
border-bottom:1px solid var(--or-border-soft, rgba(127,127,127,.22));
}
.or-section-head > div{min-width:0;flex:1}
.or-step{
flex-shrink:0;width:1.55rem;height:1.55rem;border-radius:999px;
display:inline-flex;align-items:center;justify-content:center;
font-size:.72rem;font-weight:700;
background:var(--or-accent-bg, rgba(99,102,241,.28));
color:var(--or-accent-fg, #c7c9ff);
border:1px solid var(--or-accent-border, rgba(129,140,248,.45));
}
.or-section-title{font-size:.95rem;font-weight:650;line-height:1.3;color:var(--or-title, inherit)}
.or-section-desc{margin:4px 0 0;font-size:.72rem;opacity:.72;line-height:1.45;color:var(--or-muted, inherit)}
.or-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.or-tab{border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;padding:5px 10px;border-radius:6px;cursor:pointer;font-size:.78rem}
.or-tab.active{background:rgba(59,130,246,.25);border-color:rgba(59,130,246,.55)}
.or-badge{display:inline-block;padding:1px 6px;border-radius:999px;background:rgba(127,127,127,.2);font-size:.7rem}
.or-stat-card{border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:8px;font-size:.78rem}
.or-trades-table{font-size:.78rem}
.or-trades-table tr.or-row-active{outline:1px solid rgba(59,130,246,.55);background:rgba(59,130,246,.08)}
.or-toolbar{display:flex;flex-direction:column;gap:0;margin:0}
.or-tab{
border:1px solid var(--or-border, rgba(127,127,127,.35));background:transparent;color:inherit;
padding:6px 12px;border-radius:8px;cursor:pointer;font-size:.78rem;
}
.or-tab.active{
background:var(--or-accent-bg, rgba(99,102,241,.28));
border-color:var(--or-accent-border, rgba(129,140,248,.55));
color:var(--or-accent-fg, #e8e9ff);font-weight:600;
}
.or-filters{
display:flex;flex-wrap:wrap;gap:8px;align-items:center;
margin:0;padding:10px 12px;border-radius:10px;
background:var(--or-filters-bg, rgba(0,0,0,.22));
border:1px solid var(--or-border-soft, rgba(127,127,127,.18));
}
.or-filters select,.or-filters input[type="search"],.or-filters input[type="datetime-local"]{
font-size:.76rem;min-height:2rem;
}
.or-filters #or-filter-q{max-width:168px}
.or-filters label{display:flex;align-items:center;gap:5px;font-size:.72rem;opacity:.85}
.or-badge{
display:inline-block;padding:1px 7px;border-radius:999px;
background:var(--or-badge-bg, rgba(127,127,127,.22));font-size:.7rem;vertical-align:middle;
}
.or-list-title{display:none}
.or-trades-table,.or-reviewed-table{font-size:.78rem}
.or-trades-table tr.or-row-active{
outline:1px solid var(--or-accent-border, rgba(129,140,248,.55));
background:var(--or-row-active-bg, rgba(99,102,241,.1));
}
.or-reviewed-table tbody tr{cursor:pointer}
.or-reviewed-table tbody tr:hover{background:var(--or-row-hover-bg, rgba(99,102,241,.08))}
.or-pager{
display:flex;align-items:center;gap:8px;margin-top:10px;
padding-top:8px;border-top:1px dashed var(--or-border-soft, rgba(127,127,127,.2));font-size:.74rem;
}
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem;overflow-x:auto}
.or-reviewed-table{min-width:980px}
.or-kpi-row{
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
gap:8px;margin-bottom:12px;
}
.or-kpi-tile{
border:1px solid var(--or-border-soft, rgba(127,127,127,.22));border-radius:10px;
padding:10px 12px;background:var(--or-tile-bg, rgba(0,0,0,.2));min-width:0;
}
.or-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
.or-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
.or-stats-grid{
display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;
}
.or-stat-card{
border:1px solid var(--or-border-soft, rgba(127,127,127,.22));border-radius:10px;
padding:10px 12px;background:var(--or-tile-bg, rgba(0,0,0,.16));font-size:.76rem;
}
.or-stat-card-title{
font-weight:650;margin-bottom:8px;font-size:.78rem;
padding-bottom:6px;border-bottom:1px solid var(--or-border-soft, rgba(127,127,127,.18));
}
.or-stat-row{
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid var(--or-border-faint, rgba(127,127,127,.1));
}
.or-stat-row:last-child{border-bottom:none;padding-bottom:0}
.or-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
.or-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
.or-journal-card{font-size:.78rem}
.or-journal-card h2{font-size:.92rem}
.or-journal-card input,
@@ -22,56 +112,117 @@
.or-journal-card textarea,
.or-journal-card button{font-size:.76rem}
.or-journal-card .or-form-grid,
.or-journal-card .or-form-grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:6px}
.or-journal-card .or-form-grid2{
display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:6px;
}
.or-journal-card .or-mood-grid{display:flex;flex-wrap:wrap;gap:6px 12px;margin:8px 0;font-size:.74rem}
.or-journal-card .muted,
.or-journal-card .sub{font-size:.7rem}
.or-journal-card .muted,.or-journal-card .sub{font-size:.7rem}
.or-journal-card.hidden{display:none!important}
.or-reviewed-table tbody tr{cursor:pointer}
.or-detail-panel{margin-top:10px;padding-top:10px;border-top:1px solid rgba(127,127,127,.25)}
.or-detail-panel.hidden{display:none!important}
.or-detail-backdrop{
position:fixed;inset:0;z-index:1300;
display:flex;align-items:center;justify-content:center;
padding:16px;background:var(--or-backdrop, rgba(0,0,0,.72));
}
.or-detail-backdrop[hidden]{display:none!important}
.or-img-lightbox{
position:fixed;inset:0;z-index:2200;
display:flex;align-items:center;justify-content:center;
padding:16px;background:rgba(0,0,0,.86);cursor:zoom-out;
}
.or-img-lightbox[hidden]{display:none!important}
.or-img-lightbox img{
max-width:min(96vw,1200px);max-height:92vh;
object-fit:contain;border-radius:8px;
box-shadow:0 12px 40px rgba(0,0,0,.55);
}
.or-detail-modal{
width:min(96vw,920px);max-height:90vh;overflow:auto;
background:var(--or-modal-bg, var(--card-bg, #121726));color:var(--or-text, inherit);
border:1px solid var(--or-border, rgba(127,127,127,.35));border-radius:10px;
padding:14px 16px 18px;box-shadow:var(--or-modal-shadow, 0 12px 40px rgba(0,0,0,.45));
}
.or-detail-modal-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.or-detail-modal-head h3{margin:0;margin-right:auto;font-size:.95rem;color:var(--or-title, inherit)}
.or-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px 12px;font-size:.76rem;margin-bottom:8px}
.or-detail-images{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:8px 0}
.or-detail-img-cell{border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:6px;text-align:center}
.or-detail-img-label{display:block;font-size:.7rem;margin-bottom:4px;opacity:.8}
.or-detail-img-thumb{max-width:100%;max-height:160px;border-radius:4px;cursor:pointer}
.or-pager{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:.74rem}
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem}
.or-detail-images{
display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px;margin:10px 0 4px;
}
.or-detail-img-cell{
min-width:0;border:1px solid var(--or-border-soft, rgba(127,127,127,.25));border-radius:8px;
padding:8px;display:flex;flex-direction:column;gap:6px;
background:var(--or-tile-bg, rgba(0,0,0,.18));
}
.or-detail-img-label{font-size:.72rem;opacity:.85;font-weight:600}
.or-detail-img-thumb{
width:100%;max-height:280px;object-fit:contain;
border-radius:6px;cursor:zoom-in;background:var(--or-img-bg, rgba(0,0,0,.25));
}
.or-detail-img-miss{
min-height:120px;display:flex;align-items:center;justify-content:center;
font-size:.72rem;opacity:.65;border-radius:6px;background:rgba(127,127,127,.12);
}
.or-slot-thumb{
display:block;margin-top:6px;max-width:160px;max-height:90px;
object-fit:contain;border-radius:4px;border:1px solid var(--or-border, rgba(127,127,127,.3));
background:var(--or-img-bg, rgba(0,0,0,.2));cursor:zoom-in;
}
@media (max-width:900px){
.or-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:640px){
.or-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
.or-detail-images{grid-template-columns:1fr}
.or-detail-img-thumb{max-height:220px}
}
</style>
{# 1. 交易记录(含 Tab/筛选,固定约5行) #}
<div class="card" style="margin-bottom:10px">
<div class="form-row" style="flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:6px">
<h2 style="margin:0;margin-right:auto">期权复盘</h2>
<span class="muted" id="or-sync-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
<div class="or-page-head">
<h2>期权复盘</h2>
<span class="muted" id="or-sync-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
{# Tab + 筛选:放在各内容卡片上方,全局作用于下方列表/统计 #}
<div class="or-toolbar">
<div class="or-tabs" role="tablist" aria-label="复盘分类">
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
<button type="button" class="or-tab" data-source="options_options" role="tab">期期对冲记录</button>
<button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button>
</div>
<p class="muted" style="margin:0 0 8px;font-size:.72rem">待复盘交易(每页5条).点「复盘」填写表单;保存后进入下方复盘记录.</p>
<div class="form-row" style="flex-wrap:wrap;gap:6px;margin-bottom:8px">
<select id="or-filter-uly" style="font-size:.76rem">
<div class="or-filters">
<select id="or-filter-uly" autocomplete="off">
<option value="">标的:全部</option>
<option value="ETH">ETH</option>
<option value="BTC">BTC</option>
</select>
<select id="or-filter-opt" style="font-size:.76rem">
<select id="or-filter-opt" autocomplete="off">
<option value="">Call/Put:全部</option>
<option value="C">Call</option>
<option value="P">Put</option>
</select>
<input type="text" id="or-filter-strategy" placeholder="策略标签" style="max-width:110px;font-size:.76rem">
<input type="datetime-local" id="or-filter-from" title="平仓起" style="font-size:.76rem">
<input type="datetime-local" id="or-filter-to" title="平仓止" style="font-size:.76rem">
<label class="muted" style="display:flex;align-items:center;gap:4px;font-size:.72rem">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input type="search" id="or-filter-q" name="or_filter_q" placeholder="搜索标的/合约/策略"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
<input type="datetime-local" id="or-filter-from" title="平仓起" autocomplete="off">
<input type="datetime-local" id="or-filter-to" title="平仓止" autocomplete="off">
<label class="muted">
<input type="checkbox" id="or-include-hedge-legs"> 含已归属对冲的期权腿
</label>
</div>
<h3 id="or-list-title" style="margin-top:0">期权交易记录</h3>
</div>
{# 1. 交易记录 #}
<section class="or-section" aria-labelledby="or-list-title">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">1</span>
<div>
<div class="or-section-title" id="or-list-title">期权交易记录</div>
<p class="or-section-desc">点「复盘」填写表单;已复盘仍保留在此,也可在下方查看详情。</p>
</div>
</div>
<div class="options-strike-table-wrap or-trades-table-wrap" id="or-trades-wrap">
<table class="options-strike-table or-trades-table" id="or-trades-table">
<thead>
@@ -79,13 +230,14 @@
<th>类型</th>
<th>标的/合约</th>
<th>盈亏</th>
<th>/平</th>
<th>仓时间</th>
<th>平仓时间</th>
<th>持有</th>
<th>操作</th>
</tr>
</thead>
<tbody id="or-trades-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr>
<tr><td colspan="7" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
@@ -94,23 +246,33 @@
<span class="muted" id="or-trades-page-label">第 1 / 1 页</span>
<button type="button" class="btn-secondary" id="or-trades-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div>
</div>
</section>
{# 2. 复盘上传(默认隐藏,点交易「复盘」后显示) #}
<div class="card journal-card or-journal-card hidden" id="or-journal-card" style="margin-bottom:10px">
<h2>复盘记录上传(含截图)</h2>
<p class="muted" id="or-journal-summary" style="margin-top:0">截图槽位与合约复盘相同(5m / 15m / 1h / 4h).</p>
{# 2. 复盘上传(默认隐藏) #}
<section class="or-section journal-card or-journal-card hidden" id="or-journal-card">
<div class="or-section-head">
<span class="or-step" aria-hidden="true"></span>
<div>
<div class="or-section-title">填写复盘</div>
<p class="or-section-desc" id="or-journal-summary">截图槽位 5m / 15m / 1h / 4h,选文件后即时上传。</p>
</div>
</div>
<div class="or-journal-body">
<form id="or-journal-form" onsubmit="return false;">
<input type="hidden" id="or-trade-id" value="">
<input type="hidden" id="or-draft-id" value="">
<div class="or-form-grid">
<input type="datetime-local" id="or-f-open" title="开仓时间">
<input type="datetime-local" id="or-f-close" title="平仓时间">
<input type="text" id="or-f-coin" placeholder="标的(如 ETH)">
<input type="text" id="or-f-inst" placeholder="合约/计划">
<input type="text" id="or-f-pnl" placeholder="盈亏(U)">
<input type="text" id="or-f-hold" placeholder="持有时长" readonly>
<input type="datetime-local" id="or-f-open" title="开仓时间" autocomplete="off">
<input type="datetime-local" id="or-f-close" title="平仓时间" autocomplete="off">
<input type="text" id="or-f-coin" name="or_f_coin" placeholder="标的(如 ETH)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-inst" name="or_f_inst" placeholder="合约/计划"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-pnl" name="or_f_pnl" placeholder="盈亏(U)"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-hold" name="or_f_hold" placeholder="持有时长" readonly autocomplete="off">
</div>
<div class="or-form-grid2">
<select id="or-f-strategy" title="策略标签" required>
@@ -145,13 +307,12 @@
<option value="">入场逻辑</option>
</select>
<input type="hidden" id="journal-draft-id" value="">
<div class="journal-upload-slots" id="or-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %}
<div class="journal-upload-row" data-tf="{{ tf }}">
<span class="journal-upload-slot-label">{{ tf }}</span>
<input type="file" accept="image/*" class="journal-upload-slot-input or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="journal-upload-hidden-file or-upload-hidden" data-tf="{{ tf }}" value="">
<input type="file" accept="image/*" class="or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="or-upload-hidden" data-tf="{{ tf }}" value="">
<span class="journal-upload-status or-upload-status" data-tf="{{ tf }}" aria-live="polite"></span>
</div>
{% endfor %}
@@ -176,26 +337,36 @@
<div id="or-legs-host" style="margin-top:10px;font-size:.74rem"></div>
</form>
</div>
</div>
</section>
{# 3. 已复盘记录 + 详情 #}
<div class="card" style="margin-bottom:10px">
<h3>复盘记录</h3>
<p class="muted" style="margin:0 0 8px;font-size:.72rem">已保存的复盘(每页5条).点一行查看详情.</p>
{# 3. 已复盘记录 #}
<section class="or-section" aria-labelledby="or-reviewed-heading">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">2</span>
<div>
<div class="or-section-title" id="or-reviewed-heading">复盘记录</div>
<p class="or-section-desc">已保存的复盘内容,点一行查看详情与截图。</p>
</div>
</div>
<div class="options-strike-table-wrap or-reviewed-table-wrap" id="or-reviewed-wrap">
<table class="options-strike-table or-reviewed-table" id="or-reviewed-table">
<thead>
<tr>
<th>类型</th>
<th>标的/合约</th>
<th>方向</th>
<th>盈亏</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持仓时长</th>
<th>策略</th>
<th>入场逻辑</th>
<th>结果</th>
<th>复盘时间</th>
</tr>
</thead>
<tbody id="or-reviewed-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr>
<tr><td colspan="11" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
@@ -204,24 +375,37 @@
<span class="muted" id="or-reviewed-page-label">第 1 / 1 页</span>
<button type="button" class="btn-secondary" id="or-reviewed-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div>
<div class="or-detail-panel hidden" id="or-detail-panel">
<div class="form-row" style="align-items:center;gap:8px;margin-bottom:6px">
<h3 style="margin:0;margin-right:auto" id="or-detail-title">复盘详情</h3>
</section>
{# 详情 / 放大 #}
<div id="or-detail-backdrop" class="or-detail-backdrop" hidden>
<div class="or-detail-modal" role="dialog" aria-modal="true" aria-labelledby="or-detail-title" id="or-detail-panel">
<div class="or-detail-modal-head">
<h3 id="or-detail-title">复盘详情</h3>
<button type="button" class="btn-secondary" id="or-detail-edit-btn" style="font-size:.72rem;padding:2px 8px">编辑</button>
<button type="button" class="btn-secondary" id="or-detail-close-btn" style="font-size:.72rem;padding:2px 8px">收起</button>
<button type="button" class="btn-secondary" id="or-detail-close-btn" style="font-size:.72rem;padding:2px 8px">关闭</button>
</div>
<div class="or-detail-grid" id="or-detail-meta"></div>
<div id="or-detail-text" style="font-size:.76rem;line-height:1.5;margin-bottom:8px"></div>
<div class="or-detail-images" id="or-detail-images"></div>
</div>
</div>
<div id="or-img-lightbox" class="or-img-lightbox" hidden>
<img id="or-img-lightbox-img" src="" alt="截图放大">
</div>
{# 4. 统计 #}
<div class="card" style="margin-bottom:10px">
<h3>统计</h3>
<div id="or-kpi" class="form-row" style="flex-wrap:wrap;gap:10px"></div>
<div id="or-stats-groups" style="margin-top:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px"></div>
</div>
<section class="or-section" aria-labelledby="or-stats-heading">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">3</span>
<div>
<div class="or-section-title" id="or-stats-heading">统计</div>
<p class="or-section-desc">跟随上方 Tab 与筛选条件汇总。</p>
</div>
</div>
<div id="or-kpi" class="or-kpi-row"></div>
<div id="or-stats-groups" class="or-stats-grid"></div>
</section>
</div>
<script src="/static/options_review.js?v=10"></script>
<script src="/static/options_review.js?v=22"></script>
@@ -1,4 +1,4 @@
{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #}
<div id="options-settings-root" hidden
data-sub-account="{{ instance_settings.options_sub_account | default('', true) }}"></div>
<script src="/static/options_settings.js?v=8"></script>
<script src="/static/options_settings.js?v=9"></script>
@@ -1,11 +1,14 @@
<div class="options-settings-section">
<p class="options-settings-hint">主账户资金账户:USDT ↔ USDC 现货市价单.</p>
<div class="form-row settings-transfer-form options-settings-row">
<select id="opt-set-swap-dir" aria-label="兑换方向">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-swap-dir" aria-label="兑换方向" autocomplete="off">
<option value="usdt_to_usdc" selected>USDT → USDC</option>
<option value="usdc_to_usdt">USDC → USDT</option>
</select>
<input type="number" id="opt-set-swap-amount" min="0.01" step="0.01" placeholder="数量">
<input type="number" id="opt-set-swap-amount" name="cm_opt_swap_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-swap-all-btn">全部兑换</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-swap-btn">市价兑换</button>
</div>
@@ -1,7 +1,9 @@
<div class="options-settings-section">
<div class="options-settings-subtitle">主账户内</div>
<div class="form-row settings-transfer-form options-settings-row">
<select id="opt-set-int-ccy" aria-label="币种">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-int-ccy" aria-label="币种" autocomplete="off">
<option value="USDC" selected>USDC</option>
<option value="USDT">USDT</option>
</select>
@@ -13,7 +15,8 @@
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-set-int-amount" min="0.01" step="0.01" placeholder="数量">
<input type="number" id="opt-set-int-amount" name="cm_opt_int_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-int-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-int-btn">划转</button>
</div>
@@ -26,7 +29,9 @@
<span class="muted">({{ instance_settings.options_sub_account or '未配置' }})</span>
</div>
<div class="form-row settings-transfer-form options-settings-row">
<select id="opt-set-cross-dir" aria-label="主子方向">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-cross-dir" aria-label="主子方向" autocomplete="off">
<option value="main_to_sub" selected>主 → 子</option>
<option value="sub_to_main">子 → 主</option>
</select>
@@ -42,7 +47,8 @@
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-set-cross-amount" min="0.01" step="0.01" placeholder="数量">
<input type="number" id="opt-set-cross-amount" name="cm_opt_cross_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-cross-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-cross-btn">划转</button>
</div>
@@ -7,14 +7,17 @@
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};<strong>持仓中不划转</strong>并微信通知)
</div>
</details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
<select name="from_account">
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account">
<select name="to_account" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
@@ -1,13 +1,13 @@
{# 复盘表单:首行按字段宽度比例;下单类型/开仓类型与离场触发同一行 #}
{% macro journal_form_fields(entry_reason_options, order_type_options) -%}
<div class="form-grid journal-form-row1">
<input type="datetime-local" name="open_datetime" class="journal-field-datetime" required>
<input type="datetime-local" name="close_datetime" class="journal-field-datetime" required>
<input name="coin" class="journal-field-coin" placeholder="BTC" required>
<input name="tf" class="journal-field-tf" placeholder="5m" required>
<input name="pnl" class="journal-field-num" placeholder="盈亏(U)" required>
<input name="expect_rr" class="journal-field-num" placeholder="预期RR">
<input name="real_rr" class="journal-field-num" placeholder="实际RR">
<input type="datetime-local" name="open_datetime" class="journal-field-datetime" required autocomplete="off">
<input type="datetime-local" name="close_datetime" class="journal-field-datetime" required autocomplete="off">
<input name="coin" class="journal-field-coin" placeholder="BTC" required autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="tf" class="journal-field-tf" placeholder="5m" required autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="pnl" class="journal-field-num" placeholder="盈亏(U)" required autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="expect_rr" class="journal-field-num" placeholder="预期RR" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input name="real_rr" class="journal-field-num" placeholder="实际RR" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<div class="form-grid journal-form-row2">
<select name="direction" id="journal-direction" class="journal-field-direction" required title="做多/做空">
@@ -38,7 +38,7 @@
<option value="止损">止损</option>
<option value="其他">其他</option>
</select>
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
</div>
{%- endmacro %}
@@ -11,7 +11,7 @@
{% endfor %}
</select>
{% else %}
<input id="{{ id }}" name="{{ name }}" placeholder="{{ placeholder }}" {% if required %}required{% endif %} value="{{ value }}">
<input id="{{ id }}" name="{{ name }}" placeholder="{{ placeholder }}" {% if required %}required{% endif %} value="{{ value }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
{% endif %}
{%- endmacro %}
+69 -2
View File
@@ -86,6 +86,14 @@ def manual_close_daily_limit() -> int:
return 2
def daily_loss_limit() -> int:
"""日亏损次数上限:达限当日冻结开仓;0=不因亏损次数冻结."""
try:
return max(0, int(os.getenv("RISK_DAILY_LOSS_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def max_active_positions_from_env(default: int = 1) -> int:
try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default))))
@@ -116,6 +124,7 @@ def ensure_account_risk_schema(conn) -> None:
id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
daily_loss_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
@@ -124,10 +133,18 @@ def ensure_account_risk_schema(conn) -> None:
updated_at TEXT
)"""
)
cols = {
str(r[1])
for r in conn.execute("PRAGMA table_info(account_risk_state)").fetchall()
}
if "daily_loss_count" not in cols:
conn.execute(
"ALTER TABLE account_risk_state ADD COLUMN daily_loss_count INTEGER DEFAULT 0"
)
row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone()
if not row:
conn.execute(
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_loss_count, daily_frozen) VALUES (1, '', 0, 0, 0)"
)
@@ -268,6 +285,7 @@ def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) ->
"""UPDATE account_risk_state SET
trading_day=?,
manual_close_count=0,
daily_loss_count=0,
daily_frozen=0,
cooloff_until_ms=?,
cooloff_hours=?,
@@ -600,6 +618,43 @@ def on_manual_close(
)
def on_closed_trade_pnl(
conn,
*,
pnl_amount: Any,
trading_day: str,
now: Optional[datetime] = None,
) -> None:
"""
已平仓交易记盈亏后调用:亏损笔数达 RISK_DAILY_LOSS_LIMIT 则当日冻结开仓.
上限为 0 时不启用本规则.
"""
if not risk_control_enabled():
return
limit = daily_loss_limit()
if limit <= 0:
return
try:
pnl = float(pnl_amount)
except (TypeError, ValueError):
return
if pnl >= 0:
return
row = _sync_trading_day(conn, trading_day, now=now)
if int(_row_get(row, "daily_frozen") or 0) == 1:
return
count = int(_row_get(row, "daily_loss_count") or 0) + 1
conn.execute(
"""UPDATE account_risk_state SET
daily_loss_count=?,
updated_at=?
WHERE id=1""",
(count, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
)
if count >= limit:
_set_daily_frozen(conn, trading_day=trading_day, now=now)
def on_journal_saved(
conn,
*,
@@ -762,6 +817,7 @@ def compute_account_risk_status(
"cooloff_until_ms": None,
"cooloff_until": None,
"manual_close_count": 0,
"daily_loss_count": 0,
"daily_frozen": False,
}
row = _sync_trading_day(conn, trading_day, now=now)
@@ -784,12 +840,21 @@ def compute_account_risk_status(
row = _load_state(conn)
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
daily_loss_count = int(_row_get(row, "daily_loss_count") or 0)
loss_limit = daily_loss_limit()
status = STATUS_NORMAL
reason = ""
if daily_frozen:
status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
parts = []
if loss_limit > 0 and daily_loss_count >= loss_limit:
parts.append(f"日亏损 {daily_loss_count}/{loss_limit}")
if manual_close_count >= manual_close_daily_limit():
parts.append(f"手动平仓 {manual_close_count}")
if not parts:
parts.append("手动平仓/日亏损达限或复盘情绪标签")
reason = "账户今日已冻结(" + "".join(parts) + ")"
elif cooloff_until_ms is not None:
remaining_ms = cooloff_until_ms - now_ms
hours = _cooloff_hours_value(row)
@@ -818,6 +883,8 @@ def compute_account_risk_status(
if fmt_local_ms and cooloff_until_ms
else None,
"manual_close_count": manual_close_count,
"daily_loss_count": daily_loss_count,
"daily_loss_limit": loss_limit,
"daily_frozen": daily_frozen,
"pending_journal_trade_id": pending,
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
+1 -1
View File
@@ -7,7 +7,7 @@
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **策略说明** | 执行手册 + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
| **使用说明** | 本页:中控与实例怎么用 |
| **行情区** | 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"
_OPTIONS_EXPIRY_COUNTDOWN_JS = _REPO_STATIC / "options_expiry_countdown.js"
_OPTIONS_POSITION_CARDS_JS = _REPO_STATIC / "options_position_cards.js"
_AUTOFILL_GUARD_JS = _REPO_STATIC / "autofill_guard.js"
@app.get("/assets/autofill_guard.js")
def hub_autofill_guard_js():
if not _AUTOFILL_GUARD_JS.is_file():
raise HTTPException(status_code=404, detail="autofill_guard.js not found")
return FileResponse(
str(_AUTOFILL_GUARD_JS),
media_type="application/javascript; charset=utf-8",
)
@app.get("/assets/account_risk_badge.css")
+108 -2
View File
@@ -291,25 +291,45 @@ def _find_plan_tpsl_for_position(
if not isinstance(hub_mon, dict):
return None, None, False
side_l = (side or "").lower()
if side_l in ("buy",):
side_l = "long"
elif side_l in ("sell",):
side_l = "short"
for o in hub_mon.get("orders") or []:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
if (o.get("direction") or "").lower() != side_l:
o_side = (o.get("direction") or "").lower()
if o_side and o_side != side_l:
continue
return (
_safe_float(o.get("stop_loss")),
_safe_float(o.get("take_profit")),
False,
)
for r in hub_mon.get("rolls") or []:
if not isinstance(r, dict):
continue
o_sym = r.get("exchange_symbol") or r.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
o_side = (r.get("direction") or "").lower()
if o_side and o_side != side_l:
continue
return (
_safe_float(r.get("stop_loss")),
_safe_float(r.get("take_profit")),
False,
)
for t in hub_mon.get("trends") or []:
if not isinstance(t, dict):
continue
if not _symbols_match(symbol, t.get("symbol") or ""):
continue
if (t.get("direction") or "").lower() != side_l:
t_side = (t.get("direction") or "").lower()
if t_side and t_side != side_l:
continue
plan_tp = t.get("take_profit")
tp = _safe_float(plan_tp) if plan_tp not in (None, "") else None
@@ -1059,6 +1079,76 @@ def resolve_position_monitor_source(pos: dict, hub_mon: Optional[dict]) -> str:
return candidates[0][1]
def resolve_position_reward_at_tp(pos: dict, hub_mon: Optional[dict]) -> Optional[float]:
"""与监控区「盈利金额」一致:有字段用字段,否则按止盈价×张数推算."""
sym = str(pos.get("symbol") or "")
side = str(pos.get("side") or "").lower()
if side in ("buy",):
side = "long"
elif side in ("sell",):
side = "short"
matched: Optional[dict] = None
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False and sym:
for bucket in ("orders", "rolls", "trends"):
for o in hub_mon.get(bucket) or []:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(sym, str(o_sym)):
continue
o_side = str(o.get("direction") or "").lower()
# 与前端 findMonitorOrder 一致:方向为空也可匹配
if o_side and o_side != side:
continue
matched = o
v = _safe_float(o.get("reward_at_tp_usdt"))
if v is not None:
return v
break
if matched is not None:
break
v = _safe_float(pos.get("reward_at_tp_usdt"))
if v is not None:
return v
entry = _safe_float(pos.get("entry_price"))
if entry is None and matched is not None:
entry = _safe_float(
matched.get("avg_entry_price")
or matched.get("entry_price")
or matched.get("avg_px")
)
tp = None
if matched is not None:
tp = _safe_float(matched.get("take_profit"))
if tp is None:
tp = _safe_float(matched.get("take_profit_display"))
if tp is None:
tpsl = _resolve_position_tpsl(pos, hub_mon)
tp = tpsl.get("tp")
contracts = pos.get("contracts")
if contracts is None:
contracts = pos.get("size")
if contracts is None and matched is not None:
contracts = matched.get("contracts")
try:
qty = abs(float(contracts)) if contracts is not None else None
except (TypeError, ValueError):
qty = None
cs = _safe_float(pos.get("contract_size"))
if cs is None or cs <= 0:
cs = 1.0
if entry is None or tp is None or not qty:
return None
try:
from lib.strategy.strategy_roll_ui_lib import reward_at_tp_usdt
return reward_at_tp_usdt(side or "long", float(entry), float(tp), float(qty), contract_size=float(cs))
except Exception:
return None
def _options_source_label(p: dict) -> str:
"""看板期权来源:期期/永期对冲,其余为纯期权."""
source = str(p.get("source") or "").strip()
@@ -1100,6 +1190,13 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
contracts = p.get("size")
upnl = _position_float_pnl(p)
source = resolve_position_monitor_source(p, hub_mon)
entry = _safe_float(p.get("entry_price"))
mark = _safe_float(p.get("mark_price"))
notional = _safe_float(p.get("notional_usdt"))
if notional is None:
notional = _safe_float(p.get("notional"))
reward_tp = resolve_position_reward_at_tp(p, hub_mon)
tpsl = _resolve_position_tpsl(p, hub_mon)
position_lines.append(
{
"kind": "position",
@@ -1107,6 +1204,15 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]:
"symbol": sym,
"side": side,
"contracts": contracts,
"entry_price": entry,
"entry_price_fmt": p.get("entry_price_fmt"),
"mark_price": mark,
"mark_price_fmt": p.get("mark_price_fmt"),
"notional_usdt": notional,
"stop_loss": tpsl.get("sl"),
"take_profit": tpsl.get("tp"),
"tp_note": tpsl.get("tp_note") or "",
"reward_at_tp_usdt": round(reward_tp, 4) if reward_tp is not None else None,
"text": f"{sym} {side}",
"pnl": round(upnl, 4),
}
+312 -21
View File
@@ -392,6 +392,11 @@ button.ghost:hover:not(:disabled) {
display: none;
}
/* 通用隐藏:策略正文/执行清单等 Tab 面板依赖此类互斥显示 */
.hidden {
display: none !important;
}
.page-head {
margin: 24px 0 16px;
}
@@ -4633,7 +4638,16 @@ body.login-page {
display: none;
}
/* 行情全屏:竖屏提示转横;横屏吃满 */
body.hub-phone #page-calculator .calc-result-title,
body.hub-phone #page-calculator .calc-result-placeholder {
display: none !important;
}
body.hub-phone #page-calculator .calc-result {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border-soft);
}
@media (orientation: portrait) {
body.hub-phone.market-chart-fs-open .market-chart-wrap.is-fullscreen::before {
content: "全屏看图请横持手机";
@@ -9564,32 +9578,56 @@ body.funds-fullscreen-open {
}
}
/* 电脑端计算器改为单页 Tab;手机继续使用原有紧凑 Tab 样式 */
/* 电脑端计算器:上方 Tab + 左输入 / 右结果双卡;手机端保持原布局 */
.calc-tab-label-desktop {
display: none;
}
.calc-pane-split {
display: block;
}
.calc-result-title {
display: none;
margin: 0 0 10px;
font-size: 1rem;
color: var(--text);
}
.calc-result-placeholder {
display: none;
margin: 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
.calc-result-panel:has(.calc-result.hidden) .calc-result-placeholder {
display: block;
}
body:not(.hub-phone) #page-calculator .calc-workspace {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
display: flex;
flex-direction: column;
gap: 14px;
align-items: start;
align-items: stretch;
}
body:not(.hub-phone) #page-calculator .calc-mobile-tabs {
display: grid;
grid-template-columns: minmax(0, 1fr);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
max-width: none;
margin: 0;
padding: 5px;
padding: 6px;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: var(--nav-bg);
}
body:not(.hub-phone) #page-calculator .calc-m-tab {
min-height: 42px;
min-height: 40px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
@@ -9598,8 +9636,8 @@ body:not(.hub-phone) #page-calculator .calc-m-tab {
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
padding: 9px 12px;
text-align: center;
padding: 8px 16px;
}
body:not(.hub-phone) #page-calculator .calc-m-tab:hover {
@@ -9632,9 +9670,61 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
display: none;
}
body:not(.hub-phone) #page-calculator .calc-card {
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
height: auto;
}
body:not(.hub-phone) #page-calculator .calc-pane-split {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 14px;
align-items: stretch;
}
body:not(.hub-phone) #page-calculator .calc-input-panel,
body:not(.hub-phone) #page-calculator .calc-result-panel {
padding: 16px 18px;
border-radius: 12px;
background: var(--panel, var(--dash-card-bg, #121820));
border: 1px solid var(--border-soft, var(--border));
box-shadow: var(--card-glow, none);
min-height: 280px;
height: 100%;
box-sizing: border-box;
}
body:not(.hub-phone) #page-calculator .calc-result-panel {
display: flex;
flex-direction: column;
}
body:not(.hub-phone) #page-calculator .calc-result-title {
display: block;
}
body:not(.hub-phone) #page-calculator .calc-result {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
body:not(.hub-phone) #page-calculator .calc-result.hidden {
display: none !important;
}
@media (min-width: 1200px) {
body:not(.hub-phone) #page-calculator .calc-form-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
body:not(.hub-phone) #page-calculator .calc-pane-split {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -9659,6 +9749,26 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
flex-wrap: wrap;
margin-bottom: 12px;
}
.strategy-view-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.strategy-view-tab {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-soft);
cursor: pointer;
font-size: 0.88rem;
}
.strategy-view-tab.is-active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--text);
font-weight: 600;
}
.strategy-tabs {
display: flex;
gap: 8px;
@@ -9678,6 +9788,127 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
border-color: var(--accent);
color: var(--text);
}
.strategy-doc-panel {
padding: 18px 22px 20px;
min-width: 0;
display: flex;
flex-direction: column;
height: calc(100dvh - 168px);
min-height: 420px;
max-height: calc(100dvh - 140px);
}
.strategy-blog-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
gap: 16px;
align-items: stretch;
flex: 1 1 auto;
min-height: 0;
}
.strategy-blog-main {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.strategy-toc-card {
padding: 14px 12px 16px;
position: sticky;
top: 0;
align-self: stretch;
height: 100%;
max-height: none;
overflow: auto;
display: flex;
flex-direction: column;
}
.strategy-toc-title {
margin: 0 0 12px;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-soft);
text-transform: uppercase;
}
.strategy-toc-nav {
display: flex;
flex-direction: column;
gap: 4px;
border-left: 2px solid var(--border-soft);
padding-left: 2px;
flex: 1 1 auto;
min-height: 0;
}
.strategy-toc-item {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: start;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
border-radius: 0 8px 8px 0;
background: transparent;
color: var(--text-soft);
font-size: 0.84rem;
line-height: 1.35;
cursor: pointer;
text-decoration: none;
border-left: 2px solid transparent;
margin-left: -2px;
}
.strategy-toc-item.level-3 {
padding-left: 18px;
font-size: 0.8rem;
}
.strategy-toc-item:hover {
background: var(--surface-2, rgba(255, 255, 255, 0.04));
color: var(--text);
}
.strategy-toc-item.is-active {
background: var(--accent-soft, rgba(0, 212, 255, 0.12));
color: var(--accent);
font-weight: 600;
border-left-color: var(--accent);
}
.strategy-toc-num {
flex: 0 0 auto;
min-width: 1.7em;
height: 1.7em;
padding: 0 5px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
background: rgba(0, 212, 255, 0.12);
border: 1px solid rgba(0, 212, 255, 0.35);
}
.strategy-toc-tag {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.68rem;
line-height: 1.4;
color: var(--muted);
background: var(--inset-surface, rgba(0, 0, 0, 0.28));
border: 1px solid var(--border-soft);
white-space: nowrap;
}
.strategy-toc-item.is-active .strategy-toc-tag {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.35);
}
.strategy-toc-text {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
}
.strategy-layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
@@ -9717,26 +9948,69 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
.strategy-doc-body {
flex: 1 1 auto;
min-height: 0;
max-height: 100%;
max-height: none;
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
overflow-y: auto;
overflow-x: hidden;
padding: 2px 10px 2px 4px;
padding: 8px 22px 20px 18px;
scrollbar-gutter: stable;
}
.strategy-doc-body h2 {
font-size: 1rem;
margin: 0.6em 0 0.5em;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 1.05rem;
margin: 1.1em 0 0.55em;
color: var(--text);
scroll-margin-top: 12px;
}
.strategy-doc-body h2:first-child {
margin-top: 0;
}
.strategy-sec-mark {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 2px;
}
.strategy-sec-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.7em;
height: 1.7em;
padding: 0 6px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #041018;
background: var(--accent);
}
.strategy-sec-tag {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--accent);
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.35);
}
.strategy-checklist-card {
height: calc(100dvh - 168px);
min-height: 420px;
max-height: calc(100dvh - 140px);
}
.strategy-doc-body h3 {
font-size: 0.92rem;
font-size: 0.94rem;
margin: 1em 0 0.4em;
scroll-margin-top: 12px;
}
.strategy-doc-body table {
width: 100%;
@@ -9769,8 +10043,9 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
color: var(--muted);
}
.strategy-checklist-body {
flex: 0 0 auto;
overflow: visible;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 2px 10px 2px 4px;
}
.strategy-checklist-body ul {
@@ -9818,12 +10093,28 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c
.strategy-layout {
grid-template-columns: 1fr;
}
.strategy-doc-panel,
.strategy-checklist-card {
height: auto;
max-height: none;
min-height: 0;
}
.strategy-blog-layout {
grid-template-columns: 1fr;
}
.strategy-toc-card {
position: static;
height: auto;
max-height: min(36vh, 280px);
order: -1;
}
.strategy-doc-card,
.strategy-checklist-card {
align-self: stretch;
}
.strategy-doc-body {
max-height: min(50vh, 520px);
.strategy-doc-body,
.strategy-checklist-body {
max-height: min(58vh, 560px);
}
}
+16 -4
View File
@@ -2528,6 +2528,17 @@
renderMonitorGrid(lastMonitorRows);
};
});
fsInner.querySelectorAll(".btn-expand-dashboard").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeExchangeFullscreen();
if (window.hubNavigateTo) window.hubNavigateTo("/dashboard");
else {
history.pushState({}, "", "/dashboard");
setActiveNav();
}
};
});
} catch (err) {
console.error("renderFullscreenExchange", err);
closeExchangeFullscreen();
@@ -4000,6 +4011,7 @@
</div>
<div class="fs-head-actions">
<button type="button" class="ghost btn-expand-back">返回监控</button>
<button type="button" class="ghost btn-expand-dashboard">返回数据看板</button>
${flaskOpen ? `<a class="btn-link btn-open-instance btn-open-trade" href="#" data-ex-id="${esc(row.id)}" data-next="/trade" data-new-tab="1">打开实例</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""}
@@ -5009,12 +5021,12 @@
<div class="settings-card-head">
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
${envOff}
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" />
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</div>
<div class="settings-grid">
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></div>
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></div>
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder=" /records" /></div>
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></div>
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></div>
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder=" /records" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></div>
</div>
<div class="cap-chips">
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
+30
View File
@@ -197,9 +197,11 @@
const elPrevCloseLine = document.getElementById("market-prev-close-line");
const elPrevHlLines = document.getElementById("market-prev-hl-lines");
const elDaySplit = document.getElementById("market-day-split");
const elOptionExpirySplit = document.getElementById("market-option-expiry-split");
const PREV_CLOSE_LINE_STORAGE_KEY = "hub-market-prev-close-line";
const PREV_HL_LINES_STORAGE_KEY = "hub-market-prev-hl-lines";
const DAY_SPLIT_STORAGE_KEY = "hub-market-day-split";
const OPTION_EXPIRY_SPLIT_STORAGE_KEY = "hub-market-option-expiry-split";
const BJ_OFFSET_SEC = 8 * 60 * 60;
const elFsToolbar = document.getElementById("market-fs-toolbar");
const elFsExchange = document.getElementById("market-fs-exchange");
@@ -342,6 +344,14 @@
saveBoolPref(DAY_SPLIT_STORAGE_KEY, on);
}
function loadOptionExpirySplitPref() {
return loadBoolPref(OPTION_EXPIRY_SPLIT_STORAGE_KEY, false);
}
function saveOptionExpirySplitPref(on) {
saveBoolPref(OPTION_EXPIRY_SPLIT_STORAGE_KEY, on);
}
function loadPrevCloseLinePref() {
return loadBoolPref(PREV_CLOSE_LINE_STORAGE_KEY, false);
}
@@ -456,6 +466,18 @@
applyTradingDaySplit(on);
}
function applyOptionExpirySplit(enabled) {
if (window.HubChartDraw && typeof window.HubChartDraw.setOptionExpirySplit === "function") {
window.HubChartDraw.setOptionExpirySplit(enabled);
}
}
function syncOptionExpirySplitUi() {
const on = !!(elOptionExpirySplit && elOptionExpirySplit.checked);
saveOptionExpirySplitPref(on);
applyOptionExpirySplit(on);
}
function ensureDrawLayer() {
if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return;
window.HubChartDraw.attach({
@@ -471,6 +493,9 @@
});
window.HubChartDraw.setViewKey(currentChartViewKey());
applyTradingDaySplit(elDaySplit ? elDaySplit.checked : loadDaySplitPref());
applyOptionExpirySplit(
elOptionExpirySplit ? elOptionExpirySplit.checked : loadOptionExpirySplitPref()
);
drawAttached = true;
}
@@ -3481,6 +3506,11 @@
elDaySplit.addEventListener("change", syncTradingDaySplitUi);
applyTradingDaySplit(elDaySplit.checked);
}
if (elOptionExpirySplit) {
elOptionExpirySplit.checked = loadOptionExpirySplitPref();
elOptionExpirySplit.addEventListener("change", syncOptionExpirySplitUi);
applyOptionExpirySplit(elOptionExpirySplit.checked);
}
const pageMarket = document.getElementById("page-market");
const fsKeyTargets = [window, pageMarket, elChartWrap, chartHost].filter(Boolean);
fsKeyTargets.forEach(function (el) {
+52
View File
@@ -68,6 +68,7 @@
let unsubClick = null;
let mainBound = false;
let tradingDaySplitEnabled = false;
let optionExpirySplitEnabled = false;
const BJ_OFFSET_SEC = 8 * 60 * 60;
function uid() {
@@ -409,6 +410,50 @@
ctx.restore();
}
/** OKX 期权到期切日:每天 08:00 UTC(=北京 16:00),对应日期权 1/2/3 日间隔 */
function collectOptionExpiryBoundaries(candles) {
if (!candles.length) return [];
const minT = Number(candles[0].time);
const maxT = Number(candles[candles.length - 1].time);
if (!Number.isFinite(minT) || !Number.isFinite(maxT)) return [];
// 北京日历日 → UTC 当天 08:00(=北京 16:00)
const minP = utcSecToBjParts(minT);
const maxP = utcSecToBjParts(maxT);
const out = [];
let curMs = Date.UTC(minP.y, minP.m, minP.d, 8, 0, 0) - 86400000;
const endMs = Date.UTC(maxP.y, maxP.m, maxP.d, 8, 0, 0) + 2 * 86400000;
while (curMs <= endMs) {
const boundary = Math.floor(curMs / 1000);
if (boundary >= minT - 3600 && boundary <= maxT + 3600) {
if (!out.length || out[out.length - 1] !== boundary) out.push(boundary);
}
curMs += 86400000;
}
return out;
}
function drawOptionExpirySplits(ctx, w, h) {
if (!optionExpirySplitEnabled || !chart) return;
const candles = getCandles();
if (!candles.length) return;
const boundaries = collectOptionExpiryBoundaries(candles);
if (!boundaries.length) return;
ctx.save();
ctx.strokeStyle = "#eab308";
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
boundaries.forEach(function (t) {
const x = timeToX(t);
if (x == null || !Number.isFinite(x) || x < -2 || x > w + 2) return;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
});
ctx.setLineDash([]);
ctx.restore();
}
function drawRect(ctx, x1, y1, x2, y2, selected) {
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
const l = Math.min(x1, x2);
@@ -743,6 +788,7 @@
const h = hostEl.clientHeight;
ctx.clearRect(0, 0, w, h);
drawTradingDaySplits(ctx, w, h);
drawOptionExpirySplits(ctx, w, h);
drawings.forEach(function (d) {
if (d.hidden) ctx.globalAlpha = 0.14;
renderDrawing(ctx, d, w, h, d.id === selectedId);
@@ -1451,10 +1497,16 @@
scheduleRedraw();
}
function setOptionExpirySplit(enabled) {
optionExpirySplitEnabled = !!enabled;
scheduleRedraw();
}
window.HubChartDraw = {
attach: attach,
setViewKey: setViewKey,
setTradingDaySplit: setTradingDaySplit,
setOptionExpirySplit: setOptionExpirySplit,
resize: scheduleRedraw,
redraw: scheduleRedraw,
destroy: destroy,
+64 -9
View File
@@ -124,7 +124,7 @@ body.hub-page-dashboard .page#page-dashboard {
justify-content: space-between;
gap: 0;
width: 100%;
padding: 10px 4px;
padding: 14px 6px;
border-radius: 12px;
background: var(--dash-card-bg);
border: 1px solid var(--dash-card-border);
@@ -136,7 +136,7 @@ body.hub-page-dashboard .page#page-dashboard {
flex: 1 1 0;
min-width: 0;
max-width: none;
padding: 4px 8px;
padding: 6px 10px;
position: relative;
text-align: center;
}
@@ -145,8 +145,8 @@ body.hub-page-dashboard .page#page-dashboard {
content: "";
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
top: 16%;
bottom: 16%;
width: 1px;
background: color-mix(in srgb, var(--dash-card-border) 85%, transparent);
}
@@ -167,10 +167,10 @@ body.hub-page-dashboard .page#page-dashboard {
}
.dash-kpi-label {
font-size: 0.65rem;
font-size: 0.78rem;
color: var(--dash-muted);
letter-spacing: 0.04em;
margin-bottom: 4px;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -178,8 +178,8 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-kpi-value {
font-family: JetBrains Mono, monospace;
font-size: 0.92rem;
font-weight: 600;
font-size: 1.18rem;
font-weight: 650;
line-height: 1.25;
color: var(--dash-text);
white-space: nowrap;
@@ -247,11 +247,66 @@ body.hub-page-dashboard .page#page-dashboard {
.dash-ac-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
grid-template-columns: 1fr;
gap: 12px;
padding: 14px;
}
.dash-pos-unified-card {
width: 100%;
}
.dash-ex-link {
appearance: none;
border: 0;
background: transparent;
color: var(--dash-accent);
font: inherit;
font-weight: 600;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.dash-ex-link:hover {
color: color-mix(in srgb, var(--dash-accent) 80%, #fff);
}
.dash-tp-profit {
color: var(--dash-accent);
font-variant-numeric: tabular-nums;
}
.dash-tp-program {
color: var(--dash-accent);
font-size: 0.72rem;
font-weight: 600;
}
.dash-side {
font-weight: 700;
}
.dash-side-long {
color: #3dd68c;
}
.dash-side-short {
color: #ff6b7a;
}
.dash-empty-inline {
padding: 8px 0 4px;
font-size: 0.78rem;
}
.dash-pos-alert-note {
margin-top: 4px;
font-size: 0.75rem;
color: var(--dash-warn);
}
.dash-ac-card {
position: relative;
padding: 14px 16px;
+189 -105
View File
@@ -88,22 +88,6 @@
</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) {
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—";
@@ -130,6 +114,60 @@
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) {
const s = String(source || "");
if (s.indexOf("对冲") >= 0) return "is-hedge";
@@ -141,23 +179,41 @@
return "is-none";
}
function renderDashboardPerpTable(lines) {
const rows = Array.isArray(lines) ? lines : [];
function sourceTypeCell(source) {
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 "";
const body = rows
.map((ln) => {
.map(({ ac, ln }) => {
const source = String((ln && ln.source) || "—");
const symbol = esc((ln && (ln.symbol || ln.text)) || "—");
const side = esc((ln && ln.side) || "—");
const contracts =
ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—";
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
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>${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 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>`;
})
.join("");
@@ -166,7 +222,7 @@
<div class="dash-table-wrap">
<table class="dash-table dash-pos-table">
<thead><tr>
<th>来源</th><th></th><th></th><th></th><th></th>
<th>交易所</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody>${body}</tbody>
</table>
@@ -188,64 +244,110 @@
return null;
}
function renderDashboardOptionsTable(positions) {
const pos = Array.isArray(positions) ? positions : [];
if (!pos.length) return "";
const rows = pos
.map((p) => {
const optType =
(p.opt_type || "").toUpperCase() === "C"
? "Call"
: (p.opt_type || "").toUpperCase() === "P"
? "Put"
: p.opt_type || "—";
const source = String(p.source_label || p.source || "纯期权");
const target = String(p.target_monitor_text || "—");
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
const net = optionsNetPnl(p);
return `<tr>
<td><span class="dash-pos-source ${sourceBadgeClass(source)}">${esc(source)}</span></td>
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
<td>${esc(optType)}</td>
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td>
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
</tr>`;
})
.join("");
function optionsHedgePlanId(p) {
if (!p || typeof p !== "object") return "";
const hedge = p.hedge_plan_target;
if (hedge && typeof hedge === "object" && hedge.plan_id != null && hedge.plan_id !== "") {
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 =
(p.opt_type || "").toUpperCase() === "C"
? "Call"
: (p.opt_type || "").toUpperCase() === "P"
? "Put"
: p.opt_type || "—";
const source = optionsTypeLabel(p);
const target = String(p.target_monitor_text || "—");
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
const net = optionsNetPnl(p);
const roi = optionsRoiPct(p);
return `<tr>
<td>${exchangeLinkCell(ac)}</td>
<td>${sourceTypeCell(source)}</td>
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
<td>${esc(optType)}</td>
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td>
<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>
</tr>`;
}
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">
<div class="dash-ac-section-label">期权持仓</div>
<div class="dash-table-wrap dash-options-table-wrap">
<table class="dash-table dash-options-table">
<thead><tr>
<th>来源</th><th></th><th></th><th></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>
<tbody>${rows}</tbody>
<tbody>${body}</tbody>
</table>
</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() {
if (!elAccounts) return;
elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => {
elAccounts.querySelectorAll("[data-dash-ex-id]").forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -256,44 +358,26 @@
}
function renderAccounts(accounts, threshold) {
const rows = (Array.isArray(accounts) ? accounts : []).filter(accountHasOpenPositions);
if (!rows.length) {
const unified = collectUnifiedPositions(accounts);
const perpHtml = renderUnifiedPerpTable(unified.perp);
const optionsHtml = renderUnifiedOptionsTable(unified.options);
if (!perpHtml && !optionsHtml) {
elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>';
return;
}
elAccounts.innerHTML = rows
.map((ac) => {
const alert = !!ac.loss_alert;
const unmon = !ac.monitored;
const lossPct = Number(ac.daily_loss_pct);
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 =
alert && barW > 0
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>`
: "";
const cardCls = ac.options_layout ? " dash-ac-card-options" : "";
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>
${lossBar}
<div class="dash-ac-pos-body">${renderAccountPositions(ac)}</div>
</article>`;
})
.join("");
const alertAccounts = (Array.isArray(accounts) ? accounts : []).filter((a) => a && a.loss_alert);
const alertNote = alertAccounts.length
? `<div class="dash-pos-alert-note">风险: ${esc(
alertAccounts.map((a) => a.name || "").filter(Boolean).join("、")
)} 单日亏损 ${threshold}%</div>`
: "";
elAccounts.innerHTML = `<article class="dash-ac-card dash-pos-unified-card">
<div class="dash-ac-pos-body">
${perpHtml}
${optionsHtml}
${alertNote}
</div>
</article>`;
bindDashboardExpand();
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
OptionsExpiryCountdown.ensureTimer();
+182 -151
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<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="theme-color" content="#0b0e18" />
<meta name="apple-mobile-web-app-title" content="中控" />
@@ -15,11 +16,11 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260717-archive-cal-chart" />
<link rel="stylesheet" href="/assets/app.css?v=20260720-calc-equal-height" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
<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>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -113,15 +114,15 @@
</label>
<label class="plan-field">
<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 class="plan-field">
<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 class="plan-field plan-field-full">
<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>
</div>
<button type="submit" class="primary plan-submit-btn">保存并进入进行中</button>
@@ -323,6 +324,9 @@
<label class="market-day-split-opt" title="北京时间 8:00 交易切日竖线">
<input type="checkbox" id="market-day-split" /> 交易间隔日
</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">
<summary>技术指标</summary>
<div class="market-ind-options">
@@ -811,127 +815,143 @@
</div>
<div class="calc-layout" data-calc-tab="trend">
<section class="calc-card card" data-calc-pane="trend">
<h2>趋势回调计算器</h2>
<p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%.</p>
<form id="calc-trend-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-trend-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-trend-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-trend-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-trend-capital" type="number" min="0.01" step="any" value="1000" required />
</label>
<label class="calc-field">
<span>风险 %</span>
<input id="calc-trend-risk" type="number" min="0.1" step="0.1" value="5" required />
</label>
<label class="calc-field">
<span>杠杆</span>
<input id="calc-trend-leverage" type="number" min="1" step="1" value="5" required />
</label>
<label class="calc-field">
<span>方向</span>
<select id="calc-trend-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="calc-field">
<span>首仓入场价</span>
<input id="calc-trend-entry" type="number" min="0" step="any" placeholder="手动输入" required />
</label>
<label class="calc-field">
<span>止损价</span>
<input id="calc-trend-sl" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span id="calc-trend-add-label">补仓上沿价</span>
<input id="calc-trend-add-upper" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>止盈价</span>
<input id="calc-trend-tp" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>补仓档数</span>
<input id="calc-trend-dca-legs" type="number" min="1" max="20" step="1" value="5" />
</label>
<div class="calc-pane-split">
<div class="calc-input-panel">
<h2>趋势回调计算器</h2>
<p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%.</p>
<form id="calc-trend-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-trend-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-trend-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-trend-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-trend-capital" type="number" min="0.01" step="any" value="1000" required />
</label>
<label class="calc-field">
<span>风险 %</span>
<input id="calc-trend-risk" type="number" min="0.1" step="0.1" value="5" required />
</label>
<label class="calc-field">
<span>杠杆</span>
<input id="calc-trend-leverage" type="number" min="1" step="1" value="5" required />
</label>
<label class="calc-field">
<span>方向</span>
<select id="calc-trend-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="calc-field">
<span>首仓入场价</span>
<input id="calc-trend-entry" type="number" min="0" step="any" placeholder="手动输入" required />
</label>
<label class="calc-field">
<span>止损价</span>
<input id="calc-trend-sl" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span id="calc-trend-add-label">补仓上沿价</span>
<input id="calc-trend-add-upper" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>止盈价</span>
<input id="calc-trend-tp" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>补仓档数</span>
<input id="calc-trend-dca-legs" type="number" min="1" max="20" step="1" value="5" />
</label>
</div>
<div class="calc-actions">
<button type="submit" class="primary">计算</button>
</div>
</form>
</div>
<div class="calc-actions">
<button type="submit" class="primary"></button>
</div>
</form>
<div id="calc-trend-result" class="calc-result hidden"></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>
</aside>
</div>
</section>
<section class="calc-card card" data-calc-pane="roll">
<h2>滚仓计算器</h2>
<p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变.最多 3 次滚仓.</p>
<form id="calc-roll-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-roll-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-roll-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-roll-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-roll-capital" type="number" min="0.01" step="any" value="1000" required />
</label>
<label class="calc-field">
<span>单次风险 %</span>
<input id="calc-roll-risk" type="number" min="0.1" step="0.1" value="5" required />
</label>
<label class="calc-field">
<span>方向</span>
<select id="calc-roll-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="calc-field">
<span>首仓入场价</span>
<input id="calc-roll-entry" type="number" min="0" step="any" placeholder="手动输入" required />
</label>
<label class="calc-field">
<span>首仓止损价</span>
<input id="calc-roll-sl" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>止盈价(锁定)</span>
<input id="calc-roll-tp" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>已完成滚仓次数</span>
<input id="calc-roll-legs-done" type="number" min="0" max="3" step="1" value="0" />
</label>
<div class="calc-pane-split">
<div class="calc-input-panel">
<h2>滚仓计算器</h2>
<p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变.最多 3 次滚仓.</p>
<form id="calc-roll-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-roll-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-roll-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-roll-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-roll-capital" type="number" min="0.01" step="any" value="1000" required />
</label>
<label class="calc-field">
<span>单次风险 %</span>
<input id="calc-roll-risk" type="number" min="0.1" step="0.1" value="5" required />
</label>
<label class="calc-field">
<span>方向</span>
<select id="calc-roll-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="calc-field">
<span>首仓入场价</span>
<input id="calc-roll-entry" type="number" min="0" step="any" placeholder="手动输入" required />
</label>
<label class="calc-field">
<span>首仓止损价</span>
<input id="calc-roll-sl" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>止盈价(锁定)</span>
<input id="calc-roll-tp" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>已完成滚仓次数</span>
<input id="calc-roll-legs-done" type="number" min="0" max="3" step="1" value="0" />
</label>
</div>
<div class="calc-roll-legs-head">
<strong>滚仓加仓(最多 3 次)</strong>
<button type="button" id="calc-roll-add-leg" class="ghost">+ 添加滚仓</button>
</div>
<div id="calc-roll-legs-list" class="calc-roll-legs-list"></div>
<div class="calc-actions">
<button type="submit" class="primary">计算</button>
</div>
</form>
</div>
<div class="calc-roll-legs-head">
<strong>滚仓加仓(最多 3 次)</strong>
<button type="button" id="calc-roll-add-leg" class="ghost">+ 添加滚仓</button>
</div>
<div id="calc-roll-legs-list" class="calc-roll-legs-list"></div>
<div class="calc-actions">
<button type="submit" class="primary">计算</button>
</div>
</form>
<div id="calc-roll-result" class="calc-result hidden"></div>
</section>
<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>
</aside>
</div>
</section>
</div>
</div>
</div>
@@ -940,35 +960,46 @@
<div class="page-head strategy-page-head">
<div>
<h1><span class="head-tag">STR</span> 策略说明</h1>
<p class="page-desc">三所策略文档 · 开仓前检查清单 · 可打印 / 下载</p>
<p class="page-desc">执行手册 · 三所策略正文(带目录) · 执行清单(打印对照)</p>
</div>
<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 class="strategy-toolbar no-print">
<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>
</div>
<div id="strategy-print-root" class="strategy-print-root">
<div class="strategy-layout">
<section class="card strategy-doc-card">
<div class="strategy-col-head">
<h3 class="strategy-col-title">策略说明</h3>
<button type="button" id="strategy-btn-print-doc" class="ghost strategy-col-print">打印</button>
<section id="strategy-panel-doc" class="card strategy-doc-panel" data-strategy-view="doc">
<div class="strategy-blog-layout">
<div class="strategy-blog-main">
<div class="strategy-col-head">
<h3 class="strategy-col-title">策略正文</h3>
</div>
<div id="strategy-doc-body" class="strategy-doc-body prose"></div>
<p id="strategy-doc-source" class="strategy-doc-source"></p>
</div>
<div id="strategy-doc-body" class="strategy-doc-body prose"></div>
<p id="strategy-doc-source" class="strategy-doc-source"></p>
</section>
<section class="card strategy-checklist-card">
<div class="strategy-col-head">
<h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3>
<button type="button" id="strategy-btn-print-checklist" class="ghost strategy-col-print">打印</button>
</div>
<div id="strategy-checklist-body" class="strategy-checklist-body"></div>
<ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul>
</section>
</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 id="strategy-panel-checklist" class="card strategy-checklist-card hidden" data-strategy-view="checklist">
<div class="strategy-col-head">
<h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3>
<button type="button" id="strategy-btn-print-checklist-inline" class="ghost strategy-col-print">打印</button>
</div>
<div id="strategy-checklist-body" class="strategy-checklist-body"></div>
<ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul>
</section>
</div>
</div>
@@ -1066,10 +1097,10 @@
</div>
<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="field"><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"><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-old" autocomplete="current-password"></div>
<div class="field" data-password-settings="1"><label>新用户名(可选)</label><input type="text" id="hub-pwd-new-username" autocomplete="username"></div>
<div class="field" data-password-settings="1"><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-confirm" autocomplete="new-password"></div>
</div>
<div class="hub-settings-tab-actions">
<button type="button" class="primary" id="hub-pwd-save-btn">保存密码</button>
@@ -1365,23 +1396,23 @@
<div id="toast"></div>
<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.js?v=20260715-fs-landscape"></script>
<script src="/assets/plan.js?v=20260614-plan-refresh"></script>
<script src="/assets/chart_draw.js?v=20260720-option-day-1600"></script>
<script src="/assets/chart.js?v=20260720-option-day-1600"></script>
<script src="/assets/plan.js?v=20260720-autofill"></script>
<script src="/assets/calculator.js?v=20260715-calc-tabs"></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/quotes.js?v=20260717-quotes-feed"></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/strategy.js?v=4"></script>
<script src="/assets/dashboard.js?v=20260720-dash-sl-tp"></script>
<script src="/assets/strategy.js?v=9"></script>
<script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.js?v=1"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=2"></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>
</html>
+3 -3
View File
@@ -481,7 +481,7 @@
"</select></label>" +
'<label class="plan-field"><span>币种</span><input name="symbol" type="text" value="' +
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>' +
opts(meta.plan_types, "plan_type", "value", "label") +
"</select></label>" +
@@ -496,10 +496,10 @@
"</span></label>" +
'<label class="plan-field"><span>目标位</span><input name="target_level" type="text" value="' +
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="' +
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>' +
opts(meta.entry_schemes, "entry_scheme", "value", "label") +
"</select></label>" +
+167 -27
View File
@@ -1,28 +1,32 @@
/**
* 策略说明:三所 MD + 开仓检查清单 JSON.
* 策略说明:策略正文(目录 h2 + 章节标识) + 执行清单 Tab.
*/
(function () {
const page = document.getElementById("page-strategy");
if (!page) return;
const tabsEl = document.getElementById("strategy-tabs");
const viewTabsEl = document.getElementById("strategy-view-tabs");
const statusEl = document.getElementById("strategy-load-status");
const docBody = document.getElementById("strategy-doc-body");
const docSource = document.getElementById("strategy-doc-source");
const docCard = page.querySelector(".strategy-doc-card");
const checklistCard = page.querySelector(".strategy-checklist-card");
const docToc = document.getElementById("strategy-doc-toc");
const panelDoc = document.getElementById("strategy-panel-doc");
const panelChecklist = document.getElementById("strategy-panel-checklist");
const checklistTitle = document.getElementById("strategy-checklist-title");
const checklistBody = document.getElementById("strategy-checklist-body");
const footnotesEl = document.getElementById("strategy-checklist-footnotes");
const btnPrintDoc = document.getElementById("strategy-btn-print-doc");
const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist");
const btnPrintChecklistInline = document.getElementById("strategy-btn-print-checklist-inline");
const btnDownload = document.getElementById("strategy-btn-download");
let activeKey = "binance";
let activeKey = "playbook";
let activeView = "doc";
let tabsMeta = [];
let cache = {};
let bound = false;
let heightSyncRaf = 0;
let scrollSpyObs = null;
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
@@ -44,23 +48,31 @@
.replace(/"/g, "&quot;");
}
function syncDocCardHeight() {
if (!docCard || !checklistCard || window.matchMedia("(max-width: 960px)").matches) {
if (docCard) docCard.style.height = "";
return;
function slugify(text) {
return String(text || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w\u4e00-\u9fff\-]+/g, "")
.replace(/\-+/g, "-")
.replace(/^\-|\-$/g, "")
.slice(0, 48);
}
function setView(view) {
activeView = view === "checklist" ? "checklist" : "doc";
if (panelDoc) panelDoc.classList.toggle("hidden", activeView !== "doc");
if (panelChecklist) panelChecklist.classList.toggle("hidden", activeView !== "checklist");
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");
});
}
docCard.style.height = `${checklistCard.offsetHeight}px`;
}
function scheduleHeightSync() {
if (heightSyncRaf) cancelAnimationFrame(heightSyncRaf);
heightSyncRaf = requestAnimationFrame(() => {
heightSyncRaf = 0;
syncDocCardHeight();
});
}
function renderTabs() {
function renderExchangeTabs() {
if (!tabsEl) return;
tabsEl.innerHTML = tabsMeta
.map(
@@ -73,12 +85,133 @@
const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return;
activeKey = key;
renderTabs();
renderExchangeTabs();
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) {
const cl = checklist || {};
const title = cl.title || "开仓检查清单";
@@ -102,7 +235,6 @@
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
footnotesEl.classList.toggle("hidden", !notes.length);
}
scheduleHeightSync();
}
function renderPayload(data) {
@@ -111,8 +243,8 @@
const ver = data.version ? ` · ${data.version}` : "";
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
}
buildDocToc();
renderChecklist(data.checklist);
scheduleHeightSync();
}
async function loadExchange(key) {
@@ -128,8 +260,8 @@
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
if (docBody) docBody.innerHTML = "";
if (docToc) docToc.innerHTML = "";
if (checklistBody) checklistBody.innerHTML = "";
scheduleHeightSync();
}
}
@@ -139,13 +271,12 @@
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
activeKey = tabsMeta[0].key;
}
renderTabs();
renderExchangeTabs();
}
async function printSection(mode) {
const part = mode === "checklist" ? "checklist" : "doc";
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
// 同步打开空白页保留用户手势;再 fetch 写入,避免 noopener 丢句柄 / 短页误关窗
const w = window.open("about:blank", "_blank");
if (!w) {
if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览";
@@ -183,18 +314,24 @@
function bindActions() {
if (bound) return;
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 (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist"));
if (btnPrintChecklistInline) btnPrintChecklistInline.addEventListener("click", () => printSection("checklist"));
if (btnDownload) {
btnDownload.addEventListener("click", () => {
window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`;
});
}
window.addEventListener("resize", scheduleHeightSync);
}
async function init() {
bindActions();
setView(activeView);
try {
await loadMeta();
await loadExchange(activeKey);
@@ -204,7 +341,10 @@
}
function destroy() {
window.removeEventListener("resize", scheduleHeightSync);
if (scrollSpyObs) {
scrollSpyObs.disconnect();
scrollSpyObs = null;
}
}
window.hubStrategyPage = { init, destroy };
+37
View File
@@ -20,6 +20,7 @@ from lib.trade.account_risk_lib import (
enrich_risk_status_countdown,
ensure_account_risk_schema,
max_active_positions_from_env,
on_closed_trade_pnl,
on_journal_saved,
on_manual_close,
on_user_initiated_close,
@@ -58,6 +59,7 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
os.environ["RISK_DAILY_LOSS_LIMIT"] = "2"
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
os.environ["APP_TIMEZONE"] = "Asia/Shanghai"
@@ -521,6 +523,41 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["MAX_ACTIVE_POSITIONS"] = "3"
self.assertEqual(max_active_positions_from_env(), 3)
def test_daily_loss_limit_freezes_on_second_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-1.5, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 1)
self.assertEqual(st["status"], STATUS_NORMAL)
on_closed_trade_pnl(conn, pnl_amount=-0.2, trading_day="2026-06-14", now=now)
st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st2["daily_loss_count"], 2)
self.assertEqual(st2["status"], STATUS_DAILY)
self.assertFalse(st2["can_trade"])
self.assertIn("日亏损", st2["reason"])
def test_daily_loss_limit_zero_disables(self):
os.environ["RISK_DAILY_LOSS_LIMIT"] = "0"
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["daily_loss_limit"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
def test_profitable_close_does_not_count_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=3.2, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=0, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
if __name__ == "__main__":
unittest.main()
+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_premium_total,
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):
@@ -69,6 +72,32 @@ class TestHedgePlanCalc(unittest.TestCase):
self.assertFalse(g["can_start"])
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):
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}
@@ -81,6 +110,9 @@ class TestHedgePlanCalc(unittest.TestCase):
)
self.assertEqual(p["summary"]["premium_paid"], 10)
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(p["scenarios"][0]["id"], "target_up")
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(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__":
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()
+17 -7
View File
@@ -133,15 +133,15 @@ class TestHedgePlanOrderPath(unittest.TestCase):
def test_dry_run_oo(self):
quote = MagicMock(
return_value={
side_effect=lambda _ex, inst_id: {
"ok": True,
"ask": 10,
"ask_sz": 5,
"ask_sz": 50,
"can_open": True,
"ct_mult": 0.01,
"tick_sz": "0.1",
"strike": 1800,
"meta": {"optType": "C"},
"meta": {"optType": "C" if inst_id == "A" else "P"},
}
)
cfg = {
@@ -149,17 +149,27 @@ class TestHedgePlanOrderPath(unittest.TestCase):
"quote_option_contract": quote,
"place_option_limit_order": MagicMock(),
"td_mode_for_option_buy": lambda x: "isolated",
"trade_budget_usdc": 10,
"budget_buffer": 0.95,
}
body = {
"target_price": 1900,
"target_price_up": 1950,
"target_price_down": 1750,
"leg_a": {"inst_id": "A", "sheets": 1},
"leg_b": {"inst_id": "B", "sheets": 1},
"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"},
}
out = execute_options_options_start(cfg, body, dry_run=True)
self.assertTrue(out["ok"])
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)
self.assertTrue(out["ok"], out)
self.assertEqual(len(out["results"]), 2)
self.assertTrue(out.get("refresh", {}).get("ok"))
def test_buy_rejects_without_ask_depth(self):
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()
+11 -2
View File
@@ -12,10 +12,19 @@ from lib.hub.hub_strategy_lib import (
class TestHubStrategyLib(unittest.TestCase):
def test_meta_has_three_exchanges(self):
def test_meta_has_playbook_and_exchanges(self):
meta = strategy_meta_payload()
keys = [x["key"] for x in meta["exchanges"]]
self.assertEqual(keys, ["binance", "okx", "gate"])
self.assertEqual(keys, ["playbook", "binance", "okx", "gate"])
def test_load_playbook_payload(self):
p = load_strategy_payload("playbook")
self.assertTrue(p["ok"])
self.assertEqual(p["label"], "执行手册")
self.assertIn("交易执行手册", p["md_source"])
self.assertIn("strategy_html", p)
self.assertIn("<h2", p["strategy_html"].lower())
self.assertIn("总原则", p["strategy_html"])
def test_load_binance_payload(self):
p = load_strategy_payload("binance")
@@ -28,6 +28,13 @@ class TestInstanceDisplayPrefs(unittest.TestCase):
on = normalize_display_prefs({"show_nav_dashboard": True})
self.assertTrue(tab_allowed("dashboard", on))
def test_system_guide_nav_default_off(self):
prefs = normalize_display_prefs({})
self.assertFalse(prefs["show_nav_system_guide"])
self.assertFalse(tab_allowed("system_guide", prefs))
on = normalize_display_prefs({"show_nav_system_guide": True})
self.assertTrue(tab_allowed("system_guide", on))
class TestEnvFileLib(unittest.TestCase):
def test_upsert_and_read(self):
+2
View File
@@ -30,9 +30,11 @@ def test_embed_tabs_cover_main_nav():
assert "records" in EMBED_TABS
assert "env_config" in EMBED_TABS
assert "risk_policy" in EMBED_TABS
assert "system_guide" in EMBED_TABS
assert "settings" in EMBED_TABS
assert path_to_embed_tab("/env_config") == "env_config"
assert path_to_embed_tab("/risk_policy") == "risk_policy"
assert path_to_embed_tab("/system_guide") == "system_guide"
assert path_to_embed_tab("/settings") == "settings"
+31
View File
@@ -0,0 +1,31 @@
"""系统说明 Markdown 加载与目录."""
from __future__ import annotations
from lib.instance.instance_system_guide_lib import (
load_system_guide_payload,
system_guide_md_path,
system_guide_template_context,
)
def test_system_guide_md_exists():
assert system_guide_md_path().is_file()
def test_system_guide_payload_has_toc_and_html():
payload = load_system_guide_payload()
assert payload["html"]
assert "系统说明" in payload["html"] or "总览" in payload["html"]
toc = payload["toc"]
assert isinstance(toc, list)
assert len(toc) >= 3
assert all(item.get("id") and item.get("title") for item in toc)
assert any("总览" in (item.get("title") or "") for item in toc)
assert any("期权" in (item.get("title") or "") for item in toc)
assert any("对冲" in (item.get("title") or "") for item in toc)
def test_system_guide_template_context():
ctx = system_guide_template_context()
assert ctx["system_guide_html"]
assert ctx["system_guide_toc"]
+90
View File
@@ -0,0 +1,90 @@
"""期权开仓:等待完全成交门禁."""
from __future__ import annotations
from lib.exchange.okx_options_lib import wait_option_order_full_fill
class _FakeEx:
def __init__(self, sequence: list[dict]):
self._seq = list(sequence)
self.cancelled = False
def private_get_trade_order(self, params):
if not self._seq:
return {"data": []}
row = self._seq.pop(0)
return {"data": [row]}
def private_post_trade_cancel_order(self, params):
self.cancelled = True
return {"data": [{"sCode": "0"}]}
def test_wait_fill_success_when_filled():
ex = _FakeEx(
[
{
"ordId": "1",
"instId": "ETH-USD_UM-260720-1870-C",
"state": "live",
"sz": "50",
"accFillSz": "0",
},
{
"ordId": "1",
"instId": "ETH-USD_UM-260720-1870-C",
"state": "filled",
"sz": "50",
"accFillSz": "50",
"avgPx": "12.5",
},
]
)
out = wait_option_order_full_fill(
ex, # type: ignore[arg-type]
inst_id="ETH-USD_UM-260720-1870-C",
ord_id="1",
need_sheets=50,
timeout_sec=2,
poll_sec=0.01,
)
assert out["ok"] is True
assert out["filled_sheets"] == 50
assert float(out["avg_px"]) == 12.5
assert ex.cancelled is False
def test_wait_fill_timeout_cancels_and_fails():
ex = _FakeEx(
[
{
"ordId": "2",
"instId": "ETH-USD_UM-260720-1870-C",
"state": "live",
"sz": "50",
"accFillSz": "0",
}
for _ in range(40)
]
+ [
{
"ordId": "2",
"instId": "ETH-USD_UM-260720-1870-C",
"state": "canceled",
"sz": "50",
"accFillSz": "0",
}
]
)
out = wait_option_order_full_fill(
ex, # type: ignore[arg-type]
inst_id="ETH-USD_UM-260720-1870-C",
ord_id="2",
need_sheets=50,
timeout_sec=0.6,
poll_sec=0.05,
cancel_on_timeout=True,
)
assert out["ok"] is False
assert "超时" in (out.get("msg") or "")
assert ex.cancelled is True
+66
View File
@@ -284,6 +284,31 @@ class OptionsReviewTests(unittest.TestCase):
self.assertEqual(float(row["realized_pnl_total"]), 3.2)
self.assertEqual(row["source_type"], SOURCE_OPTION)
def test_image_paths_resolve_legacy_journal_root(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
# 误存到 UPLOAD 根目录的 journal_*
legacy = root / "journal_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_5m.png"
legacy.write_bytes(b"img")
# 正常 options_journal 子目录
sub = root / "options_journal"
sub.mkdir()
modern = sub / "options_journal_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_5m.png"
modern.write_bytes(b"img2")
from lib.options.options_review_images_lib import options_review_image_paths
class Row:
images_json = (
'[{"tf":"5m","file":"journal_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_5m.png"},'
'{"tf":"5m","file":"options_journal_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_5m.png"}]'
)
image = None
paths = options_review_image_paths(Row(), str(root))
self.assertEqual(len(paths), 2)
self.assertTrue(any(p.endswith(legacy.name) for p in paths))
self.assertTrue(any(p.endswith(modern.name) for p in paths))
def test_image_namespace(self):
with tempfile.TemporaryDirectory() as tmp:
folder = options_review_upload_dir(tmp)
@@ -337,6 +362,47 @@ class OptionsReviewTests(unittest.TestCase):
self.assertEqual(stats["by_strategy"][0]["key"], "假破")
self.assertEqual(stats["kpi"]["total"], 2)
def test_q_search_btcusdt_matches_btc_pending(self):
from lib.options.options_review_lib import count_review_trades
conn = _conn()
upsert_option_history_row(
conn,
{
"history_key": "ex:btc1",
"inst_id": "BTC-USD-260328-90000-C",
"underlying": "BTC",
"opt_type": "C",
"realized_pnl": 1.2,
"created_at": "2026-01-01 00:00:00",
"closed_at": "2026-01-01 02:00:00",
},
)
upsert_option_history_row(
conn,
{
"history_key": "ex:eth1",
"inst_id": "ETH-USD-260328-2000-C",
"underlying": "ETH",
"opt_type": "C",
"realized_pnl": 2.0,
"created_at": "2026-01-01 00:00:00",
"closed_at": "2026-01-01 03:00:00",
},
)
# 旧精确 strategy_tag 会把待复盘滤成空
self.assertEqual(
count_review_trades(conn, strategy_tag="BTCUSDT", reviewed="0"),
0,
)
self.assertEqual(
count_review_trades(conn, q="BTCUSDT", reviewed="0"),
1,
)
rows = list_review_trades(conn, q="BTCUSDT", reviewed="0")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["underlying"], "BTC")
if __name__ == "__main__":
unittest.main()

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