8b5080bdda
Co-authored-by: Cursor <cursoragent@cursor.com>
412 lines
13 KiB
Python
412 lines
13 KiB
Python
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
|
||
|
||
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:
|
||
from lib.options.options_notify_lib import resolve_options_premium_ccy
|
||
|
||
ccy = resolve_options_premium_ccy(inst_id=inst_id)
|
||
d = 6 if ccy in ("ETH", "BTC") else 4
|
||
mode = "币本位" if ccy != "USDC" else "USDC"
|
||
reason = f"翻倍出场({mult:g}倍)"
|
||
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=reason,
|
||
sheets=result.get("submitted_sheets"),
|
||
premium_received=result.get("premium_received"),
|
||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||
premium_ccy=ccy,
|
||
)
|
||
# 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推
|
||
return
|
||
except Exception:
|
||
pass
|
||
if not send_wechat:
|
||
return
|
||
try:
|
||
prem_txt = f"{float(premium_paid):.{d}f}" if premium_paid is not None else "—"
|
||
recv_txt = f"{float(recycle):.{d}f}" if recycle is not None else "—"
|
||
send_wechat(
|
||
"\n".join(
|
||
[
|
||
"【OKX期权·翻倍出场】",
|
||
f"账户:{account_label}",
|
||
f"本位:{mode}",
|
||
f"合约:{inst_id}",
|
||
f"倍数:{mult:g}(1倍=盈利=权利金)",
|
||
f"权利金:{prem_txt} {ccy}",
|
||
f"可回收:{recv_txt} {ccy}",
|
||
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)
|
||
_notify_profit_exit_close(
|
||
cfg,
|
||
send_wechat,
|
||
account_label=account_label,
|
||
inst_id=inst_id,
|
||
mult=mult,
|
||
premium_paid=prem,
|
||
recycle=None,
|
||
result={**result, "fully_closed": True},
|
||
conn=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)
|
||
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,
|
||
)
|
||
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
|