Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7216428ab | |||
| 77f66bf200 | |||
| 488b931959 | |||
| b89cba3b6e | |||
| e7f8e9201e | |||
| 301a464f29 | |||
| a4be294c06 | |||
| 1a163c0a43 | |||
| 2a60d47b2d | |||
| 64b24fd6a6 | |||
| 60ff45f098 | |||
| 67a09b1de8 | |||
| 7e7666adfb | |||
| 6876515160 | |||
| 1bc12a32c6 | |||
| 6def61fae3 | |||
| 10ee7614b5 | |||
| 221e8c3cad | |||
| 29c080f8e0 | |||
| 7a01535802 | |||
| f467f94fe9 | |||
| 913c7d5be6 | |||
| 0f5fe801e2 | |||
| 977d62bddf | |||
| 3ed8dabc76 | |||
| eb2afb3c55 | |||
| ee3c1bcdca | |||
| d2028ed0ee | |||
| 3ae7def999 | |||
| c9229d64bf | |||
| 3923818508 | |||
| 4a3e6a2a1d | |||
| 00d76ba20f | |||
| 5b346a5760 | |||
| a43cb35d9a | |||
| 8597e47596 | |||
| 88d460d484 | |||
| ed1ec6f14a | |||
| 7eb098def6 | |||
| 972ef4f910 | |||
| 4e614eb9ed | |||
| 547eedeec4 | |||
| 19b46d19fd | |||
| debfb116fd | |||
| 3c21680763 | |||
| bc08a5852d | |||
| a2d4507028 | |||
| 17cd835e5d | |||
| ac018cb618 | |||
| 220aab9b63 | |||
| 72dddc5106 | |||
| 12574ae88a | |||
| 426afb8dbe | |||
| 38ac258497 | |||
| 7938628485 | |||
| e3cd2a75de | |||
| bc797cb1db | |||
| 0ed2eabf0d | |||
| 5e0ce43415 | |||
| eea4d4ff8f | |||
| d74d0aeae0 | |||
| e11c13747a | |||
| 899a2de931 | |||
| fa2127d66c | |||
| d5f3f315dc | |||
| 1c155328b4 | |||
| 3093d07167 | |||
| 79420904f4 | |||
| 3119486105 | |||
| b3548240cc | |||
| f21e4d1166 | |||
| 9e1343981d | |||
| 2f7e6355a1 | |||
| 023bf6a814 | |||
| 9198aa0dcd | |||
| 82c910fbf8 |
@@ -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
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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` 结束计划时登记风控
|
||||
|
||||
@@ -131,6 +131,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
|
||||
| 手动平仓冷静(小时) | |
|
||||
| 复盘情绪冷静(小时) | |
|
||||
| 日手动平仓次数上限 | |
|
||||
| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 |
|
||||
| 情绪标签日冻结 | |
|
||||
|
||||
详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 为辅、仓位与日停手规则) |
|
||||
@@ -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
@@ -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`;**全部腿终态后结束**(亏损腿到期后结账) |
|
||||
| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 < 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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 部署依赖安装失败
|
||||
|
||||
### 修改原因
|
||||
|
||||
@@ -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
@@ -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
@@ -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 等,不必与本说明书一一粘贴。
|
||||
@@ -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;
|
||||
})();
|
||||
+721
-115
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
})();
|
||||
|
||||
Vendored
+14
@@ -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]] = {
|
||||
|
||||
Vendored
+92
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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'))}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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> · 张数=期权张(整张) · 卖一/买一=价格/张.期权买入仅认真实卖一价且卖一深度>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>
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 [],
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>开仓只认真实卖一价且卖一深度>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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| **资金概况** | 总资金曲线、分户权益、回撤与 24h 变化 |
|
||||
| **开仓计划** | 事前写下计划、跟踪进行中、统计历史胜率 |
|
||||
| **监控区** | **核心操作台**:三所持仓卡片、全平/撤单、关键位与趋势计划摘要 |
|
||||
| **策略说明** | 三所策略 playbook + 开仓检查清单(非系统操作手册) |
|
||||
| **策略说明** | 执行手册 + 三所策略 playbook + 开仓检查清单(非系统操作手册) |
|
||||
| **使用说明** | 本页:中控与实例怎么用 |
|
||||
| **行情区** | K 线、指标、画线;可从持仓跳转带币种 |
|
||||
| **计算器** | 趋势回调 / 滚仓张数与盈亏测算(手动填价) |
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>" +
|
||||
|
||||
@@ -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, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user