Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab42b1b53 | |||
| d592632834 | |||
| a488e2fabd | |||
| 6fad68f7b1 | |||
| 14a7adae1f | |||
| 24bb8532c4 | |||
| c514a75026 | |||
| 5c3969674a | |||
| 3b56e15fb1 |
@@ -10042,14 +10042,14 @@ def _hub_meta_bundle():
|
|||||||
|
|
||||||
|
|
||||||
def _hub_account_bundle():
|
def _hub_account_bundle():
|
||||||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
# 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=False)
|
||||||
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
|
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
|
||||||
available = get_available_trading_usdt()
|
|
||||||
return {
|
return {
|
||||||
"funding_usdt": funding_usdt,
|
"funding_usdt": funding_usdt,
|
||||||
"trading_usdt": trading_usdt,
|
"trading_usdt": trading_usdt,
|
||||||
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
|
"available_trading_usdt": trading_usdt,
|
||||||
"trading_day": get_trading_day(app_now()),
|
"trading_day": get_trading_day(app_now()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,8 @@ from lib.exchange.gate_ccxt_lib import gate_ccxt_class
|
|||||||
# Gate.io USDT 永续(swap)
|
# Gate.io USDT 永续(swap)
|
||||||
exchange = gate_ccxt_class()({
|
exchange = gate_ccxt_class()({
|
||||||
"enableRateLimit": True,
|
"enableRateLimit": True,
|
||||||
|
# 避免关键位监控/账户拉取无限挂起拖垮中控
|
||||||
|
"timeout": int(os.getenv("GATE_CCXT_TIMEOUT_MS", "8000")),
|
||||||
"options": {
|
"options": {
|
||||||
"defaultType": "swap",
|
"defaultType": "swap",
|
||||||
"defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE,
|
"defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE,
|
||||||
@@ -4611,14 +4613,25 @@ def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason):
|
|||||||
conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
|
conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
|
||||||
|
|
||||||
|
|
||||||
|
_RS_BAR_CACHE: dict[str, dict] = {}
|
||||||
|
_RS_BAR_CACHE_TTL_SEC = float(os.getenv("GATE_RS_BAR_CACHE_SEC", "45"))
|
||||||
|
|
||||||
|
|
||||||
def _fetch_last_closed_bar(symbol):
|
def _fetch_last_closed_bar(symbol):
|
||||||
"""最近一根闭合 K:[ts, o, h, l, c, v] 或 None."""
|
"""最近一根闭合 K:[ts, o, h, l, c, v] 或 None.短缓存减轻关键位监控打爆 ccxt."""
|
||||||
ex_sym = normalize_exchange_symbol(symbol)
|
ex_sym = normalize_exchange_symbol(symbol)
|
||||||
|
now = time.time()
|
||||||
|
cached = _RS_BAR_CACHE.get(ex_sym)
|
||||||
|
if cached and now - float(cached.get("updated_at") or 0) < _RS_BAR_CACHE_TTL_SEC:
|
||||||
|
return cached.get("bar")
|
||||||
bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or []
|
bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or []
|
||||||
if len(bars) < 2:
|
if len(bars) < 2:
|
||||||
|
_RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": None}
|
||||||
return None
|
return None
|
||||||
closed = bars[:-1]
|
closed = bars[:-1]
|
||||||
return closed[-1] if closed else None
|
bar = closed[-1] if closed else None
|
||||||
|
_RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": bar}
|
||||||
|
return bar
|
||||||
|
|
||||||
|
|
||||||
def _key_rs_gate_preview(symbol, upper, lower):
|
def _key_rs_gate_preview(symbol, upper, lower):
|
||||||
@@ -9893,14 +9906,14 @@ def _hub_meta_bundle():
|
|||||||
|
|
||||||
|
|
||||||
def _hub_account_bundle():
|
def _hub_account_bundle():
|
||||||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
# 中控看板高频拉取:仅走余额缓存;不再额外 fetch_balance(会与关键位监控争用 ccxt)
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=False)
|
||||||
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||||||
trading_usdt = round(trading_capital, 2) if trading_capital is not None else None
|
trading_usdt = round(trading_capital, 2) if trading_capital is not None else None
|
||||||
available = get_available_trading_usdt()
|
|
||||||
return {
|
return {
|
||||||
"funding_usdt": funding_usdt,
|
"funding_usdt": funding_usdt,
|
||||||
"trading_usdt": trading_usdt,
|
"trading_usdt": trading_usdt,
|
||||||
"available_trading_usdt": round(available, 2) if available is not None else None,
|
"available_trading_usdt": trading_usdt,
|
||||||
"trading_day": get_trading_day(app_now()),
|
"trading_day": get_trading_day(app_now()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,10 +115,6 @@ OKX_OPTIONS_ENABLED=false
|
|||||||
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
|
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
|
||||||
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||||
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||||
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
|
|
||||||
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
|
|
||||||
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
|
|
||||||
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
|
|
||||||
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
|
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
|
||||||
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
|
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
|
||||||
OKX_TRADE_MODE=options
|
OKX_TRADE_MODE=options
|
||||||
|
|||||||
@@ -6875,17 +6875,6 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
|
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
|
||||||
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
|
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
|
||||||
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
|
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
|
||||||
options_compound_full_enabled=os.getenv(
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"
|
|
||||||
).lower()
|
|
||||||
in ("1", "true", "yes", "on"),
|
|
||||||
options_compound_full_cap_enabled=os.getenv(
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
|
|
||||||
).lower()
|
|
||||||
in ("1", "true", "yes", "on"),
|
|
||||||
options_compound_full_cap_usdc=float(
|
|
||||||
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
|
|
||||||
),
|
|
||||||
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
|
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
|
||||||
options_chain_ask_liq_filter=os.getenv(
|
options_chain_ask_liq_filter=os.getenv(
|
||||||
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
|
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
|
||||||
@@ -9676,14 +9665,14 @@ def _hub_meta_bundle():
|
|||||||
|
|
||||||
|
|
||||||
def _hub_account_bundle():
|
def _hub_account_bundle():
|
||||||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
# 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=False)
|
||||||
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
|
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
|
||||||
available = get_available_trading_usdt()
|
|
||||||
return {
|
return {
|
||||||
"funding_usdt": funding_usdt,
|
"funding_usdt": funding_usdt,
|
||||||
"trading_usdt": trading_usdt,
|
"trading_usdt": trading_usdt,
|
||||||
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
|
"available_trading_usdt": trading_usdt,
|
||||||
"trading_day": get_trading_day(app_now()),
|
"trading_day": get_trading_day(app_now()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
|
|
||||||
|
|
||||||
> 状态:**方案待实现**(按本文落地;改需求先改本文).
|
|
||||||
> 范围:**仅 `crypto_monitor_okx` 单笔期权**;对冲计划(永期/期期)**不接币本位**.
|
|
||||||
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景与动机
|
|
||||||
|
|
||||||
当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平.
|
|
||||||
|
|
||||||
实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量.
|
|
||||||
|
|
||||||
币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 目标(首版)
|
|
||||||
|
|
||||||
1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥).
|
|
||||||
2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量).
|
|
||||||
3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT.
|
|
||||||
4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**.
|
|
||||||
5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`.
|
|
||||||
6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**.
|
|
||||||
7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 不做(首版外)
|
|
||||||
|
|
||||||
- 对冲计划(永期/期期)币本位腿或双模式混开
|
|
||||||
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
|
|
||||||
- 按权利金精确计算后再买现货(明确不做;见 §5)
|
|
||||||
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
|
|
||||||
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
|
|
||||||
- 多笔并行单笔期权仓(维持「一次一仓」)
|
|
||||||
- 中控代下币本位期权
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 模式开关与互斥
|
|
||||||
|
|
||||||
### 4.1 env(草案)
|
|
||||||
|
|
||||||
| 变量 | 含义 | 默认 |
|
|
||||||
|------|------|------|
|
|
||||||
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `usdc` |
|
|
||||||
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` |
|
|
||||||
| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` |
|
|
||||||
| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) |
|
|
||||||
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
|
|
||||||
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
|
|
||||||
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`.
|
|
||||||
- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`.
|
|
||||||
- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致).
|
|
||||||
|
|
||||||
### 4.2 切换门禁
|
|
||||||
|
|
||||||
| 条件 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc`↔`coin` |
|
|
||||||
| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 |
|
|
||||||
| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC |
|
|
||||||
| 无仓且无半成品 | 允许改 env 并重启后生效 |
|
|
||||||
|
|
||||||
启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 币本位资金桥与开平流水
|
|
||||||
|
|
||||||
### 5.1 开仓(先买满,再开满)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 读取交易账户 USDT 可用
|
|
||||||
2. 计算 budget_usdt(§4.1)
|
|
||||||
3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致)
|
|
||||||
4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权
|
|
||||||
- 受:最小张数、卖一深度、单笔一仓规则约束
|
|
||||||
- 不要求「币数量精确等于权利金」;允许开满后仍残留部分币
|
|
||||||
5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 平仓(先平期权,再卖回 USDT)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 按现有纪律买一限价卖出期权(可分批深度)
|
|
||||||
2. 期权仓清零(或本轮目标完成)后:
|
|
||||||
现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT
|
|
||||||
3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 失败回滚(必须)
|
|
||||||
|
|
||||||
| 失败点 | 处理 |
|
|
||||||
|--------|------|
|
|
||||||
| 现货买入失败 | 不开期权;报错 |
|
|
||||||
| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 |
|
|
||||||
| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 |
|
|
||||||
| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 复利与「人工转走」
|
|
||||||
|
|
||||||
### 6.1 口径
|
|
||||||
|
|
||||||
- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算).
|
|
||||||
- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」.
|
|
||||||
- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`):
|
|
||||||
- **默认关闭** → 纯靠人工转走控规模.
|
|
||||||
- **开启** → `min(账户×0.95, N)`,防止单笔过大.
|
|
||||||
|
|
||||||
### 6.2 与永续「全仓」的关系
|
|
||||||
|
|
||||||
思想同类(吃可用 × 缓冲),但资产不同:
|
|
||||||
|
|
||||||
- 永续全仓:USDT 保证金 × 杠杆 → 合约名义
|
|
||||||
- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金
|
|
||||||
|
|
||||||
**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合.
|
|
||||||
|
|
||||||
### 6.3 一次一仓
|
|
||||||
|
|
||||||
复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 产品与 UI
|
|
||||||
|
|
||||||
### 7.1 模式可见性
|
|
||||||
|
|
||||||
- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`.
|
|
||||||
- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态.
|
|
||||||
- USDC 模式保持现有 USDC 余额与预算展示.
|
|
||||||
|
|
||||||
### 7.2 开仓按钮文案(示例)
|
|
||||||
|
|
||||||
- 币本位:`买币并开仓(预算 ≈ xx USDT)`
|
|
||||||
- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT.
|
|
||||||
|
|
||||||
### 7.3 对冲
|
|
||||||
|
|
||||||
- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算.
|
|
||||||
- 不在此模式自动把对冲预算改成 USDT 桥.
|
|
||||||
|
|
||||||
### 7.4 复盘字段(建议)
|
|
||||||
|
|
||||||
单笔 round-trip 尽量可拆:
|
|
||||||
|
|
||||||
- 期权腿盈亏(币或折合 USDT)
|
|
||||||
- 桥兑换盈亏(买币成本 vs 卖币回收)
|
|
||||||
- 合计 USDT 变化(对复利最有意义)
|
|
||||||
|
|
||||||
首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 技术要点
|
|
||||||
|
|
||||||
### 8.1 合约与报价
|
|
||||||
|
|
||||||
- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径.
|
|
||||||
- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设).
|
|
||||||
- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套.
|
|
||||||
|
|
||||||
### 8.2 模块建议
|
|
||||||
|
|
||||||
| 块 | 职责 |
|
|
||||||
|----|------|
|
|
||||||
| 模式读取 + 门禁 | env、有仓拒切、启动一致性 |
|
|
||||||
| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 |
|
|
||||||
| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 |
|
|
||||||
| 定价/张数 | 币本位分支 |
|
|
||||||
| UI/API | 预算预览、确认、半成品提示 |
|
|
||||||
|
|
||||||
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
|
|
||||||
|
|
||||||
### 8.3 权限与账户
|
|
||||||
|
|
||||||
- API 需具备:交易账户现货市价、期权开平.
|
|
||||||
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
|
|
||||||
|
|
||||||
### 8.4 测试
|
|
||||||
|
|
||||||
- 预算计算:复利开/关、上限开/关、余额边界.
|
|
||||||
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
|
|
||||||
- 门禁:有仓切换拒绝;一次一仓.
|
|
||||||
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 验收标准
|
|
||||||
|
|
||||||
1. `usdc` 模式:单笔期权行为与现网一致.
|
|
||||||
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
|
|
||||||
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
|
|
||||||
4. 上限开关默认关;开启后预算不超过 N.
|
|
||||||
5. 有持仓或半成品时切换模式被拒绝.
|
|
||||||
6. 币本位下对冲不能误开币本位腿.
|
|
||||||
7. 开仓失败自动卖回 USDT,不留下无主现货.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 实现顺序建议
|
|
||||||
|
|
||||||
1. 模式 env + 有仓/半成品门禁 + UI 展示当前模式
|
|
||||||
2. 现货桥(买/卖/回滚/待卖回) + 单测
|
|
||||||
3. 币本位合约适配 + 卖一开/买一平接入编排
|
|
||||||
4. 复利预算预览与开仓确认
|
|
||||||
5. 上限开关
|
|
||||||
6. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 决策摘要(已拍板)
|
|
||||||
|
|
||||||
| 决策 | 结论 |
|
|
||||||
|------|------|
|
|
||||||
| 对冲 | 暂不接币本位 |
|
|
||||||
| 单笔模式 | env:`usdc` ↔ `coin` |
|
|
||||||
| 有持仓切换 | **拒绝** |
|
|
||||||
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
|
|
||||||
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
|
|
||||||
| 单笔不超过 N U | **独立开关,默认关闭** |
|
|
||||||
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 风险与说明
|
|
||||||
|
|
||||||
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
|
|
||||||
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
|
|
||||||
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
|
|
||||||
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
# 实盘下单 · 盘口深度预览 — 开发方案
|
|
||||||
|
|
||||||
> 状态:**方案待实现**(按本文落地;改需求先改本文).
|
|
||||||
> 范围:**三所实例**实盘下单监控(Binance / OKX / Gate);中控嵌入同一表单时一并带上.
|
|
||||||
> 相关:[manual-order-rr-preview.md](./manual-order-rr-preview.md) · [position-sizing-mode.md](./position-sizing-mode.md) · 期权侧已有「卖一开 / 买一平」深度硬约束(本方案**不照搬硬挡**,首版以预览为主).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 背景与问题
|
|
||||||
|
|
||||||
实盘下单表单目前只展示 **标的现价/标记价**,再按止损与计仓模式算出预估风险 / 预估 RR.
|
|
||||||
|
|
||||||
- **资金小**:名义仓位通常远小于盘口前几档,市价成交贴近买卖一,现价参考够用.
|
|
||||||
- **资金大**(尤其 `POSITION_SIZING_MODE=full_margin`):名义 = 可用保证金 × 缓冲 × 杠杆,容易到数十万 U. 市价单会沿对手盘穿档,入场均价偏离「现价」后,止损距离与有效盈亏比都会偏.
|
|
||||||
|
|
||||||
典型例子:
|
|
||||||
|
|
||||||
| 条件 | 含义 |
|
|
||||||
|------|------|
|
|
||||||
| 可用约 1 万 U,20 倍杠杆,全仓 | 计划名义约 **20 万 U** |
|
|
||||||
| **市价做空** | 立刻卖出 ≈ 20 万 U 名义 → 吃 **买单(bid)** |
|
|
||||||
| **市价做多** | 立刻买入 ≈ 20 万 U 名义 → 吃 **卖单(ask)** |
|
|
||||||
|
|
||||||
用户需要的不是整本订单簿娱乐墙,而是回答:
|
|
||||||
|
|
||||||
> 当前计划名义下,对手盘前几档**能不能接住**,接住后的**预估均价 / 滑点**大概多少?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 目标(首版)
|
|
||||||
|
|
||||||
在「实盘下单监控」开仓区增加 **计划名义 vs 对手盘深度** 的只读预览:
|
|
||||||
|
|
||||||
1. 按当前表单算出的 **计划名义(USDT)** 与 **方向**,取对应一侧盘口.
|
|
||||||
2. 从最优档往外累加,直到累计名义 ≥ 计划名义(或盘口耗尽).
|
|
||||||
3. 展示:吃到第几档、累计可吸收名义、预估成交均价(VWAP)、相对参考价的滑点(bps 或 %).
|
|
||||||
4. **不拦截下单**(首版);可选标黄提示,见 §6.
|
|
||||||
|
|
||||||
与现有「预估风险 / 预估盈利 / 预估盈亏比」并列,作为下单前参考,不替代服务端风控与交易所真实成交.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 不做(首版外)
|
|
||||||
|
|
||||||
- 完整 20/50 档盘口图、深度图动画、WebSocket 持续推送盘口(首版 REST 轮询即可)
|
|
||||||
- 按深度 **自动缩仓** 或 **禁止开仓**(期权硬约束那套;列为二期,见 §10)
|
|
||||||
- 限价挂单的「挂单价到盘口距离」专项(可后加;首版聚焦市价吃单路径)
|
|
||||||
- 平仓/止损单穿档预估(开仓侧先做;平仓可二期)
|
|
||||||
- 改开仓逻辑、改计仓公式、改交易所下单路径
|
|
||||||
- 中控独立深度页或跨所聚合盘口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 产品规则
|
|
||||||
|
|
||||||
### 4.1 对手盘方向
|
|
||||||
|
|
||||||
| 用户方向 | 市价开仓动作 | 累加侧 |
|
|
||||||
|----------|--------------|--------|
|
|
||||||
| 做多(long) | 买入 | **卖盘 asks**(卖一 → 卖 N) |
|
|
||||||
| 做空(short) | 卖出 | **买盘 bids**(买一 → 买 N) |
|
|
||||||
|
|
||||||
### 4.2 计划名义从哪来
|
|
||||||
|
|
||||||
与现有开仓计仓一致,优先复用服务端已有 sizing 口径(避免前后端各算一套):
|
|
||||||
|
|
||||||
| 计仓模式 | 计划名义 |
|
|
||||||
|----------|----------|
|
|
||||||
| `full_margin` | `notional_value` ≈ 可用 × 缓冲 × 杠杆(与 `compute_full_margin_sizing` 一致) |
|
|
||||||
| `risk`(以损定仓) | 由风险金额与止损距离反推的仓位名义(与现开仓 `add_order` 路径一致) |
|
|
||||||
|
|
||||||
表单未填齐止损/方向/币种、或无法取可用保证金时:深度预览显示「—」,不报错打断填写.
|
|
||||||
|
|
||||||
### 4.3 参考价与滑点
|
|
||||||
|
|
||||||
- **参考价**:优先与表单现价条同一口径(标记价/最新价,跟现有 `symbol_live_price` / `order_defaults` 一致).
|
|
||||||
- **预估均价(VWAP)**:按所吃各档 `价格 × 该档名义` 加权.
|
|
||||||
- **滑点**:
|
|
||||||
- 做多: `(vwap - ref) / ref`(越正越差)
|
|
||||||
- 做空: `(ref - vwap) / ref`(越正越差)
|
|
||||||
- 展示可用 **bps**(1 bps = 0.01%)或 `%`,UI 统一一种即可(建议 bps,大单更直观).
|
|
||||||
|
|
||||||
### 4.4 盘口档数
|
|
||||||
|
|
||||||
- 请求深度建议 **5~20 档**(实现时三所取各自 API 稳妥上限,默认 20).
|
|
||||||
- 累加只展示「覆盖计划名义所需」的档位摘要,不必把未吃到的远档全部渲染.
|
|
||||||
- 若累加后仍 `< 计划名义`:明确写 **深度不足 / 缺口约 X U**,不要伪装成已完全覆盖.
|
|
||||||
|
|
||||||
### 4.5 文案示例(空单 20 万 U)
|
|
||||||
|
|
||||||
```
|
|
||||||
对手盘(买):买一~买4 累计约 23.1 万 U · 预估均价 63480(相对现价约 5 bps)
|
|
||||||
```
|
|
||||||
|
|
||||||
深度不足时:
|
|
||||||
|
|
||||||
```
|
|
||||||
对手盘(买):前 20 档累计约 12.4 万 U · 缺口约 7.6 万 U · 预估均价按已有档估算(仅供参考)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 界面位置
|
|
||||||
|
|
||||||
放在实盘下单开仓区、现有预览条附近,避免抢主按钮视觉:
|
|
||||||
|
|
||||||
| 区域 | 建议 |
|
|
||||||
|------|------|
|
|
||||||
| 现价条旁或下方 | 一行摘要即可(§4.5) |
|
|
||||||
| `#order-plan-preview` | 可增一项「盘口深度」或独立 `#order-depth-preview` |
|
|
||||||
| 详细档位 | 首版可不展开;若展开,仅列出已累加到的那几档(价/量/累计名义) |
|
|
||||||
|
|
||||||
小资金且滑点低于阈值时,可用灰色弱提示「前 N 档已覆盖,滑点可忽略」,避免噪音.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 提示阈值(软提示,不挡单)
|
|
||||||
|
|
||||||
建议可配置(`.env`,有默认值),仅影响颜色/文案:
|
|
||||||
|
|
||||||
| 变量(草案) | 含义 | 默认建议 |
|
|
||||||
|------------|------|----------|
|
|
||||||
| `MANUAL_DEPTH_WARN_BPS` | 预估滑点 ≥ 此值标黄 | `5` |
|
|
||||||
| `MANUAL_DEPTH_ALERT_BPS` | 预估滑点 ≥ 此值标红/强调 | `15` |
|
|
||||||
| `MANUAL_DEPTH_SHORTFALL_WARN` | 累计名义 < 计划名义时强调 | 开 |
|
|
||||||
|
|
||||||
首版:**不**因此 `disabled` 开仓按钮;与期权「无卖一禁止开仓」区分开.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 技术设计
|
|
||||||
|
|
||||||
### 7.1 API(三所各暴露,或抽到 `lib/` 共用 handler)
|
|
||||||
|
|
||||||
建议新增(名称可微调):
|
|
||||||
|
|
||||||
`GET /api/order_depth_preview`
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `symbol` | 与开仓表单一致 |
|
|
||||||
| `direction` | `long` / `short` |
|
|
||||||
| `sl` / `sl_pct` / `fixed_rr` / `sltp_mode` 等 | 以损定仓算名义时需要;全仓模式可只传 symbol+direction |
|
|
||||||
| 或直接传 `notional_usdt` | 若前端已从其它 preview API 拿到名义,可减少重复计算(**二选一,实现时定一种主路径**) |
|
|
||||||
|
|
||||||
响应草案:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"side": "bid",
|
|
||||||
"ref_px": 63512.3,
|
|
||||||
"plan_notional_usdt": 200000,
|
|
||||||
"covered_notional_usdt": 231000,
|
|
||||||
"shortfall_usdt": 0,
|
|
||||||
"levels_used": 4,
|
|
||||||
"vwap": 63480.0,
|
|
||||||
"slippage_bps": 5.1,
|
|
||||||
"levels": [
|
|
||||||
{"px": 63510, "sz": "...", "notional_usdt": 50000, "cum_notional_usdt": 50000}
|
|
||||||
],
|
|
||||||
"msg": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
失败(拉盘口失败、币种无效):`ok=false` + 简短 `msg`;前端显示「深度暂不可用」,不影响开仓。
|
|
||||||
|
|
||||||
### 7.2 交易所盘口
|
|
||||||
|
|
||||||
| 所 | 合约盘口 | 注意 |
|
|
||||||
|----|----------|------|
|
|
||||||
| Binance | USD-M 深度 | 数量单位换算成 USDT 名义 |
|
|
||||||
| OKX | swap books | 同左;与期权 `fetch_option_book_depth` **分开**,勿混用期权接口 |
|
|
||||||
| Gate | futures order book | 同左 |
|
|
||||||
|
|
||||||
公共逻辑建议落在 `lib/trade/`(例如 `manual_order_depth_preview_lib.py`):输入档位列表 + 计划名义 + 方向 → 输出 VWAP / 缺口 / levels_used.
|
|
||||||
各所只负责 **拉 book + 单位换算成 USDT 名义**.
|
|
||||||
|
|
||||||
### 7.3 前端
|
|
||||||
|
|
||||||
- 共享脚本(建议):`lib/common/static/manual_order_depth_preview.js`
|
|
||||||
- 与 `manual_order_rr_preview.js` 同样在币种/方向/止损/模式变更时 debounce 刷新
|
|
||||||
- 轮询间隔建议 3~5s(仅表单可见且字段有效时);切页或无焦点可停
|
|
||||||
- 三所 `index` / 嵌入 fragment 引入同一脚本
|
|
||||||
|
|
||||||
### 7.4 测试
|
|
||||||
|
|
||||||
- 纯函数:给定假盘口 + 名义,断言 `levels_used` / `vwap` / `shortfall`
|
|
||||||
- 方向: long 只吃 ask, short 只吃 bid
|
|
||||||
- 深度不足与刚好覆盖边界
|
|
||||||
- 不要求联调真盘口也能合入(真盘口可手工验一次 BTC/山寨对比)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 验收标准
|
|
||||||
|
|
||||||
1. 全仓 + 已知杠杆下,预览「计划名义」与开仓实际计仓名义同量级(允许四舍五入误差).
|
|
||||||
2. 市价空只反映买盘累加;市价多只反映卖盘累加.
|
|
||||||
3. BTC 厚盘:小名义常显示「前 1~2 档已覆盖、滑点很低」.
|
|
||||||
4. 人为放大名义或选薄流动性标的:能看到多档累加或「深度不足」.
|
|
||||||
5. 拉盘口失败时不阻断开仓按钮.
|
|
||||||
6. 中控嵌入实盘下单同样可见(与实例页同源表单).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 实现顺序建议
|
|
||||||
|
|
||||||
1. `lib/trade` 累加/VWAP 纯函数 + 单测
|
|
||||||
2. 一所(建议 OKX 或当前主力所)拉 book + API + 前端一行预览
|
|
||||||
3. 抽换算差异,补 Binance / Gate
|
|
||||||
4. 接入软提示阈值与文案打磨
|
|
||||||
5. 文档验收记录补进本文或 `docs/更新文档.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 二期(明确不做进首版)
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| 深度不够自动缩名义 | 类似期权 `cap_by_ask_depth` |
|
|
||||||
| 滑点超阈值二次确认 / 禁止市价 | 产品确认后再做硬门禁 |
|
|
||||||
| 平仓与止损穿档预估 | 持仓卡或平仓按钮旁 |
|
|
||||||
| WS 盘口 | 降低 REST 压力、更即时 |
|
|
||||||
| 限价开仓:挂单价相对盘口位置 | 另一套提示 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 决策摘要(已拍板)
|
|
||||||
|
|
||||||
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
|
|
||||||
- **做空看买单,做多看卖单**.
|
|
||||||
- **首版只展示 + 软提示,不挡单**.
|
|
||||||
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 1 轮)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
期权链接入 OKX WS 推送后,生产中控出现「期权数据不可用 / 子代理不可用」。按要求**先回滚 WS 链路**,再全量审计并修复。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
| 提交 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `a488e2f` | Revert fast-path(依赖 WS 热缓存) |
|
||||||
|
| `d592632` | Revert OKX WS + SSE 推送整栈 |
|
||||||
|
|
||||||
|
恢复为 **REST 拉链 + 前端约 15s soft-poll**(commit `24bb853` 行为),删除:
|
||||||
|
|
||||||
|
- `lib/exchange/okx_public_ws_lib.py`
|
||||||
|
- `lib/options/options_quote_live_lib.py`
|
||||||
|
- `tests/test_options_quote_live_lib.py`
|
||||||
|
|
||||||
|
## 根因结论(非仅 WS)
|
||||||
|
|
||||||
|
| 级别 | 问题 | 证据 |
|
||||||
|
|------|------|------|
|
||||||
|
| Critical | Gate 关键位 RS 监控在后台线程高频 `fetch_ohlcv`,与 `/api/hub/account` 争用同一 ccxt 客户端,账户/子代理超时 | 日志 `[key_rs_level_alert] BTC/USDT id=13`;本机 `5000/api/hub/account` 25s 超时 |
|
||||||
|
| Critical | 中控期权快照对每仓拉 books 深度,易超 `HUB_FLASK_TIMEOUT=10` | `build_display_option_positions` → `attach_close_preview` → `fetch_option_book_depth` |
|
||||||
|
| High | Soft 拉链 3 次重试 + 无单飞,易与 SSE tick 叠打 OKX | `options_panel.js` loadChain |
|
||||||
|
| High | 中控账户接口每轮 `force=True` 绕过余额缓存 | Gate/OKX/Binance `_hub_account_bundle` |
|
||||||
|
| Medium | Flask 超时错误只有 `error` 无 `msg`,前端易落默认文案 | `hub.py` `_fetch_flask_json` |
|
||||||
|
| Medium | `options` 为 null 时前端当成「0 仓」而非不可用 | `app.js` renderOptionsSectionBody |
|
||||||
|
|
||||||
|
WS 部署触发的**全进程重启**放大了 Gate 争用与快照超时,表现为「全不可用」;OKX 快照在轻负载下仍可 `ok:true`。
|
||||||
|
|
||||||
|
## 本轮修复
|
||||||
|
|
||||||
|
1. **Hub 期权快照**关闭逐仓 `close_preview`/books(`with_close_preview=False`)
|
||||||
|
2. **Hub 账户**三所改为 `get_exchange_capitals(force=False)`
|
||||||
|
3. **Gate ccxt** 增加 `timeout=8000ms`;RS K 线 **45s 缓存**
|
||||||
|
4. **期权 tickers** 恢复 **10s** 短缓存(无 WS)
|
||||||
|
5. **前端 soft 拉链**:单飞 + soft 仅 1 次尝试;已有链不先清空表格
|
||||||
|
6. **Hub**:超时补 `msg`;期权快照与 account/monitor **并行 gather**
|
||||||
|
7. **中控 UI**:capabilities 含 options 且 snapshot 缺失时显式「期权数据不可用」
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
- 强刷中控监控区:OKX 期权资金/持仓应恢复;Gate 子代理 status 应在数秒内恢复
|
||||||
|
- 期权页「刷新链」不应长时间白屏;指数行显示约 15s 静默刷新
|
||||||
|
- Gate 关键位监控日志不应再每秒刷屏 `fetch_ohlcv` 失败
|
||||||
|
|
||||||
|
## 残留风险(交第 2 轮)
|
||||||
|
|
||||||
|
- Gate 仍与监控共用单一 ccxt 客户端(未加全局锁)
|
||||||
|
- 中控 board 仍可能被最慢交易所拉长整轮等待
|
||||||
|
- Soft-poll 仍是 REST,非真·实时
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 2 轮)
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
复查第 1 轮修复是否引入回归,并扫清仍会导致「中控不可用」的残留高优先级问题。
|
||||||
|
|
||||||
|
## 复查结论
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
|----|------|
|
||||||
|
| Hub 并行 options 索引进位 | 正确(day / options 组合无错位) |
|
||||||
|
| Hub 关闭 close_preview | UI 降级为 upl/`—`,不崩 |
|
||||||
|
| Gate RS 缓存 / timeout | timeout 已为 int;缓存可接受 |
|
||||||
|
| board row capabilities | `_fetch_agent_status` 始终带上 |
|
||||||
|
| `with_close_preview` 默认 | 实例路径仍为 True |
|
||||||
|
|
||||||
|
## 本轮新发现问题与修复
|
||||||
|
|
||||||
|
| 级别 | 问题 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| High | Hub 账户在 `force=False` 后仍调用 `get_available_trading_usdt()` 再打一枪 `fetch_balance`,Gate 争用依旧 | 三所 `_hub_account_bundle` 改为用缓存的 `trading_usdt` 作为 `available_trading_usdt` |
|
||||||
|
| Medium | `loadChain` soft 门禁在 `seq++` 之后,叠刷可导致 `chainLoadInFlight` 永不清理 | soft 门禁移到 `seq++` 之前 |
|
||||||
|
|
||||||
|
## 与第 1 轮一并交付的状态
|
||||||
|
|
||||||
|
- WS 推送链路已回滚(REST + 15s soft-poll)
|
||||||
|
- 中控期权快照轻量化 + 并行拉取
|
||||||
|
- Gate RS K 线短缓存 + ccxt timeout
|
||||||
|
- 期权 tickers 10s 缓存;soft 单飞/单次尝试
|
||||||
|
|
||||||
|
## 已知残留(不阻塞本次部署)
|
||||||
|
|
||||||
|
- Gate 监控与账户仍共用单一 ccxt 客户端(无全局锁)
|
||||||
|
- 中控 board 仍可能被最慢交易所拉长整轮
|
||||||
|
- Soft-poll 非真·实时报价
|
||||||
|
|
||||||
|
## 部署后验收
|
||||||
|
|
||||||
|
1. 中控强刷:OKX 期权区有资金数字,不再长期「期权数据不可用」
|
||||||
|
2. Gate 卡:子代理恢复绿色/有资金;不再长时间「子代理不可用」
|
||||||
|
3. 期权页刷新链不白屏;约 15s 静默更新时间戳
|
||||||
+7
-16
@@ -47,13 +47,6 @@
|
|||||||
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
|
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
|
||||||
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
|
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
|
||||||
|
|
||||||
### 2.4 翻倍出场(可选)
|
|
||||||
|
|
||||||
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
|
|
||||||
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
|
|
||||||
- 可随时关闭;与目标位监控并行,谁先达标谁平.
|
|
||||||
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 监控逻辑
|
## 3. 监控逻辑
|
||||||
@@ -65,21 +58,19 @@
|
|||||||
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
|
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
|
||||||
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
|
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
|
||||||
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
|
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
|
||||||
| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
|
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
|
||||||
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
|
|
||||||
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
|
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 平仓校验(门控)
|
## 4. 平仓校验(门控)
|
||||||
|
|
||||||
| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
|
| 门控 | 手动买一平 | 目标自动平 | 说明 |
|
||||||
|------|------------|------------|----------|------|
|
|------|------------|------------|------|
|
||||||
| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
|
| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
|
||||||
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
|
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 |
|
||||||
| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
|
| 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 |
|
||||||
| 锁定买一价 | ✅ | ✅ | ✅ | 下单价 = 通过校验时的买一 |
|
| 市价兜底 | ❌ | ❌ | 永不市价 |
|
||||||
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1165,11 +1165,9 @@
|
|||||||
fillExpSelect($("hp-oo-exp-select"), d);
|
fillExpSelect($("hp-oo-exp-select"), d);
|
||||||
renderListStrikes();
|
renderListStrikes();
|
||||||
renderTStrikes();
|
renderTStrikes();
|
||||||
if (d.index_px) {
|
// 期期盈亏比默认 2,不再用指数自动填上破/下破
|
||||||
// 盈亏比默认2,不随指数自动改写
|
if ($("hp-oo-rr") && !$("hp-oo-rr").value) {
|
||||||
if ($("hp-profit-rr") && !$("hp-profit-rr").value) {
|
$("hp-oo-rr").value = "2";
|
||||||
$("hp-profit-rr").value = "2";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1578,7 +1576,7 @@
|
|||||||
if ($("hp-contracts")) $("hp-contracts").value = "";
|
if ($("hp-contracts")) $("hp-contracts").value = "";
|
||||||
if ($("hp-tp")) $("hp-tp").value = "";
|
if ($("hp-tp")) $("hp-tp").value = "";
|
||||||
if ($("hp-sl")) $("hp-sl").value = "";
|
if ($("hp-sl")) $("hp-sl").value = "";
|
||||||
if ($("hp-profit-rr")) $("hp-profit-rr").value = "2";
|
if ($("hp-oo-rr")) $("hp-oo-rr").value = "2";
|
||||||
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
|
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
|
||||||
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
|
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
|
||||||
if ($("hp-oo-sheets-a")) {
|
if ($("hp-oo-sheets-a")) {
|
||||||
@@ -1614,11 +1612,11 @@
|
|||||||
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
||||||
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
||||||
}
|
}
|
||||||
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
|
const rr = numInput("hp-oo-rr", 2);
|
||||||
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
|
if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
|
||||||
body = {
|
body = {
|
||||||
plan_type: "options_options",
|
plan_type: "options_options",
|
||||||
profit_rr: rr,
|
oo_profit_rr: rr,
|
||||||
index_px: indexPx() || 0,
|
index_px: indexPx() || 0,
|
||||||
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
||||||
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
|
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
|
||||||
@@ -1711,34 +1709,44 @@
|
|||||||
fmt(s.premium_paid) +
|
fmt(s.premium_paid) +
|
||||||
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
|
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
|
||||||
} else {
|
} else {
|
||||||
const rrTarget = s.profit_rr != null ? s.profit_rr : null;
|
const rr = s.oo_profit_rr != null ? s.oo_profit_rr : s.rr_target;
|
||||||
let rrLine = "";
|
const tgt = s.target_profit != null ? s.target_profit : s.at_target_total;
|
||||||
if (rrTarget != null) {
|
if (rr != null) {
|
||||||
rrLine =
|
summary.innerHTML =
|
||||||
" · 目标盈亏比 " +
|
"盈亏比 ×" +
|
||||||
fmt(rrTarget, 2) +
|
fmt(rr, 2) +
|
||||||
'<span class="muted">(盈利金额/总权利金)</span>';
|
" · 目标盈利 " +
|
||||||
} else if (s.rr_at_up != null || s.rr_at_down != null) {
|
fmtPnlHtml(tgt) +
|
||||||
rrLine =
|
" · 到期现价 " +
|
||||||
" · 盈亏比 上破 " +
|
fmtPnlHtml(s.expiry_flat_total) +
|
||||||
fmtRr(s.rr_at_up) +
|
" · 保费 " +
|
||||||
(s.at_target_down_total != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
|
fmt(s.premium_paid) +
|
||||||
'<span class="muted">(亏=全额保费 ' +
|
'<span class="muted">(达标全平;不达标等到期)</span>' +
|
||||||
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
|
(s.expiry_is_loss ? " · 到期现价情景为亏" : "");
|
||||||
")</span>";
|
} else {
|
||||||
|
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
|
||||||
|
const dnTot = s.at_target_down_total;
|
||||||
|
let rrLine = "";
|
||||||
|
if (s.rr_at_up != null || s.rr_at_down != null) {
|
||||||
|
rrLine =
|
||||||
|
" · 盈亏比 上破 " +
|
||||||
|
fmtRr(s.rr_at_up) +
|
||||||
|
(dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
|
||||||
|
'<span class="muted">(亏=全额保费 ' +
|
||||||
|
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
|
||||||
|
")</span>";
|
||||||
|
}
|
||||||
|
summary.innerHTML =
|
||||||
|
"上破 " +
|
||||||
|
fmtPnlHtml(upTot) +
|
||||||
|
(dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") +
|
||||||
|
" · 到期现价 " +
|
||||||
|
fmtPnlHtml(s.expiry_flat_total) +
|
||||||
|
" · 保费 " +
|
||||||
|
fmt(s.premium_paid) +
|
||||||
|
rrLine +
|
||||||
|
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
|
||||||
}
|
}
|
||||||
const aTot = s.at_rr_a_full_total != null ? s.at_rr_a_full_total : s.at_target_up_total;
|
|
||||||
const bTot = s.at_rr_b_full_total != null ? s.at_rr_b_full_total : s.at_target_down_total;
|
|
||||||
summary.innerHTML =
|
|
||||||
(rrTarget != null ? "腿A达标 " : "上破 ") +
|
|
||||||
fmtPnlHtml(aTot) +
|
|
||||||
(bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") +
|
|
||||||
" · 到期现价 " +
|
|
||||||
fmtPnlHtml(s.expiry_flat_total) +
|
|
||||||
" · 保费 " +
|
|
||||||
fmt(s.premium_paid) +
|
|
||||||
rrLine +
|
|
||||||
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
@@ -2055,7 +2063,7 @@
|
|||||||
"hp-tp",
|
"hp-tp",
|
||||||
"hp-sl",
|
"hp-sl",
|
||||||
"hp-sheets",
|
"hp-sheets",
|
||||||
"hp-profit-rr",
|
"hp-oo-rr",
|
||||||
]);
|
]);
|
||||||
if ($("hp-preview-btn"))
|
if ($("hp-preview-btn"))
|
||||||
$("hp-preview-btn").addEventListener("click", function () {
|
$("hp-preview-btn").addEventListener("click", function () {
|
||||||
@@ -2168,8 +2176,8 @@
|
|||||||
if (p.plan_type === "perp_options") {
|
if (p.plan_type === "perp_options") {
|
||||||
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
|
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
|
||||||
}
|
}
|
||||||
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
|
if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
|
||||||
return "盈亏比 " + fmt(p.profit_rr, 2);
|
return "盈亏比 ×" + fmt(p.oo_profit_rr, 2) + "(达标全平)";
|
||||||
}
|
}
|
||||||
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
|
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
|
||||||
}
|
}
|
||||||
@@ -2330,9 +2338,10 @@
|
|||||||
target_win_leg: "期期平盈利腿",
|
target_win_leg: "期期平盈利腿",
|
||||||
target_up_win_leg: "期期上破·平盈利腿",
|
target_up_win_leg: "期期上破·平盈利腿",
|
||||||
target_down_win_leg: "期期下破·平盈利腿",
|
target_down_win_leg: "期期下破·平盈利腿",
|
||||||
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
|
oo_rr_target: "期期盈亏比达标",
|
||||||
oo_rest_closing: "期期残值平·清亏损腿中",
|
oo_rr_closing: "期期盈亏比平仓中",
|
||||||
oo_rest_closed: "期期残值平·两腿已平",
|
oo_rest_closing: "期期全平·清残腿中",
|
||||||
|
oo_rest_closed: "期期全平·两腿已平",
|
||||||
orphaned_after_tp: "止盈后持有至到期",
|
orphaned_after_tp: "止盈后持有至到期",
|
||||||
orphaned_option_expiry: "残腿到期",
|
orphaned_option_expiry: "残腿到期",
|
||||||
hold_to_expiry: "持有至到期",
|
hold_to_expiry: "持有至到期",
|
||||||
@@ -2405,20 +2414,18 @@
|
|||||||
"x · 张数 " +
|
"x · 张数 " +
|
||||||
fmt(p.perp_size, 4) +
|
fmt(p.perp_size, 4) +
|
||||||
"</div>";
|
"</div>";
|
||||||
|
} else if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
|
||||||
|
html +=
|
||||||
|
"<div><span class=\"muted\">盈亏比</span> ×" +
|
||||||
|
fmt(p.oo_profit_rr, 2) +
|
||||||
|
"(浮盈达标全平;不达标等到期)</div>";
|
||||||
} else {
|
} else {
|
||||||
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
|
html +=
|
||||||
html +=
|
"<div><span class=\"muted\">目标价</span> 上破 " +
|
||||||
"<div><span class=\"muted\">盈亏比</span> " +
|
fmt(p.target_price_up || p.target_price) +
|
||||||
fmt(p.profit_rr, 2) +
|
" · 下破 " +
|
||||||
" <span class=\"muted\">(盈利金额/总权利金)</span></div>";
|
fmt(p.target_price_down || p.target_price) +
|
||||||
} else {
|
"</div>";
|
||||||
html +=
|
|
||||||
"<div><span class=\"muted\">目标价</span> 上破 " +
|
|
||||||
fmt(p.target_price_up || p.target_price) +
|
|
||||||
" · 下破 " +
|
|
||||||
fmt(p.target_price_down || p.target_price) +
|
|
||||||
"</div>";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
html +=
|
html +=
|
||||||
"<div><span class=\"muted\">权利金合计</span> " +
|
"<div><span class=\"muted\">权利金合计</span> " +
|
||||||
@@ -2634,12 +2641,12 @@
|
|||||||
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
||||||
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
||||||
}
|
}
|
||||||
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
|
const rr = numInput("hp-oo-rr", 2);
|
||||||
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
|
if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
|
||||||
body = {
|
body = {
|
||||||
plan_type: "options_options",
|
plan_type: "options_options",
|
||||||
underlying: state.underlying,
|
underlying: state.underlying,
|
||||||
profit_rr: rr,
|
oo_profit_rr: rr,
|
||||||
index_px: indexPx() || 0,
|
index_px: indexPx() || 0,
|
||||||
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
|
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
|
||||||
oo_sheets_mode: state.ooSheetsMode || "same_sheets",
|
oo_sheets_mode: state.ooSheetsMode || "same_sheets",
|
||||||
|
|||||||
@@ -202,7 +202,6 @@
|
|||||||
function renderEnvFieldRow(field) {
|
function renderEnvFieldRow(field) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
|
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
|
||||||
row.dataset.envKey = field.key;
|
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.className = "env-field-label";
|
label.className = "env-field-label";
|
||||||
label.htmlFor = "env-f-" + field.key;
|
label.htmlFor = "env-f-" + field.key;
|
||||||
@@ -295,10 +294,6 @@
|
|||||||
input.dataset.envKey = field.key;
|
input.dataset.envKey = field.key;
|
||||||
input.className = "env-field-input";
|
input.className = "env-field-input";
|
||||||
row.appendChild(input);
|
row.appendChild(input);
|
||||||
if (field.hidden) {
|
|
||||||
row.hidden = true;
|
|
||||||
row.style.display = "none";
|
|
||||||
}
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,41 +345,9 @@
|
|||||||
body.appendChild(panelsWrap);
|
body.appendChild(panelsWrap);
|
||||||
body.dataset.envModeSectionIdx = String(modeSectionIdx);
|
body.dataset.envModeSectionIdx = String(modeSectionIdx);
|
||||||
bindTradeModeAutoRefresh(body);
|
bindTradeModeAutoRefresh(body);
|
||||||
bindCompoundBudgetVisibility(body);
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function envFieldRowByKey(body, key) {
|
|
||||||
if (!body || !key) return null;
|
|
||||||
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
|
|
||||||
if (byRow) return byRow;
|
|
||||||
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
|
|
||||||
return input ? input.closest(".env-field-row") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCompoundBudgetVisibility(body) {
|
|
||||||
if (!body) return;
|
|
||||||
const compoundSel = body.querySelector(
|
|
||||||
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
|
|
||||||
);
|
|
||||||
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
|
|
||||||
if (!budgetRow) return;
|
|
||||||
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
|
|
||||||
budgetRow.hidden = compoundOn;
|
|
||||||
budgetRow.style.display = compoundOn ? "none" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindCompoundBudgetVisibility(body) {
|
|
||||||
if (!body) return;
|
|
||||||
syncCompoundBudgetVisibility(body);
|
|
||||||
const compoundSel = body.querySelector(
|
|
||||||
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
|
|
||||||
);
|
|
||||||
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
|
|
||||||
compoundSel.dataset.compoundBudgetBound = "1";
|
|
||||||
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindTradeModeAutoRefresh(body) {
|
function bindTradeModeAutoRefresh(body) {
|
||||||
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
|
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
|
||||||
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
|
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
|
||||||
@@ -579,10 +542,7 @@
|
|||||||
loadEnvConfig(false);
|
loadEnvConfig(false);
|
||||||
const root = envConfigRoot();
|
const root = envConfigRoot();
|
||||||
const body = root && root.querySelector("#env-config-body");
|
const body = root && root.querySelector("#env-config-body");
|
||||||
if (body) {
|
if (body) bindTradeModeAutoRefresh(body);
|
||||||
bindTradeModeAutoRefresh(body);
|
|
||||||
bindCompoundBudgetVisibility(body);
|
|
||||||
}
|
|
||||||
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
|
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2494,11 +2494,6 @@ html[data-theme="light"] .journal-detail-img-thumb {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */
|
|
||||||
.env-field-row[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.env-field-row--restart .env-field-label {
|
.env-field-row--restart .env-field-label {
|
||||||
color: #d4c4a0;
|
color: #d4c4a0;
|
||||||
}
|
}
|
||||||
@@ -4424,9 +4419,6 @@ html[data-theme="light"] .opt-pending-item {
|
|||||||
.opt-size-mode-chip {
|
.opt-size-mode-chip {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.opt-size-mode-chip[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.opt-size-mode-chip input[type="radio"] {
|
.opt-size-mode-chip input[type="radio"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -4450,22 +4442,6 @@ html[data-theme="light"] .opt-pending-item {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.options-estimate-row .opt-profit-exit-mult,
|
|
||||||
.options-page-wrap .opt-pos-profit-exit-mult {
|
|
||||||
width: 4.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 6px 8px;
|
|
||||||
min-height: 32px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-profit-exit-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.options-estimate-row .k {
|
.options-estimate-row .k {
|
||||||
color: #8892b0;
|
color: #8892b0;
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-420
@@ -28,8 +28,6 @@
|
|||||||
posTab: "live",
|
posTab: "live",
|
||||||
/** 未点设定前的目标输入草稿,避免持仓轮询重绘清空 */
|
/** 未点设定前的目标输入草稿,避免持仓轮询重绘清空 */
|
||||||
targetDraftByInst: {},
|
targetDraftByInst: {},
|
||||||
/** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */
|
|
||||||
profitExitDraftByInst: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastGoodPositions = null;
|
let lastGoodPositions = null;
|
||||||
@@ -39,9 +37,15 @@
|
|||||||
let selectSeq = 0;
|
let selectSeq = 0;
|
||||||
let refreshAllTimer = null;
|
let refreshAllTimer = null;
|
||||||
let pendingRefreshTimer = null;
|
let pendingRefreshTimer = null;
|
||||||
|
let chainSoftTimer = null;
|
||||||
|
let lastChainSoftAt = 0;
|
||||||
|
let chainQuotedAt = 0;
|
||||||
|
let chainLoadInFlight = false;
|
||||||
let pendingTtlSeconds = 600;
|
let pendingTtlSeconds = 600;
|
||||||
const POSITIONS_STALE_MS = 45000;
|
const POSITIONS_STALE_MS = 45000;
|
||||||
const PENDING_POLL_MS = 8000;
|
const PENDING_POLL_MS = 8000;
|
||||||
|
/** 链卖一/买一静默刷新节流:无推送,靠拉;过密会撞 OKX 50011 */
|
||||||
|
const CHAIN_SOFT_POLL_MS = 15000;
|
||||||
const orderPanelHome = (function () {
|
const orderPanelHome = (function () {
|
||||||
const host = document.getElementById("opt-order-panel-host");
|
const host = document.getElementById("opt-order-panel-host");
|
||||||
return host ? host.parentElement : null;
|
return host ? host.parentElement : null;
|
||||||
@@ -273,32 +277,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function compoundFullEnabled() {
|
|
||||||
// 缺省按关闭,避免热更关闭后仍误用全仓复利
|
|
||||||
return !!(root && String(root.dataset.compoundFullEnabled || "0") === "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentSizeMode() {
|
function currentSizeMode() {
|
||||||
const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
|
const el = document.querySelector('input[name="opt-size-mode"]:checked');
|
||||||
if (el) return el.value;
|
return el ? el.value : "sheets";
|
||||||
const any = document.querySelector('input[name="opt-size-mode"]:checked');
|
|
||||||
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets";
|
|
||||||
if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full";
|
|
||||||
return "sheets";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCompoundModeUi(compoundOn) {
|
|
||||||
if (root) root.dataset.compoundFullEnabled = compoundOn ? "1" : "0";
|
|
||||||
updateSizeInputs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCompoundFlagsFromPayload(d) {
|
|
||||||
if (!d || typeof d !== "object") return;
|
|
||||||
if (d.compound_full_enabled != null) {
|
|
||||||
applyCompoundModeUi(!!d.compound_full_enabled);
|
|
||||||
} else if (d.cfg && d.cfg.compound_full_enabled != null) {
|
|
||||||
applyCompoundModeUi(!!d.cfg.compound_full_enabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSizeInputs() {
|
function updateSizeInputs() {
|
||||||
@@ -306,69 +287,18 @@
|
|||||||
const sheetsEl = document.getElementById("opt-sheets-amount");
|
const sheetsEl = document.getElementById("opt-sheets-amount");
|
||||||
const ethEl = document.getElementById("opt-eth-amount");
|
const ethEl = document.getElementById("opt-eth-amount");
|
||||||
const hint = document.getElementById("opt-budget-full-hint");
|
const hint = document.getElementById("opt-budget-full-hint");
|
||||||
const compoundHint = document.getElementById("opt-compound-full-hint");
|
|
||||||
const budgetWrap = document.getElementById("opt-size-mode-budget-wrap");
|
|
||||||
const compoundWrap = document.getElementById("opt-size-mode-compound-wrap");
|
|
||||||
const capEl = document.getElementById("opt-budget-full-cap");
|
const capEl = document.getElementById("opt-budget-full-cap");
|
||||||
const compoundCapLine = document.getElementById("opt-compound-cap-line");
|
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
|
||||||
const compoundOn = compoundFullEnabled();
|
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
|
||||||
if (budgetWrap) {
|
if (hint) hint.style.display = mode === "budget_full" ? "" : "none";
|
||||||
budgetWrap.hidden = !!compoundOn;
|
|
||||||
budgetWrap.style.display = compoundOn ? "none" : "";
|
|
||||||
const radio = budgetWrap.querySelector('input[name="opt-size-mode"]');
|
|
||||||
if (radio) radio.disabled = !!compoundOn;
|
|
||||||
}
|
|
||||||
if (compoundWrap) {
|
|
||||||
compoundWrap.hidden = !compoundOn;
|
|
||||||
compoundWrap.style.display = compoundOn ? "" : "none";
|
|
||||||
const radio = compoundWrap.querySelector('input[name="opt-size-mode"]');
|
|
||||||
if (radio) radio.disabled = !compoundOn;
|
|
||||||
}
|
|
||||||
if (compoundOn && (mode === "budget_full" || mode === "compound_full")) {
|
|
||||||
const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]');
|
|
||||||
if (compoundRadio) {
|
|
||||||
compoundRadio.disabled = false;
|
|
||||||
compoundRadio.checked = true;
|
|
||||||
}
|
|
||||||
} else if (!compoundOn) {
|
|
||||||
const compoundRadio = document.querySelector('input[name="opt-size-mode"][value="compound_full"]');
|
|
||||||
if (compoundRadio) {
|
|
||||||
compoundRadio.checked = false;
|
|
||||||
compoundRadio.disabled = true;
|
|
||||||
}
|
|
||||||
// currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓
|
|
||||||
const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
|
|
||||||
if (!checkedOk) {
|
|
||||||
const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]');
|
|
||||||
if (sheetsRadio) {
|
|
||||||
sheetsRadio.disabled = false;
|
|
||||||
sheetsRadio.checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const modeNow = currentSizeMode();
|
|
||||||
if (sheetsEl) sheetsEl.style.display = modeNow === "sheets" ? "" : "none";
|
|
||||||
if (ethEl) ethEl.style.display = modeNow === "eth_amount" ? "" : "none";
|
|
||||||
if (hint) hint.style.display = modeNow === "budget_full" && !compoundOn ? "" : "none";
|
|
||||||
if (compoundHint) compoundHint.style.display = modeNow === "compound_full" && compoundOn ? "" : "none";
|
|
||||||
if (capEl && root && root.dataset.tradeBudget) {
|
if (capEl && root && root.dataset.tradeBudget) {
|
||||||
const n = Number(root.dataset.tradeBudget);
|
const n = Number(root.dataset.tradeBudget);
|
||||||
if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2);
|
if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2);
|
||||||
}
|
}
|
||||||
if (compoundCapLine && root) {
|
|
||||||
const on = String(root.dataset.compoundCapEnabled || "") === "1";
|
|
||||||
const cap = Number(root.dataset.compoundCapUsdc);
|
|
||||||
if (on && Number.isFinite(cap) && cap > 0) {
|
|
||||||
compoundCapLine.textContent = "全仓上限已开启:" + cap.toFixed(2) + "U";
|
|
||||||
} else {
|
|
||||||
compoundCapLine.textContent = "全仓上限关闭(env可开)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
|
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
|
||||||
const radio = chip.querySelector('input[name="opt-size-mode"]');
|
const radio = chip.querySelector('input[name="opt-size-mode"]');
|
||||||
const selected = !!(radio && radio.checked && !radio.disabled);
|
chip.classList.toggle("is-selected", !!(radio && radio.checked));
|
||||||
chip.classList.toggle("is-selected", selected);
|
chip.classList.toggle("active", !!(radio && radio.checked));
|
||||||
chip.classList.toggle("active", selected);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,14 +327,13 @@
|
|||||||
[
|
[
|
||||||
"opt-sheets-amount",
|
"opt-sheets-amount",
|
||||||
"opt-eth-amount",
|
"opt-eth-amount",
|
||||||
"opt-target-idx",
|
"opt-profit-rr",
|
||||||
].forEach(function (id) {
|
].forEach(function (id) {
|
||||||
harden(document.getElementById(id));
|
harden(document.getElementById(id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteUrl(instId) {
|
function quoteUrl(instId) {
|
||||||
updateSizeInputs();
|
|
||||||
const mode = currentSizeMode();
|
const mode = currentSizeMode();
|
||||||
let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode;
|
let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode;
|
||||||
if (mode === "eth_amount") {
|
if (mode === "eth_amount") {
|
||||||
@@ -709,6 +638,15 @@
|
|||||||
if (el) el.textContent = fmt(buf, 2);
|
if (el) el.textContent = fmt(buf, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtChainQuotedAt() {
|
||||||
|
if (!chainQuotedAt) return "";
|
||||||
|
const d = new Date(chainQuotedAt);
|
||||||
|
const pad = function (n) {
|
||||||
|
return n < 10 ? "0" + n : String(n);
|
||||||
|
};
|
||||||
|
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
function renderIndexLine() {
|
function renderIndexLine() {
|
||||||
const idx = state.chain && state.chain.index_px;
|
const idx = state.chain && state.chain.index_px;
|
||||||
const dte = state.chain && state.chain.chain_max_dte_days;
|
const dte = state.chain && state.chain.chain_max_dte_days;
|
||||||
@@ -722,12 +660,37 @@
|
|||||||
const line = document.getElementById("opt-index-line");
|
const line = document.getElementById("opt-index-line");
|
||||||
if (line) {
|
if (line) {
|
||||||
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
||||||
|
const ageHint = chainQuotedAt ? " · 链报价 " + fmtChainQuotedAt() + "(约每15s静默刷新)" : "";
|
||||||
line.textContent =
|
line.textContent =
|
||||||
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
||||||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外";
|
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + ageHint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function softRefreshChainThrottled(force) {
|
||||||
|
if (document.hidden) return;
|
||||||
|
if (!document.getElementById("options-root")) return;
|
||||||
|
if (chainLoadInFlight) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return;
|
||||||
|
lastChainSoftAt = now;
|
||||||
|
void loadChain({ soft: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startChainSoftPoll() {
|
||||||
|
if (chainSoftTimer) return;
|
||||||
|
chainSoftTimer = setInterval(function () {
|
||||||
|
if (!document.getElementById("options-root")) {
|
||||||
|
if (chainSoftTimer) {
|
||||||
|
clearInterval(chainSoftTimer);
|
||||||
|
chainSoftTimer = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
softRefreshChainThrottled(false);
|
||||||
|
}, CHAIN_SOFT_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function pickNearestExpiry(exps) {
|
function pickNearestExpiry(exps) {
|
||||||
if (!exps || !exps.length) return "";
|
if (!exps || !exps.length) return "";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -933,19 +896,6 @@
|
|||||||
return Math.round((value - prem) * 100) / 100;
|
return Math.round((value - prem) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 盈亏比 = 盈利金额 / 本合约权利金(目标位仅作到期实值参考). */
|
|
||||||
function estimateProfitRr(profit, totalPremium) {
|
|
||||||
const pnl = Number(profit);
|
|
||||||
const prem = Number(totalPremium);
|
|
||||||
if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null;
|
|
||||||
return Math.round((pnl / prem) * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtProfitRr(v) {
|
|
||||||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
|
||||||
return Number(v).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcContractLeverage(indexPx, ethAmount, totalPremium) {
|
function calcContractLeverage(indexPx, ethAmount, totalPremium) {
|
||||||
if (indexPx == null || ethAmount == null || totalPremium == null) return null;
|
if (indexPx == null || ethAmount == null || totalPremium == null) return null;
|
||||||
const idx = Number(indexPx);
|
const idx = Number(indexPx);
|
||||||
@@ -984,11 +934,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateOrderEstimates() {
|
function updateOrderEstimates() {
|
||||||
const levEl = document.getElementById("opt-order-leverage");
|
|
||||||
const valueEl = document.getElementById("opt-est-value");
|
const valueEl = document.getElementById("opt-est-value");
|
||||||
const profitEl = document.getElementById("opt-est-profit");
|
const profitEl = document.getElementById("opt-est-profit");
|
||||||
const rrEl = document.getElementById("opt-est-rr") || document.getElementById("opt-est-leverage");
|
const levEl = document.getElementById("opt-order-leverage");
|
||||||
const targetEl = document.getElementById("opt-target-idx");
|
const rrEl = document.getElementById("opt-profit-rr");
|
||||||
const q = state.orderQuote;
|
const q = state.orderQuote;
|
||||||
if (!q || !q.ok || !q.can_open) {
|
if (!q || !q.ok || !q.can_open) {
|
||||||
if (levEl) levEl.textContent = "—";
|
if (levEl) levEl.textContent = "—";
|
||||||
@@ -997,10 +946,6 @@
|
|||||||
profitEl.textContent = "—";
|
profitEl.textContent = "—";
|
||||||
profitEl.className = "v";
|
profitEl.className = "v";
|
||||||
}
|
}
|
||||||
if (rrEl) {
|
|
||||||
rrEl.textContent = "—";
|
|
||||||
rrEl.className = "v";
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sz = q.sizing || {};
|
const sz = q.sizing || {};
|
||||||
@@ -1009,41 +954,19 @@
|
|||||||
const lev = calcContractLeverage(q.index_px, ethAmount, premium);
|
const lev = calcContractLeverage(q.index_px, ethAmount, premium);
|
||||||
if (levEl) levEl.textContent = fmtLeverage(lev);
|
if (levEl) levEl.textContent = fmtLeverage(lev);
|
||||||
|
|
||||||
if (valueEl && profitEl && targetEl) {
|
if (valueEl && profitEl && rrEl) {
|
||||||
const targetRaw = targetEl.value;
|
const rrRaw = rrEl.value;
|
||||||
if (targetRaw === "" || targetRaw == null) {
|
const rr = rrRaw === "" || rrRaw == null ? NaN : Number(rrRaw);
|
||||||
|
if (!Number.isFinite(rr) || rr <= 0 || !(Number(premium) > 0)) {
|
||||||
valueEl.textContent = "—";
|
valueEl.textContent = "—";
|
||||||
profitEl.textContent = "—";
|
profitEl.textContent = "—";
|
||||||
profitEl.className = "v";
|
profitEl.className = "v";
|
||||||
if (rrEl) {
|
|
||||||
rrEl.textContent = "—";
|
|
||||||
rrEl.className = "v";
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
|
const targetProfit = Number(premium) * rr;
|
||||||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium);
|
const needRecycle = Number(premium) + targetProfit;
|
||||||
const rr = estimateProfitRr(profit, premium);
|
valueEl.textContent = fmtUsdc(needRecycle) + " USDC";
|
||||||
if (value == null || Number.isNaN(value)) {
|
profitEl.textContent = fmtUsdcSigned(targetProfit);
|
||||||
valueEl.textContent = "—";
|
profitEl.className = "v " + pnlCls(targetProfit);
|
||||||
} else {
|
|
||||||
valueEl.textContent = fmtUsdc(value) + " USDC";
|
|
||||||
}
|
|
||||||
if (profit == null || Number.isNaN(profit)) {
|
|
||||||
profitEl.textContent = "—";
|
|
||||||
profitEl.className = "v";
|
|
||||||
} else {
|
|
||||||
profitEl.textContent = fmtUsdcSigned(profit);
|
|
||||||
profitEl.className = "v " + pnlCls(profit);
|
|
||||||
}
|
|
||||||
if (rrEl) {
|
|
||||||
if (rr == null || Number.isNaN(rr)) {
|
|
||||||
rrEl.textContent = "—";
|
|
||||||
rrEl.className = "v";
|
|
||||||
} else {
|
|
||||||
rrEl.textContent = fmtProfitRr(rr);
|
|
||||||
rrEl.className = "v " + pnlCls(rr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1226,7 +1149,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fillOrderPanel(d) {
|
function fillOrderPanel(d) {
|
||||||
syncCompoundFlagsFromPayload(d);
|
|
||||||
state.orderQuote = d && d.ok ? d : null;
|
state.orderQuote = d && d.ok ? d : null;
|
||||||
const sz = d.sizing || {};
|
const sz = d.sizing || {};
|
||||||
const canOpen = !!(d && d.ok && d.can_open);
|
const canOpen = !!(d && d.ok && d.can_open);
|
||||||
@@ -1319,11 +1241,16 @@
|
|||||||
|
|
||||||
async function loadChain(opts) {
|
async function loadChain(opts) {
|
||||||
const soft = !!(opts && opts.soft);
|
const soft = !!(opts && opts.soft);
|
||||||
|
// soft 门禁必须在 seq++ 之前,否则叠刷会抬高 seq 导致 inFlight 永不清理
|
||||||
|
if (chainLoadInFlight && soft) return;
|
||||||
const uly = state.underlying;
|
const uly = state.underlying;
|
||||||
const seq = ++chainLoadSeq;
|
const seq = ++chainLoadSeq;
|
||||||
const btn = document.getElementById("opt-load-chain");
|
const btn = document.getElementById("opt-load-chain");
|
||||||
|
const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly;
|
||||||
|
chainLoadInFlight = true;
|
||||||
if (btn && !soft) btn.disabled = true;
|
if (btn && !soft) btn.disabled = true;
|
||||||
if (!soft) {
|
// 已有链时不先清空,避免刷新白屏
|
||||||
|
if (!soft && !hadChain) {
|
||||||
setExpirySelectStatus("加载到期日中…");
|
setExpirySelectStatus("加载到期日中…");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
@@ -1334,20 +1261,27 @@
|
|||||||
try {
|
try {
|
||||||
let d = null;
|
let d = null;
|
||||||
let lastMsg = "";
|
let lastMsg = "";
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
// soft 只试 1 次,避免与 15s 轮询叠加重试打爆 OKX
|
||||||
|
const maxAttempts = soft ? 1 : 3;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
|
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
if (d && d.ok && chainHasExpiries(d)) break;
|
if (d && d.ok && chainHasExpiries(d)) break;
|
||||||
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
||||||
const rateLimited =
|
const rateLimited =
|
||||||
/50011|Too Many Requests|RateLimit/i.test(String(lastMsg || ""));
|
!!(d && d.rate_limited) ||
|
||||||
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
|
||||||
d = null;
|
d = null;
|
||||||
if (attempt === 0 && !rateLimited) {
|
if (attempt < maxAttempts - 1) {
|
||||||
if (!soft) setExpirySelectStatus("重试加载到期日…");
|
if (!soft && !hadChain) {
|
||||||
await new Promise(function (resolve) { setTimeout(resolve, 400); });
|
setExpirySelectStatus(
|
||||||
} else {
|
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
|
||||||
break;
|
);
|
||||||
|
}
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 400);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
@@ -1362,13 +1296,19 @@
|
|||||||
if (soft) return;
|
if (soft) return;
|
||||||
setExpirySelectStatus("选择到期日");
|
setExpirySelectStatus("选择到期日");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
|
const friendly =
|
||||||
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""))
|
||||||
|
? "OKX 请求过于频繁,请稍后再点「刷新链」"
|
||||||
|
: lastMsg || "暂无到期日,请点「刷新链」";
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' +
|
'<tr><td colspan="' +
|
||||||
(lastMsg || "暂无到期日,请点「刷新链」") +
|
strikeTableColspan() +
|
||||||
|
'" class="muted">' +
|
||||||
|
friendly +
|
||||||
"</td></tr>";
|
"</td></tr>";
|
||||||
}
|
}
|
||||||
alert(lastMsg || "加载到期日失败,请点「刷新链」重试");
|
alert(friendly);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
||||||
@@ -1376,6 +1316,8 @@
|
|||||||
panelCache.chain = d;
|
panelCache.chain = d;
|
||||||
panelCache.underlying = uly;
|
panelCache.underlying = uly;
|
||||||
panelCache.optType = state.optType;
|
panelCache.optType = state.optType;
|
||||||
|
chainQuotedAt = Date.now();
|
||||||
|
lastChainSoftAt = chainQuotedAt;
|
||||||
syncAskLiqFilterFromChain(d);
|
syncAskLiqFilterFromChain(d);
|
||||||
if (!soft) {
|
if (!soft) {
|
||||||
state.selectedInst = null;
|
state.selectedInst = null;
|
||||||
@@ -1406,7 +1348,10 @@
|
|||||||
"</td></tr>";
|
"</td></tr>";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === chainLoadSeq && btn) btn.disabled = false;
|
if (seq === chainLoadSeq) {
|
||||||
|
chainLoadInFlight = false;
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1427,7 +1372,6 @@
|
|||||||
const btn = document.getElementById("opt-open-btn");
|
const btn = document.getElementById("opt-open-btn");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
updateSizeInputs();
|
|
||||||
const mode = currentSizeMode();
|
const mode = currentSizeMode();
|
||||||
const body = {
|
const body = {
|
||||||
inst_id: state.selectedInst,
|
inst_id: state.selectedInst,
|
||||||
@@ -1437,31 +1381,15 @@
|
|||||||
if (mode === "eth_amount") {
|
if (mode === "eth_amount") {
|
||||||
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
|
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
|
||||||
} else if (mode === "sheets") {
|
} else if (mode === "sheets") {
|
||||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1;
|
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
|
||||||
} else if (mode === "compound_full" && !compoundFullEnabled()) {
|
|
||||||
body.mode = "sheets";
|
|
||||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10) || 1;
|
|
||||||
}
|
}
|
||||||
const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim();
|
const rrRaw = (document.getElementById("opt-profit-rr").value || "").trim();
|
||||||
if (tgtRaw !== "") {
|
const rr = rrRaw === "" ? 2 : parseFloat(rrRaw);
|
||||||
const tgt = parseFloat(tgtRaw);
|
if (!Number.isFinite(rr) || rr <= 0) {
|
||||||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
alert("盈亏比无效");
|
||||||
alert("目标位无效");
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
body.target_index = tgt;
|
|
||||||
}
|
|
||||||
const peEnabled = !!(document.getElementById("opt-profit-exit-enabled") || {}).checked;
|
|
||||||
if (peEnabled) {
|
|
||||||
const multRaw = (document.getElementById("opt-profit-exit-mult") || {}).value;
|
|
||||||
const mult = parseFloat(multRaw);
|
|
||||||
if (!Number.isFinite(mult) || mult <= 0) {
|
|
||||||
alert("翻倍倍数无效");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
body.profit_exit_enabled = true;
|
|
||||||
body.profit_exit_mult = mult;
|
|
||||||
}
|
}
|
||||||
|
body.profit_rr = rr;
|
||||||
const d = await apiJson("/api/options/open", {
|
const d = await apiJson("/api/options/open", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -1540,57 +1468,7 @@
|
|||||||
const hint = closeGateHint(closePreview);
|
const hint = closeGateHint(closePreview);
|
||||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||||
})() +
|
})() +
|
||||||
renderTargetDelegateRow(p) +
|
renderTargetDelegateRow(p)
|
||||||
renderProfitExitRow(p)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProfitExitMultLabel(mult) {
|
|
||||||
const n = Number(mult);
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return "1倍";
|
|
||||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "倍";
|
|
||||||
return fmt(n, 2) + "倍";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProfitExitRow(p) {
|
|
||||||
const inst = p.inst_id || "";
|
|
||||||
if (p.hedge_plan_target) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const enabled = !!p.profit_exit_enabled;
|
|
||||||
const serverMult = p.profit_exit_mult != null && Number(p.profit_exit_mult) > 0
|
|
||||||
? Number(p.profit_exit_mult)
|
|
||||||
: 1;
|
|
||||||
const draft = state.profitExitDraftByInst[inst];
|
|
||||||
const multDisp = draft != null && String(draft).trim() !== ""
|
|
||||||
? String(draft)
|
|
||||||
: String(serverMult);
|
|
||||||
const multNum = Number(multDisp);
|
|
||||||
const multLabel = formatProfitExitMultLabel(
|
|
||||||
Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult
|
|
||||||
);
|
|
||||||
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
|
||||||
const req = p.profit_exit_required_recycle;
|
|
||||||
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
|
||||||
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
|
||||||
return (
|
|
||||||
'<div class="opt-target-row opt-profit-exit-pos-row" data-inst="' + inst + '">' +
|
|
||||||
'<span class="opt-target-row-label">翻倍</span>' +
|
|
||||||
'<label class="opt-profit-exit-toggle"><input type="checkbox" class="opt-pos-profit-exit-enabled" data-inst="' +
|
|
||||||
inst + '"' + (enabled ? " checked" : "") + "> 开启</label>" +
|
|
||||||
'<input type="number" class="opt-pos-profit-exit-mult" data-inst="' + inst +
|
|
||||||
'" min="0.1" step="0.1" value="' + multDisp +
|
|
||||||
'" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true">' +
|
|
||||||
'<button type="button" class="btn-secondary opt-profit-exit-save-btn" data-inst="' +
|
|
||||||
inst + '" data-mode="' + (enabled ? "cancel" : "apply") + '">' +
|
|
||||||
(enabled ? "取消" : "应用") + "</button>" +
|
|
||||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
|
||||||
'<span class="muted opt-target-row-hint">' +
|
|
||||||
(enabled
|
|
||||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : ""))
|
|
||||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
|
||||||
"</span>" +
|
|
||||||
"</div>"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,18 +1480,17 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) {
|
function formatRrEstimateHtml(rr, premiumPaid) {
|
||||||
const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount);
|
const r = Number(rr);
|
||||||
const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid);
|
const prem = Number(premiumPaid);
|
||||||
const rr = estimateProfitRr(profit, premiumPaid);
|
if (!Number.isFinite(r) || r <= 0 || !Number.isFinite(prem) || prem <= 0) return "";
|
||||||
if (value == null && profit == null && rr == null) return "";
|
const profit = Math.round(prem * r * 100) / 100;
|
||||||
|
const need = Math.round((prem + profit) * 100) / 100;
|
||||||
let html = '<span class="opt-target-est">';
|
let html = '<span class="opt-target-est">';
|
||||||
html += '<span class="opt-target-est-item"><span class="k">价值</span><span class="v">' +
|
html += '<span class="opt-target-est-item"><span class="k">目标盈利</span><span class="v ' + pnlCls(profit) + '">' +
|
||||||
(value == null ? "—" : fmtUsdc(value) + " USDC") + "</span></span>";
|
fmtUsdcSigned(profit) + "</span></span>";
|
||||||
html += '<span class="opt-target-est-item"><span class="k">预估盈利</span><span class="v ' + pnlCls(profit) + '">' +
|
html += '<span class="opt-target-est-item"><span class="k">需回收</span><span class="v">' +
|
||||||
(profit == null ? "—" : fmtUsdcSigned(profit)) + "</span></span>";
|
fmtUsdc(need) + " USDC</span></span>";
|
||||||
html += '<span class="opt-target-est-item"><span class="k">盈亏比</span><span class="v ' + pnlCls(rr) + '">' +
|
|
||||||
(rr == null ? "—" : fmtProfitRr(rr)) + "</span></span>";
|
|
||||||
html += "</span>";
|
html += "</span>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -1621,63 +1498,73 @@
|
|||||||
function renderTargetDelegateRow(p) {
|
function renderTargetDelegateRow(p) {
|
||||||
const inst = p.inst_id || "";
|
const inst = p.inst_id || "";
|
||||||
const hedgeTarget = p.hedge_plan_target || null;
|
const hedgeTarget = p.hedge_plan_target || null;
|
||||||
if (hedgeTarget) {
|
if (hedgeTarget && hedgeTarget.managed_by === "hedge_plan") {
|
||||||
const rr = hedgeTarget.profit_rr != null ? Number(hedgeTarget.profit_rr) : null;
|
const rr = hedgeTarget.oo_profit_rr != null ? Number(hedgeTarget.oo_profit_rr) : null;
|
||||||
if (rr != null && rr > 0) {
|
const armedTxt =
|
||||||
return (
|
rr != null && Number.isFinite(rr) && rr > 0
|
||||||
'<div class="opt-target-row opt-target-row--managed">' +
|
? "盈亏比 ×" + fmt(rr, 2)
|
||||||
'<span class="opt-target-row-label">对冲计划</span>' +
|
: hedgeTarget.target_index != null
|
||||||
'<span class="opt-target-armed">计划 #' +
|
? "目标 " + fmt(hedgeTarget.target_index, 1)
|
||||||
hedgeTarget.plan_id +
|
: "托管中";
|
||||||
" · 盈亏比 " +
|
return (
|
||||||
fmt(rr, 2) +
|
'<div class="opt-target-row opt-target-row--managed">' +
|
||||||
"</span>" +
|
'<span class="opt-target-row-label">对冲计划</span>' +
|
||||||
'<span class="muted opt-target-row-hint">进行中 · 盈利达总权利金×盈亏比仅平盈利腿;亏损腿按本合约残值平或到期平</span>' +
|
'<span class="opt-target-armed">计划 #' +
|
||||||
"</div>"
|
hedgeTarget.plan_id +
|
||||||
);
|
" · " +
|
||||||
}
|
armedTxt +
|
||||||
if (Number(hedgeTarget.target_index) > 0) {
|
"</span>" +
|
||||||
const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控</span>' +
|
||||||
return (
|
"</div>"
|
||||||
'<div class="opt-target-row opt-target-row--managed">' +
|
);
|
||||||
'<span class="opt-target-row-label">对冲计划</span>' +
|
|
||||||
'<span class="opt-target-armed">计划 #' +
|
|
||||||
hedgeTarget.plan_id +
|
|
||||||
" · " +
|
|
||||||
side +
|
|
||||||
" " +
|
|
||||||
fmt(hedgeTarget.target_index, 1) +
|
|
||||||
"</span>" +
|
|
||||||
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</span>' +
|
|
||||||
"</div>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null;
|
const rrArmed =
|
||||||
const armed = tgt != null && Number.isFinite(tgt) && tgt > 0;
|
p.profit_rr != null && p.profit_rr !== ""
|
||||||
const ethAmt = posEthAmount(p);
|
? Number(p.profit_rr)
|
||||||
|
: p.target_monitor && p.target_monitor.profit_rr != null
|
||||||
|
? Number(p.target_monitor.profit_rr)
|
||||||
|
: null;
|
||||||
|
const armed = rrArmed != null && Number.isFinite(rrArmed) && rrArmed > 0;
|
||||||
const prem = p.premium_paid;
|
const prem = p.premium_paid;
|
||||||
|
const draft =
|
||||||
|
state.targetDraftByInst[inst] != null
|
||||||
|
? String(state.targetDraftByInst[inst])
|
||||||
|
: armed
|
||||||
|
? String(rrArmed)
|
||||||
|
: "2";
|
||||||
const estHtml = armed
|
const estHtml = armed
|
||||||
? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem)
|
? formatRrEstimateHtml(rrArmed, prem)
|
||||||
: '<span class="opt-target-est opt-target-est--idle"></span>';
|
: '<span class="opt-target-est opt-target-est--idle"></span>';
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-row" data-inst="' + inst + '"' +
|
'<div class="opt-target-row" data-inst="' +
|
||||||
' data-opt-type="' + (p.opt_type || "") + '"' +
|
inst +
|
||||||
' data-strike="' + (p.strike != null ? p.strike : "") + '"' +
|
'"' +
|
||||||
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' +
|
' data-prem="' +
|
||||||
' data-prem="' + (prem != null ? prem : "") + '"' +
|
(prem != null ? prem : "") +
|
||||||
' data-armed-target="' + (armed ? tgt : "") + '">' +
|
'"' +
|
||||||
|
' data-armed-rr="' +
|
||||||
|
(armed ? rrArmed : "") +
|
||||||
|
'">' +
|
||||||
'<span class="opt-target-row-label">委托</span>' +
|
'<span class="opt-target-row-label">委托</span>' +
|
||||||
'<input type="number" class="opt-pos-target-input" data-inst="' + inst + '" step="0.1" min="0" placeholder="监控目标指数" value="' +
|
'<input type="number" class="opt-pos-target-input" data-inst="' +
|
||||||
(state.targetDraftByInst[inst] != null ? String(state.targetDraftByInst[inst]) : "") + '">' +
|
inst +
|
||||||
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' + inst + '">设定</button>' +
|
'" step="0.1" min="0.1" placeholder="盈亏比" value="' +
|
||||||
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' + inst + '"' + (armed ? "" : " disabled") + ">取消</button>" +
|
draft +
|
||||||
(armed
|
'">' +
|
||||||
? '<span class="opt-target-armed">目标 ' + fmt(tgt, 1) + "</span>"
|
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' +
|
||||||
: "") +
|
inst +
|
||||||
|
'">设定</button>' +
|
||||||
|
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' +
|
||||||
|
inst +
|
||||||
|
'"' +
|
||||||
|
(armed ? "" : " disabled") +
|
||||||
|
">取消</button>" +
|
||||||
|
(armed ? '<span class="opt-target-armed">盈亏比 ×' + fmt(rrArmed, 2) + "</span>" : "") +
|
||||||
estHtml +
|
estHtml +
|
||||||
'<span class="muted opt-target-row-hint">' +
|
'<span class="muted opt-target-row-hint">' +
|
||||||
(armed ? "监控中 · 目标位参考 · 到位按买一限价平" : "目标位参考(到期实值估盈亏比) · 到位按买一限价平 · 到期即止损") +
|
(armed
|
||||||
|
? "监控中 · 买一浮盈达盈亏比后全平"
|
||||||
|
: "默认2 · 买一浮盈达盈亏比×权利金后全平 · 不达标等到期") +
|
||||||
"</span>" +
|
"</span>" +
|
||||||
"</div>"
|
"</div>"
|
||||||
);
|
);
|
||||||
@@ -1689,20 +1576,14 @@
|
|||||||
if (!est) return;
|
if (!est) return;
|
||||||
const inp = row.querySelector(".opt-pos-target-input");
|
const inp = row.querySelector(".opt-pos-target-input");
|
||||||
const typed = inp ? String(inp.value || "").trim() : "";
|
const typed = inp ? String(inp.value || "").trim() : "";
|
||||||
const armed = row.getAttribute("data-armed-target") || "";
|
const armed = row.getAttribute("data-armed-rr") || "";
|
||||||
const targetRaw = typed !== "" ? typed : armed;
|
const rrRaw = typed !== "" ? typed : armed;
|
||||||
if (targetRaw === "") {
|
if (rrRaw === "") {
|
||||||
est.className = "opt-target-est opt-target-est--idle";
|
est.className = "opt-target-est opt-target-est--idle";
|
||||||
est.innerHTML = "";
|
est.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const html = formatTargetEstimateHtml(
|
const html = formatRrEstimateHtml(rrRaw, row.getAttribute("data-prem"));
|
||||||
row.getAttribute("data-opt-type"),
|
|
||||||
row.getAttribute("data-strike"),
|
|
||||||
targetRaw,
|
|
||||||
row.getAttribute("data-eth"),
|
|
||||||
row.getAttribute("data-prem")
|
|
||||||
);
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
est.className = "opt-target-est opt-target-est--idle";
|
est.className = "opt-target-est opt-target-est--idle";
|
||||||
est.innerHTML = "";
|
est.innerHTML = "";
|
||||||
@@ -1792,37 +1673,6 @@
|
|||||||
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
|
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
container.querySelectorAll(".opt-profit-exit-save-btn").forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
savePositionProfitExit(btn.getAttribute("data-inst"), btn);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
container.querySelectorAll(".opt-pos-profit-exit-enabled").forEach(function (cb) {
|
|
||||||
cb.addEventListener("click", function (e) { e.stopPropagation(); });
|
|
||||||
});
|
|
||||||
container.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) {
|
|
||||||
// 倍数随时可改;「开启/应用」只控制是否监控,不再因未勾选而 disabled
|
|
||||||
inp.disabled = false;
|
|
||||||
inp.removeAttribute("readonly");
|
|
||||||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
|
||||||
inp.addEventListener("mousedown", function (e) { e.stopPropagation(); });
|
|
||||||
inp.addEventListener("focus", function (e) { e.stopPropagation(); });
|
|
||||||
inp.addEventListener("input", function () {
|
|
||||||
const id = inp.getAttribute("data-inst") || "";
|
|
||||||
if (!id) return;
|
|
||||||
const draft = String(inp.value || "");
|
|
||||||
if (draft.trim() === "") delete state.profitExitDraftByInst[id];
|
|
||||||
else state.profitExitDraftByInst[id] = draft;
|
|
||||||
});
|
|
||||||
inp.addEventListener("keydown", function (e) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
savePositionProfitExit(inp.getAttribute("data-inst"), null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
||||||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
||||||
inp.addEventListener("input", function () {
|
inp.addEventListener("input", function () {
|
||||||
@@ -1865,9 +1715,9 @@
|
|||||||
const row = card ? card.querySelector(".opt-target-row") : null;
|
const row = card ? card.querySelector(".opt-target-row") : null;
|
||||||
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
|
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
|
||||||
const raw = inp ? String(inp.value || "").trim() : "";
|
const raw = inp ? String(inp.value || "").trim() : "";
|
||||||
const tgt = parseFloat(raw);
|
const rr = raw === "" ? 2 : parseFloat(raw);
|
||||||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
if (!Number.isFinite(rr) || rr <= 0) {
|
||||||
alert("请输入有效目标指数价");
|
alert("请输入有效盈亏比(相对权利金,默认2)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
@@ -1875,17 +1725,16 @@
|
|||||||
const d = await apiJson("/api/options/target", {
|
const d = await apiJson("/api/options/target", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ inst_id: inst, target_index: tgt }),
|
body: JSON.stringify({ inst_id: inst, profit_rr: rr }),
|
||||||
});
|
});
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
alert(d.msg || "设定失败");
|
alert(d.msg || "设定失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete state.targetDraftByInst[inst];
|
delete state.targetDraftByInst[inst];
|
||||||
if (inp) inp.value = "";
|
if (inp) inp.value = String(rr);
|
||||||
if (row) {
|
if (row) {
|
||||||
row.setAttribute("data-armed-target", String(tgt));
|
row.setAttribute("data-armed-rr", String(rr));
|
||||||
updatePosTargetEstimate(row);
|
|
||||||
}
|
}
|
||||||
await refreshAllPositions();
|
await refreshAllPositions();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1913,46 +1762,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePositionProfitExit(inst, btn) {
|
|
||||||
if (!inst) return;
|
|
||||||
const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') ||
|
|
||||||
document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]');
|
|
||||||
const row = card ? card.querySelector(".opt-profit-exit-pos-row") : null;
|
|
||||||
const enabledEl = row ? row.querySelector(".opt-pos-profit-exit-enabled") : null;
|
|
||||||
const multEl = row ? row.querySelector(".opt-pos-profit-exit-mult") : null;
|
|
||||||
const mode = btn && btn.getAttribute("data-mode");
|
|
||||||
let enabled = !!(enabledEl && enabledEl.checked);
|
|
||||||
if (mode === "cancel") enabled = false;
|
|
||||||
if (mode === "apply") {
|
|
||||||
enabled = true;
|
|
||||||
if (enabledEl) enabledEl.checked = true;
|
|
||||||
}
|
|
||||||
let mult = 1;
|
|
||||||
if (enabled) {
|
|
||||||
mult = parseFloat(multEl ? multEl.value : "1");
|
|
||||||
if (!Number.isFinite(mult) || mult <= 0) {
|
|
||||||
alert("翻倍倍数无效");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const d = await apiJson("/api/options/profit-exit", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ inst_id: inst, enabled: enabled, mult: mult }),
|
|
||||||
});
|
|
||||||
if (!d.ok) {
|
|
||||||
alert(d.msg || "保存失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
delete state.profitExitDraftByInst[inst];
|
|
||||||
await refreshAllPositions();
|
|
||||||
} finally {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintTargetMonitors(list) {
|
function paintTargetMonitors(list) {
|
||||||
const box = document.getElementById("opt-target-monitors");
|
const box = document.getElementById("opt-target-monitors");
|
||||||
const host = document.getElementById("opt-target-monitors-list");
|
const host = document.getElementById("opt-target-monitors-list");
|
||||||
@@ -1965,12 +1774,22 @@
|
|||||||
}
|
}
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
host.innerHTML = rows.map(function (t) {
|
host.innerHTML = rows.map(function (t) {
|
||||||
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
|
||||||
const managed = t.managed_by === "hedge_plan";
|
const managed = t.managed_by === "hedge_plan";
|
||||||
|
let rule;
|
||||||
|
if (t.profit_rr != null && Number(t.profit_rr) > 0) {
|
||||||
|
rule = "盈亏比 ×" + fmt(t.profit_rr, 2);
|
||||||
|
} else if (t.oo_profit_rr != null && Number(t.oo_profit_rr) > 0) {
|
||||||
|
rule = "盈亏比 ×" + fmt(t.oo_profit_rr, 2);
|
||||||
|
} else if (t.target_index != null && Number(t.target_index) > 0) {
|
||||||
|
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||||||
|
rule = side + " " + fmt(t.target_index, 1);
|
||||||
|
} else {
|
||||||
|
rule = "委托中";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
|
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
|
||||||
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
|
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
|
||||||
'<span class="opt-target-mon-rule">' + side + " " + fmt(t.target_index, 1) + "</span>" +
|
'<span class="opt-target-mon-rule">' + rule + "</span>" +
|
||||||
(managed
|
(managed
|
||||||
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
|
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
|
||||||
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
|
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
|
||||||
@@ -2100,14 +1919,6 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 正在输入翻倍倍数:同样跳过重绘,避免被默认 1 冲掉
|
|
||||||
if (active && active.classList && active.classList.contains("opt-pos-profit-exit-mult")) {
|
|
||||||
const focusInst = active.getAttribute("data-inst") || "";
|
|
||||||
if (focusInst) {
|
|
||||||
state.profitExitDraftByInst[focusInst] = String(active.value || "");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 未聚焦时也同步可见输入,防止漏掉 input 事件
|
// 未聚焦时也同步可见输入,防止漏掉 input 事件
|
||||||
wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
||||||
const id = inp.getAttribute("data-inst") || "";
|
const id = inp.getAttribute("data-inst") || "";
|
||||||
@@ -2116,13 +1927,6 @@
|
|||||||
if (v.trim() === "") delete state.targetDraftByInst[id];
|
if (v.trim() === "") delete state.targetDraftByInst[id];
|
||||||
else state.targetDraftByInst[id] = v;
|
else state.targetDraftByInst[id] = v;
|
||||||
});
|
});
|
||||||
wrap.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) {
|
|
||||||
const id = inp.getAttribute("data-inst") || "";
|
|
||||||
if (!id) return;
|
|
||||||
const v = String(inp.value || "");
|
|
||||||
if (v.trim() === "") delete state.profitExitDraftByInst[id];
|
|
||||||
else state.profitExitDraftByInst[id] = v;
|
|
||||||
});
|
|
||||||
wrap.innerHTML = "";
|
wrap.innerHTML = "";
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
if (empty) empty.style.display = "";
|
if (empty) empty.style.display = "";
|
||||||
@@ -2166,24 +1970,25 @@
|
|||||||
paintPositions(list);
|
paintPositions(list);
|
||||||
const fromPos = list.reduce(function (targets, p) {
|
const fromPos = list.reduce(function (targets, p) {
|
||||||
if (!p) return targets;
|
if (!p) return targets;
|
||||||
if (p.target_index != null) {
|
if (p.profit_rr != null || p.target_index != null) {
|
||||||
targets.push({
|
targets.push({
|
||||||
id: p.target_monitor_id,
|
id: p.target_monitor_id,
|
||||||
inst_id: p.inst_id,
|
inst_id: p.inst_id,
|
||||||
opt_type: p.opt_type,
|
opt_type: p.opt_type,
|
||||||
target_index: p.target_index,
|
target_index: p.target_index,
|
||||||
|
profit_rr: p.profit_rr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const hedgeTarget = p.hedge_plan_target;
|
const hedgeTarget = p.hedge_plan_target;
|
||||||
if (hedgeTarget && (hedgeTarget.target_index != null || hedgeTarget.profit_rr != null)) {
|
if (hedgeTarget) {
|
||||||
targets.push({
|
targets.push({
|
||||||
inst_id: p.inst_id,
|
inst_id: p.inst_id,
|
||||||
opt_type: p.opt_type || hedgeTarget.opt_type,
|
opt_type: p.opt_type || hedgeTarget.opt_type,
|
||||||
target_index: hedgeTarget.target_index,
|
target_index: hedgeTarget.target_index,
|
||||||
profit_rr: hedgeTarget.profit_rr,
|
oo_profit_rr: hedgeTarget.oo_profit_rr,
|
||||||
|
profit_rr: hedgeTarget.oo_profit_rr,
|
||||||
plan_id: hedgeTarget.plan_id,
|
plan_id: hedgeTarget.plan_id,
|
||||||
managed_by: hedgeTarget.managed_by,
|
managed_by: hedgeTarget.managed_by,
|
||||||
exit_mode: hedgeTarget.exit_mode,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return targets;
|
return targets;
|
||||||
@@ -2448,20 +2253,12 @@
|
|||||||
function bootOptionsPanel() {
|
function bootOptionsPanel() {
|
||||||
applyBudgetBuffer(state.budgetBuffer);
|
applyBudgetBuffer(state.budgetBuffer);
|
||||||
updateSizeInputs();
|
updateSizeInputs();
|
||||||
void (async function syncLiveCompoundFlag() {
|
|
||||||
try {
|
|
||||||
const d = await apiJson("/api/options/balances");
|
|
||||||
syncCompoundFlagsFromPayload(d);
|
|
||||||
if (d && d.trade_budget != null && root) {
|
|
||||||
root.dataset.tradeBudget = String(d.trade_budget);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
})();
|
|
||||||
syncMoneyFilterButtons();
|
syncMoneyFilterButtons();
|
||||||
syncChainViewUI();
|
syncChainViewUI();
|
||||||
updateUnderlyingLabel();
|
updateUnderlyingLabel();
|
||||||
refreshPendingOrders();
|
refreshPendingOrders();
|
||||||
startPendingOrdersPoll();
|
startPendingOrdersPoll();
|
||||||
|
startChainSoftPoll();
|
||||||
const hasCache =
|
const hasCache =
|
||||||
chainHasExpiries(panelCache.chain) &&
|
chainHasExpiries(panelCache.chain) &&
|
||||||
panelCache.underlying === state.underlying &&
|
panelCache.underlying === state.underlying &&
|
||||||
@@ -2471,8 +2268,8 @@
|
|||||||
renderExpiries();
|
renderExpiries();
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
refreshAllPositions();
|
refreshAllPositions();
|
||||||
// 后台静默刷新,避免缓存过期后到期日变空
|
// 后台静默刷新,避免缓存过期后到期日变空 / 卖一过期
|
||||||
loadChain({ soft: true });
|
softRefreshChainThrottled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
@@ -2550,18 +2347,6 @@
|
|||||||
bindOptionsPosTabs();
|
bindOptionsPosTabs();
|
||||||
hardenOrderAutofill();
|
hardenOrderAutofill();
|
||||||
|
|
||||||
(function bindProfitExitOpenControls() {
|
|
||||||
const peCb = document.getElementById("opt-profit-exit-enabled");
|
|
||||||
const peMult = document.getElementById("opt-profit-exit-mult");
|
|
||||||
if (!peCb || !peMult) return;
|
|
||||||
// 倍数始终可手输;勾选只决定开仓是否带上翻倍出场
|
|
||||||
peMult.disabled = false;
|
|
||||||
peMult.removeAttribute("readonly");
|
|
||||||
peCb.addEventListener("change", function () {
|
|
||||||
if (peCb.checked && (!peMult.value || Number(peMult.value) <= 0)) peMult.value = "1";
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
|
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
|
||||||
r.addEventListener("change", function () {
|
r.addEventListener("change", function () {
|
||||||
updateSizeInputs();
|
updateSizeInputs();
|
||||||
@@ -2588,17 +2373,17 @@
|
|||||||
}
|
}
|
||||||
bindOrderDialogChrome();
|
bindOrderDialogChrome();
|
||||||
|
|
||||||
["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) {
|
["opt-sheets-amount", "opt-eth-amount", "opt-profit-rr"].forEach(function (id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.addEventListener("change", function () {
|
el.addEventListener("change", function () {
|
||||||
if (id === "opt-target-idx") {
|
if (id === "opt-profit-rr") {
|
||||||
updateEstimatedProfit();
|
updateEstimatedProfit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.selectedInst) selectContract(state.selectedInst, null, true);
|
if (state.selectedInst) selectContract(state.selectedInst, null, true);
|
||||||
});
|
});
|
||||||
if (id === "opt-target-idx") {
|
if (id === "opt-profit-rr") {
|
||||||
el.addEventListener("input", updateEstimatedProfit);
|
el.addEventListener("input", updateEstimatedProfit);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2608,6 +2393,8 @@
|
|||||||
window.OptionsPanelLive = {
|
window.OptionsPanelLive = {
|
||||||
refreshSoft: function () {
|
refreshSoft: function () {
|
||||||
refreshAllPositions();
|
refreshAllPositions();
|
||||||
|
// embed SSE 只通知「该拉了」,不推送链报价;这里节流拉新鲜卖一/买一
|
||||||
|
softRefreshChainThrottled(false);
|
||||||
},
|
},
|
||||||
refreshChain: loadChain,
|
refreshChain: loadChain,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -219,38 +219,42 @@
|
|||||||
const hint = closeGateHint(closePreview);
|
const hint = closeGateHint(closePreview);
|
||||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||||
})() +
|
})() +
|
||||||
(p.target_index != null
|
(p.profit_rr != null || p.target_index != null
|
||||||
? (function () {
|
? (function () {
|
||||||
const eth = p.eth_amount != null ? Number(p.eth_amount)
|
const hedgeTarget = p.hedge_plan_target || null;
|
||||||
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null);
|
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
||||||
const strike = Number(p.strike);
|
const rr =
|
||||||
const tgt = Number(p.target_index);
|
managed && hedgeTarget.oo_profit_rr != null
|
||||||
|
? Number(hedgeTarget.oo_profit_rr)
|
||||||
|
: p.profit_rr != null
|
||||||
|
? Number(p.profit_rr)
|
||||||
|
: null;
|
||||||
const prem = Number(p.premium_paid);
|
const prem = Number(p.premium_paid);
|
||||||
let profit = null;
|
let profit = null;
|
||||||
let value = null;
|
let need = null;
|
||||||
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
|
if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) {
|
||||||
const o = String(p.opt_type || "").toUpperCase();
|
profit = Math.round(prem * rr * 100) / 100;
|
||||||
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
|
need = Math.round((prem + profit) * 100) / 100;
|
||||||
if (intrinsic != null) {
|
|
||||||
value = Math.round(intrinsic * eth * 100) / 100;
|
|
||||||
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
||||||
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
||||||
const hedgeTarget = p.hedge_plan_target || null;
|
|
||||||
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
|
||||||
const profitSpan = hidePnl
|
const profitSpan = hidePnl
|
||||||
? ""
|
? ""
|
||||||
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
|
: '<span class="pos-value' + profitCls + '">目标盈利 ' + profitTxt + "</span>";
|
||||||
|
const ruleTxt =
|
||||||
|
rr != null && Number.isFinite(rr) && rr > 0
|
||||||
|
? "盈亏比 ×" + fmt(rr, 2)
|
||||||
|
: p.target_index != null
|
||||||
|
? "目标 " + fmt(p.target_index, 1)
|
||||||
|
: "委托中";
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
||||||
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
||||||
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
|
'<span class="pos-value">' + ruleTxt + "</span>" +
|
||||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
|
(need != null ? '<span class="pos-value">需回收 ' + fmtUsdc(need) + " USDC</span>" : "") +
|
||||||
profitSpan +
|
profitSpan +
|
||||||
'<span class="muted opt-target-row-hint">' +
|
'<span class="muted opt-target-row-hint">' +
|
||||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
(managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") +
|
||||||
"</span></div>"
|
"</span></div>"
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -76,9 +76,10 @@
|
|||||||
target_win_leg: "期期平盈利腿",
|
target_win_leg: "期期平盈利腿",
|
||||||
target_up_win_leg: "期期上破·平盈利腿",
|
target_up_win_leg: "期期上破·平盈利腿",
|
||||||
target_down_win_leg: "期期下破·平盈利腿",
|
target_down_win_leg: "期期下破·平盈利腿",
|
||||||
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
|
oo_rr_target: "期期盈亏比达标",
|
||||||
oo_rest_closing: "期期残值平·清亏损腿中",
|
oo_rr_closing: "期期盈亏比平仓中",
|
||||||
oo_rest_closed: "期期残值平·两腿已平",
|
oo_rest_closing: "期期全平·清残腿中",
|
||||||
|
oo_rest_closed: "期期全平·两腿已平",
|
||||||
orphaned_after_tp: "止盈后持有至到期",
|
orphaned_after_tp: "止盈后持有至到期",
|
||||||
orphaned_option_expiry: "残腿到期",
|
orphaned_option_expiry: "残腿到期",
|
||||||
hold_to_expiry: "持有至到期",
|
hold_to_expiry: "持有至到期",
|
||||||
|
|||||||
Vendored
-5
@@ -95,11 +95,6 @@ HOT_RELOAD_EXACT = frozenset({
|
|||||||
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
|
||||||
"OKX_OPTIONS_MAX_DTE_DAYS",
|
"OKX_OPTIONS_MAX_DTE_DAYS",
|
||||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
|
||||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
|
||||||
"OKX_OPTIONS_BUDGET_BUFFER",
|
|
||||||
"OKX_TRADE_MODE",
|
"OKX_TRADE_MODE",
|
||||||
"MAX_ACTIVE_HEDGE_PLANS",
|
"MAX_ACTIVE_HEDGE_PLANS",
|
||||||
"HEDGE_PLAN_LIVE_ORDER",
|
"HEDGE_PLAN_LIVE_ORDER",
|
||||||
|
|||||||
Vendored
+2
-42
@@ -143,27 +143,8 @@ _OPTIONS_SECTION: dict[str, Any] = {
|
|||||||
"fields": [
|
"fields": [
|
||||||
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
|
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
|
||||||
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
||||||
(
|
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
|
||||||
"OKX_OPTIONS_TRADE_BUDGET_USDC",
|
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
|
||||||
"单笔预算(USDC)",
|
|
||||||
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
|
|
||||||
),
|
|
||||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
|
|
||||||
(
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
|
|
||||||
"全仓复利开关",
|
|
||||||
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
|
|
||||||
"全仓复利上限开关",
|
|
||||||
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
|
|
||||||
"全仓复利上限(USDC)",
|
|
||||||
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
|
||||||
"期权持仓上限(笔)",
|
"期权持仓上限(笔)",
|
||||||
@@ -472,7 +453,6 @@ def build_env_ui_payload(
|
|||||||
_build_field(key, label, note, schema, values)
|
_build_field(key, label, note, schema, values)
|
||||||
for key, label, note in sec["fields"]
|
for key, label, note in sec["fields"]
|
||||||
]
|
]
|
||||||
fields = _mark_compound_budget_hidden(fields)
|
|
||||||
groups.append({
|
groups.append({
|
||||||
"title": sec["title"],
|
"title": sec["title"],
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
@@ -481,26 +461,6 @@ def build_env_ui_payload(
|
|||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
|
|
||||||
compound_on = True
|
|
||||||
for f in fields:
|
|
||||||
if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
|
|
||||||
compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
|
|
||||||
break
|
|
||||||
if not compound_on:
|
|
||||||
return fields
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for f in fields:
|
|
||||||
if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
|
|
||||||
item = dict(f)
|
|
||||||
item["hidden"] = True
|
|
||||||
out.append(item)
|
|
||||||
else:
|
|
||||||
out.append(f)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
|
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
|
||||||
allowed = ui_allowed_keys(exchange_key)
|
allowed = ui_allowed_keys(exchange_key)
|
||||||
return {k: v for k, v in (updates or {}).items() if k in allowed}
|
return {k: v for k, v in (updates or {}).items() if k in allowed}
|
||||||
|
|||||||
+125
-95
@@ -19,11 +19,34 @@ from lib.options.options_pricing_lib import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_OKX_OPTION_ERR_ZH: dict[str, str] = {
|
_OKX_OPTION_ERR_ZH: dict[str, str] = {
|
||||||
"51008": "可用余额或保证金不足(期权买入请确认交易账户 USDC 足够)",
|
"51008": "资金账户 USDT 可用余额不足",
|
||||||
"51018": "期权账户不能持有净空头头寸",
|
"51018": "期权账户不能持有净空头头寸",
|
||||||
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
|
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
||||||
|
# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链
|
||||||
|
_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
||||||
|
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
|
||||||
|
_INSTRUMENTS_STALE_SEC = 600.0
|
||||||
|
_TICKERS_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_TICKERS_CACHE_LOCK = threading.Lock()
|
||||||
|
_TICKERS_CACHE_TTL_SEC = float(os.getenv("OKX_OPTIONS_TICKERS_CACHE_SEC", "10") or "10")
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_options_balance_cache() -> None:
|
||||||
|
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
|
||||||
|
_OPTIONS_BALANCE_CACHE["data"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
|
||||||
|
with _INSTRUMENTS_CACHE_LOCK:
|
||||||
|
if inst_family:
|
||||||
|
_INSTRUMENTS_CACHE.pop(str(inst_family), None)
|
||||||
|
else:
|
||||||
|
_INSTRUMENTS_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
||||||
row: dict[str, Any] | None = None
|
row: dict[str, Any] | None = None
|
||||||
@@ -44,18 +67,10 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
|
|||||||
pass
|
pass
|
||||||
if row:
|
if row:
|
||||||
code = str(row.get("sCode") or "")
|
code = str(row.get("sCode") or "")
|
||||||
msg = str(row.get("sMsg") or "").strip()
|
|
||||||
low = msg.lower()
|
|
||||||
if code == "51008":
|
|
||||||
# 勿写死「资金账户 USDT」:期权开仓常因交易户 USDC 不足
|
|
||||||
if "usdc" in low:
|
|
||||||
return "交易账户 USDC 可用余额不足"
|
|
||||||
if "usdt" in low:
|
|
||||||
return "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)"
|
|
||||||
return _OKX_OPTION_ERR_ZH["51008"]
|
|
||||||
zh = _OKX_OPTION_ERR_ZH.get(code)
|
zh = _OKX_OPTION_ERR_ZH.get(code)
|
||||||
if zh:
|
if zh:
|
||||||
return zh
|
return zh
|
||||||
|
msg = str(row.get("sMsg") or "").strip()
|
||||||
if msg:
|
if msg:
|
||||||
return msg
|
return msg
|
||||||
if exc is not None:
|
if exc is not None:
|
||||||
@@ -66,28 +81,6 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
|
|||||||
return "下单失败"
|
return "下单失败"
|
||||||
|
|
||||||
|
|
||||||
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
|
||||||
|
|
||||||
# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
|
|
||||||
_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
|
|
||||||
_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
|
||||||
_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
|
|
||||||
_OPTION_INSTRUMENTS_STALE_MAX = 600.0
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_options_balance_cache() -> None:
|
|
||||||
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
|
|
||||||
_OPTIONS_BALANCE_CACHE["data"] = None
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
|
|
||||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
|
||||||
if inst_family:
|
|
||||||
_OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
|
|
||||||
else:
|
|
||||||
_OPTION_INSTRUMENTS_CACHE.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def td_mode_for_option_buy(configured: str | None = None) -> str:
|
def td_mode_for_option_buy(configured: str | None = None) -> str:
|
||||||
"""OKX 买入期权(多头)必须使用逐仓."""
|
"""OKX 买入期权(多头)必须使用逐仓."""
|
||||||
mode = (configured or "isolated").strip().lower()
|
mode = (configured or "isolated").strip().lower()
|
||||||
@@ -430,31 +423,25 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] |
|
|||||||
family = inst_family_from_inst_id(inst_id)
|
family = inst_family_from_inst_id(inst_id)
|
||||||
if not family:
|
if not family:
|
||||||
return None
|
return None
|
||||||
# 优先从全族缓存取,避免每选一腿再打 instruments
|
|
||||||
try:
|
|
||||||
cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
|
|
||||||
for r in cached_rows:
|
|
||||||
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
|
||||||
return r
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
last_err: BaseException | None = None
|
last_err: BaseException | None = None
|
||||||
for attempt in range(2):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
rows = ex.public_get_public_instruments(
|
rows = ex.public_get_public_instruments(
|
||||||
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
|
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
|
||||||
).get("data") or []
|
).get("data") or []
|
||||||
if rows and isinstance(rows[0], dict):
|
if rows and isinstance(rows[0], dict):
|
||||||
return rows[0]
|
return rows[0]
|
||||||
rows = fetch_option_instruments(ex, family, allow_stale=True)
|
rows = ex.public_get_public_instruments(
|
||||||
|
{"instType": "OPTION", "instFamily": family}
|
||||||
|
).get("data") or []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
|
||||||
return r
|
return r
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
if _is_okx_rate_limit(e) and attempt < 1:
|
if _is_okx_rate_limit(e) and attempt < 2:
|
||||||
time.sleep(1.2)
|
time.sleep(0.45 * (attempt + 1))
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
if last_err is not None and _is_okx_rate_limit(last_err):
|
if last_err is not None and _is_okx_rate_limit(last_err):
|
||||||
@@ -676,53 +663,103 @@ def fetch_option_instruments(
|
|||||||
inst_family: str,
|
inst_family: str,
|
||||||
*,
|
*,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
allow_stale: bool = True,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""拉取 OPTION instruments;进程内缓存,50011 时回退旧列表."""
|
"""拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存."""
|
||||||
family = str(inst_family or "").strip()
|
family = (inst_family or "").strip()
|
||||||
if not family:
|
if not family:
|
||||||
return []
|
return []
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
with _INSTRUMENTS_CACHE_LOCK:
|
||||||
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
|
cached = _INSTRUMENTS_CACHE.get(family)
|
||||||
if (
|
if (
|
||||||
not force
|
not force
|
||||||
and entry is not None
|
and cached
|
||||||
and entry.get("rows") is not None
|
and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC
|
||||||
and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL
|
and isinstance(cached.get("rows"), list)
|
||||||
|
and cached["rows"]
|
||||||
|
):
|
||||||
|
return list(cached["rows"])
|
||||||
|
|
||||||
|
last_err: BaseException | None = None
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for attempt in range(4):
|
||||||
|
try:
|
||||||
|
raw = ex.public_get_public_instruments(
|
||||||
|
{"instType": "OPTION", "instFamily": family}
|
||||||
|
).get("data") or []
|
||||||
|
rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"]
|
||||||
|
last_err = None
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if _is_okx_rate_limit(e) and attempt < 3:
|
||||||
|
time.sleep(0.8 * (2**attempt))
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
with _INSTRUMENTS_CACHE_LOCK:
|
||||||
|
_INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)}
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」
|
||||||
|
if cached and isinstance(cached.get("rows"), list) and cached["rows"]:
|
||||||
|
age = now - float(cached.get("updated_at") or 0)
|
||||||
|
if age < _INSTRUMENTS_STALE_SEC and (
|
||||||
|
last_err is None or _is_okx_rate_limit(last_err) or not rows
|
||||||
):
|
):
|
||||||
return list(entry["rows"])
|
return list(cached["rows"])
|
||||||
|
|
||||||
try:
|
if last_err is not None:
|
||||||
rows = ex.public_get_public_instruments(
|
raise last_err
|
||||||
{"instType": "OPTION", "instFamily": family}
|
return []
|
||||||
).get("data") or []
|
|
||||||
live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
|
||||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
|
||||||
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
|
|
||||||
return list(live)
|
|
||||||
except Exception as e:
|
|
||||||
if allow_stale:
|
|
||||||
with _OPTION_INSTRUMENTS_CACHE_LOCK:
|
|
||||||
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
|
|
||||||
if entry is not None and entry.get("rows") is not None:
|
|
||||||
age = now - float(entry.get("updated_at") or 0)
|
|
||||||
if age <= _OPTION_INSTRUMENTS_STALE_MAX:
|
|
||||||
return list(entry["rows"])
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
def fetch_option_tickers(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
inst_family: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
family = (inst_family or "").strip()
|
||||||
|
if not family:
|
||||||
|
return {}
|
||||||
|
now = time.time()
|
||||||
|
with _TICKERS_CACHE_LOCK:
|
||||||
|
cached = _TICKERS_CACHE.get(family)
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and cached
|
||||||
|
and now - float(cached.get("updated_at") or 0) < max(1.0, _TICKERS_CACHE_TTL_SEC)
|
||||||
|
and isinstance(cached.get("rows"), dict)
|
||||||
|
and cached["rows"]
|
||||||
|
):
|
||||||
|
return dict(cached["rows"])
|
||||||
|
|
||||||
out: dict[str, dict[str, Any]] = {}
|
out: dict[str, dict[str, Any]] = {}
|
||||||
try:
|
last_err: BaseException | None = None
|
||||||
rows = ex.public_get_market_tickers(
|
for attempt in range(3):
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
try:
|
||||||
).get("data") or []
|
rows = ex.public_get_market_tickers(
|
||||||
for r in rows:
|
{"instType": "OPTION", "instFamily": family}
|
||||||
if isinstance(r, dict) and r.get("instId"):
|
).get("data") or []
|
||||||
out[str(r["instId"])] = r
|
for r in rows:
|
||||||
except Exception:
|
if isinstance(r, dict) and r.get("instId"):
|
||||||
pass
|
out[str(r["instId"])] = r
|
||||||
|
if out:
|
||||||
|
with _TICKERS_CACHE_LOCK:
|
||||||
|
_TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)}
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if _is_okx_rate_limit(e) and attempt < 2:
|
||||||
|
time.sleep(0.6 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
if cached and isinstance(cached.get("rows"), dict) and cached["rows"]:
|
||||||
|
return dict(cached["rows"])
|
||||||
|
if last_err is not None and _is_okx_rate_limit(last_err):
|
||||||
|
return out
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -743,26 +780,17 @@ def build_option_chain(
|
|||||||
max_ms = now_ms + max_dte_days * 86400 * 1000
|
max_ms = now_ms + max_dte_days * 86400 * 1000
|
||||||
instruments_err = ""
|
instruments_err = ""
|
||||||
instruments: list[dict[str, Any]] = []
|
instruments: list[dict[str, Any]] = []
|
||||||
|
rate_limited = False
|
||||||
try:
|
try:
|
||||||
instruments = fetch_option_instruments(ex, family)
|
instruments = fetch_option_instruments(ex, family)
|
||||||
if not instruments:
|
|
||||||
# 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
|
|
||||||
time.sleep(0.5)
|
|
||||||
instruments = fetch_option_instruments(ex, family, force=True)
|
|
||||||
if not instruments:
|
if not instruments:
|
||||||
instruments_err = "期权合约列表为空"
|
instruments_err = "期权合约列表为空"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
instruments = []
|
instruments = []
|
||||||
instruments_err = str(e) or e.__class__.__name__
|
instruments_err = str(e) or e.__class__.__name__
|
||||||
# 限频:再等一下用 stale/缓存,不要连打
|
rate_limited = _is_okx_rate_limit(e)
|
||||||
if _is_okx_rate_limit(e):
|
if rate_limited:
|
||||||
time.sleep(1.5)
|
instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
|
||||||
try:
|
|
||||||
instruments = fetch_option_instruments(ex, family, allow_stale=True)
|
|
||||||
if instruments:
|
|
||||||
instruments_err = ""
|
|
||||||
except Exception as e2:
|
|
||||||
instruments_err = str(e2) or e2.__class__.__name__
|
|
||||||
tickers = fetch_option_tickers(ex, family)
|
tickers = fetch_option_tickers(ex, family)
|
||||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||||
skipped_no_index = 0
|
skipped_no_index = 0
|
||||||
@@ -841,6 +869,8 @@ def build_option_chain(
|
|||||||
"expiries": exp_list,
|
"expiries": exp_list,
|
||||||
"instruments_count": len(instruments),
|
"instruments_count": len(instruments),
|
||||||
}
|
}
|
||||||
|
if rate_limited:
|
||||||
|
out["rate_limited"] = True
|
||||||
if not exp_list:
|
if not exp_list:
|
||||||
if instruments_err:
|
if instruments_err:
|
||||||
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
||||||
|
|||||||
@@ -58,42 +58,6 @@ def option_expiry_pnl(
|
|||||||
return value - float(premium_paid)
|
return value - float(premium_paid)
|
||||||
|
|
||||||
|
|
||||||
def spot_from_expiry_intrinsic_profit(
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
strike: float,
|
|
||||||
sheets: float,
|
|
||||||
ct_mult: float,
|
|
||||||
premium_paid: float,
|
|
||||||
profit: float,
|
|
||||||
) -> float | None:
|
|
||||||
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
|
|
||||||
|
|
||||||
到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数).
|
|
||||||
Call: spot=K+实值/币; Put: spot=K−实值/币.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
k = float(strike)
|
|
||||||
n = float(sheets or 0)
|
|
||||||
ct = float(ct_mult or 0.01)
|
|
||||||
prem = float(premium_paid or 0)
|
|
||||||
pnl = float(profit)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
denom = n * ct
|
|
||||||
if denom <= 0:
|
|
||||||
return None
|
|
||||||
need = (pnl + prem) / denom
|
|
||||||
if need < 0:
|
|
||||||
need = 0.0
|
|
||||||
o = (opt_type or "").strip().upper()
|
|
||||||
if o in ("C", "CALL"):
|
|
||||||
return round(k + need, 2)
|
|
||||||
if o in ("P", "PUT"):
|
|
||||||
return round(k - need, 2)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def suggest_contracts_from_notional(
|
def suggest_contracts_from_notional(
|
||||||
*,
|
*,
|
||||||
notional: float,
|
notional: float,
|
||||||
@@ -480,18 +444,18 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
|
|||||||
|
|
||||||
def build_options_options_preview(
|
def build_options_options_preview(
|
||||||
*,
|
*,
|
||||||
|
profit_rr: float | None = None,
|
||||||
target_price: float | None = None,
|
target_price: float | None = None,
|
||||||
target_price_up: float | None = None,
|
target_price_up: float | None = None,
|
||||||
target_price_down: float | None = None,
|
target_price_down: float | None = None,
|
||||||
profit_rr: float | None = None,
|
|
||||||
index_px: float,
|
index_px: float,
|
||||||
leg_a: dict[str, Any],
|
leg_a: dict[str, Any],
|
||||||
leg_b: dict[str, Any],
|
leg_b: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
||||||
|
|
||||||
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
|
profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期.
|
||||||
残值按亏损腿本合约权利金的 20% 计.
|
仍接受旧上破/下破参数仅作兼容测算.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
||||||
@@ -504,124 +468,70 @@ def build_options_options_preview(
|
|||||||
premium_paid=float(leg.get("premium_paid") or 0),
|
premium_paid=float(leg.get("premium_paid") or 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
prem_a = float(leg_a.get("premium_paid") or 0)
|
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
|
||||||
prem_b = float(leg_b.get("premium_paid") or 0)
|
a_flat = _leg_pnl(leg_a, index_px)
|
||||||
prem = prem_a + prem_b
|
b_flat = _leg_pnl(leg_b, index_px)
|
||||||
rr = float(profit_rr) if profit_rr is not None else None
|
flat_total = a_flat + b_flat
|
||||||
|
|
||||||
# 新:盈亏比情景(不依赖指数上下破价)
|
rr = None
|
||||||
|
if profit_rr not in (None, ""):
|
||||||
|
try:
|
||||||
|
rr = float(profit_rr)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rr = None
|
||||||
if rr is not None and rr > 0:
|
if rr is not None and rr > 0:
|
||||||
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
|
target_pnl = rr * prem
|
||||||
win_profit = rr * prem
|
|
||||||
a_at_a = win_profit
|
|
||||||
b_at_a_full = -prem_b
|
|
||||||
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
|
|
||||||
b_at_b = win_profit
|
|
||||||
a_at_b_full = -prem_a
|
|
||||||
a_at_b_res = -prem_a * 0.8
|
|
||||||
|
|
||||||
spot_a = spot_from_expiry_intrinsic_profit(
|
|
||||||
opt_type=str(leg_a.get("opt_type") or ""),
|
|
||||||
strike=float(leg_a["strike"]),
|
|
||||||
sheets=float(leg_a.get("sheets") or 0),
|
|
||||||
ct_mult=float(leg_a.get("ct_mult") or 0.01),
|
|
||||||
premium_paid=prem_a,
|
|
||||||
profit=win_profit,
|
|
||||||
)
|
|
||||||
spot_b = spot_from_expiry_intrinsic_profit(
|
|
||||||
opt_type=str(leg_b.get("opt_type") or ""),
|
|
||||||
strike=float(leg_b["strike"]),
|
|
||||||
sheets=float(leg_b.get("sheets") or 0),
|
|
||||||
ct_mult=float(leg_b.get("ct_mult") or 0.01),
|
|
||||||
premium_paid=prem_b,
|
|
||||||
profit=win_profit,
|
|
||||||
)
|
|
||||||
|
|
||||||
a_flat = _leg_pnl(leg_a, index_px)
|
|
||||||
b_flat = _leg_pnl(leg_b, index_px)
|
|
||||||
flat_total = a_flat + b_flat
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"plan_type": "options_options",
|
"plan_type": "options_options",
|
||||||
"premium_paid": round(prem, 6),
|
"premium_paid": round(prem, 6),
|
||||||
"profit_rr": rr,
|
"oo_profit_rr": round(rr, 4),
|
||||||
"target_price": None,
|
"target_profit": round(target_pnl, 4),
|
||||||
"target_price_up": None,
|
|
||||||
"target_price_down": None,
|
|
||||||
"winner_at_up": "a",
|
|
||||||
"winner_at_down": "b",
|
|
||||||
"winner_at_target": "a",
|
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
{
|
{
|
||||||
"id": "rr_leg_a_full",
|
"id": "rr_target",
|
||||||
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
|
"label": f"盈亏比×{rr:g}",
|
||||||
"spot": spot_a,
|
"spot": None,
|
||||||
"leg_a_pnl": round(a_at_a, 4),
|
"leg_a_pnl": None,
|
||||||
"leg_b_pnl": round(b_at_a_full, 4),
|
"leg_b_pnl": None,
|
||||||
"total": round(a_at_a + b_at_a_full, 4),
|
"total": round(target_pnl, 4),
|
||||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
"note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期",
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rr_leg_b_full",
|
|
||||||
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
|
|
||||||
"spot": spot_b,
|
|
||||||
"leg_a_pnl": round(a_at_b_full, 4),
|
|
||||||
"leg_b_pnl": round(b_at_b, 4),
|
|
||||||
"total": round(a_at_b_full + b_at_b, 4),
|
|
||||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rr_leg_a_residual",
|
|
||||||
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
|
|
||||||
"spot": spot_a,
|
|
||||||
"leg_a_pnl": round(a_at_a, 4),
|
|
||||||
"leg_b_pnl": round(b_at_a_res, 4),
|
|
||||||
"total": round(a_at_a + b_at_a_res, 4),
|
|
||||||
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "expiry_flat",
|
"id": "expiry_flat",
|
||||||
"label": "到期·现价",
|
"label": "到期·现价(未达标)",
|
||||||
"spot": index_px,
|
"spot": index_px,
|
||||||
"leg_a_pnl": round(a_flat, 4),
|
"leg_a_pnl": round(a_flat, 4),
|
||||||
"leg_b_pnl": round(b_flat, 4),
|
"leg_b_pnl": round(b_flat, 4),
|
||||||
"total": round(flat_total, 4),
|
"total": round(flat_total, 4),
|
||||||
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
|
"note": "中途未达盈亏比则持有至到期结算",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "max_premium_loss",
|
"id": "max_premium_loss",
|
||||||
"label": "最大保费损耗",
|
"label": "最大保费损耗",
|
||||||
"spot": None,
|
"spot": None,
|
||||||
"leg_a_pnl": round(-prem_a, 4),
|
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
|
||||||
"leg_b_pnl": round(-prem_b, 4),
|
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
|
||||||
"total": round(-prem, 4),
|
"total": round(-prem, 4),
|
||||||
"note": "双腿权利金全部损失",
|
"note": "双腿权利金全部损失",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"summary": {
|
"summary": {
|
||||||
"profit_rr": rr,
|
"oo_profit_rr": round(rr, 4),
|
||||||
"spot_at_rr_a": spot_a,
|
"target_profit": round(target_pnl, 4),
|
||||||
"spot_at_rr_b": spot_b,
|
"at_target_total": round(target_pnl, 4),
|
||||||
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
|
|
||||||
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
|
|
||||||
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
|
|
||||||
"at_target_total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"expiry_flat_total": round(flat_total, 4),
|
"expiry_flat_total": round(flat_total, 4),
|
||||||
"premium_paid": round(prem, 6),
|
"premium_paid": round(prem, 6),
|
||||||
"expiry_is_loss": flat_total <= 0,
|
"expiry_is_loss": flat_total <= 0,
|
||||||
"rr_risk_premium": round(prem, 6),
|
"rr_risk_premium": round(prem, 6),
|
||||||
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
|
"rr_target": round(rr, 4),
|
||||||
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
|
# 兼容旧上破/下破测算
|
||||||
up = target_price_up if target_price_up is not None else target_price
|
up = target_price_up if target_price_up is not None else target_price
|
||||||
down = target_price_down if target_price_down is not None else target_price
|
down = target_price_down if target_price_down is not None else target_price
|
||||||
if up is None or down is None:
|
if up is None or down is None:
|
||||||
raise ValueError("缺少盈亏比或上破/下破目标价")
|
raise ValueError("请填写盈亏比(相对权利金,默认2)")
|
||||||
up_f = float(up)
|
up_f = float(up)
|
||||||
down_f = float(down)
|
down_f = float(down)
|
||||||
|
|
||||||
@@ -635,15 +545,10 @@ def build_options_options_preview(
|
|||||||
at_dn = a_dn + b_dn
|
at_dn = a_dn + b_dn
|
||||||
win_dn = "a" if a_dn >= b_dn else "b"
|
win_dn = "a" if a_dn >= b_dn else "b"
|
||||||
|
|
||||||
a_flat = _leg_pnl(leg_a, index_px)
|
|
||||||
b_flat = _leg_pnl(leg_b, index_px)
|
|
||||||
flat_total = a_flat + b_flat
|
|
||||||
expiry_loss = flat_total if flat_total <= 0 else flat_total
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"plan_type": "options_options",
|
"plan_type": "options_options",
|
||||||
"premium_paid": round(prem, 6),
|
"premium_paid": round(prem, 6),
|
||||||
"target_price": up_f, # 兼容旧字段,取上破
|
"target_price": up_f,
|
||||||
"target_price_up": up_f,
|
"target_price_up": up_f,
|
||||||
"target_price_down": down_f,
|
"target_price_down": down_f,
|
||||||
"winner_at_up": win_up,
|
"winner_at_up": win_up,
|
||||||
@@ -681,8 +586,8 @@ def build_options_options_preview(
|
|||||||
"id": "max_premium_loss",
|
"id": "max_premium_loss",
|
||||||
"label": "最大保费损耗",
|
"label": "最大保费损耗",
|
||||||
"spot": None,
|
"spot": None,
|
||||||
"leg_a_pnl": round(-prem_a, 4),
|
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
|
||||||
"leg_b_pnl": round(-prem_b, 4),
|
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
|
||||||
"total": round(-prem, 4),
|
"total": round(-prem, 4),
|
||||||
"note": "双腿权利金全部损失",
|
"note": "双腿权利金全部损失",
|
||||||
},
|
},
|
||||||
@@ -691,10 +596,9 @@ def build_options_options_preview(
|
|||||||
"at_target_up_total": round(at_up, 4),
|
"at_target_up_total": round(at_up, 4),
|
||||||
"at_target_down_total": round(at_dn, 4),
|
"at_target_down_total": round(at_dn, 4),
|
||||||
"at_target_total": round(at_up, 4),
|
"at_target_total": round(at_up, 4),
|
||||||
"expiry_flat_total": round(expiry_loss, 4),
|
"expiry_flat_total": round(flat_total, 4),
|
||||||
"premium_paid": round(prem, 6),
|
"premium_paid": round(prem, 6),
|
||||||
"expiry_is_loss": flat_total <= 0,
|
"expiry_is_loss": flat_total <= 0,
|
||||||
# 盈亏比:盈利/全亏保费(风险=权利金全损)
|
|
||||||
"rr_risk_premium": round(prem, 6),
|
"rr_risk_premium": round(prem, 6),
|
||||||
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
|
"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,
|
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
|
||||||
|
|||||||
@@ -72,9 +72,9 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
||||||
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
||||||
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
|
# 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期
|
||||||
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
|
_ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL")
|
||||||
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
|
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
|
||||||
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
||||||
# 永期「以期权为主」
|
# 永期「以期权为主」
|
||||||
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
|
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
|
||||||
@@ -266,7 +266,7 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
|
|||||||
|
|
||||||
|
|
||||||
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||||
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
|
"""返回由进行中「期期对冲」托管的期权目标位,仅供期权页只读展示。
|
||||||
|
|
||||||
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
|
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
|
||||||
否则两套监控会同时尝试平掉同一条期权腿。
|
否则两套监控会同时尝试平掉同一条期权腿。
|
||||||
@@ -274,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
||||||
p.profit_rr, l.inst_id, l.opt_type
|
p.oo_profit_rr, l.inst_id, l.opt_type
|
||||||
FROM hedge_plans p
|
FROM hedge_plans p
|
||||||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
||||||
WHERE p.plan_type = 'options_options'
|
WHERE p.plan_type = 'options_options'
|
||||||
@@ -289,25 +289,36 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
|||||||
for raw in rows:
|
for raw in rows:
|
||||||
row = dict(raw)
|
row = dict(raw)
|
||||||
inst_id = str(row.get("inst_id") or "")
|
inst_id = str(row.get("inst_id") or "")
|
||||||
opt_type = str(row.get("opt_type") or "").upper()
|
|
||||||
if not inst_id or inst_id in out:
|
if not inst_id or inst_id in out:
|
||||||
continue
|
continue
|
||||||
profit_rr = _sf(row.get("profit_rr"))
|
opt_type = str(row.get("opt_type") or "").upper()
|
||||||
if profit_rr is not None and profit_rr > 0:
|
rr = _sf(row.get("oo_profit_rr"))
|
||||||
|
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
||||||
|
target_f = _sf(target)
|
||||||
|
# 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示
|
||||||
|
if rr is not None and rr > 0:
|
||||||
out[inst_id] = {
|
out[inst_id] = {
|
||||||
"plan_id": int(row["plan_id"]),
|
"plan_id": int(row["plan_id"]),
|
||||||
"inst_id": inst_id,
|
"inst_id": inst_id,
|
||||||
"underlying": row.get("underlying"),
|
"underlying": row.get("underlying"),
|
||||||
"opt_type": opt_type,
|
"opt_type": opt_type,
|
||||||
"profit_rr": profit_rr,
|
|
||||||
"target_index": None,
|
"target_index": None,
|
||||||
"exit_mode": "profit_rr",
|
"oo_profit_rr": rr,
|
||||||
|
"plan_type": "options_options",
|
||||||
"managed_by": "hedge_plan",
|
"managed_by": "hedge_plan",
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
|
||||||
target_f = _sf(target)
|
|
||||||
if target_f is None or target_f <= 0:
|
if target_f is None or target_f <= 0:
|
||||||
|
# 无目标价也标记托管,避免期权页误拆组
|
||||||
|
out[inst_id] = {
|
||||||
|
"plan_id": int(row["plan_id"]),
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"underlying": row.get("underlying"),
|
||||||
|
"opt_type": opt_type,
|
||||||
|
"target_index": None,
|
||||||
|
"plan_type": "options_options",
|
||||||
|
"managed_by": "hedge_plan",
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
out[inst_id] = {
|
out[inst_id] = {
|
||||||
"plan_id": int(row["plan_id"]),
|
"plan_id": int(row["plan_id"]),
|
||||||
|
|||||||
@@ -173,11 +173,11 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
|
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
|
||||||
"""盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平).
|
"""盈利腿平后另一腿:close_all(全平) / hold_expiry(到期平).
|
||||||
|
|
||||||
- 方案C关闭 → 强制到期平
|
- 方案C关闭 → 强制到期平
|
||||||
- 计划未写 oo_close_mode(旧单) → 到期平,避免误清残腿
|
- 计划未写 oo_close_mode(旧单) → 到期平,避免误清残腿
|
||||||
- 新开仓默认写入 close_all(残值平:权利金≤初始20%且有买一)
|
- 新开仓默认写入 close_all
|
||||||
"""
|
"""
|
||||||
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
|
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
|
||||||
return "hold_expiry"
|
return "hold_expiry"
|
||||||
@@ -190,12 +190,6 @@ def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
|
|||||||
return "close_all"
|
return "close_all"
|
||||||
|
|
||||||
|
|
||||||
# 期期亏损腿残值平:当前买一回收 ≤ 本合约初始权利金 × 该比例
|
|
||||||
OO_LOSS_LEG_RESIDUAL_RATIO = 0.20
|
|
||||||
# 期期默认盈亏比:盈利金额 / 总权利金
|
|
||||||
OO_DEFAULT_PROFIT_RR = 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
|
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||||
out = []
|
out = []
|
||||||
for x in legs:
|
for x in legs:
|
||||||
@@ -206,63 +200,6 @@ def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) ->
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _oo_quote_bid(cfg: dict[str, Any], inst_id: str) -> tuple[Optional[float], Optional[float]]:
|
|
||||||
quote_fn = cfg.get("quote_option_contract")
|
|
||||||
ex_opt = cfg.get("exchange_options")
|
|
||||||
if not callable(quote_fn) or ex_opt is None or not inst_id:
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
q = quote_fn(ex_opt, inst_id)
|
|
||||||
if not q.get("ok"):
|
|
||||||
return None, None
|
|
||||||
return _sf(q.get("bid")), _sf(q.get("bid_sz"))
|
|
||||||
except Exception:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _oo_leg_mark_value(leg: dict[str, Any], bid: Optional[float]) -> Optional[float]:
|
|
||||||
"""买一可回收金额(USDC)= bid × 张数 × ct_mult."""
|
|
||||||
b = _sf(bid)
|
|
||||||
if b is None or b < 0:
|
|
||||||
return None
|
|
||||||
sheets = float(leg.get("size") or 1)
|
|
||||||
ct = float(leg.get("ct_mult") or 0.01)
|
|
||||||
return float(b) * sheets * ct
|
|
||||||
|
|
||||||
|
|
||||||
def _oo_plan_premium_total(plan: dict[str, Any], legs: list[dict[str, Any]]) -> float:
|
|
||||||
"""双腿总权利金:优先计划字段,否则对期权腿 premium 求和."""
|
|
||||||
total = _sf(plan.get("premium_total"))
|
|
||||||
if total is not None and total > 0:
|
|
||||||
return float(total)
|
|
||||||
s = 0.0
|
|
||||||
for leg in legs:
|
|
||||||
if not str(leg.get("leg_role") or "").startswith("option"):
|
|
||||||
continue
|
|
||||||
s += float(leg.get("premium") or 0)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def _oo_leg_profit_rr(
|
|
||||||
leg: dict[str, Any], bid: Optional[float], *, total_premium: float
|
|
||||||
) -> Optional[float]:
|
|
||||||
"""盈亏比 = 该腿盈利金额 / 总权利金;盈利金额 = 买一回收 − 本腿权利金."""
|
|
||||||
if total_premium <= 0:
|
|
||||||
return None
|
|
||||||
leg_prem = float(leg.get("premium") or 0)
|
|
||||||
value = _oo_leg_mark_value(leg, bid)
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return (value - leg_prem) / total_premium
|
|
||||||
|
|
||||||
|
|
||||||
def _oo_resolve_profit_rr(plan: dict[str, Any]) -> Optional[float]:
|
|
||||||
rr = _sf(plan.get("profit_rr"))
|
|
||||||
if rr is not None and rr > 0:
|
|
||||||
return rr
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _finalize_oo_all_closed(
|
def _finalize_oo_all_closed(
|
||||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
|
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -1048,12 +985,7 @@ def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Opti
|
|||||||
def _tick_oo_close_rest(
|
def _tick_oo_close_rest(
|
||||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
"""盈利腿已平后:残值平模式清亏损腿.
|
"""盈利腿已平后:全平模式清残腿(无2×门控,买一失败则下轮重试)."""
|
||||||
|
|
||||||
条件:买一回收 ≤ 本合约初始权利金×20%,且买一有流动性;失败或未达条件则下轮重试.
|
|
||||||
"""
|
|
||||||
from lib.hedge_plan.hedge_plan_option_primary_lib import option_bid_liquidity_ok
|
|
||||||
|
|
||||||
if resolve_oo_rest_close_mode(plan) != "close_all":
|
if resolve_oo_rest_close_mode(plan) != "close_all":
|
||||||
return None
|
return None
|
||||||
open_legs = _oo_option_legs(legs, statuses=("open",))
|
open_legs = _oo_option_legs(legs, statuses=("open",))
|
||||||
@@ -1066,8 +998,9 @@ def _tick_oo_close_rest(
|
|||||||
"target_win_leg",
|
"target_win_leg",
|
||||||
"target_up_win_leg",
|
"target_up_win_leg",
|
||||||
"target_down_win_leg",
|
"target_down_win_leg",
|
||||||
"profit_rr_win_leg",
|
|
||||||
"oo_rest_closing",
|
"oo_rest_closing",
|
||||||
|
"oo_rr_closing",
|
||||||
|
"oo_rr_target",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
if reason0 not in allowed_reasons and not (
|
if reason0 not in allowed_reasons and not (
|
||||||
@@ -1077,45 +1010,23 @@ def _tick_oo_close_rest(
|
|||||||
|
|
||||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||||
acted = False
|
acted = False
|
||||||
waiting = False
|
|
||||||
for leg in list(open_legs):
|
for leg in list(open_legs):
|
||||||
inst_id = str(leg.get("inst_id") or "")
|
close_r = _sell_option(
|
||||||
sheets = float(leg.get("size") or 1)
|
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
|
||||||
premium = float(leg.get("premium") or 0)
|
)
|
||||||
bid, bid_sz = _oo_quote_bid(cfg, inst_id)
|
|
||||||
value = _oo_leg_mark_value(leg, bid)
|
|
||||||
# 残值门槛:相对本合约初始权利金,买一回收须 ≤ 20%
|
|
||||||
if premium > 0:
|
|
||||||
if value is None:
|
|
||||||
waiting = True
|
|
||||||
continue
|
|
||||||
if value > premium * OO_LOSS_LEG_RESIDUAL_RATIO + 1e-12:
|
|
||||||
waiting = True
|
|
||||||
continue
|
|
||||||
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets)
|
|
||||||
if not liq_ok:
|
|
||||||
waiting = True
|
|
||||||
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
|
|
||||||
return {
|
|
||||||
"plan_id": plan["id"],
|
|
||||||
"msg": "残值平等待买一流动性",
|
|
||||||
"detail": liq_msg,
|
|
||||||
"waiting": True,
|
|
||||||
}
|
|
||||||
close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets)
|
|
||||||
if not close_r.get("ok"):
|
if not close_r.get("ok"):
|
||||||
notify_hedge(
|
notify_hedge(
|
||||||
cfg,
|
cfg,
|
||||||
build_hedge_alert_message(
|
build_hedge_alert_message(
|
||||||
title="期期残值平·亏损腿平仓失败(将重试)",
|
title="期期全平·残腿平仓失败(将重试)",
|
||||||
plan_id=plan.get("id"),
|
plan_id=plan.get("id"),
|
||||||
detail=str(close_r.get("msg") or close_r),
|
detail=str(close_r.get("msg") or close_r),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
|
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
|
||||||
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
|
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
|
||||||
bid_fill = _sf(close_r.get("bid")) or bid
|
bid = _sf(close_r.get("bid"))
|
||||||
est = _estimate_leg_close_pnl(leg, idx, bid_fill)
|
est = _estimate_leg_close_pnl(leg, idx, bid)
|
||||||
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
|
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||||
@@ -1126,9 +1037,6 @@ def _tick_oo_close_rest(
|
|||||||
acted = True
|
acted = True
|
||||||
|
|
||||||
if not acted:
|
if not acted:
|
||||||
if waiting:
|
|
||||||
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
|
|
||||||
return {"plan_id": plan["id"], "msg": "残值平等待本合约权利金≤20%", "waiting": True}
|
|
||||||
return None
|
return None
|
||||||
legs2 = get_plan_legs(conn, int(plan["id"]))
|
legs2 = get_plan_legs(conn, int(plan["id"]))
|
||||||
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
|
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
|
||||||
@@ -1140,126 +1048,116 @@ def _tick_oo_close_rest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _after_oo_winner_closed(
|
|
||||||
cfg: dict[str, Any],
|
|
||||||
conn: Any,
|
|
||||||
plan: dict[str, Any],
|
|
||||||
open_legs: list[dict[str, Any]],
|
|
||||||
best: dict[str, Any],
|
|
||||||
*,
|
|
||||||
reason: str,
|
|
||||||
extra: Optional[dict[str, Any]] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""盈利腿已平后:残值平同轮尝试 / 到期平标记 hold_to_expiry."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
out: dict[str, Any] = {
|
|
||||||
"plan_id": plan["id"],
|
|
||||||
"close_reason": reason,
|
|
||||||
"closed_leg": best.get("id"),
|
|
||||||
"oo_close_mode": rest_mode,
|
|
||||||
}
|
|
||||||
if extra:
|
|
||||||
out.update(extra)
|
|
||||||
|
|
||||||
if rest_mode == "close_all":
|
|
||||||
legs2 = get_plan_legs(conn, int(plan["id"]))
|
|
||||||
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
|
|
||||||
if rest:
|
|
||||||
out["rest"] = rest
|
|
||||||
return out
|
|
||||||
|
|
||||||
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 out
|
|
||||||
|
|
||||||
|
|
||||||
def _tick_oo_profit_rr(
|
|
||||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, rr_target: float
|
|
||||||
) -> Optional[dict[str, Any]]:
|
|
||||||
"""期期:任一开仓腿盈亏比(该腿盈利金额/总权利金)达目标 → 平盈利腿."""
|
|
||||||
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
|
|
||||||
return None
|
|
||||||
open_legs = _oo_option_legs(legs, statuses=("open",))
|
|
||||||
if len(open_legs) < 2:
|
|
||||||
return None
|
|
||||||
total_prem = _oo_plan_premium_total(plan, legs)
|
|
||||||
if total_prem <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ranked: list[tuple[float, float, dict[str, Any]]] = []
|
|
||||||
for leg in open_legs:
|
|
||||||
bid, _bid_sz = _oo_quote_bid(cfg, str(leg.get("inst_id") or ""))
|
|
||||||
rr = _oo_leg_profit_rr(leg, bid, total_premium=total_prem)
|
|
||||||
if rr is None:
|
|
||||||
continue
|
|
||||||
value = _oo_leg_mark_value(leg, bid) or 0.0
|
|
||||||
premium = float(leg.get("premium") or 0)
|
|
||||||
pnl = value - premium
|
|
||||||
ranked.append((rr, pnl, leg))
|
|
||||||
if not ranked:
|
|
||||||
return None
|
|
||||||
ranked.sort(key=lambda x: x[0], reverse=True)
|
|
||||||
best_rr, best_pnl, best = ranked[0]
|
|
||||||
if best_rr + 1e-12 < float(rr_target) or best_pnl <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
close_r = _sell_option(
|
|
||||||
cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.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),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
|
|
||||||
|
|
||||||
reason = "profit_rr_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(), closed_pnl, best["id"]),
|
|
||||||
)
|
|
||||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
|
||||||
return _after_oo_winner_closed(
|
|
||||||
cfg,
|
|
||||||
conn,
|
|
||||||
plan,
|
|
||||||
open_legs,
|
|
||||||
best,
|
|
||||||
reason=reason,
|
|
||||||
extra={
|
|
||||||
"profit_rr": best_rr,
|
|
||||||
"rr_target": float(rr_target),
|
|
||||||
"total_premium": total_prem,
|
|
||||||
"index": idx,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tick_oo_target(
|
def _tick_oo_target(
|
||||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
"""期期:优先按盈亏比平盈利腿;旧单无 profit_rr 时回退上/下破目标价."""
|
"""期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破."""
|
||||||
rr_target = _oo_resolve_profit_rr(plan)
|
rr = _sf(plan.get("oo_profit_rr"))
|
||||||
if rr_target is not None:
|
if rr is not None and rr > 0:
|
||||||
return _tick_oo_profit_rr(cfg, conn, plan, legs, rr_target=rr_target)
|
return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr))
|
||||||
|
return _tick_oo_price_target(cfg, conn, plan, legs)
|
||||||
|
|
||||||
|
|
||||||
|
def _tick_oo_rr_target(
|
||||||
|
cfg: dict[str, Any],
|
||||||
|
conn: Any,
|
||||||
|
plan: dict[str, Any],
|
||||||
|
legs: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
rr: float,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期."""
|
||||||
|
open_legs = _oo_option_legs(legs, statuses=("open",))
|
||||||
|
if len(open_legs) < 1:
|
||||||
|
return None
|
||||||
|
premium = float(plan.get("premium_total") or 0)
|
||||||
|
if premium <= 0:
|
||||||
|
premium = sum(float(x.get("premium") or 0) for x in open_legs)
|
||||||
|
if premium <= 0:
|
||||||
|
return None
|
||||||
|
need = float(rr) * premium
|
||||||
|
quote_fn = cfg.get("quote_option_contract")
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
if not callable(quote_fn) or ex is None:
|
||||||
|
return None
|
||||||
|
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||||
|
total_pnl = 0.0
|
||||||
|
missing_bid = 0
|
||||||
|
for leg in open_legs:
|
||||||
|
inst = str(leg.get("inst_id") or "")
|
||||||
|
bid = None
|
||||||
|
try:
|
||||||
|
q = quote_fn(ex, inst) if inst else {}
|
||||||
|
if isinstance(q, dict) and q.get("ok"):
|
||||||
|
bid = _sf(q.get("bid"))
|
||||||
|
except Exception:
|
||||||
|
bid = None
|
||||||
|
if bid is None or float(bid) <= 0:
|
||||||
|
missing_bid += 1
|
||||||
|
# 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过
|
||||||
|
total_pnl += _estimate_leg_close_pnl(leg, idx, None)
|
||||||
|
else:
|
||||||
|
total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid))
|
||||||
|
if missing_bid >= len(open_legs):
|
||||||
|
return None
|
||||||
|
if total_pnl + 1e-9 < need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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=(
|
||||||
|
f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; "
|
||||||
|
f"{close_r.get('msg') or close_r}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
|
||||||
|
return {
|
||||||
|
"plan_id": plan["id"],
|
||||||
|
"msg": "盈亏比达标但平仓失败",
|
||||||
|
"close": close_r,
|
||||||
|
"retry": True,
|
||||||
|
"rr": rr,
|
||||||
|
"need": need,
|
||||||
|
"mtm": total_pnl,
|
||||||
|
}
|
||||||
|
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_rr_target", _now(), round(pnl, 4), leg["id"]),
|
||||||
|
)
|
||||||
|
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_rr_closing")
|
||||||
|
return {
|
||||||
|
"plan_id": plan["id"],
|
||||||
|
"msg": "盈亏比达标·部分已平,继续重试",
|
||||||
|
"remaining": len(still_open),
|
||||||
|
"rr": rr,
|
||||||
|
}
|
||||||
|
return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target")
|
||||||
|
|
||||||
|
|
||||||
|
def _tick_oo_price_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"))
|
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||||
if idx is None:
|
if idx is None:
|
||||||
return None
|
return None
|
||||||
@@ -1275,8 +1173,10 @@ def _tick_oo_target(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
hit_side: Optional[str] = None
|
hit_side: Optional[str] = None
|
||||||
|
# 上破:现价接近或超过上破目标
|
||||||
if up is not None and idx >= up * 0.998:
|
if up is not None and idx >= up * 0.998:
|
||||||
hit_side = "up"
|
hit_side = "up"
|
||||||
|
# 下破:现价接近或低于下破目标
|
||||||
elif down is not None and idx <= down * 1.002:
|
elif down is not None and idx <= down * 1.002:
|
||||||
hit_side = "down"
|
hit_side = "down"
|
||||||
if not hit_side:
|
if not hit_side:
|
||||||
@@ -1310,20 +1210,52 @@ def _tick_oo_target(
|
|||||||
)
|
)
|
||||||
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
|
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
|
||||||
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
|
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
|
||||||
|
# 选腿用内在估算;落库优先交易所已实现盈亏
|
||||||
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
|
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||||
("closed", reason, _now(), closed_pnl, best["id"]),
|
("closed", reason, _now(), closed_pnl, best["id"]),
|
||||||
)
|
)
|
||||||
return _after_oo_winner_closed(
|
rest_mode = resolve_oo_rest_close_mode(plan)
|
||||||
cfg,
|
update_plan(conn, int(plan["id"]), close_reason=reason)
|
||||||
conn,
|
mid = dict(plan)
|
||||||
plan,
|
mid["close_reason"] = reason
|
||||||
open_legs,
|
mid["status"] = "active"
|
||||||
best,
|
mid["oo_close_mode"] = rest_mode
|
||||||
reason=reason,
|
notify_plan_end(cfg, conn, mid)
|
||||||
extra={"hit_side": hit_side, "index": idx},
|
|
||||||
)
|
# 全平:同轮尝试清残腿;失败则下轮 _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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _tick_oo_expiry(
|
def _tick_oo_expiry(
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rr = plan.get("profit_rr")
|
rr = plan.get("oo_profit_rr")
|
||||||
if rr not in (None, ""):
|
if rr not in (None, ""):
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
|
f"🎯 盈亏比:{_fmt(rr)}×权利金(达标全平;不达标等到期)",
|
||||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -90,9 +90,10 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
|||||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
"target_win_leg": "期期已平盈利腿(中间态)",
|
||||||
"target_up_win_leg": "期期上破·已平盈利腿",
|
"target_up_win_leg": "期期上破·已平盈利腿",
|
||||||
"target_down_win_leg": "期期下破·已平盈利腿",
|
"target_down_win_leg": "期期下破·已平盈利腿",
|
||||||
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
|
"oo_rr_target": "期期盈亏比达标·两腿已平",
|
||||||
"oo_rest_closing": "期期残值平·清亏损腿中",
|
"oo_rr_closing": "期期盈亏比达标·平仓中",
|
||||||
"oo_rest_closed": "期期残值平·两腿已平",
|
"oo_rest_closing": "期期全平·清残腿中",
|
||||||
|
"oo_rest_closed": "期期全平·两腿已平",
|
||||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
||||||
"oo_expiry_win": "期期到期仍盈利",
|
"oo_expiry_win": "期期到期仍盈利",
|
||||||
"expiry": "到期收口",
|
"expiry": "到期收口",
|
||||||
@@ -162,37 +163,36 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
|
|||||||
"target_win_leg",
|
"target_win_leg",
|
||||||
"target_up_win_leg",
|
"target_up_win_leg",
|
||||||
"target_down_win_leg",
|
"target_down_win_leg",
|
||||||
"profit_rr_win_leg",
|
|
||||||
"oo_rest_closing",
|
"oo_rest_closing",
|
||||||
|
"oo_rr_closing",
|
||||||
) and (plan.get("status") or "") != "closed":
|
) and (plan.get("status") or "") != "closed":
|
||||||
cr = str(plan.get("close_reason") or "")
|
if "oo_rr" in str(plan.get("close_reason") or ""):
|
||||||
if "profit_rr" in cr:
|
notify_hedge(
|
||||||
side = "盈亏比达标"
|
cfg,
|
||||||
elif "up" in cr:
|
build_hedge_alert_message(
|
||||||
side = "上破"
|
title="期期盈亏比达标·平仓进行中",
|
||||||
elif "down" in cr:
|
plan_id=plan.get("id"),
|
||||||
side = "下破"
|
detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金",
|
||||||
else:
|
),
|
||||||
side = "目标"
|
)
|
||||||
|
return True
|
||||||
|
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()
|
mode = (plan.get("oo_close_mode") or "").strip().lower()
|
||||||
if mode in ("close_all", "全平", "残值平"):
|
if mode in ("close_all", "全平"):
|
||||||
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
|
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)"
|
||||||
else:
|
else:
|
||||||
rest_txt = "另一腿到期平(持有至到期结算)"
|
rest_txt = "另一腿到期平(持有至到期结算)"
|
||||||
rr = plan.get("profit_rr")
|
|
||||||
if rr not in (None, ""):
|
|
||||||
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
|
|
||||||
else:
|
|
||||||
detail = (
|
|
||||||
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
|
||||||
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
|
|
||||||
)
|
|
||||||
notify_hedge(
|
notify_hedge(
|
||||||
cfg,
|
cfg,
|
||||||
build_hedge_alert_message(
|
build_hedge_alert_message(
|
||||||
title=f"期期{side}已平盈利腿 · {rest_txt}",
|
title=f"期期{side}已平盈利腿 · {rest_txt}",
|
||||||
plan_id=plan.get("id"),
|
plan_id=plan.get("id"),
|
||||||
detail=detail,
|
detail=(
|
||||||
|
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
||||||
|
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1146,16 +1146,17 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
|||||||
b = body.get("leg_b") or {}
|
b = body.get("leg_b") or {}
|
||||||
if not a.get("inst_id") or not b.get("inst_id"):
|
if not a.get("inst_id") or not b.get("inst_id"):
|
||||||
return "请选用两条期权腿"
|
return "请选用两条期权腿"
|
||||||
rr_raw = body.get("profit_rr")
|
rr_raw = body.get("oo_profit_rr")
|
||||||
|
if rr_raw in (None, ""):
|
||||||
|
rr_raw = body.get("profit_rr")
|
||||||
if rr_raw not in (None, ""):
|
if rr_raw not in (None, ""):
|
||||||
try:
|
try:
|
||||||
rr = float(rr_raw)
|
rr = float(rr_raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return "盈亏比无效"
|
return "盈亏比无效"
|
||||||
if rr <= 0:
|
if rr <= 0:
|
||||||
return "盈亏比须大于0"
|
return "盈亏比须大于 0"
|
||||||
else:
|
else:
|
||||||
# 兼容旧上/下破
|
|
||||||
up = body.get("target_price_up")
|
up = body.get("target_price_up")
|
||||||
down = body.get("target_price_down")
|
down = body.get("target_price_down")
|
||||||
legacy = body.get("target_price")
|
legacy = body.get("target_price")
|
||||||
@@ -1164,7 +1165,7 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
|||||||
if down in (None, "") and legacy not in (None, ""):
|
if down in (None, "") and legacy not in (None, ""):
|
||||||
down = legacy
|
down = legacy
|
||||||
if up in (None, "") or down in (None, ""):
|
if up in (None, "") or down in (None, ""):
|
||||||
return "请填写盈亏比"
|
return "请填写盈亏比(相对权利金,默认2)"
|
||||||
try:
|
try:
|
||||||
if float(up) <= float(down):
|
if float(up) <= float(down):
|
||||||
return "上破目标价必须大于下破目标价"
|
return "上破目标价必须大于下破目标价"
|
||||||
|
|||||||
@@ -537,36 +537,25 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
|||||||
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
|
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
|
float(b.get("premium") or 0) if b_ok else 0.0
|
||||||
)
|
)
|
||||||
rr_raw = body.get("profit_rr")
|
rr_raw = body.get("oo_profit_rr")
|
||||||
|
if rr_raw in (None, ""):
|
||||||
|
rr_raw = body.get("profit_rr")
|
||||||
try:
|
try:
|
||||||
profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
|
oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
profit_rr = 2.0
|
oo_rr = 2.0
|
||||||
if profit_rr <= 0:
|
if oo_rr <= 0:
|
||||||
profit_rr = 2.0
|
oo_rr = 2.0
|
||||||
# 旧字段兼容:不再要求上/下破;有传则原样落库
|
|
||||||
def _opt_float(key: str, *alts: str) -> float | None:
|
|
||||||
for k in (key, *alts):
|
|
||||||
v = body.get(k)
|
|
||||||
if v not in (None, ""):
|
|
||||||
try:
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
up_f = _opt_float("target_price_up", "target_price")
|
|
||||||
down_f = _opt_float("target_price_down", "target_price")
|
|
||||||
plan_id = insert_plan(
|
plan_id = insert_plan(
|
||||||
conn,
|
conn,
|
||||||
{
|
{
|
||||||
"plan_type": "options_options",
|
"plan_type": "options_options",
|
||||||
"status": "partial" if is_partial else "active",
|
"status": "partial" if is_partial else "active",
|
||||||
"underlying": str(body.get("underlying") or "ETH").upper(),
|
"underlying": str(body.get("underlying") or "ETH").upper(),
|
||||||
"target_price": up_f,
|
"target_price": None,
|
||||||
"target_price_up": up_f,
|
"target_price_up": None,
|
||||||
"target_price_down": down_f,
|
"target_price_down": None,
|
||||||
"profit_rr": profit_rr,
|
"oo_profit_rr": oo_rr,
|
||||||
"sizing_mode_at_open": load_position_sizing_mode(),
|
"sizing_mode_at_open": load_position_sizing_mode(),
|
||||||
"premium_total": premium,
|
"premium_total": premium,
|
||||||
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
|
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
|
||||||
@@ -1247,32 +1236,24 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
||||||
|
|
||||||
rr_raw = body.get("profit_rr")
|
rr_raw = body.get("oo_profit_rr")
|
||||||
profit_rr = None
|
if rr_raw in (None, ""):
|
||||||
|
rr_raw = body.get("profit_rr")
|
||||||
|
rr = None
|
||||||
if rr_raw not in (None, ""):
|
if rr_raw not in (None, ""):
|
||||||
profit_rr = float(rr_raw)
|
try:
|
||||||
if profit_rr <= 0:
|
rr = float(rr_raw)
|
||||||
raise ValueError("盈亏比须大于0")
|
except (TypeError, ValueError) as e:
|
||||||
up = body.get("target_price_up")
|
raise ValueError("盈亏比无效") from e
|
||||||
down = body.get("target_price_down")
|
if rr <= 0:
|
||||||
legacy = body.get("target_price")
|
raise ValueError("盈亏比须大于 0")
|
||||||
if up in (None, "") and legacy not in (None, ""):
|
|
||||||
up = legacy
|
|
||||||
if down in (None, "") and legacy not in (None, ""):
|
|
||||||
down = legacy
|
|
||||||
if profit_rr is None and (up in (None, "") or down in (None, "")):
|
|
||||||
raise ValueError("请填写盈亏比")
|
|
||||||
up_f = float(up) if up not in (None, "") else None
|
|
||||||
down_f = float(down) if down not in (None, "") else None
|
|
||||||
if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f:
|
|
||||||
raise ValueError("上破目标价必须大于下破目标价")
|
|
||||||
index_px = body.get("index_px")
|
index_px = body.get("index_px")
|
||||||
if index_px in (None, ""):
|
try:
|
||||||
if up_f is not None and down_f is not None:
|
index_px_f = float(index_px) if index_px not in (None, "") else 0.0
|
||||||
index_px = (up_f + down_f) / 2
|
except (TypeError, ValueError):
|
||||||
else:
|
index_px_f = 0.0
|
||||||
raise ValueError("缺少指数价格")
|
|
||||||
index_px = float(index_px)
|
|
||||||
leg_a = body.get("leg_a") or {}
|
leg_a = body.get("leg_a") or {}
|
||||||
leg_b = body.get("leg_b") or {}
|
leg_b = body.get("leg_b") or {}
|
||||||
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
|
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
|
||||||
@@ -1286,14 +1267,38 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
if leg.get("premium_paid") is None:
|
if leg.get("premium_paid") is None:
|
||||||
raise ValueError(f"缺少 {name} 权利金")
|
raise ValueError(f"缺少 {name} 权利金")
|
||||||
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px)
|
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px_f or None)
|
||||||
if money_err:
|
if money_err:
|
||||||
raise ValueError(money_err)
|
raise ValueError(money_err)
|
||||||
|
|
||||||
|
if rr is not None:
|
||||||
|
return build_options_options_preview(
|
||||||
|
profit_rr=rr,
|
||||||
|
index_px=index_px_f,
|
||||||
|
leg_a=leg_a,
|
||||||
|
leg_b=leg_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 兼容旧上破/下破
|
||||||
|
up = body.get("target_price_up")
|
||||||
|
down = body.get("target_price_down")
|
||||||
|
legacy = body.get("target_price")
|
||||||
|
if up in (None, "") and legacy not in (None, ""):
|
||||||
|
up = legacy
|
||||||
|
if down in (None, "") and legacy not in (None, ""):
|
||||||
|
down = legacy
|
||||||
|
if up in (None, "") or down in (None, ""):
|
||||||
|
raise ValueError("请填写盈亏比(相对权利金,默认2)")
|
||||||
|
up_f = float(up)
|
||||||
|
down_f = float(down)
|
||||||
|
if up_f <= down_f:
|
||||||
|
raise ValueError("上破目标价必须大于下破目标价")
|
||||||
|
if index_px_f <= 0:
|
||||||
|
index_px_f = (up_f + down_f) / 2
|
||||||
return build_options_options_preview(
|
return build_options_options_preview(
|
||||||
profit_rr=profit_rr,
|
|
||||||
target_price_up=up_f,
|
target_price_up=up_f,
|
||||||
target_price_down=down_f,
|
target_price_down=down_f,
|
||||||
index_px=index_px,
|
index_px=index_px_f,
|
||||||
leg_a=leg_a,
|
leg_a=leg_a,
|
||||||
leg_b=leg_b,
|
leg_b=leg_b,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
<div class="tip-collapse-body rule-tip">
|
<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>:两腿都在<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>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
||||||
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
|
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(相对权利金,默认 2=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理。</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="form-row hp-uly-row">
|
<div class="form-row hp-uly-row">
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row hp-target-row hp-oo-target-row">
|
<div class="form-row hp-target-row hp-oo-target-row">
|
||||||
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
<label title="目标盈利 = 盈亏比 × 两腿权利金合计;例 2=赚满 2 倍权利金后全平">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-oo-rr" value="2" placeholder="默认2" 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>
|
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hp-oo-controls">
|
<div class="hp-oo-controls">
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
|
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
|
||||||
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
|
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
|
||||||
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
|
<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="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true">✓</span>残值平</button>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -405,4 +405,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/hedge_plan.js?v=46"></script>
|
<script src="/static/hedge_plan.js?v=47"></script>
|
||||||
|
|||||||
@@ -121,43 +121,33 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _format_profit_exit_mult(mult: Any) -> str:
|
|
||||||
try:
|
|
||||||
n = float(mult)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return "1倍"
|
|
||||||
if n <= 0:
|
|
||||||
return "1倍"
|
|
||||||
if abs(n - round(n)) < 1e-9:
|
|
||||||
return f"{int(round(n))}倍"
|
|
||||||
return f"{n:g}倍"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_options_target(p: dict[str, Any]) -> str:
|
def _format_options_target(p: dict[str, Any]) -> str:
|
||||||
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
|
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
|
||||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
|
||||||
if hedge:
|
if hedge:
|
||||||
rr = _safe_float(hedge.get("profit_rr"))
|
rr = _safe_float(hedge.get("oo_profit_rr") or hedge.get("profit_rr"))
|
||||||
pid = hedge.get("plan_id")
|
pid = hedge.get("plan_id")
|
||||||
if rr is not None and rr > 0:
|
if rr is not None and rr > 0:
|
||||||
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}"
|
return f"对冲#{pid} 盈亏比×{rr:g}" if pid is not None else f"盈亏比×{rr:g}"
|
||||||
ot = str(hedge.get("opt_type") or opt_type).upper()
|
ot = str(hedge.get("opt_type") or p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
side = "Put ≤" if ot == "P" else "Call ≥"
|
side = "Put ≤" if ot == "P" else "Call ≥"
|
||||||
tgt = _safe_float(hedge.get("target_index"))
|
tgt = _safe_float(hedge.get("target_index"))
|
||||||
if tgt is not None:
|
if tgt is not None:
|
||||||
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
|
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
|
||||||
parts: list[str] = []
|
mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None
|
||||||
|
rr = _safe_float(p.get("profit_rr"))
|
||||||
|
if rr is None and mon:
|
||||||
|
rr = _safe_float(mon.get("profit_rr"))
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
return f"盈亏比×{rr:g}"
|
||||||
tgt = _safe_float(p.get("target_index"))
|
tgt = _safe_float(p.get("target_index"))
|
||||||
|
if tgt is None and mon:
|
||||||
|
tgt = _safe_float(mon.get("target_index"))
|
||||||
if tgt is not None and tgt > 0:
|
if tgt is not None and tgt > 0:
|
||||||
|
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
||||||
parts.append(f"{side} {tgt:g}")
|
return f"{side} {tgt:g}"
|
||||||
if p.get("profit_exit_enabled"):
|
|
||||||
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
|
|
||||||
if parts:
|
|
||||||
return " · ".join(parts)
|
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
||||||
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
||||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
@@ -370,39 +360,11 @@ def collect_options_items(
|
|||||||
raw = fetch_options_positions() or []
|
raw = fetch_options_positions() or []
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
pe_map: dict[str, dict[str, Any]] = {}
|
|
||||||
tgt_map: dict[str, dict[str, Any]] = {}
|
|
||||||
hedge_map: dict[str, dict[str, Any]] = {}
|
|
||||||
if conn is not None:
|
|
||||||
try:
|
|
||||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
|
||||||
from lib.options.options_target_lib import targets_by_inst
|
|
||||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
|
||||||
|
|
||||||
pe_map = profit_exit_by_inst(conn)
|
|
||||||
tgt_map = targets_by_inst(conn)
|
|
||||||
hedge_map = active_options_targets_by_inst(conn)
|
|
||||||
except Exception:
|
|
||||||
pe_map, tgt_map, hedge_map = {}, {}, {}
|
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for p in raw:
|
for p in raw:
|
||||||
if not isinstance(p, dict):
|
if not isinstance(p, dict):
|
||||||
continue
|
continue
|
||||||
row = dict(p)
|
out.append(_format_options_item(p, conn=conn))
|
||||||
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
|
|
||||||
mon = tgt_map.get(inst)
|
|
||||||
if mon:
|
|
||||||
row["target_index"] = mon.get("target_index")
|
|
||||||
pe = pe_map.get(inst)
|
|
||||||
if pe:
|
|
||||||
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
|
|
||||||
row["profit_exit_mult"] = pe.get("profit_exit_mult")
|
|
||||||
hedge = hedge_map.get(inst)
|
|
||||||
if hedge:
|
|
||||||
row["hedge_plan_target"] = hedge
|
|
||||||
if not mon:
|
|
||||||
row["target_index"] = hedge.get("target_index")
|
|
||||||
out.append(_format_options_item(row, conn=conn))
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=117">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=114">
|
||||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
@@ -170,7 +170,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
|||||||
<script>
|
<script>
|
||||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/instance_settings_prefs.js?v=21"></script>
|
<script src="/static/instance_settings_prefs.js?v=19"></script>
|
||||||
<script src="/static/instance_live.js?v=6"></script>
|
<script src="/static/instance_live.js?v=6"></script>
|
||||||
<script src="/static/instance_embed.js?v=31"></script>
|
<script src="/static/instance_embed.js?v=31"></script>
|
||||||
<script src="/static/instance_mobile_nav.js?v=2"></script>
|
<script src="/static/instance_mobile_nav.js?v=2"></script>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="env-form-grid">
|
<div class="env-form-grid">
|
||||||
{% for field in group.fields %}
|
{% for field in group.fields %}
|
||||||
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}" data-env-key="{{ field.key }}"{% if field.hidden %} hidden style="display:none"{% endif %}>
|
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}">
|
||||||
<label class="env-field-label" for="env-f-{{ field.key }}">
|
<label class="env-field-label" for="env-f-{{ field.key }}">
|
||||||
{{ field.label or field.key }}
|
{{ field.label or field.key }}
|
||||||
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
|
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||||
<title>{{ pwa_app_name }}</title>
|
<title>{{ pwa_app_name }}</title>
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=117">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=114">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -2045,6 +2045,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/instance_settings_prefs.js?v=21"></script>
|
<script src="/static/instance_settings_prefs.js?v=19"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -72,11 +72,14 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
|
|||||||
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||||
if mon:
|
if mon:
|
||||||
row["target_index"] = mon.get("target_index")
|
row["target_index"] = mon.get("target_index")
|
||||||
|
row["profit_rr"] = mon.get("profit_rr")
|
||||||
row["target_monitor_id"] = mon.get("id")
|
row["target_monitor_id"] = mon.get("id")
|
||||||
row["target_monitor"] = mon
|
row["target_monitor"] = mon
|
||||||
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
||||||
if hedge_target:
|
if hedge_target:
|
||||||
row["hedge_plan_target"] = hedge_target
|
row["hedge_plan_target"] = hedge_target
|
||||||
|
if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None:
|
||||||
|
row["profit_rr"] = hedge_target.get("oo_profit_rr")
|
||||||
if not mon:
|
if not mon:
|
||||||
row["target_index"] = hedge_target.get("target_index")
|
row["target_index"] = hedge_target.get("target_index")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
|||||||
for ddl in (
|
for ddl in (
|
||||||
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
|
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
|
||||||
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
|
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
|
||||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
|
|
||||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
|
|
||||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
conn.execute(ddl)
|
conn.execute(ddl)
|
||||||
|
|||||||
@@ -22,48 +22,32 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
|
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
|
||||||
positions = build_display_option_positions(cfg, ex, raw)
|
# 中控看板不拉逐仓 books(易超 HUB_FLASK_TIMEOUT);实例页仍走完整 preview
|
||||||
|
positions = build_display_option_positions(cfg, ex, raw, with_close_preview=False)
|
||||||
target_monitors: list[dict[str, Any]] = []
|
target_monitors: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
|
||||||
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
|
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
|
||||||
|
|
||||||
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
|
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
|
||||||
tgt_map = targets_by_inst(conn)
|
tgt_map = targets_by_inst(conn)
|
||||||
hedge_target_map = active_options_targets_by_inst(conn)
|
hedge_target_map = active_options_targets_by_inst(conn)
|
||||||
profit_exit_map = profit_exit_by_inst(conn)
|
|
||||||
target_monitors.extend(hedge_target_map.values())
|
target_monitors.extend(hedge_target_map.values())
|
||||||
for pe in profit_exit_map.values():
|
|
||||||
if pe.get("profit_exit_enabled"):
|
|
||||||
target_monitors.append(
|
|
||||||
{
|
|
||||||
"inst_id": pe.get("inst_id"),
|
|
||||||
"exit_mode": "profit_exit",
|
|
||||||
"profit_exit_mult": pe.get("profit_exit_mult"),
|
|
||||||
"profit_exit_enabled": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for p in positions:
|
for p in positions:
|
||||||
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
||||||
if mon:
|
if mon:
|
||||||
p["target_index"] = mon.get("target_index")
|
p["target_index"] = mon.get("target_index")
|
||||||
|
p["profit_rr"] = mon.get("profit_rr")
|
||||||
p["target_monitor_id"] = mon.get("id")
|
p["target_monitor_id"] = mon.get("id")
|
||||||
p["target_monitor"] = mon
|
p["target_monitor"] = mon
|
||||||
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
|
|
||||||
if pe:
|
|
||||||
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
|
|
||||||
p["profit_exit_mult"] = pe.get("profit_exit_mult")
|
|
||||||
p["profit_exit_state"] = pe.get("profit_exit_state")
|
|
||||||
p["profit_exit_required_recycle"] = pe.get("required_recycle")
|
|
||||||
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
||||||
if hedge_target:
|
if hedge_target:
|
||||||
p["hedge_plan_target"] = hedge_target
|
p["hedge_plan_target"] = hedge_target
|
||||||
if not mon:
|
if not mon:
|
||||||
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
|
|
||||||
p["target_index"] = hedge_target.get("target_index")
|
p["target_index"] = hedge_target.get("target_index")
|
||||||
|
p["profit_rr"] = hedge_target.get("oo_profit_rr")
|
||||||
try:
|
try:
|
||||||
from lib.instance.instance_dashboard_lib import (
|
from lib.instance.instance_dashboard_lib import (
|
||||||
_format_options_target,
|
_format_options_target,
|
||||||
|
|||||||
@@ -428,8 +428,6 @@ def options_monitor_loop(
|
|||||||
profit_ratio: float,
|
profit_ratio: float,
|
||||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||||
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
||||||
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
|
||||||
profit_exit_cfg: dict[str, Any] | None = None,
|
|
||||||
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
|
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
|
||||||
stop_event: Any = None,
|
stop_event: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -457,25 +455,11 @@ def options_monitor_loop(
|
|||||||
conn,
|
conn,
|
||||||
positions,
|
positions,
|
||||||
close_fn=target_close_fn,
|
close_fn=target_close_fn,
|
||||||
|
bid_fn=ticker_bid_fn,
|
||||||
send_wechat=send_wechat,
|
send_wechat=send_wechat,
|
||||||
account_label=account_label,
|
account_label=account_label,
|
||||||
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
||||||
)
|
)
|
||||||
if profit_exit_close_fn is not None:
|
|
||||||
from lib.options.options_profit_exit_lib import run_options_profit_exits
|
|
||||||
|
|
||||||
pe_cfg = dict(profit_exit_cfg or {})
|
|
||||||
pe_cfg.setdefault("send_wechat", send_wechat)
|
|
||||||
pe_cfg.setdefault("account_label", account_label)
|
|
||||||
run_options_profit_exits(
|
|
||||||
conn,
|
|
||||||
positions,
|
|
||||||
close_fn=profit_exit_close_fn,
|
|
||||||
send_wechat=send_wechat,
|
|
||||||
account_label=account_label,
|
|
||||||
cfg=pe_cfg,
|
|
||||||
ex=pe_cfg.get("exchange_options"),
|
|
||||||
)
|
|
||||||
if sync_trades_fn is not None:
|
if sync_trades_fn is not None:
|
||||||
sync_trades_fn(conn)
|
sync_trades_fn(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def build_options_open_message(
|
|||||||
premium_paid: Any = None,
|
premium_paid: Any = None,
|
||||||
open_quote: Any = None,
|
open_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
signal_note: str = "",
|
signal_note: str = "",
|
||||||
trade_id: Any = None,
|
trade_id: Any = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -73,7 +74,12 @@ def build_options_open_message(
|
|||||||
f"权利金:{_fmt(premium_paid)} USDC",
|
f"权利金:{_fmt(premium_paid)} USDC",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if target_index is not None and str(target_index).strip() != "":
|
if profit_rr is not None and str(profit_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
lines.append(f"盈亏比:{profit_rr}")
|
||||||
|
elif target_index is not None and str(target_index).strip() != "":
|
||||||
try:
|
try:
|
||||||
lines.append(f"目标指数:{float(target_index):g}")
|
lines.append(f"目标指数:{float(target_index):g}")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -96,6 +102,7 @@ def build_options_close_message(
|
|||||||
realized_pnl: Any = None,
|
realized_pnl: Any = None,
|
||||||
close_quote: Any = None,
|
close_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
trigger_idx: Any = None,
|
trigger_idx: Any = None,
|
||||||
trade_id: Any = None,
|
trade_id: Any = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -116,7 +123,12 @@ def build_options_close_message(
|
|||||||
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
|
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if target_index is not None and str(target_index).strip() != "":
|
if profit_rr is not None and str(profit_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
lines.append(f"盈亏比:×{float(profit_rr):g}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
lines.append(f"盈亏比:{profit_rr}")
|
||||||
|
elif target_index is not None and str(target_index).strip() != "":
|
||||||
try:
|
try:
|
||||||
lines.append(f"目标指数:{float(target_index):g}")
|
lines.append(f"目标指数:{float(target_index):g}")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -141,6 +153,7 @@ def notify_options_open(
|
|||||||
premium_paid: Any = None,
|
premium_paid: Any = None,
|
||||||
open_quote: Any = None,
|
open_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
signal_note: str = "",
|
signal_note: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
ensure_options_notify_columns(conn) if conn is not None else None
|
ensure_options_notify_columns(conn) if conn is not None else None
|
||||||
@@ -160,6 +173,7 @@ def notify_options_open(
|
|||||||
premium_paid=premium_paid,
|
premium_paid=premium_paid,
|
||||||
open_quote=open_quote,
|
open_quote=open_quote,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
signal_note=signal_note,
|
signal_note=signal_note,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
)
|
)
|
||||||
@@ -196,6 +210,7 @@ def notify_options_close(
|
|||||||
realized_pnl: Any = None,
|
realized_pnl: Any = None,
|
||||||
close_quote: Any = None,
|
close_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
trigger_idx: Any = None,
|
trigger_idx: Any = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -257,6 +272,7 @@ def notify_options_close(
|
|||||||
realized_pnl=total_pnl,
|
realized_pnl=total_pnl,
|
||||||
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
|
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
trigger_idx=trigger_idx,
|
trigger_idx=trigger_idx,
|
||||||
trade_id=head.get("id") if len(rows) == 1 else None,
|
trade_id=head.get("id") if len(rows) == 1 else None,
|
||||||
)
|
)
|
||||||
@@ -286,6 +302,7 @@ def notify_options_close(
|
|||||||
realized_pnl=realized_pnl,
|
realized_pnl=realized_pnl,
|
||||||
close_quote=close_quote,
|
close_quote=close_quote,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
trigger_idx=trigger_idx,
|
trigger_idx=trigger_idx,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,26 +119,3 @@ def option_position_limit_block_msg(
|
|||||||
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
|
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
|
||||||
)
|
)
|
||||||
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
|
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
|
||||||
|
|
||||||
|
|
||||||
def compound_full_single_position_block_msg(
|
|
||||||
ex: Any,
|
|
||||||
*,
|
|
||||||
fetch_positions=None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
|
|
||||||
fetch = fetch_positions
|
|
||||||
if fetch is None:
|
|
||||||
from lib.exchange.okx_options_lib import fetch_option_positions
|
|
||||||
|
|
||||||
fetch = fetch_option_positions
|
|
||||||
try:
|
|
||||||
rows = fetch(ex)
|
|
||||||
except Exception:
|
|
||||||
rows = None
|
|
||||||
if rows is None:
|
|
||||||
return "无法获取期权持仓,全仓复利模式暂不可开仓"
|
|
||||||
active = count_live_option_positions(rows)
|
|
||||||
if active >= 1:
|
|
||||||
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -145,8 +145,10 @@ def build_display_option_positions(
|
|||||||
cfg: dict[str, Any],
|
cfg: dict[str, Any],
|
||||||
ex: Any,
|
ex: Any,
|
||||||
raw_positions: list[dict[str, Any]],
|
raw_positions: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
with_close_preview: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""与实例 /api/options/positions 相同 enrichment + close_preview."""
|
"""与实例 /api/options/positions 相同 enrichment;中控可关 close_preview 避免逐仓拉盘口超时."""
|
||||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
@@ -162,7 +164,8 @@ def build_display_option_positions(
|
|||||||
meta_cache=meta_cache,
|
meta_cache=meta_cache,
|
||||||
premium_override=premium_override,
|
premium_override=premium_override,
|
||||||
)
|
)
|
||||||
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
if with_close_preview:
|
||||||
|
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -264,25 +264,6 @@ def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> f
|
|||||||
return min(float(trading_usdc), float(trade_budget_usdc))
|
return min(float(trading_usdc), float(trade_budget_usdc))
|
||||||
|
|
||||||
|
|
||||||
def resolve_compound_full_usdc(
|
|
||||||
trading_usdc: float,
|
|
||||||
*,
|
|
||||||
cap_enabled: bool = False,
|
|
||||||
cap_usdc: float | None = None,
|
|
||||||
) -> float:
|
|
||||||
"""全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶."""
|
|
||||||
bal = max(0.0, float(trading_usdc or 0))
|
|
||||||
if not cap_enabled:
|
|
||||||
return bal
|
|
||||||
try:
|
|
||||||
cap = float(cap_usdc) if cap_usdc is not None else 0.0
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
cap = 0.0
|
|
||||||
if cap <= 0:
|
|
||||||
return bal
|
|
||||||
return min(bal, cap)
|
|
||||||
|
|
||||||
|
|
||||||
def calc_order_size(
|
def calc_order_size(
|
||||||
*,
|
*,
|
||||||
quote_per_unit: float,
|
quote_per_unit: float,
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
|
|
||||||
|
|
||||||
1 倍 = 盈利金额等于初始权利金 ⇒ 买一可回收 ≥ 权利金 × (1 + 倍数).
|
|
||||||
与「目标位」并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: Any) -> float | None:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_profit_exit_columns(conn: sqlite3.Connection) -> None:
|
|
||||||
init_options_tables(conn)
|
|
||||||
for ddl in (
|
|
||||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
|
|
||||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
|
|
||||||
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
conn.execute(ddl)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float:
|
|
||||||
try:
|
|
||||||
mult = float(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
mult = float(default)
|
|
||||||
if mult <= 0:
|
|
||||||
mult = float(default)
|
|
||||||
return round(mult, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def profit_exit_hit(
|
|
||||||
*,
|
|
||||||
premium_paid: float,
|
|
||||||
recycle_usdc: float,
|
|
||||||
mult: float,
|
|
||||||
) -> bool:
|
|
||||||
"""1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult)."""
|
|
||||||
prem = float(premium_paid or 0)
|
|
||||||
recv = float(recycle_usdc or 0)
|
|
||||||
m = float(mult or 0)
|
|
||||||
if prem <= 0 or m <= 0 or recv <= 0:
|
|
||||||
return False
|
|
||||||
return recv + 1e-9 >= prem * (1.0 + m)
|
|
||||||
|
|
||||||
|
|
||||||
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
|
|
||||||
prem = float(premium_paid or 0)
|
|
||||||
m = float(mult or 0)
|
|
||||||
if prem <= 0 or m <= 0:
|
|
||||||
return None
|
|
||||||
return round(prem * (1.0 + m), 4)
|
|
||||||
|
|
||||||
|
|
||||||
def set_profit_exit(
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
*,
|
|
||||||
inst_id: str,
|
|
||||||
enabled: bool,
|
|
||||||
mult: float | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
ensure_profit_exit_columns(conn)
|
|
||||||
inst = (inst_id or "").strip()
|
|
||||||
if not inst:
|
|
||||||
return {"ok": False, "msg": "缺少 inst_id"}
|
|
||||||
m = normalize_profit_exit_mult(mult if mult is not None else 1.0)
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT id FROM options_trades
|
|
||||||
WHERE inst_id = ? AND status = 'open'
|
|
||||||
""",
|
|
||||||
(inst,),
|
|
||||||
).fetchall()
|
|
||||||
if not rows:
|
|
||||||
return {"ok": False, "msg": "未找到该合约的本地开仓记录"}
|
|
||||||
if enabled:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE options_trades
|
|
||||||
SET profit_exit_enabled = 1,
|
|
||||||
profit_exit_mult = ?,
|
|
||||||
profit_exit_state = 'active'
|
|
||||||
WHERE inst_id = ? AND status = 'open'
|
|
||||||
""",
|
|
||||||
(m, inst),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE options_trades
|
|
||||||
SET profit_exit_enabled = 0,
|
|
||||||
profit_exit_state = 'idle'
|
|
||||||
WHERE inst_id = ? AND status = 'open'
|
|
||||||
""",
|
|
||||||
(inst,),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"inst_id": inst,
|
|
||||||
"profit_exit_enabled": bool(enabled),
|
|
||||||
"profit_exit_mult": m if enabled else None,
|
|
||||||
"updated": len(rows),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
|
||||||
"""进行中(active/closing)的翻倍出场,按合约取最新一条规则."""
|
|
||||||
ensure_profit_exit_columns(conn)
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state
|
|
||||||
FROM options_trades
|
|
||||||
WHERE status = 'open'
|
|
||||||
AND (
|
|
||||||
CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1
|
|
||||||
OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing')
|
|
||||||
)
|
|
||||||
ORDER BY id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
out: dict[str, dict[str, Any]] = {}
|
|
||||||
for r in rows:
|
|
||||||
inst = str(r["inst_id"] or "").strip()
|
|
||||||
if not inst or inst in out:
|
|
||||||
continue
|
|
||||||
enabled = int(r["profit_exit_enabled"] or 0) == 1
|
|
||||||
state = str(r["profit_exit_state"] or "idle")
|
|
||||||
if not enabled and state not in ("active", "closing"):
|
|
||||||
continue
|
|
||||||
mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0)
|
|
||||||
out[inst] = {
|
|
||||||
"inst_id": inst,
|
|
||||||
"profit_exit_enabled": enabled or state in ("active", "closing"),
|
|
||||||
"profit_exit_mult": mult,
|
|
||||||
"profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"),
|
|
||||||
"required_recycle": None,
|
|
||||||
}
|
|
||||||
for inst, info in out.items():
|
|
||||||
prem = sum_open_premium_paid(conn, inst)
|
|
||||||
if prem is not None:
|
|
||||||
info["premium_paid"] = prem
|
|
||||||
info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"]))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE options_trades
|
|
||||||
SET profit_exit_state = ?
|
|
||||||
WHERE inst_id = ? AND status = 'open'
|
|
||||||
""",
|
|
||||||
(state, inst_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _commit(conn: sqlite3.Connection) -> None:
|
|
||||||
try:
|
|
||||||
conn.commit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _result_fully_done(result: dict[str, Any]) -> bool:
|
|
||||||
if result.get("already_flat"):
|
|
||||||
return True
|
|
||||||
if result.get("fully_closed"):
|
|
||||||
return True
|
|
||||||
remaining = result.get("remaining_sheets")
|
|
||||||
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def close_option_by_bid_profit_exit(
|
|
||||||
cfg: dict[str, Any],
|
|
||||||
ex: Any,
|
|
||||||
inst_id: str,
|
|
||||||
*,
|
|
||||||
sheets: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
from lib.options.options_close_exec_lib import close_option_by_bid1
|
|
||||||
|
|
||||||
return close_option_by_bid1(
|
|
||||||
cfg,
|
|
||||||
ex,
|
|
||||||
inst_id,
|
|
||||||
sheets=sheets,
|
|
||||||
require_recycle_gate=False,
|
|
||||||
signal_note="翻倍出场",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _estimate_recycle(
|
|
||||||
cfg: dict[str, Any],
|
|
||||||
ex: Any,
|
|
||||||
pos: dict[str, Any],
|
|
||||||
premium_paid: float | None,
|
|
||||||
) -> float | None:
|
|
||||||
from lib.options.options_positions_lib import attach_close_preview
|
|
||||||
|
|
||||||
row = dict(pos)
|
|
||||||
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
|
|
||||||
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
|
||||||
if preview.get("bid_invalid"):
|
|
||||||
return None
|
|
||||||
return _safe_float(preview.get("total_received"))
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_profit_exit_close(
|
|
||||||
cfg: dict[str, Any] | None,
|
|
||||||
send_wechat: Callable[[str], None] | None,
|
|
||||||
*,
|
|
||||||
account_label: str,
|
|
||||||
inst_id: str,
|
|
||||||
mult: float,
|
|
||||||
premium_paid: float | None,
|
|
||||||
recycle: float | None,
|
|
||||||
result: dict[str, Any],
|
|
||||||
conn: Any = None,
|
|
||||||
) -> None:
|
|
||||||
if result.get("fully_closed") or result.get("already_flat"):
|
|
||||||
if cfg is not None:
|
|
||||||
try:
|
|
||||||
from lib.options.options_notify_lib import notify_options_close
|
|
||||||
|
|
||||||
notify_options_close(
|
|
||||||
cfg,
|
|
||||||
conn,
|
|
||||||
inst_id=inst_id,
|
|
||||||
reason=f"翻倍出场({mult:g}倍)",
|
|
||||||
sheets=result.get("submitted_sheets"),
|
|
||||||
premium_received=result.get("premium_received"),
|
|
||||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not send_wechat:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
send_wechat(
|
|
||||||
"\n".join(
|
|
||||||
[
|
|
||||||
"【OKX期权·翻倍出场】",
|
|
||||||
f"账户:{account_label}",
|
|
||||||
f"合约:{inst_id}",
|
|
||||||
f"倍数:{mult:g}(1倍=盈利=权利金)",
|
|
||||||
f"权利金:{premium_paid if premium_paid is not None else '—'}",
|
|
||||||
f"可回收:{recycle if recycle is not None else '—'}",
|
|
||||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
|
||||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def run_options_profit_exits(
|
|
||||||
conn: sqlite3.Connection,
|
|
||||||
positions: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
close_fn: Callable[[str], dict[str, Any]],
|
|
||||||
recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None,
|
|
||||||
send_wechat: Callable[[str], None] | None = None,
|
|
||||||
account_label: str = "OKX期权",
|
|
||||||
cfg: dict[str, Any] | None = None,
|
|
||||||
ex: Any = None,
|
|
||||||
) -> int:
|
|
||||||
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
|
|
||||||
ensure_profit_exit_columns(conn)
|
|
||||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
|
||||||
hedge_managed: set[str] = set()
|
|
||||||
try:
|
|
||||||
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
|
|
||||||
|
|
||||||
init_hedge_plan_tables(conn)
|
|
||||||
hedge_managed = active_hedge_option_inst_ids(conn)
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
rules = profit_exit_by_inst(conn)
|
|
||||||
triggered = 0
|
|
||||||
|
|
||||||
for inst_id, info in list(rules.items()):
|
|
||||||
if not inst_id:
|
|
||||||
continue
|
|
||||||
if inst_id in hedge_managed:
|
|
||||||
_mark_state(conn, inst_id, "idle")
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE options_trades
|
|
||||||
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
|
|
||||||
WHERE inst_id = ? AND status = 'open'
|
|
||||||
""",
|
|
||||||
(inst_id,),
|
|
||||||
)
|
|
||||||
_commit(conn)
|
|
||||||
continue
|
|
||||||
pos = pos_by_inst.get(inst_id)
|
|
||||||
if not pos:
|
|
||||||
# 持仓已平:收尾
|
|
||||||
_mark_state(conn, inst_id, "done")
|
|
||||||
_commit(conn)
|
|
||||||
continue
|
|
||||||
|
|
||||||
state = str(info.get("profit_exit_state") or "active")
|
|
||||||
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
|
|
||||||
prem = sum_open_premium_paid(conn, inst_id)
|
|
||||||
if prem is None or prem <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if state == "closing":
|
|
||||||
result = close_fn(inst_id)
|
|
||||||
if result.get("already_flat") or _result_fully_done(result):
|
|
||||||
_mark_state(conn, inst_id, "done")
|
|
||||||
_commit(conn)
|
|
||||||
else:
|
|
||||||
_mark_state(conn, inst_id, "closing")
|
|
||||||
_commit(conn)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not info.get("profit_exit_enabled"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if recycle_fn is not None:
|
|
||||||
recycle = recycle_fn(pos, prem)
|
|
||||||
elif cfg is not None and ex is not None:
|
|
||||||
recycle = _estimate_recycle(cfg, ex, pos, prem)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if recycle is None:
|
|
||||||
continue
|
|
||||||
if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = close_fn(inst_id)
|
|
||||||
if result.get("already_flat"):
|
|
||||||
_mark_state(conn, inst_id, "done")
|
|
||||||
_commit(conn)
|
|
||||||
continue
|
|
||||||
if not result.get("ok"):
|
|
||||||
_mark_state(conn, inst_id, "active")
|
|
||||||
_commit(conn)
|
|
||||||
continue
|
|
||||||
|
|
||||||
done = _result_fully_done(result)
|
|
||||||
_mark_state(conn, inst_id, "done" if done else "closing")
|
|
||||||
_commit(conn)
|
|
||||||
triggered += 1
|
|
||||||
_notify_profit_exit_close(
|
|
||||||
cfg,
|
|
||||||
send_wechat,
|
|
||||||
account_label=account_label,
|
|
||||||
inst_id=inst_id,
|
|
||||||
mult=mult,
|
|
||||||
premium_paid=prem,
|
|
||||||
recycle=recycle,
|
|
||||||
result=result,
|
|
||||||
conn=conn,
|
|
||||||
)
|
|
||||||
return triggered
|
|
||||||
+75
-305
@@ -103,9 +103,6 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
|
|||||||
"render_main_page": app_module.render_main_page,
|
"render_main_page": app_module.render_main_page,
|
||||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
|
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
|
||||||
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
|
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
|
||||||
"compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True),
|
|
||||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
|
||||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
|
||||||
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
||||||
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
|
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
|
||||||
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
|
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
|
||||||
@@ -177,69 +174,6 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
|||||||
return resolve_budget_full_usdc(trading, float(cap)), ""
|
return resolve_budget_full_usdc(trading, float(cap)), ""
|
||||||
|
|
||||||
|
|
||||||
def _compound_full_enabled() -> bool:
|
|
||||||
return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True)
|
|
||||||
|
|
||||||
|
|
||||||
def _budget_full_blocked_by_compound_msg() -> str | None:
|
|
||||||
if _compound_full_enabled():
|
|
||||||
return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _size_mode_budget_cap(
|
|
||||||
cfg: dict[str, Any], mode: str, budget_cap: float | None
|
|
||||||
) -> float | None:
|
|
||||||
"""全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制)."""
|
|
||||||
if mode in ("budget_full", "compound_full"):
|
|
||||||
return budget_cap
|
|
||||||
if mode in ("sheets", "eth_amount"):
|
|
||||||
if _compound_full_enabled():
|
|
||||||
return None
|
|
||||||
return budget_cap
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_size_mode(mode: str) -> tuple[str, str | None]:
|
|
||||||
"""全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓."""
|
|
||||||
m = (mode or "sheets").strip() or "sheets"
|
|
||||||
if m == "compound_full" and not _compound_full_enabled():
|
|
||||||
return "sheets", "全仓复利已关闭,已改用指定张数"
|
|
||||||
if m == "budget_full" and _compound_full_enabled():
|
|
||||||
return "compound_full", None
|
|
||||||
return m, None
|
|
||||||
|
|
||||||
|
|
||||||
def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
|
||||||
"""全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer."""
|
|
||||||
if not _compound_full_enabled():
|
|
||||||
return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)"
|
|
||||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
|
||||||
from lib.options.options_pricing_lib import resolve_compound_full_usdc
|
|
||||||
|
|
||||||
raw = fetch_options_trading_usdc(ex)
|
|
||||||
if raw is None or float(raw) <= 0:
|
|
||||||
return None, "交易账户 USDC 可用余额不足"
|
|
||||||
trading = float(raw)
|
|
||||||
# 额度热更读 env(与模板启动值无关)
|
|
||||||
cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False)
|
|
||||||
cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0)
|
|
||||||
if cap_on and cap_v <= 0:
|
|
||||||
return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)"
|
|
||||||
return (
|
|
||||||
resolve_compound_full_usdc(
|
|
||||||
trading,
|
|
||||||
cap_enabled=cap_on,
|
|
||||||
cap_usdc=cap_v,
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_budget_mode(mode: str) -> bool:
|
|
||||||
return mode in ("budget_full", "compound_full")
|
|
||||||
|
|
||||||
|
|
||||||
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
|
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
@@ -421,16 +355,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
return jsonify({"ok": False, "msg": err})
|
return jsonify({"ok": False, "msg": err})
|
||||||
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||||
bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
|
bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
|
||||||
return jsonify(
|
return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
**bal,
|
|
||||||
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
|
|
||||||
"compound_full_enabled": _compound_full_enabled(),
|
|
||||||
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
|
|
||||||
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/options/chain")
|
@app.route("/api/options/chain")
|
||||||
@lr
|
@lr
|
||||||
@@ -494,7 +419,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
ask = q.get("ask")
|
ask = q.get("ask")
|
||||||
ct_mult = q.get("ct_mult") or 0.01
|
ct_mult = q.get("ct_mult") or 0.01
|
||||||
min_sz = q.get("min_sz") or 1
|
min_sz = q.get("min_sz") or 1
|
||||||
mode = (request.args.get("mode") or "sheets").strip()
|
mode = (request.args.get("mode") or "budget_full").strip()
|
||||||
sheet_count = None
|
sheet_count = None
|
||||||
try:
|
try:
|
||||||
if request.args.get("sheets"):
|
if request.args.get("sheets"):
|
||||||
@@ -505,45 +430,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
paid = _open_premium_paid(cfg, inst_id)
|
paid = _open_premium_paid(cfg, inst_id)
|
||||||
target = sheet_count if sheet_count is not None else 0
|
target = sheet_count if sheet_count is not None else 0
|
||||||
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
|
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
|
||||||
mode, mode_note = _normalize_size_mode(mode)
|
|
||||||
budget = cfg["trade_budget"]
|
budget = cfg["trade_budget"]
|
||||||
budget_cap = cfg["trade_budget"]
|
budget_cap = cfg["trade_budget"]
|
||||||
available_usdc = None
|
available_usdc = None
|
||||||
if mode == "budget_full":
|
if mode == "budget_full":
|
||||||
blocked = _budget_full_blocked_by_compound_msg()
|
|
||||||
if blocked:
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"ok": False,
|
|
||||||
"msg": blocked,
|
|
||||||
"compound_full_enabled": _compound_full_enabled(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
budget, budget_err = _budget_full_usdc(cfg, ex)
|
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||||
if budget is None:
|
if budget is None:
|
||||||
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()})
|
return jsonify({"ok": False, "msg": budget_err})
|
||||||
budget_cap = budget
|
budget_cap = budget
|
||||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||||
|
|
||||||
available_usdc = fetch_options_trading_usdc(ex)
|
available_usdc = fetch_options_trading_usdc(ex)
|
||||||
elif mode == "compound_full":
|
|
||||||
if not _compound_full_enabled():
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"ok": False,
|
|
||||||
"msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)",
|
|
||||||
"compound_full_enabled": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
budget, budget_err = _compound_full_usdc(cfg, ex)
|
|
||||||
if budget is None:
|
|
||||||
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True})
|
|
||||||
budget_cap = budget
|
|
||||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
|
||||||
|
|
||||||
available_usdc = fetch_options_trading_usdc(ex)
|
|
||||||
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
|
|
||||||
budget_cap = None
|
|
||||||
eth_amount = None
|
eth_amount = None
|
||||||
try:
|
try:
|
||||||
if request.args.get("eth_amount"):
|
if request.args.get("eth_amount"):
|
||||||
@@ -574,7 +471,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
},
|
},
|
||||||
"available_usdc": available_usdc,
|
"available_usdc": available_usdc,
|
||||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -611,7 +507,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
},
|
},
|
||||||
"available_usdc": available_usdc,
|
"available_usdc": available_usdc,
|
||||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -636,39 +531,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
},
|
},
|
||||||
"available_usdc": available_usdc,
|
"available_usdc": available_usdc,
|
||||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
from lib.options.options_position_limit_lib import (
|
from lib.options.options_position_limit_lib import option_position_limit_block_msg
|
||||||
compound_full_single_position_block_msg,
|
|
||||||
option_position_limit_block_msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode == "compound_full":
|
|
||||||
compound_block = compound_full_single_position_block_msg(
|
|
||||||
ex, fetch_positions=cfg.get("fetch_option_positions")
|
|
||||||
)
|
|
||||||
if compound_block:
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
**q,
|
|
||||||
"ok": True,
|
|
||||||
"can_open": False,
|
|
||||||
"msg": compound_block,
|
|
||||||
"quote_per_unit": ask,
|
|
||||||
"premium_per_sheet": None,
|
|
||||||
"sizing": {
|
|
||||||
"ok": False,
|
|
||||||
"msg": compound_block,
|
|
||||||
"sheets": 0,
|
|
||||||
"eth_amount": 0.0,
|
|
||||||
"total_premium": 0.0,
|
|
||||||
},
|
|
||||||
"available_usdc": available_usdc,
|
|
||||||
"budget_full_usdc": None,
|
|
||||||
"compound_full_usdc": budget,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
pos_limit_msg = option_position_limit_block_msg(
|
pos_limit_msg = option_position_limit_block_msg(
|
||||||
ex,
|
ex,
|
||||||
@@ -693,20 +558,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
},
|
},
|
||||||
"available_usdc": available_usdc,
|
"available_usdc": available_usdc,
|
||||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
sizing = calc_order_size(
|
sizing = calc_order_size(
|
||||||
quote_per_unit=float(ask),
|
quote_per_unit=float(ask),
|
||||||
ct_mult=float(ct_mult),
|
ct_mult=float(ct_mult),
|
||||||
min_sz=int(min_sz),
|
min_sz=int(min_sz),
|
||||||
budget_usdc=budget if _is_budget_mode(mode) else None,
|
budget_usdc=budget if mode == "budget_full" else None,
|
||||||
budget_buffer=cfg["budget_buffer"],
|
budget_buffer=cfg["budget_buffer"],
|
||||||
eth_amount=eth_amount if mode == "eth_amount" else None,
|
eth_amount=eth_amount if mode == "eth_amount" else None,
|
||||||
sheets=sheet_count if mode == "sheets" else None,
|
sheets=sheet_count if mode == "sheets" else None,
|
||||||
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
if sizing.get("ok"):
|
if sizing.get("ok"):
|
||||||
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
|
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
|
||||||
@@ -728,9 +590,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
ct_mult=float(ct_mult),
|
ct_mult=float(ct_mult),
|
||||||
min_sz=int(min_sz),
|
min_sz=int(min_sz),
|
||||||
sheets=capped,
|
sheets=capped,
|
||||||
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
if sizing.get("ok"):
|
if sizing.get("ok"):
|
||||||
sizing["ask_depth_capped"] = True
|
sizing["ask_depth_capped"] = True
|
||||||
@@ -752,10 +612,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"sizing": sizing,
|
"sizing": sizing,
|
||||||
"available_usdc": available_usdc,
|
"available_usdc": available_usdc,
|
||||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
"compound_full_usdc": budget if mode == "compound_full" else None,
|
|
||||||
"mode": mode,
|
|
||||||
"mode_note": mode_note,
|
|
||||||
"compound_full_enabled": _compound_full_enabled(),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -787,13 +643,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"})
|
return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"})
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
inst_id = (data.get("inst_id") or "").strip()
|
inst_id = (data.get("inst_id") or "").strip()
|
||||||
mode = (data.get("mode") or "sheets").strip()
|
mode = (data.get("mode") or "budget_full").strip()
|
||||||
mode, mode_note = _normalize_size_mode(mode)
|
|
||||||
signal_note = (data.get("signal_note") or "").strip()
|
signal_note = (data.get("signal_note") or "").strip()
|
||||||
if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full":
|
|
||||||
# 前端残留全仓复利选中时,已自动改指定张数;继续开仓
|
|
||||||
pass
|
|
||||||
target_index = None
|
target_index = None
|
||||||
|
profit_rr = None
|
||||||
|
raw_rr = data.get("profit_rr")
|
||||||
|
if raw_rr is None or str(raw_rr).strip() == "":
|
||||||
|
raw_rr = data.get("oo_profit_rr")
|
||||||
|
if raw_rr is not None and str(raw_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
profit_rr = float(raw_rr)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比无效"})
|
||||||
|
if profit_rr <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
|
||||||
raw_target = data.get("target_index")
|
raw_target = data.get("target_index")
|
||||||
if raw_target is not None and str(raw_target).strip() != "":
|
if raw_target is not None and str(raw_target).strip() != "":
|
||||||
try:
|
try:
|
||||||
@@ -802,12 +665,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
if target_index <= 0:
|
if target_index <= 0:
|
||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
profit_exit_enabled = bool(data.get("profit_exit_enabled"))
|
# 未显式传目标时默认盈亏比 2
|
||||||
profit_exit_mult = 1.0
|
if profit_rr is None and target_index is None:
|
||||||
if profit_exit_enabled:
|
profit_rr = 2.0
|
||||||
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
|
|
||||||
|
|
||||||
profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
|
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||||
q = cfg["quote_option_contract"](ex, inst_id)
|
q = cfg["quote_option_contract"](ex, inst_id)
|
||||||
@@ -826,17 +686,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"ref_ask": q.get("ref_ask"),
|
"ref_ask": q.get("ref_ask"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
from lib.options.options_position_limit_lib import (
|
from lib.options.options_position_limit_lib import option_position_limit_block_msg
|
||||||
compound_full_single_position_block_msg,
|
|
||||||
option_position_limit_block_msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode == "compound_full":
|
|
||||||
compound_block = compound_full_single_position_block_msg(
|
|
||||||
ex, fetch_positions=cfg.get("fetch_option_positions")
|
|
||||||
)
|
|
||||||
if compound_block:
|
|
||||||
return jsonify({"ok": False, "msg": compound_block, "can_open": False})
|
|
||||||
|
|
||||||
pos_limit_msg = option_position_limit_block_msg(
|
pos_limit_msg = option_position_limit_block_msg(
|
||||||
ex,
|
ex,
|
||||||
@@ -858,49 +708,23 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
try:
|
try:
|
||||||
sheet_count = int(data.get("sheets"))
|
sheet_count = int(data.get("sheets"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
sheet_count = None
|
return jsonify({"ok": False, "msg": "张数无效"})
|
||||||
if sheet_count is None or int(sheet_count) < 1:
|
|
||||||
# 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1
|
|
||||||
if (data.get("mode") or "").strip() == "compound_full":
|
|
||||||
sheet_count = 1
|
|
||||||
else:
|
|
||||||
return jsonify({"ok": False, "msg": "张数无效"})
|
|
||||||
budget = cfg["trade_budget"]
|
budget = cfg["trade_budget"]
|
||||||
budget_cap = cfg["trade_budget"]
|
budget_cap = cfg["trade_budget"]
|
||||||
if mode == "budget_full":
|
if mode == "budget_full":
|
||||||
blocked = _budget_full_blocked_by_compound_msg()
|
|
||||||
if blocked:
|
|
||||||
return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()})
|
|
||||||
budget, budget_err = _budget_full_usdc(cfg, ex)
|
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||||
if budget is None:
|
if budget is None:
|
||||||
return jsonify({"ok": False, "msg": budget_err})
|
return jsonify({"ok": False, "msg": budget_err})
|
||||||
budget_cap = budget
|
budget_cap = budget
|
||||||
elif mode == "compound_full":
|
|
||||||
if not _compound_full_enabled():
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"ok": False,
|
|
||||||
"msg": "全仓复利未开启,请改用指定张数或先开启全仓复利",
|
|
||||||
"compound_full_enabled": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
budget, budget_err = _compound_full_usdc(cfg, ex)
|
|
||||||
if budget is None:
|
|
||||||
return jsonify({"ok": False, "msg": budget_err})
|
|
||||||
budget_cap = budget
|
|
||||||
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
|
|
||||||
budget_cap = None
|
|
||||||
sizing = calc_order_size(
|
sizing = calc_order_size(
|
||||||
quote_per_unit=float(ask),
|
quote_per_unit=float(ask),
|
||||||
ct_mult=ct_mult,
|
ct_mult=ct_mult,
|
||||||
min_sz=min_sz,
|
min_sz=min_sz,
|
||||||
budget_usdc=budget if _is_budget_mode(mode) else None,
|
budget_usdc=budget if mode == "budget_full" else None,
|
||||||
budget_buffer=cfg["budget_buffer"],
|
budget_buffer=cfg["budget_buffer"],
|
||||||
eth_amount=eth_amount,
|
eth_amount=eth_amount,
|
||||||
sheets=sheet_count,
|
sheets=sheet_count,
|
||||||
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
|
|
||||||
else None,
|
|
||||||
)
|
)
|
||||||
if not sizing.get("ok"):
|
if not sizing.get("ok"):
|
||||||
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||||
@@ -983,9 +807,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
open_opt_type = None
|
open_opt_type = None
|
||||||
try:
|
try:
|
||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
from lib.options.options_profit_exit_lib import ensure_profit_exit_columns
|
|
||||||
|
|
||||||
ensure_profit_exit_columns(conn)
|
|
||||||
meta = q.get("meta") or {}
|
meta = q.get("meta") or {}
|
||||||
u = str(meta.get("uly") or inst_id).split("-")[0]
|
u = str(meta.get("uly") or inst_id).split("-")[0]
|
||||||
opt_type = meta.get("optType")
|
opt_type = meta.get("optType")
|
||||||
@@ -995,9 +816,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"""
|
"""
|
||||||
INSERT INTO options_trades
|
INSERT INTO options_trades
|
||||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||||
open_quote, premium_paid, status, signal_note, exchange_ord_id,
|
open_quote, premium_paid, status, signal_note, exchange_ord_id)
|
||||||
profit_exit_enabled, profit_exit_mult, profit_exit_state)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
|
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
inst_id,
|
inst_id,
|
||||||
@@ -1011,26 +831,22 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
sizing["total_premium"],
|
sizing["total_premium"],
|
||||||
signal_note,
|
signal_note,
|
||||||
ord_id,
|
ord_id,
|
||||||
1 if profit_exit_enabled else 0,
|
|
||||||
profit_exit_mult if profit_exit_enabled else 1.0,
|
|
||||||
"active" if profit_exit_enabled else "idle",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
trade_id = int(cur.lastrowid)
|
trade_id = int(cur.lastrowid)
|
||||||
if target_index is not None:
|
if profit_rr is not None or target_index is not None:
|
||||||
from lib.options.options_target_lib import upsert_target_monitor
|
from lib.options.options_target_lib import upsert_target_monitor
|
||||||
|
|
||||||
target_mon = upsert_target_monitor(
|
target_mon = upsert_target_monitor(
|
||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
underlying=u,
|
underlying=u,
|
||||||
opt_type=str(opt_type) if opt_type else None,
|
opt_type=str(opt_type) if opt_type else None,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
sheets=sheets,
|
sheets=sheets,
|
||||||
)
|
)
|
||||||
if profit_exit_enabled:
|
|
||||||
pass # 列已由 init_options_tables / ensure 迁移
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1053,6 +869,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
premium_paid=sizing.get("total_premium"),
|
premium_paid=sizing.get("total_premium"),
|
||||||
open_quote=fill_px,
|
open_quote=fill_px,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
signal_note=signal_note,
|
signal_note=signal_note,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1149,11 +966,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
from lib.options.options_target_lib import targets_by_inst
|
from lib.options.options_target_lib import targets_by_inst
|
||||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
|
||||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
||||||
|
|
||||||
tgt_map = targets_by_inst(conn)
|
tgt_map = targets_by_inst(conn)
|
||||||
profit_exit_map = profit_exit_by_inst(conn)
|
|
||||||
hedge_target_map = active_options_targets_by_inst(conn)
|
hedge_target_map = active_options_targets_by_inst(conn)
|
||||||
rows = []
|
rows = []
|
||||||
for p in raw:
|
for p in raw:
|
||||||
@@ -1170,17 +985,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
mon = tgt_map.get(inst)
|
mon = tgt_map.get(inst)
|
||||||
if mon:
|
if mon:
|
||||||
row["target_index"] = mon.get("target_index")
|
row["target_index"] = mon.get("target_index")
|
||||||
|
row["profit_rr"] = mon.get("profit_rr")
|
||||||
row["target_monitor_id"] = mon.get("id")
|
row["target_monitor_id"] = mon.get("id")
|
||||||
row["target_monitor"] = mon
|
row["target_monitor"] = mon
|
||||||
pe = profit_exit_map.get(inst)
|
|
||||||
if pe:
|
|
||||||
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
|
|
||||||
row["profit_exit_mult"] = pe.get("profit_exit_mult")
|
|
||||||
row["profit_exit_state"] = pe.get("profit_exit_state")
|
|
||||||
row["profit_exit_required_recycle"] = pe.get("required_recycle")
|
|
||||||
hedge_target = hedge_target_map.get(inst)
|
hedge_target = hedge_target_map.get(inst)
|
||||||
if hedge_target:
|
if hedge_target:
|
||||||
row["hedge_plan_target"] = hedge_target
|
row["hedge_plan_target"] = hedge_target
|
||||||
|
if hedge_target.get("oo_profit_rr") is not None:
|
||||||
|
row.setdefault("profit_rr", hedge_target.get("oo_profit_rr"))
|
||||||
try:
|
try:
|
||||||
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
||||||
|
|
||||||
@@ -1238,12 +1050,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
conn_h.close()
|
conn_h.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
||||||
try:
|
profit_rr = None
|
||||||
target_index = float(data.get("target_index"))
|
target_index = None
|
||||||
except (TypeError, ValueError):
|
raw_rr = data.get("profit_rr")
|
||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
if raw_rr is None or str(raw_rr).strip() == "":
|
||||||
if target_index <= 0:
|
raw_rr = data.get("oo_profit_rr")
|
||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
if raw_rr is not None and str(raw_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
profit_rr = float(raw_rr)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比无效"})
|
||||||
|
if profit_rr <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
|
||||||
|
raw_tgt = data.get("target_index")
|
||||||
|
if raw_tgt is not None and str(raw_tgt).strip() != "":
|
||||||
|
try:
|
||||||
|
target_index = float(raw_tgt)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
|
if target_index <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
|
if profit_rr is None and target_index is None:
|
||||||
|
profit_rr = 2.0
|
||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||||
@@ -1272,6 +1100,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
underlying=str(underlying) if underlying else None,
|
underlying=str(underlying) if underlying else None,
|
||||||
opt_type=str(opt_type) if opt_type else None,
|
opt_type=str(opt_type) if opt_type else None,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
@@ -1304,62 +1133,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/profit-exit", methods=["POST"])
|
|
||||||
@lr
|
|
||||||
def api_options_profit_exit_set():
|
|
||||||
ex, err = _require_options_ex(cfg)
|
|
||||||
if ex is None:
|
|
||||||
return jsonify({"ok": False, "msg": err})
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
inst_id = (data.get("inst_id") or "").strip()
|
|
||||||
if not inst_id:
|
|
||||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
|
||||||
try:
|
|
||||||
from lib.hedge_plan.hedge_plan_db import (
|
|
||||||
active_hedge_option_inst_ids,
|
|
||||||
init_hedge_plan_tables,
|
|
||||||
)
|
|
||||||
|
|
||||||
conn_h = cfg["get_db"]()
|
|
||||||
try:
|
|
||||||
init_hedge_plan_tables(conn_h)
|
|
||||||
if inst_id in active_hedge_option_inst_ids(conn_h):
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"ok": False,
|
|
||||||
"msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn_h.close()
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
|
||||||
enabled_raw = data.get("enabled")
|
|
||||||
if enabled_raw is None:
|
|
||||||
enabled_raw = data.get("profit_exit_enabled")
|
|
||||||
enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in (
|
|
||||||
"0",
|
|
||||||
"false",
|
|
||||||
"off",
|
|
||||||
"no",
|
|
||||||
)
|
|
||||||
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit
|
|
||||||
|
|
||||||
mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0)
|
|
||||||
raw = cfg["fetch_option_positions"](ex)
|
|
||||||
if raw is None:
|
|
||||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
|
||||||
if not _find_position(raw, inst_id):
|
|
||||||
return jsonify({"ok": False, "msg": "未找到持仓"})
|
|
||||||
conn = cfg["get_db"]()
|
|
||||||
try:
|
|
||||||
out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult)
|
|
||||||
if out.get("ok"):
|
|
||||||
conn.commit()
|
|
||||||
return jsonify(out)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@app.route("/api/options/close", methods=["POST"])
|
@app.route("/api/options/close", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_close():
|
def api_options_close():
|
||||||
@@ -1674,7 +1447,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return []
|
return []
|
||||||
return [cfg["format_position_row"](p) for p in raw]
|
rows = [cfg["format_position_row"](p) for p in raw]
|
||||||
|
try:
|
||||||
|
from lib.options.options_db import sum_open_premium_paid
|
||||||
|
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
for row in rows:
|
||||||
|
inst = str(row.get("inst_id") or "")
|
||||||
|
if not inst:
|
||||||
|
continue
|
||||||
|
paid = sum_open_premium_paid(conn, inst)
|
||||||
|
if paid is not None:
|
||||||
|
row["premium_paid"] = paid
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return rows
|
||||||
|
|
||||||
def _sync(conn):
|
def _sync(conn):
|
||||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||||
@@ -1713,24 +1503,6 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
pass
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _profit_exit_close(inst_id: str) -> dict[str, Any]:
|
|
||||||
from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit
|
|
||||||
|
|
||||||
ex = cfg.get("exchange_options")
|
|
||||||
if ex is None:
|
|
||||||
return {"ok": False, "msg": "期权 exchange 未就绪"}
|
|
||||||
result = close_option_by_bid_profit_exit(cfg, ex, inst_id)
|
|
||||||
if result.get("ok"):
|
|
||||||
try:
|
|
||||||
_sync_options_trades(cfg, force=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
_mark_balances_stale(cfg)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _stale_pending() -> dict[str, Any]:
|
def _stale_pending() -> dict[str, Any]:
|
||||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||||
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
|
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
|
||||||
@@ -1781,8 +1553,6 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"profit_ratio": cfg["profit_ratio"],
|
"profit_ratio": cfg["profit_ratio"],
|
||||||
"sync_trades_fn": _sync,
|
"sync_trades_fn": _sync,
|
||||||
"target_close_fn": _target_close,
|
"target_close_fn": _target_close,
|
||||||
"profit_exit_close_fn": _profit_exit_close,
|
|
||||||
"profit_exit_cfg": cfg,
|
|
||||||
"stale_pending_fn": _stale_pending,
|
"stale_pending_fn": _stale_pending,
|
||||||
},
|
},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
|
|||||||
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
|
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
|
||||||
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
|
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
|
||||||
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
|
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
|
||||||
_ensure_column(conn, "options_review_trades", "profit_rr", "REAL")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||||
|
|||||||
@@ -450,7 +450,6 @@ def upsert_hedge_plan_row(
|
|||||||
"target_price": _safe_float(plan.get("target_price")),
|
"target_price": _safe_float(plan.get("target_price")),
|
||||||
"target_price_up": _safe_float(plan.get("target_price_up")),
|
"target_price_up": _safe_float(plan.get("target_price_up")),
|
||||||
"target_price_down": _safe_float(plan.get("target_price_down")),
|
"target_price_down": _safe_float(plan.get("target_price_down")),
|
||||||
"profit_rr": _safe_float(plan.get("profit_rr")),
|
|
||||||
"legs_json": _legs_json_from_plan(legs),
|
"legs_json": _legs_json_from_plan(legs),
|
||||||
}
|
}
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
|
"""期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算).
|
||||||
|
|
||||||
|
兼容旧「目标指数」委托:无 profit_rr 时仍按指数到位触发.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables
|
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||||
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +21,18 @@ def _safe_float(v: Any) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||||
|
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
names: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
names.add(str(r["name"]))
|
||||||
|
except (TypeError, KeyError, IndexError):
|
||||||
|
names.add(str(r[1]))
|
||||||
|
if col not in names:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||||
|
|
||||||
|
|
||||||
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
||||||
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
||||||
|
|
||||||
@@ -63,21 +78,44 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None:
|
|||||||
ON options_target_monitors(status)
|
ON options_target_monitors(status)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0
|
||||||
|
_ensure_column(conn, "options_target_monitors", "profit_rr", "REAL")
|
||||||
|
|
||||||
|
|
||||||
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
||||||
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
|
"""旧逻辑:Call 指数≥目标;Put 指数≤目标."""
|
||||||
ot = (opt_type or "").strip().upper()
|
ot = (opt_type or "").strip().upper()
|
||||||
if ot == "P":
|
if ot == "P":
|
||||||
return index_px <= target_index
|
return index_px <= target_index
|
||||||
return index_px >= target_index
|
return index_px >= target_index
|
||||||
|
|
||||||
|
|
||||||
|
def profit_rr_hit(
|
||||||
|
*,
|
||||||
|
premium: float,
|
||||||
|
bid: float | None,
|
||||||
|
sheets: float,
|
||||||
|
ct_mult: float,
|
||||||
|
profit_rr: float,
|
||||||
|
) -> bool:
|
||||||
|
"""买一回收 − 权利金 ≥ 盈亏比 × 权利金."""
|
||||||
|
if premium <= 0 or profit_rr <= 0:
|
||||||
|
return False
|
||||||
|
if bid is None or float(bid) <= 0:
|
||||||
|
return False
|
||||||
|
if sheets <= 0 or ct_mult <= 0:
|
||||||
|
return False
|
||||||
|
recycle = float(bid) * float(sheets) * float(ct_mult)
|
||||||
|
pnl = recycle - float(premium)
|
||||||
|
return pnl + 1e-9 >= float(profit_rr) * float(premium)
|
||||||
|
|
||||||
|
|
||||||
def upsert_target_monitor(
|
def upsert_target_monitor(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
target_index: float,
|
target_index: float | None = None,
|
||||||
|
profit_rr: float | None = None,
|
||||||
underlying: str | None = None,
|
underlying: str | None = None,
|
||||||
opt_type: str | None = None,
|
opt_type: str | None = None,
|
||||||
trade_id: int | None = None,
|
trade_id: int | None = None,
|
||||||
@@ -87,9 +125,18 @@ def upsert_target_monitor(
|
|||||||
inst_id = (inst_id or "").strip()
|
inst_id = (inst_id or "").strip()
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
return {"ok": False, "msg": "缺少 inst_id"}
|
return {"ok": False, "msg": "缺少 inst_id"}
|
||||||
if target_index is None or float(target_index) <= 0:
|
|
||||||
return {"ok": False, "msg": "目标位无效"}
|
rr = _safe_float(profit_rr)
|
||||||
target_index = float(target_index)
|
tgt = _safe_float(target_index)
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0
|
||||||
|
rr_store = float(rr)
|
||||||
|
elif tgt is not None and tgt > 0:
|
||||||
|
tgt_store = float(tgt)
|
||||||
|
rr_store = None
|
||||||
|
else:
|
||||||
|
return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"}
|
||||||
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM options_target_monitors
|
SELECT id FROM options_target_monitors
|
||||||
@@ -104,6 +151,7 @@ def upsert_target_monitor(
|
|||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET target_index = ?,
|
SET target_index = ?,
|
||||||
|
profit_rr = ?,
|
||||||
underlying = COALESCE(?, underlying),
|
underlying = COALESCE(?, underlying),
|
||||||
opt_type = COALESCE(?, opt_type),
|
opt_type = COALESCE(?, opt_type),
|
||||||
trade_id = COALESCE(?, trade_id),
|
trade_id = COALESCE(?, trade_id),
|
||||||
@@ -115,14 +163,13 @@ def upsert_target_monitor(
|
|||||||
triggered_at = NULL
|
triggered_at = NULL
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
(tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
||||||
)
|
)
|
||||||
mon_id = int(row["id"])
|
mon_id = int(row["id"])
|
||||||
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET status = 'cancelled', message = '被新目标位覆盖'
|
SET status = 'cancelled', message = '被新目标委托覆盖'
|
||||||
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
||||||
""",
|
""",
|
||||||
(inst_id, mon_id),
|
(inst_id, mon_id),
|
||||||
@@ -131,13 +178,21 @@ def upsert_target_monitor(
|
|||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO options_target_monitors
|
INSERT INTO options_target_monitors
|
||||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
|
(inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
|
||||||
""",
|
""",
|
||||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
|
(inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets),
|
||||||
)
|
)
|
||||||
mon_id = int(cur.lastrowid)
|
mon_id = int(cur.lastrowid)
|
||||||
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
|
out: dict[str, Any] = {
|
||||||
|
"ok": True,
|
||||||
|
"id": mon_id,
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"target_index": tgt_store if tgt_store > 0 else None,
|
||||||
|
}
|
||||||
|
if rr_store is not None:
|
||||||
|
out["profit_rr"] = rr_store
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
||||||
@@ -166,12 +221,19 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non
|
|||||||
|
|
||||||
|
|
||||||
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
tgt = _safe_float(r["target_index"])
|
||||||
|
rr = None
|
||||||
|
try:
|
||||||
|
rr = _safe_float(r["profit_rr"])
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
rr = None
|
||||||
return {
|
return {
|
||||||
"id": int(r["id"]),
|
"id": int(r["id"]),
|
||||||
"inst_id": r["inst_id"],
|
"inst_id": r["inst_id"],
|
||||||
"underlying": r["underlying"],
|
"underlying": r["underlying"],
|
||||||
"opt_type": r["opt_type"],
|
"opt_type": r["opt_type"],
|
||||||
"target_index": _safe_float(r["target_index"]),
|
"target_index": tgt if tgt is not None and tgt > 0 else None,
|
||||||
|
"profit_rr": rr if rr is not None and rr > 0 else None,
|
||||||
"trade_id": r["trade_id"],
|
"trade_id": r["trade_id"],
|
||||||
"sheets": r["sheets"],
|
"sheets": r["sheets"],
|
||||||
"status": r["status"],
|
"status": r["status"],
|
||||||
@@ -180,16 +242,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_TARGET_SELECT = (
|
||||||
|
"SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, "
|
||||||
|
"status, message, created_at FROM options_target_monitors"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC"
|
||||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
|
||||||
status, message, created_at
|
|
||||||
FROM options_target_monitors
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_target(r) for r in rows]
|
return [_row_to_target(r) for r in rows]
|
||||||
|
|
||||||
@@ -198,13 +260,7 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|||||||
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC"
|
||||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
|
||||||
status, message, created_at
|
|
||||||
FROM options_target_monitors
|
|
||||||
WHERE status = 'closing'
|
|
||||||
ORDER BY id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_target(r) for r in rows]
|
return [_row_to_target(r) for r in rows]
|
||||||
|
|
||||||
@@ -286,23 +342,23 @@ def close_option_by_bid_depth(
|
|||||||
inst_id,
|
inst_id,
|
||||||
sheets=sheets,
|
sheets=sheets,
|
||||||
require_recycle_gate=True,
|
require_recycle_gate=True,
|
||||||
signal_note="目标位平仓",
|
signal_note="盈亏比平仓",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_target_close(
|
def _notify_target_close(
|
||||||
cfg: dict[str, Any] | None,
|
cfg: dict[str, Any] | None,
|
||||||
send_wechat: Callable[[str], None] | None,
|
send_wechat: Callable[[str], None] | None,
|
||||||
*,
|
*,
|
||||||
account_label: str,
|
account_label: str,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
target: float,
|
target: float | None,
|
||||||
idx: float,
|
profit_rr: float | None,
|
||||||
|
idx: float | None,
|
||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
conn: Any = None,
|
conn: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
"""目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
||||||
if result.get("fully_closed") or result.get("already_flat"):
|
if result.get("fully_closed") or result.get("already_flat"):
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
try:
|
try:
|
||||||
@@ -312,12 +368,13 @@ def _notify_target_close(
|
|||||||
cfg,
|
cfg,
|
||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
reason="目标位平仓",
|
reason="盈亏比平仓" if profit_rr else "目标位平仓",
|
||||||
sheets=result.get("submitted_sheets"),
|
sheets=result.get("submitted_sheets"),
|
||||||
premium_received=result.get("premium_received"),
|
premium_received=result.get("premium_received"),
|
||||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||||
target_index=target,
|
target_index=target,
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
|
profit_rr=profit_rr,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -325,14 +382,20 @@ def _notify_target_close(
|
|||||||
if not send_wechat:
|
if not send_wechat:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
if profit_rr is not None and profit_rr > 0:
|
||||||
|
rule = f"盈亏比×{profit_rr:g}"
|
||||||
|
elif target is not None:
|
||||||
|
rule = f"目标指数:{target:g}"
|
||||||
|
else:
|
||||||
|
rule = "目标委托"
|
||||||
send_wechat(
|
send_wechat(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
"【OKX期权·目标位平仓】",
|
"【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】",
|
||||||
f"账户:{account_label}",
|
f"账户:{account_label}",
|
||||||
f"合约:{inst_id}",
|
f"合约:{inst_id}",
|
||||||
f"目标指数:{target:g}",
|
rule,
|
||||||
f"触发指数:{idx:g}",
|
f"触发指数:{idx:g}" if idx is not None else "触发指数:—",
|
||||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||||
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
||||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
||||||
@@ -354,18 +417,77 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_should_close(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
mon: dict[str, Any],
|
||||||
|
pos: dict[str, Any],
|
||||||
|
*,
|
||||||
|
bid_fn: Callable[[str], float | None] | None,
|
||||||
|
index_fn: Callable[[dict[str, Any]], float | None] | None,
|
||||||
|
) -> tuple[bool, float | None]:
|
||||||
|
"""返回 (是否触发, 当前指数)."""
|
||||||
|
inst_id = str(mon.get("inst_id") or "")
|
||||||
|
rr = _safe_float(mon.get("profit_rr"))
|
||||||
|
if index_fn is not None:
|
||||||
|
idx = index_fn(pos)
|
||||||
|
else:
|
||||||
|
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
||||||
|
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
premium = sum_open_premium_paid(conn, inst_id)
|
||||||
|
if premium is None or premium <= 0:
|
||||||
|
premium = _safe_float(pos.get("premium_paid"))
|
||||||
|
sheets = _safe_float(mon.get("sheets"))
|
||||||
|
if sheets is None or sheets <= 0:
|
||||||
|
sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos"))
|
||||||
|
ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01
|
||||||
|
bid = None
|
||||||
|
if bid_fn is not None:
|
||||||
|
try:
|
||||||
|
bid = bid_fn(inst_id)
|
||||||
|
except Exception:
|
||||||
|
bid = None
|
||||||
|
if bid is None:
|
||||||
|
bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid"))
|
||||||
|
preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {}
|
||||||
|
if bid is None:
|
||||||
|
bid = _safe_float(preview.get("bid") or preview.get("best_bid"))
|
||||||
|
if premium is None or sheets is None:
|
||||||
|
return False, idx
|
||||||
|
return (
|
||||||
|
profit_rr_hit(
|
||||||
|
premium=float(premium),
|
||||||
|
bid=bid,
|
||||||
|
sheets=float(sheets),
|
||||||
|
ct_mult=float(ct),
|
||||||
|
profit_rr=float(rr),
|
||||||
|
),
|
||||||
|
idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
target = _safe_float(mon.get("target_index"))
|
||||||
|
if target is None or target <= 0 or idx is None:
|
||||||
|
return False, idx
|
||||||
|
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||||
|
return (
|
||||||
|
target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target),
|
||||||
|
idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_options_target_closes(
|
def run_options_target_closes(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
positions: list[dict[str, Any]],
|
positions: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
close_fn: Callable[[str], dict[str, Any]],
|
close_fn: Callable[[str], dict[str, Any]],
|
||||||
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
||||||
|
bid_fn: Callable[[str], float | None] | None = None,
|
||||||
send_wechat: Callable[[str], None] | None = None,
|
send_wechat: Callable[[str], None] | None = None,
|
||||||
account_label: str = "OKX期权",
|
account_label: str = "OKX期权",
|
||||||
cfg: dict[str, Any] | None = None,
|
cfg: dict[str, Any] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
扫描 active 目标委托;指数到位后限价平仓.
|
扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓.
|
||||||
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
||||||
未完全成交进入 closing,仅重试平仓不再推送.
|
未完全成交进入 closing,仅重试平仓不再推送.
|
||||||
返回本次新触发(并推送)的条数.
|
返回本次新触发(并推送)的条数.
|
||||||
@@ -412,7 +534,7 @@ def run_options_target_closes(
|
|||||||
status="triggered",
|
status="triggered",
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
close_ord_id=result.get("close_ord_id"),
|
close_ord_id=result.get("close_ord_id"),
|
||||||
message="目标位限价平仓完成",
|
message="盈亏比限价平仓完成",
|
||||||
)
|
)
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
continue
|
continue
|
||||||
@@ -429,8 +551,7 @@ def run_options_target_closes(
|
|||||||
triggered = 0
|
triggered = 0
|
||||||
for mon in list_active_targets(conn):
|
for mon in list_active_targets(conn):
|
||||||
inst_id = str(mon.get("inst_id") or "")
|
inst_id = str(mon.get("inst_id") or "")
|
||||||
target = _safe_float(mon.get("target_index"))
|
if not inst_id:
|
||||||
if not inst_id or target is None:
|
|
||||||
continue
|
continue
|
||||||
if inst_id in hedge_managed:
|
if inst_id in hedge_managed:
|
||||||
mark_monitor(
|
mark_monitor(
|
||||||
@@ -444,17 +565,15 @@ def run_options_target_closes(
|
|||||||
pos = pos_by_inst.get(inst_id)
|
pos = pos_by_inst.get(inst_id)
|
||||||
if not pos:
|
if not pos:
|
||||||
continue
|
continue
|
||||||
if index_fn is not None:
|
should, idx = _monitor_should_close(
|
||||||
idx = index_fn(pos)
|
conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn
|
||||||
else:
|
)
|
||||||
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
if not should:
|
||||||
if idx is None:
|
|
||||||
continue
|
|
||||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
|
||||||
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = close_fn(inst_id)
|
result = close_fn(inst_id)
|
||||||
|
rr = _safe_float(mon.get("profit_rr"))
|
||||||
|
target = _safe_float(mon.get("target_index"))
|
||||||
if result.get("already_flat"):
|
if result.get("already_flat"):
|
||||||
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
@@ -472,15 +591,19 @@ def run_options_target_closes(
|
|||||||
|
|
||||||
done = _result_fully_done(result)
|
done = _result_fully_done(result)
|
||||||
status = "triggered" if done else "closing"
|
status = "triggered" if done else "closing"
|
||||||
|
hit_msg = (
|
||||||
|
"盈亏比达标限价平仓"
|
||||||
|
if (rr is not None and rr > 0)
|
||||||
|
else "目标位触发限价平仓"
|
||||||
|
)
|
||||||
mark_monitor(
|
mark_monitor(
|
||||||
conn,
|
conn,
|
||||||
int(mon["id"]),
|
int(mon["id"]),
|
||||||
status=status,
|
status=status,
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
close_ord_id=result.get("close_ord_id"),
|
close_ord_id=result.get("close_ord_id"),
|
||||||
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
|
message=hit_msg if done else "已挂买一限价,等待成交",
|
||||||
)
|
)
|
||||||
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
|
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
triggered += 1
|
triggered += 1
|
||||||
_notify_target_close(
|
_notify_target_close(
|
||||||
@@ -489,6 +612,7 @@ def run_options_target_closes(
|
|||||||
account_label=account_label,
|
account_label=account_label,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
target=target,
|
target=target,
|
||||||
|
profit_rr=rr,
|
||||||
idx=idx,
|
idx=idx,
|
||||||
result=result,
|
result=result,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
||||||
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
|
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
|
||||||
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||||
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
|
|
||||||
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
|
|
||||||
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
|
|
||||||
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
|
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
|
||||||
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
|
|
||||||
{% if not options_enabled %}
|
{% if not options_enabled %}
|
||||||
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及 <code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
|
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及 <code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -27,8 +23,6 @@
|
|||||||
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
|
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
|
||||||
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
|
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
|
||||||
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
|
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
|
||||||
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
|
||||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
|
||||||
<li>平仓仅买一限价,详见说明文档。</li>
|
<li>平仓仅买一限价,详见说明文档。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||||
@@ -113,46 +107,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="options-estimate-row">
|
<div class="options-estimate-row">
|
||||||
<div class="opt-est-main">
|
<div class="opt-est-main">
|
||||||
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
|
<label class="btn-secondary opt-order-chip" for="opt-profit-rr" title="目标盈利=盈亏比×权利金;例2=赚满2倍权利金后全平">盈亏比</label>
|
||||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
|
<input type="number" id="opt-profit-rr" class="opt-target-idx" step="0.1" min="0.1" value="2" placeholder="默认2"
|
||||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||||
<span class="k">预计价值</span>
|
<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 id="opt-est-profit" class="v">—</span>
|
||||||
<span class="k">盈亏比</span>
|
<span class="k">需回收</span>
|
||||||
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金">—</span>
|
<span id="opt-est-value" class="v" title="权利金+目标盈利">—</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
|
<span class="muted opt-est-note">按买一浮盈达盈亏比×权利金后限价全平;不达标等到期;无止损</span>
|
||||||
</div>
|
|
||||||
<div class="options-estimate-row opt-profit-exit-row">
|
|
||||||
<div class="opt-est-main">
|
|
||||||
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
|
|
||||||
<input type="checkbox" id="opt-profit-exit-enabled">
|
|
||||||
<span>翻倍出场</span>
|
|
||||||
</label>
|
|
||||||
<label class="k" for="opt-profit-exit-mult">倍数</label>
|
|
||||||
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
|
|
||||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
|
||||||
</div>
|
|
||||||
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row options-order-mode-row">
|
<div class="form-row options-order-mode-row">
|
||||||
<div class="opt-size-mode-bar">
|
<div class="opt-size-mode-bar">
|
||||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||||
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
|
<input type="radio" name="opt-size-mode" value="sheets" checked>
|
||||||
<span>指定张数</span>
|
<span>指定张数</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
|
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
|
||||||
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
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" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}>
|
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||||
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
|
<input type="radio" name="opt-size-mode" value="budget_full">
|
||||||
<span>按可用余额打满</span>
|
<span>按可用余额打满</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
|
|
||||||
<input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
|
|
||||||
<span>全仓复利</span>
|
|
||||||
</label>
|
|
||||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
<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">
|
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
|
||||||
<span>指定币数量</span>
|
<span>指定币数量</span>
|
||||||
@@ -163,9 +139,6 @@
|
|||||||
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
|
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
|
||||||
余额 > 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
|
余额 > 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
|
||||||
</p>
|
</p>
|
||||||
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
|
|
||||||
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
|
|
||||||
</p>
|
|
||||||
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
|
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
|
||||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
|
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
|
||||||
@@ -210,7 +183,6 @@
|
|||||||
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
||||||
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
||||||
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
||||||
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
|
|
||||||
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||||
@@ -350,4 +322,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/options_panel.js?v=64"></script>
|
<script src="/static/options_panel.js?v=60"></script>
|
||||||
|
|||||||
@@ -416,4 +416,4 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/options_review.js?v=23"></script>
|
<script src="/static/options_review.js?v=24"></script>
|
||||||
|
|||||||
@@ -4,13 +4,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%}
|
{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%}
|
||||||
{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %}
|
{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %}
|
||||||
{% set wl = trade_policy.symbol_whitelist %}
|
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select">
|
||||||
{% set sole_sym = wl[0] if (wl|length) == 1 else '' %}
|
<option value="">选择币种</option>
|
||||||
{% set effective = value if value else sole_sym %}
|
{% for sym in trade_policy.symbol_whitelist %}
|
||||||
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select"{% if sole_sym %} data-sole-symbol="{{ sole_sym }}"{% endif %}>
|
<option value="{{ sym }}" {% if value and ((value|upper) == sym or (value|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
|
||||||
{% if not sole_sym %}<option value="">选择币种</option>{% endif %}
|
|
||||||
{% for sym in wl %}
|
|
||||||
<option value="{{ sym }}" {% if effective and ((effective|upper) == sym or (effective|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -17,20 +17,15 @@ def trade_policy_template_context(policy: TradePolicy) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str:
|
def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str:
|
||||||
d = (raw_default or "").strip()
|
d = (raw_default or "BTC/USDT").strip() or "BTC/USDT"
|
||||||
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
|
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
|
||||||
# 白名单仅一币时直接用 env 币种,表单下拉同步默认选中
|
|
||||||
if len(policy.symbol_whitelist) == 1:
|
|
||||||
return f"{policy.symbol_whitelist[0]}/USDT"
|
|
||||||
from lib.trade.trade_policy_lib import symbol_base_coin
|
from lib.trade.trade_policy_lib import symbol_base_coin
|
||||||
|
|
||||||
base = symbol_base_coin(d or "BTC/USDT")
|
base = symbol_base_coin(d)
|
||||||
if base not in policy.symbol_whitelist:
|
if base not in policy.symbol_whitelist:
|
||||||
return f"{policy.symbol_whitelist[0]}/USDT"
|
return f"{policy.symbol_whitelist[0]}/USDT"
|
||||||
if d:
|
return d
|
||||||
return d if "/" in d else f"{base}/USDT"
|
|
||||||
return f"{policy.symbol_whitelist[0]}/USDT"
|
|
||||||
return d or "BTC/USDT"
|
|
||||||
|
|
||||||
def check_symbol_policy(
|
def check_symbol_policy(
|
||||||
policy: TradePolicy,
|
policy: TradePolicy,
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ HUB_DISABLED_IDS=
|
|||||||
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
|
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
|
||||||
HUB_TRUST_LAN=true
|
HUB_TRUST_LAN=true
|
||||||
|
|
||||||
# 默认 true(代码默认允许公网/反代访问中控,靠 HUB_PASSWORD 保护)
|
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
|
||||||
# 仅本机调试可关: HUB_ALLOW_PUBLIC=false
|
# HUB_ALLOW_PUBLIC=true
|
||||||
HUB_ALLOW_PUBLIC=true
|
|
||||||
|
|
||||||
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
|
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
|
||||||
HUB_USERNAME=admin
|
HUB_USERNAME=admin
|
||||||
|
|||||||
+24
-11
@@ -187,9 +187,9 @@ HUB_PORT = int(os.getenv("HUB_PORT", "5100"))
|
|||||||
HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
||||||
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
|
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
|
||||||
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
||||||
# 默认 true:云端域名/反代可访问;仅靠 HUB_PASSWORD 保护.本地若要强制仅本机,设 HUB_ALLOW_PUBLIC=false
|
_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
|
||||||
_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC", "true") or "").strip().lower()
|
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
|
||||||
HUB_ALLOW_PUBLIC = _allow_pub_raw not in ("0", "false", "no", "off")
|
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
|
||||||
DIR = Path(__file__).resolve().parent
|
DIR = Path(__file__).resolve().parent
|
||||||
HUB_BUILD = "20260607-hub-archive"
|
HUB_BUILD = "20260607-hub-archive"
|
||||||
_archive_sync_stop: asyncio.Event | None = None
|
_archive_sync_stop: asyncio.Event | None = None
|
||||||
@@ -2028,7 +2028,8 @@ async def _fetch_flask_json(
|
|||||||
return parsed
|
return parsed
|
||||||
return _parse_http_json_body(r)
|
return _parse_http_json_body(r)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}
|
err = str(e)
|
||||||
|
return {"ok": False, "error": err, "msg": err}
|
||||||
|
|
||||||
|
|
||||||
async def _notify_instance_user_close(
|
async def _notify_instance_user_close(
|
||||||
@@ -2567,8 +2568,8 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict
|
|||||||
|
|
||||||
async def _fetch_exchange_flask_bundle(
|
async def _fetch_exchange_flask_bundle(
|
||||||
client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None
|
client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None
|
||||||
) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]:
|
) -> tuple:
|
||||||
"""单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取."""
|
"""单所 Flask:monitor / meta / price_snapshot / account / trades/today / options 并行拉取."""
|
||||||
caps = ex.get("capabilities") or []
|
caps = ex.get("capabilities") or []
|
||||||
tasks = [
|
tasks = [
|
||||||
_fetch_flask_json(client, ex, "/api/hub/monitor"),
|
_fetch_flask_json(client, ex, "/api/hub/monitor"),
|
||||||
@@ -2576,6 +2577,7 @@ async def _fetch_exchange_flask_bundle(
|
|||||||
]
|
]
|
||||||
has_flask = bool((ex.get("flask_url") or "").strip())
|
has_flask = bool((ex.get("flask_url") or "").strip())
|
||||||
day = (trading_day or "").strip()
|
day = (trading_day or "").strip()
|
||||||
|
want_options = has_flask and "options" in caps
|
||||||
if has_flask:
|
if has_flask:
|
||||||
tasks.extend(
|
tasks.extend(
|
||||||
[
|
[
|
||||||
@@ -2592,15 +2594,26 @@ async def _fetch_exchange_flask_bundle(
|
|||||||
params={"trading_day": day},
|
params={"trading_day": day},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if want_options:
|
||||||
|
tasks.append(_fetch_flask_json(client, ex, "/api/hub/options/snapshot"))
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
hub_mon = results[0]
|
hub_mon = results[0]
|
||||||
meta = results[1]
|
meta = results[1]
|
||||||
snap = results[2] if has_flask and len(results) > 2 else None
|
idx = 2
|
||||||
account = results[3] if has_flask and len(results) > 3 else None
|
snap = None
|
||||||
trades_today = results[4] if has_flask and day and len(results) > 4 else None
|
account = None
|
||||||
|
trades_today = None
|
||||||
options_snap = None
|
options_snap = None
|
||||||
if has_flask and "options" in caps:
|
if has_flask:
|
||||||
options_snap = await _fetch_flask_json(client, ex, "/api/hub/options/snapshot")
|
snap = results[idx]
|
||||||
|
idx += 1
|
||||||
|
account = results[idx]
|
||||||
|
idx += 1
|
||||||
|
if day:
|
||||||
|
trades_today = results[idx]
|
||||||
|
idx += 1
|
||||||
|
if want_options:
|
||||||
|
options_snap = results[idx]
|
||||||
key_prices = None
|
key_prices = None
|
||||||
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
|
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
|
||||||
if want_prices and isinstance(snap, dict):
|
if want_prices and isinstance(snap, dict):
|
||||||
|
|||||||
@@ -3928,54 +3928,34 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProfitExitMultLabel(mult) {
|
function renderOptionsTargetCell(target) {
|
||||||
const n = Number(mult);
|
if (!target) return "<td>—</td>";
|
||||||
if (!Number.isFinite(n) || n <= 0) return "1倍";
|
const rr =
|
||||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "倍";
|
target.profit_rr != null
|
||||||
return fmt(n, 2) + "倍";
|
? Number(target.profit_rr)
|
||||||
}
|
: target.oo_profit_rr != null
|
||||||
|
? Number(target.oo_profit_rr)
|
||||||
function renderOptionsTargetCell(target, pos) {
|
: null;
|
||||||
if (target && target.managed_by === "hedge_plan") {
|
if (rr != null && Number.isFinite(rr) && rr > 0) {
|
||||||
const rr = target.profit_rr != null ? Number(target.profit_rr) : null;
|
const txt = `盈亏比×${fmt(rr, 2)}`;
|
||||||
if (rr != null && rr > 0) {
|
if (target.managed_by === "hedge_plan") {
|
||||||
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} 盈亏比 ${esc(fmt(rr, 2))}</td>`;
|
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(txt)}</td>`;
|
||||||
}
|
}
|
||||||
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
|
return `<td class="hub-opt-target-cell is-on" title="盈亏比监控">${esc(txt)}</td>`;
|
||||||
const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
|
}
|
||||||
|
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
|
||||||
|
const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
|
||||||
|
if (target.managed_by === "hedge_plan") {
|
||||||
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}</td>`;
|
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}</td>`;
|
||||||
}
|
}
|
||||||
const parts = [];
|
return `<td class="hub-opt-target-cell is-on" title="目标监控">${esc(side)} ${esc(px)}</td>`;
|
||||||
const hasIndex =
|
|
||||||
target &&
|
|
||||||
target.exit_mode !== "profit_exit" &&
|
|
||||||
target.target_index != null &&
|
|
||||||
Number(target.target_index) > 0;
|
|
||||||
if (hasIndex) {
|
|
||||||
const side = String(target.opt_type || (pos && pos.opt_type) || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
|
|
||||||
parts.push(side + " " + fmt(target.target_index, 1));
|
|
||||||
}
|
|
||||||
const peOn =
|
|
||||||
!!(pos && pos.profit_exit_enabled) ||
|
|
||||||
!!(target && (target.exit_mode === "profit_exit" || target.profit_exit_enabled));
|
|
||||||
if (peOn) {
|
|
||||||
const mult =
|
|
||||||
pos && pos.profit_exit_mult != null
|
|
||||||
? pos.profit_exit_mult
|
|
||||||
: target && target.profit_exit_mult != null
|
|
||||||
? target.profit_exit_mult
|
|
||||||
: 1;
|
|
||||||
parts.push(formatProfitExitMultLabel(mult));
|
|
||||||
}
|
|
||||||
if (!parts.length) return "<td>—</td>";
|
|
||||||
return `<td class="hub-opt-target-cell is-on" title="目标监控">${esc(parts.join(" · "))}</td>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOptionsPositionsTable(pos, targets) {
|
function renderOptionsPositionsTable(pos, targets) {
|
||||||
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
|
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
|
||||||
const showPnl = showAccountPnlPref();
|
const showPnl = showAccountPnlPref();
|
||||||
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
||||||
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>";
|
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>盈亏比</th>";
|
||||||
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
|
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
|
||||||
html += "</tr></thead><tbody>";
|
html += "</tr></thead><tbody>";
|
||||||
pos.forEach((p) => {
|
pos.forEach((p) => {
|
||||||
@@ -3997,7 +3977,7 @@
|
|||||||
<td>${esc(optType)}</td>
|
<td>${esc(optType)}</td>
|
||||||
<td>${esc(p.pos)}</td>
|
<td>${esc(p.pos)}</td>
|
||||||
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
||||||
${renderOptionsTargetCell(target, p)}`;
|
${renderOptionsTargetCell(target)}`;
|
||||||
if (showPnl) {
|
if (showPnl) {
|
||||||
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
|
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
|
||||||
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
|
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
|
||||||
@@ -4042,20 +4022,26 @@
|
|||||||
function renderOptionsSectionBody(row, opts) {
|
function renderOptionsSectionBody(row, opts) {
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
const layout = options.layout || "table";
|
const layout = options.layout || "table";
|
||||||
const opt = row.options || {};
|
const caps = Array.isArray(row.capabilities) ? row.capabilities : [];
|
||||||
|
const wantsOptions = caps.indexOf("options") >= 0;
|
||||||
|
const opt = row.options;
|
||||||
let html = "";
|
let html = "";
|
||||||
if (opt.enabled === false) {
|
if (wantsOptions && (opt == null || typeof opt !== "object")) {
|
||||||
html += renderOptionsAccountStatRow(opt);
|
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
||||||
|
html += `<div class="err">期权数据不可用</div>`;
|
||||||
|
} else if ((opt || {}).enabled === false) {
|
||||||
|
html += renderOptionsAccountStatRow(opt || {});
|
||||||
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
||||||
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
|
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
|
||||||
} else if (opt.ok === false) {
|
} else if ((opt || {}).ok === false) {
|
||||||
html += renderOptionsAccountStatRow(opt);
|
html += renderOptionsAccountStatRow(opt || {});
|
||||||
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
html += '<div class="section-title hub-options-title">期权持仓</div>';
|
||||||
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
|
html += `<div class="err">${esc((opt && (opt.msg || opt.error)) || "期权数据不可用")}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const pos = Array.isArray(opt.positions) ? opt.positions : [];
|
const optSafe = opt || {};
|
||||||
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
|
const pos = Array.isArray(optSafe.positions) ? optSafe.positions : [];
|
||||||
html += renderOptionsAccountStatRow(opt);
|
const targets = Array.isArray(optSafe.target_monitors) ? optSafe.target_monitors : [];
|
||||||
|
html += renderOptionsAccountStatRow(optSafe);
|
||||||
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
|
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
|
||||||
html +=
|
html +=
|
||||||
layout === "cards"
|
layout === "cards"
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
<span class="plan-radio-row" id="plan-create-direction"></span>
|
<span class="plan-radio-row" id="plan-create-direction"></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="plan-field">
|
<label class="plan-field">
|
||||||
<span>目标位</span>
|
<span>盈亏比</span>
|
||||||
<input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
<input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||||
</label>
|
</label>
|
||||||
<label class="plan-field">
|
<label class="plan-field">
|
||||||
@@ -1765,8 +1765,8 @@
|
|||||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||||
<script src="/assets/time_close_ui.js?v=3"></script>
|
<script src="/assets/time_close_ui.js?v=3"></script>
|
||||||
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/assets/options_position_cards.js?v=4"></script>
|
<script src="/assets/options_position_cards.js?v=5"></script>
|
||||||
<script src="/assets/backup.js?v=1"></script>
|
<script src="/assets/backup.js?v=1"></script>
|
||||||
<script src="/assets/app.js?v=20260812-profit-exit"></script>
|
<script src="/assets/app.js?v=20260811-opt-rr"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (r.status === 403) {
|
if (r.status === 403) {
|
||||||
showErr("访问被拒绝(403):请确认 HUB_ALLOW_PUBLIC 未设为 false,并检查反代/登录配置");
|
showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
|
||||||
} else {
|
} else {
|
||||||
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
|
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,16 +109,12 @@ class TestHedgePlanCalc(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(p["summary"]["premium_paid"], 10)
|
self.assertEqual(p["summary"]["premium_paid"], 10)
|
||||||
self.assertTrue(p["summary"]["expiry_is_loss"])
|
self.assertTrue(p["summary"]["expiry_is_loss"])
|
||||||
self.assertEqual(p["summary"]["profit_rr"], 2)
|
self.assertEqual(p["summary"]["rr_risk_premium"], 10)
|
||||||
self.assertEqual(p["summary"]["at_rr_a_full_total"], 15) # 盈利=2*10, 亏腿-5
|
self.assertEqual(p["summary"]["oo_profit_rr"], 2)
|
||||||
self.assertEqual(len(p["scenarios"]), 5)
|
self.assertAlmostEqual(p["summary"]["target_profit"], 20.0, places=4)
|
||||||
self.assertEqual(p["scenarios"][0]["id"], "rr_leg_a_full")
|
self.assertEqual(len(p["scenarios"]), 3)
|
||||||
self.assertEqual(p["scenarios"][1]["id"], "rr_leg_b_full")
|
self.assertEqual(p["scenarios"][0]["id"], "rr_target")
|
||||||
# 到期实值反推:Call 盈利20 → 价值25 → 每币2500 → spot=3300+2500
|
self.assertEqual(p["scenarios"][1]["id"], "expiry_flat")
|
||||||
self.assertEqual(p["scenarios"][0]["spot"], 5800.0)
|
|
||||||
# Put 盈利20 → spot=3100-2500
|
|
||||||
self.assertEqual(p["scenarios"][1]["spot"], 600.0)
|
|
||||||
self.assertEqual(p["scenarios"][2]["spot"], 5800.0) # 残值情景同腿A反推
|
|
||||||
|
|
||||||
def test_oo_legacy_single_target_still_works(self):
|
def test_oo_legacy_single_target_still_works(self):
|
||||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||||
@@ -127,6 +123,21 @@ class TestHedgePlanCalc(unittest.TestCase):
|
|||||||
self.assertEqual(p["target_price_up"], 3500)
|
self.assertEqual(p["target_price_up"], 3500)
|
||||||
self.assertEqual(p["target_price_down"], 3500)
|
self.assertEqual(p["target_price_down"], 3500)
|
||||||
|
|
||||||
|
def test_oo_legacy_up_down_rr_fields(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}
|
||||||
|
p = build_options_options_preview(
|
||||||
|
target_price_up=3500,
|
||||||
|
target_price_down=3000,
|
||||||
|
index_px=3200,
|
||||||
|
leg_a=a,
|
||||||
|
leg_b=b,
|
||||||
|
)
|
||||||
|
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(p["scenarios"][0]["id"], "target_up")
|
||||||
|
self.assertEqual(p["scenarios"][1]["id"], "target_down")
|
||||||
|
|
||||||
def test_perp_short_pnl(self):
|
def test_perp_short_pnl(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
|
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class TestHedgeHistoryStats(unittest.TestCase):
|
|||||||
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
|
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
|
||||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
||||||
|
|
||||||
def test_active_options_targets_profit_rr(self):
|
def test_active_options_targets_rr_mode_marks_managed(self):
|
||||||
conn = _mem()
|
conn = _mem()
|
||||||
pid = insert_plan(
|
pid = insert_plan(
|
||||||
conn,
|
conn,
|
||||||
@@ -163,7 +163,7 @@ class TestHedgeHistoryStats(unittest.TestCase):
|
|||||||
"plan_type": "options_options",
|
"plan_type": "options_options",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"underlying": "ETH",
|
"underlying": "ETH",
|
||||||
"profit_rr": 2,
|
"oo_profit_rr": 2,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
insert_leg(
|
insert_leg(
|
||||||
@@ -177,9 +177,9 @@ class TestHedgeHistoryStats(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
targets = active_options_targets_by_inst(conn)
|
targets = active_options_targets_by_inst(conn)
|
||||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["profit_rr"], 2)
|
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
||||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["exit_mode"], "profit_rr")
|
|
||||||
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
|
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
|
||||||
|
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["oo_profit_rr"], 2.0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ class TestHedgeMoneyness(unittest.TestCase):
|
|||||||
err = validate_start_body(
|
err = validate_start_body(
|
||||||
"options_options",
|
"options_options",
|
||||||
{
|
{
|
||||||
"profit_rr": 2,
|
"target_price_up": 1900,
|
||||||
|
"target_price_down": 1700,
|
||||||
"index_px": 1800,
|
"index_px": 1800,
|
||||||
"leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700},
|
"leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700},
|
||||||
"leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900},
|
"leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900},
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ class TestHedgePlanOrderPath(unittest.TestCase):
|
|||||||
"budget_buffer": 0.95,
|
"budget_buffer": 0.95,
|
||||||
}
|
}
|
||||||
body = {
|
body = {
|
||||||
"profit_rr": 2,
|
"target_price": 1900,
|
||||||
|
"target_price_up": 1950,
|
||||||
|
"target_price_down": 1750,
|
||||||
"oo_sheets_mode": "same_sheets",
|
"oo_sheets_mode": "same_sheets",
|
||||||
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
|
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
|
||||||
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
|
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""期权合约列表缓存与限频退避."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from lib.exchange import okx_options_lib as m
|
||||||
|
|
||||||
|
|
||||||
|
class FetchOptionInstrumentsCacheTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
m.invalidate_option_instruments_cache()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
m.invalidate_option_instruments_cache()
|
||||||
|
|
||||||
|
def test_cache_hit_skips_second_api_call(self):
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.public_get_public_instruments.return_value = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"instId": "ETH-USD_UM-260812-2000-C",
|
||||||
|
"state": "live",
|
||||||
|
"expTime": "9999999999999",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
a = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
b = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
self.assertEqual(len(a), 1)
|
||||||
|
self.assertEqual(len(b), 1)
|
||||||
|
self.assertEqual(ex.public_get_public_instruments.call_count, 1)
|
||||||
|
|
||||||
|
@patch("lib.exchange.okx_options_lib.time.sleep", return_value=None)
|
||||||
|
def test_rate_limit_falls_back_to_stale_cache(self, _sleep):
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.public_get_public_instruments.return_value = {
|
||||||
|
"data": [{"instId": "ETH-USD_UM-260812-2000-C", "state": "live"}]
|
||||||
|
}
|
||||||
|
first = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
self.assertEqual(len(first), 1)
|
||||||
|
# 过期 TTL,但仍在 stale 窗口
|
||||||
|
with m._INSTRUMENTS_CACHE_LOCK:
|
||||||
|
m._INSTRUMENTS_CACHE["ETH-USD_UM"]["updated_at"] = time.time() - 120
|
||||||
|
ex.public_get_public_instruments.side_effect = Exception(
|
||||||
|
'okx {"msg":"Too Many Requests","code":"50011"}'
|
||||||
|
)
|
||||||
|
second = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
self.assertEqual(len(second), 1)
|
||||||
|
self.assertEqual(second[0]["instId"], "ETH-USD_UM-260812-2000-C")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -37,24 +37,9 @@ class TestOkxSpotSwap(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
|
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
|
||||||
self.assertFalse(result["ok"])
|
self.assertFalse(result["ok"])
|
||||||
self.assertEqual(result["msg"], "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)")
|
self.assertEqual(result["msg"], "资金账户 USDT 可用余额不足")
|
||||||
self.assertNotIn("{", result["msg"])
|
self.assertNotIn("{", result["msg"])
|
||||||
|
|
||||||
def test_insufficient_usdc_message(self):
|
|
||||||
from lib.exchange.okx_options_lib import _okx_trade_error_message
|
|
||||||
|
|
||||||
msg = _okx_trade_error_message(
|
|
||||||
resp={
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"sCode": "51008",
|
|
||||||
"sMsg": "Order failed. Insufficient USDC balance in account.",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.assertEqual(msg, "交易账户 USDC 可用余额不足")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,98 +1,16 @@
|
|||||||
"""按可用余额打满 / 全仓复利定仓."""
|
"""按可用余额打满:min(余额, 单笔预算)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
from lib.options.options_pricing_lib import resolve_budget_full_usdc
|
||||||
|
|
||||||
from lib.options.options_pricing_lib import (
|
|
||||||
resolve_budget_full_usdc,
|
|
||||||
resolve_compound_full_usdc,
|
|
||||||
)
|
|
||||||
from lib.options.options_position_limit_lib import (
|
|
||||||
compound_full_single_position_block_msg,
|
|
||||||
count_live_option_positions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestOptionsBudgetModes(unittest.TestCase):
|
def test_balance_above_budget_uses_budget():
|
||||||
def test_balance_above_budget_uses_budget(self):
|
assert resolve_budget_full_usdc(100.0, 10.0) == 10.0
|
||||||
self.assertEqual(resolve_budget_full_usdc(100.0, 10.0), 10.0)
|
|
||||||
|
|
||||||
def test_balance_below_budget_uses_balance(self):
|
|
||||||
self.assertEqual(resolve_budget_full_usdc(5.0, 10.0), 5.0)
|
|
||||||
|
|
||||||
def test_balance_equals_budget(self):
|
|
||||||
self.assertEqual(resolve_budget_full_usdc(10.0, 10.0), 10.0)
|
|
||||||
|
|
||||||
def test_compound_full_no_cap_uses_all(self):
|
|
||||||
self.assertEqual(
|
|
||||||
resolve_compound_full_usdc(200.0, cap_enabled=False, cap_usdc=50.0),
|
|
||||||
200.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_compound_full_cap_on(self):
|
|
||||||
self.assertEqual(
|
|
||||||
resolve_compound_full_usdc(200.0, cap_enabled=True, cap_usdc=50.0),
|
|
||||||
50.0,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
resolve_compound_full_usdc(30.0, cap_enabled=True, cap_usdc=50.0),
|
|
||||||
30.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_compound_full_cap_invalid_falls_back_to_balance(self):
|
|
||||||
self.assertEqual(
|
|
||||||
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=0),
|
|
||||||
80.0,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=None),
|
|
||||||
80.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_compound_full_blocks_when_position_open(self):
|
|
||||||
rows = [{"instId": "ETH-USD_UM-260812-1870-P", "pos": "1"}]
|
|
||||||
msg = compound_full_single_position_block_msg(
|
|
||||||
object(), fetch_positions=lambda _ex: rows
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(msg)
|
|
||||||
self.assertIn("1 笔", msg or "")
|
|
||||||
|
|
||||||
def test_compound_full_allows_when_flat(self):
|
|
||||||
msg = compound_full_single_position_block_msg(
|
|
||||||
object(), fetch_positions=lambda _ex: []
|
|
||||||
)
|
|
||||||
self.assertIsNone(msg)
|
|
||||||
self.assertEqual(count_live_option_positions([]), 0)
|
|
||||||
|
|
||||||
def test_normalize_size_mode_when_compound_off(self):
|
|
||||||
import os
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from lib.options import options_register as reg
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "false"}):
|
|
||||||
mode, note = reg._normalize_size_mode("compound_full")
|
|
||||||
self.assertEqual(mode, "sheets")
|
|
||||||
self.assertIsNotNone(note)
|
|
||||||
mode2, note2 = reg._normalize_size_mode("budget_full")
|
|
||||||
self.assertEqual(mode2, "budget_full")
|
|
||||||
self.assertIsNone(note2)
|
|
||||||
mode3, _ = reg._normalize_size_mode("sheets")
|
|
||||||
self.assertEqual(mode3, "sheets")
|
|
||||||
|
|
||||||
def test_normalize_size_mode_when_compound_on(self):
|
|
||||||
import os
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from lib.options import options_register as reg
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "true"}):
|
|
||||||
mode, note = reg._normalize_size_mode("budget_full")
|
|
||||||
self.assertEqual(mode, "compound_full")
|
|
||||||
self.assertIsNone(note)
|
|
||||||
mode2, _ = reg._normalize_size_mode("compound_full")
|
|
||||||
self.assertEqual(mode2, "compound_full")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def test_balance_below_budget_uses_balance():
|
||||||
unittest.main()
|
assert resolve_budget_full_usdc(5.0, 10.0) == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_balance_equals_budget():
|
||||||
|
assert resolve_budget_full_usdc(10.0, 10.0) == 10.0
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"""单独期权翻倍出场命中条件."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables
|
|
||||||
from lib.options.options_profit_exit_lib import (
|
|
||||||
normalize_profit_exit_mult,
|
|
||||||
profit_exit_by_inst,
|
|
||||||
profit_exit_hit,
|
|
||||||
required_recycle_usdc,
|
|
||||||
set_profit_exit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestOptionsProfitExit(unittest.TestCase):
|
|
||||||
def test_hit_one_x_means_profit_equals_premium(self):
|
|
||||||
# 1倍:盈利=权利金 ⇒ 回收≥2×权利金
|
|
||||||
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=20.0, mult=1.0))
|
|
||||||
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=19.9, mult=1.0))
|
|
||||||
self.assertEqual(required_recycle_usdc(10.0, 1.0), 20.0)
|
|
||||||
|
|
||||||
def test_hit_two_x(self):
|
|
||||||
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=30.0, mult=2.0))
|
|
||||||
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=29.9, mult=2.0))
|
|
||||||
|
|
||||||
def test_normalize_mult(self):
|
|
||||||
self.assertEqual(normalize_profit_exit_mult(None), 1.0)
|
|
||||||
self.assertEqual(normalize_profit_exit_mult(0), 1.0)
|
|
||||||
self.assertEqual(normalize_profit_exit_mult("1.5"), 1.5)
|
|
||||||
|
|
||||||
def test_set_and_clear(self):
|
|
||||||
with tempfile.TemporaryDirectory() as td:
|
|
||||||
db = Path(td) / "t.db"
|
|
||||||
conn = sqlite3.connect(str(db))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
init_options_tables(conn)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO options_trades
|
|
||||||
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
|
||||||
VALUES ('ETH-X', 'ETH', 'C', 1, 0.01, 10.0, 'open')
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
out = set_profit_exit(conn, inst_id="ETH-X", enabled=True, mult=1.5)
|
|
||||||
self.assertTrue(out["ok"])
|
|
||||||
conn.commit()
|
|
||||||
m = profit_exit_by_inst(conn)
|
|
||||||
self.assertTrue(m["ETH-X"]["profit_exit_enabled"])
|
|
||||||
self.assertEqual(m["ETH-X"]["profit_exit_mult"], 1.5)
|
|
||||||
self.assertEqual(m["ETH-X"]["required_recycle"], 25.0)
|
|
||||||
out2 = set_profit_exit(conn, inst_id="ETH-X", enabled=False, mult=1.5)
|
|
||||||
self.assertTrue(out2["ok"])
|
|
||||||
conn.commit()
|
|
||||||
m2 = profit_exit_by_inst(conn)
|
|
||||||
self.assertNotIn("ETH-X", m2)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""期权目标位委托单元测试."""
|
"""期权目标委托单元测试(盈亏比 + 旧指数兼容)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -8,6 +8,7 @@ from lib.options.options_target_lib import (
|
|||||||
ensure_target_tables,
|
ensure_target_tables,
|
||||||
list_active_targets,
|
list_active_targets,
|
||||||
list_closing_targets,
|
list_closing_targets,
|
||||||
|
profit_rr_hit,
|
||||||
run_options_target_closes,
|
run_options_target_closes,
|
||||||
target_hit,
|
target_hit,
|
||||||
upsert_target_monitor,
|
upsert_target_monitor,
|
||||||
@@ -21,7 +22,67 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
|
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
|
||||||
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
|
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
|
||||||
|
|
||||||
def test_upsert_and_trigger_close(self):
|
def test_profit_rr_hit(self):
|
||||||
|
# premium=10, rr=2 → need pnl≥20 → recycle≥30 → bid*sheets*ct ≥30
|
||||||
|
self.assertTrue(
|
||||||
|
profit_rr_hit(premium=10, bid=30, sheets=1, ct_mult=1, profit_rr=2)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
profit_rr_hit(premium=10, bid=29.9, sheets=1, ct_mult=1, profit_rr=2)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
profit_rr_hit(premium=10, bid=None, sheets=1, ct_mult=1, profit_rr=2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_upsert_rr_and_trigger_close(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
ensure_target_tables(conn)
|
||||||
|
out = upsert_target_monitor(
|
||||||
|
conn,
|
||||||
|
inst_id="ETH-USD_UM-260717-1900-C",
|
||||||
|
profit_rr=2,
|
||||||
|
opt_type="C",
|
||||||
|
sheets=1,
|
||||||
|
)
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertEqual(out.get("profit_rr"), 2.0)
|
||||||
|
self.assertEqual(len(list_active_targets(conn)), 1)
|
||||||
|
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
def close_fn(inst_id: str):
|
||||||
|
closed.append(inst_id)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"submitted_sheets": 1,
|
||||||
|
"premium_received": 30.0,
|
||||||
|
"close_ord_id": "oid1",
|
||||||
|
"fully_closed": True,
|
||||||
|
"remaining_sheets": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# bid=30, ct=1 → pnl=20 ≥ 2*10; premium 来自持仓字段
|
||||||
|
n = run_options_target_closes(
|
||||||
|
conn,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"inst_id": "ETH-USD_UM-260717-1900-C",
|
||||||
|
"idx_px": 1885,
|
||||||
|
"opt_type": "C",
|
||||||
|
"pos": 1,
|
||||||
|
"ct_mult": 1,
|
||||||
|
"premium_paid": 10,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
close_fn=close_fn,
|
||||||
|
bid_fn=lambda _i: 30.0,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
|
||||||
|
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||||
|
|
||||||
|
def test_upsert_and_trigger_close_legacy_index(self):
|
||||||
conn = sqlite3.connect(":memory:")
|
conn = sqlite3.connect(":memory:")
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
@@ -107,7 +168,6 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||||
self.assertEqual(len(list_closing_targets(conn)), 1)
|
self.assertEqual(len(list_closing_targets(conn)), 1)
|
||||||
|
|
||||||
# 模拟后续 sync 异常也不会再推:closing 重试静默
|
|
||||||
n2 = run_options_target_closes(
|
n2 = run_options_target_closes(
|
||||||
conn,
|
conn,
|
||||||
pos,
|
pos,
|
||||||
@@ -120,7 +180,6 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(list_closing_targets(conn)), 0)
|
self.assertEqual(len(list_closing_targets(conn)), 0)
|
||||||
|
|
||||||
def test_commit_before_wechat_survives_later_rollback(self):
|
def test_commit_before_wechat_survives_later_rollback(self):
|
||||||
"""状态在推送前已 commit,外层异常回滚不应让委托回到 active."""
|
|
||||||
conn = sqlite3.connect(":memory:")
|
conn = sqlite3.connect(":memory:")
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
@@ -149,12 +208,10 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
close_fn=close_fn,
|
close_fn=close_fn,
|
||||||
send_wechat=notices.append,
|
send_wechat=notices.append,
|
||||||
)
|
)
|
||||||
# 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit
|
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
self.assertEqual(len(notices), 1)
|
self.assertEqual(len(notices), 1)
|
||||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||||
|
|
||||||
# 下一轮不应再次触发推送
|
|
||||||
n2 = run_options_target_closes(
|
n2 = run_options_target_closes(
|
||||||
conn,
|
conn,
|
||||||
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
||||||
|
|||||||
@@ -88,29 +88,3 @@ def test_badge_parts():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH")
|
assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH")
|
||||||
|
|
||||||
|
|
||||||
def test_default_symbol_when_whitelist_sole():
|
|
||||||
from lib.trade.trade_policy_app_lib import default_symbol_for_policy
|
|
||||||
|
|
||||||
p = load_trade_policy(
|
|
||||||
{
|
|
||||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
|
||||||
"TRADE_SYMBOL_WHITELIST": "BTC",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert default_symbol_for_policy(p, "") == "BTC/USDT"
|
|
||||||
assert default_symbol_for_policy(p, "ETH/USDT") == "BTC/USDT"
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_symbol_when_whitelist_multi():
|
|
||||||
from lib.trade.trade_policy_app_lib import default_symbol_for_policy
|
|
||||||
|
|
||||||
p = load_trade_policy(
|
|
||||||
{
|
|
||||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
|
||||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert default_symbol_for_policy(p, "ETH") == "ETH/USDT"
|
|
||||||
assert default_symbol_for_policy(p, "SOL/USDT") == "BTC/USDT"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user