单独期权增加翻倍出场:可开关、自选倍数(默认1倍=盈利等于权利金),达标后买一限价平。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-12 10:31:29 +08:00
parent 8dda7500df
commit cd23ea74a6
11 changed files with 728 additions and 13 deletions
+16 -7
View File
@@ -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×权利金 |
| 锁定买一价 | | | ✅ | 下单价 = 通过校验时的买一 |
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
---
+16
View File
@@ -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;
}
+116 -1
View File
@@ -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();
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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
+3
View File
@@ -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)
+17
View File
@@ -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()
+377
View File
@@ -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
+101 -2
View File
@@ -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,
+15 -1
View File
@@ -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>
+65
View File
@@ -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()