单独期权增加翻倍出场:可开关、自选倍数(默认1倍=盈利等于权利金),达标后买一限价平。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+16
-7
@@ -47,6 +47,13 @@
|
||||
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
|
||||
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
|
||||
|
||||
### 2.4 翻倍出场(可选)
|
||||
|
||||
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
|
||||
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
|
||||
- 可随时关闭;与目标位监控并行,谁先达标谁平.
|
||||
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
|
||||
|
||||
---
|
||||
|
||||
## 3. 监控逻辑
|
||||
@@ -58,19 +65,21 @@
|
||||
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
|
||||
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
|
||||
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
|
||||
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
|
||||
| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
|
||||
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
|
||||
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 平仓校验(门控)
|
||||
|
||||
| 门控 | 手动买一平 | 目标自动平 | 说明 |
|
||||
|------|------------|------------|------|
|
||||
| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
|
||||
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 |
|
||||
| 锁定买一价 | ✅ | ✅ | 下单价 = 通过校验时的买一 |
|
||||
| 市价兜底 | ❌ | ❌ | 永不市价 |
|
||||
| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
|
||||
|------|------------|------------|----------|------|
|
||||
| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
|
||||
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
|
||||
| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
|
||||
| 锁定买一价 | ✅ | ✅ | ✅ | 下单价 = 通过校验时的买一 |
|
||||
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4447,6 +4447,22 @@ html[data-theme="light"] .opt-pending-item {
|
||||
min-height: 32px;
|
||||
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 {
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
@@ -1406,6 +1406,17 @@
|
||||
}
|
||||
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;
|
||||
}
|
||||
const d = await apiJson("/api/options/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1484,7 +1495,42 @@
|
||||
const hint = closeGateHint(closePreview);
|
||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||
})() +
|
||||
renderTargetDelegateRow(p)
|
||||
renderTargetDelegateRow(p) +
|
||||
renderProfitExitRow(p)
|
||||
);
|
||||
}
|
||||
|
||||
function renderProfitExitRow(p) {
|
||||
const inst = p.inst_id || "";
|
||||
if (p.hedge_plan_target) {
|
||||
return "";
|
||||
}
|
||||
const enabled = !!p.profit_exit_enabled;
|
||||
const mult = p.profit_exit_mult != null && Number(p.profit_exit_mult) > 0
|
||||
? Number(p.profit_exit_mult)
|
||||
: 1;
|
||||
const state = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
||||
const req = p.profit_exit_required_recycle;
|
||||
let statusTxt = enabled
|
||||
? ("监控中 · " + fmt(mult, 2) + "倍")
|
||||
: "未开启";
|
||||
if (enabled && state === "closing") statusTxt = "平仓挂单中 · " + fmt(mult, 2) + "倍";
|
||||
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="' + mult + '"' + (enabled ? "" : " disabled") + ">" +
|
||||
'<button type="button" class="btn-secondary opt-profit-exit-save-btn" data-inst="' +
|
||||
inst + '">应用</button>' +
|
||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(enabled
|
||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtUsdc(req)) : ""))
|
||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1686,6 +1732,30 @@
|
||||
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(); });
|
||||
cb.addEventListener("change", function () {
|
||||
const row = cb.closest(".opt-profit-exit-pos-row");
|
||||
const multInp = row && row.querySelector(".opt-pos-profit-exit-mult");
|
||||
if (multInp) multInp.disabled = !cb.checked;
|
||||
});
|
||||
});
|
||||
container.querySelectorAll(".opt-pos-profit-exit-mult").forEach(function (inp) {
|
||||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
||||
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) {
|
||||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
||||
inp.addEventListener("input", function () {
|
||||
@@ -1776,6 +1846,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 enabled = !!(enabledEl && enabledEl.checked);
|
||||
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;
|
||||
}
|
||||
await refreshAllPositions();
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function paintTargetMonitors(list) {
|
||||
const box = document.getElementById("opt-target-monitors");
|
||||
const host = document.getElementById("opt-target-monitors-list");
|
||||
@@ -2349,6 +2452,18 @@
|
||||
bindOptionsPosTabs();
|
||||
hardenOrderAutofill();
|
||||
|
||||
(function bindProfitExitOpenControls() {
|
||||
const peCb = document.getElementById("opt-profit-exit-enabled");
|
||||
const peMult = document.getElementById("opt-profit-exit-mult");
|
||||
if (!peCb || !peMult) return;
|
||||
function sync() {
|
||||
peMult.disabled = !peCb.checked;
|
||||
if (peCb.checked && (!peMult.value || Number(peMult.value) <= 0)) peMult.value = "1";
|
||||
}
|
||||
peCb.addEventListener("change", sync);
|
||||
sync();
|
||||
})();
|
||||
|
||||
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
|
||||
r.addEventListener("change", function () {
|
||||
updateSizeInputs();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=115">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=116">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||
<title>{{ pwa_app_name }}</title>
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=115">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=116">
|
||||
|
||||
</head>
|
||||
<body
|
||||
|
||||
@@ -98,6 +98,9 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
for ddl in (
|
||||
"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 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)
|
||||
|
||||
@@ -428,6 +428,8 @@ def options_monitor_loop(
|
||||
profit_ratio: float,
|
||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | 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,
|
||||
stop_event: Any = None,
|
||||
) -> None:
|
||||
@@ -459,6 +461,21 @@ def options_monitor_loop(
|
||||
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:
|
||||
sync_trades_fn(conn)
|
||||
conn.commit()
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
|
||||
|
||||
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
|
||||
@@ -763,6 +763,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
if target_index <= 0:
|
||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||
profit_exit_enabled = bool(data.get("profit_exit_enabled"))
|
||||
profit_exit_mult = 1.0
|
||||
if profit_exit_enabled:
|
||||
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:
|
||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||
q = cfg["quote_option_contract"](ex, inst_id)
|
||||
@@ -926,6 +932,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
open_opt_type = None
|
||||
try:
|
||||
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 {}
|
||||
u = str(meta.get("uly") or inst_id).split("-")[0]
|
||||
opt_type = meta.get("optType")
|
||||
@@ -935,8 +944,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id,
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
inst_id,
|
||||
@@ -950,6 +960,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
sizing["total_premium"],
|
||||
signal_note,
|
||||
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)
|
||||
@@ -965,6 +978,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
trade_id=trade_id,
|
||||
sheets=sheets,
|
||||
)
|
||||
if profit_exit_enabled:
|
||||
pass # 列已由 init_options_tables / ensure 迁移
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1083,9 +1098,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
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
|
||||
|
||||
tgt_map = targets_by_inst(conn)
|
||||
profit_exit_map = profit_exit_by_inst(conn)
|
||||
hedge_target_map = active_options_targets_by_inst(conn)
|
||||
rows = []
|
||||
for p in raw:
|
||||
@@ -1104,6 +1121,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
row["target_index"] = mon.get("target_index")
|
||||
row["target_monitor_id"] = mon.get("id")
|
||||
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)
|
||||
if hedge_target:
|
||||
row["hedge_plan_target"] = hedge_target
|
||||
@@ -1230,6 +1253,62 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
finally:
|
||||
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"])
|
||||
@lr
|
||||
def api_options_close():
|
||||
@@ -1583,6 +1662,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
pass
|
||||
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]:
|
||||
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
|
||||
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
|
||||
@@ -1633,6 +1730,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"profit_ratio": cfg["profit_ratio"],
|
||||
"sync_trades_fn": _sync,
|
||||
"target_close_fn": _target_close,
|
||||
"profit_exit_close_fn": _profit_exit_close,
|
||||
"profit_exit_cfg": cfg,
|
||||
"stale_pending_fn": _stale_pending,
|
||||
},
|
||||
daemon=True,
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<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>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
|
||||
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
|
||||
<li>平仓仅买一限价,详见说明文档。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
@@ -124,6 +125,18 @@
|
||||
</div>
|
||||
<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" disabled
|
||||
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 class="form-row options-order-mode-row">
|
||||
<div class="opt-size-mode-bar">
|
||||
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
|
||||
@@ -197,6 +210,7 @@
|
||||
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
||||
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
||||
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
||||
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
|
||||
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
@@ -336,4 +350,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=59"></script>
|
||||
<script src="/static/options_panel.js?v=60"></script>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""单独期权翻倍出场命中条件."""
|
||||
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()
|
||||
Reference in New Issue
Block a user