Files
crypto_monitor/lib/options/options_target_lib.py
T
dekun 5c3969674a feat(options): use premium profit RR instead of target index
单独期权与中控改为盈亏比×权利金触发买一平仓,默认2;不达标等到期。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 10:34:14 +08:00

621 lines
21 KiB
Python

"""期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算).
兼容旧「目标指数」委托:无 profit_rr 时仍按指数到位触发.
"""
from __future__ import annotations
import sqlite3
import time
from typing import Any, Callable
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
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_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]:
from lib.exchange.okx_options_lib import option_fields_from_inst_id
inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
if mark is None:
mark = fetch_option_mark_px(ex, inst_id)
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
if not opt_type or strike is None:
pt, ps = option_fields_from_inst_id(inst_id)
opt_type = opt_type or pt
if strike is None:
strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
def ensure_target_tables(conn: sqlite3.Connection) -> None:
init_options_tables(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_target_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL,
underlying TEXT,
opt_type TEXT,
target_index REAL NOT NULL,
trade_id INTEGER,
sheets INTEGER,
status TEXT DEFAULT 'active',
trigger_idx REAL,
close_ord_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
triggered_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_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:
"""旧逻辑:Call 指数≥目标;Put 指数≤目标."""
ot = (opt_type or "").strip().upper()
if ot == "P":
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(
conn: sqlite3.Connection,
*,
inst_id: str,
target_index: float | None = None,
profit_rr: float | None = None,
underlying: str | None = None,
opt_type: str | None = None,
trade_id: int | None = None,
sheets: int | None = None,
) -> dict[str, Any]:
ensure_target_tables(conn)
inst_id = (inst_id or "").strip()
if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"}
rr = _safe_float(profit_rr)
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(
"""
SELECT id FROM options_target_monitors
WHERE inst_id = ? AND status IN ('active', 'closing')
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
LIMIT 1
""",
(inst_id,),
).fetchone()
if row:
conn.execute(
"""
UPDATE options_target_monitors
SET target_index = ?,
profit_rr = ?,
underlying = COALESCE(?, underlying),
opt_type = COALESCE(?, opt_type),
trade_id = COALESCE(?, trade_id),
sheets = COALESCE(?, sheets),
status = 'active',
trigger_idx = NULL,
close_ord_id = NULL,
message = NULL,
triggered_at = NULL
WHERE id = ?
""",
(tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])),
)
mon_id = int(row["id"])
conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '被新目标委托覆盖'
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
""",
(inst_id, mon_id),
)
else:
cur = conn.execute(
"""
INSERT INTO options_target_monitors
(inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
""",
(inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets),
)
mon_id = int(cur.lastrowid)
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:
ensure_target_tables(conn)
if monitor_id is not None:
cur = conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消'
WHERE id = ? AND status IN ('active', 'closing')
""",
(int(monitor_id),),
)
return int(cur.rowcount or 0)
if inst_id:
cur = conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消'
WHERE inst_id = ? AND status IN ('active', 'closing')
""",
(inst_id.strip(),),
)
return int(cur.rowcount or 0)
return 0
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 {
"id": int(r["id"]),
"inst_id": r["inst_id"],
"underlying": r["underlying"],
"opt_type": r["opt_type"],
"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"],
"sheets": r["sheets"],
"status": r["status"],
"message": r["message"],
"created_at": r["created_at"],
}
_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]]:
ensure_target_tables(conn)
rows = conn.execute(
f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC"
).fetchall()
return [_row_to_target(r) for r in rows]
def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
ensure_target_tables(conn)
rows = conn.execute(
f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC"
).fetchall()
return [_row_to_target(r) for r in rows]
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""UI/持仓挂载:active 与 closing 都算进行中."""
out: dict[str, dict[str, Any]] = {}
for t in list_closing_targets(conn) + list_active_targets(conn):
inst = str(t.get("inst_id") or "")
if inst and inst not in out:
out[inst] = t
return out
def mark_monitor(
conn: sqlite3.Connection,
monitor_id: int,
*,
status: str,
trigger_idx: float | None = None,
close_ord_id: str | None = None,
message: str | None = None,
) -> None:
conn.execute(
"""
UPDATE options_target_monitors
SET status = ?,
trigger_idx = COALESCE(?, trigger_idx),
close_ord_id = COALESCE(?, close_ord_id),
message = COALESCE(?, message),
triggered_at = CASE
WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
ELSE triggered_at
END
WHERE id = ?
""",
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
)
def cancel_orphans_without_position(
conn: sqlite3.Connection,
*,
live_inst_ids: set[str],
) -> int:
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
ensure_target_tables(conn)
rows = list_active_targets(conn) + list_closing_targets(conn)
n = 0
for t in rows:
inst = str(t.get("inst_id") or "")
if inst and inst not in live_inst_ids:
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
n += 1
return n
def _commit_monitor(conn: sqlite3.Connection) -> None:
"""状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
try:
conn.commit()
except Exception:
pass
def close_option_by_bid_depth(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
"""目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
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=True,
signal_note="盈亏比平仓",
)
def _notify_target_close(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
target: float | None,
profit_rr: float | None,
idx: float | None,
result: dict[str, Any],
conn: Any = None,
) -> None:
"""目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
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="盈亏比平仓" if profit_rr else "目标位平仓",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target,
trigger_idx=idx,
profit_rr=profit_rr,
)
return
except Exception:
pass
if not send_wechat:
return
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(
"\n".join(
[
"【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】",
f"账户:{account_label}",
f"合约:{inst_id}",
rule,
f"触发指数:{idx:g}" if idx is not None else "触发指数:—",
f"提交张数:{result.get('submitted_sheets') or ''}",
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 '挂单中/部分'}",
]
)
)
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 _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(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
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,
account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None,
) -> int:
"""
扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数.
"""
ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
live_ids = {k for k in pos_by_inst if k}
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:
# fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿
return 0
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn)
# 先处理已挂单等待成交的,绝不再发微信
for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "")
if not inst_id:
continue
if inst_id in hedge_managed:
mark_monitor(
conn,
int(mon["id"]),
status="expired",
message="已移交对冲计划托管,跳过单独目标平仓",
)
_commit_monitor(conn)
continue
if inst_id not in pos_by_inst:
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn)
continue
result = close_fn(inst_id)
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
if result.get("already_flat") or _result_fully_done(result):
mark_monitor(
conn,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="盈亏比限价平仓完成",
)
_commit_monitor(conn)
continue
mark_monitor(
conn,
int(mon["id"]),
status="closing",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
)
_commit_monitor(conn)
triggered = 0
for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "")
if not inst_id:
continue
if inst_id in hedge_managed:
mark_monitor(
conn,
int(mon["id"]),
status="expired",
message="已移交对冲计划托管,跳过单独目标平仓",
)
_commit_monitor(conn)
continue
pos = pos_by_inst.get(inst_id)
if not pos:
continue
should, idx = _monitor_should_close(
conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn
)
if not should:
continue
result = close_fn(inst_id)
rr = _safe_float(mon.get("profit_rr"))
target = _safe_float(mon.get("target_index"))
if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn)
continue
if not result.get("ok"):
mark_monitor(
conn,
int(mon["id"]),
status="active",
trigger_idx=idx,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
)
_commit_monitor(conn)
continue
done = _result_fully_done(result)
status = "triggered" if done else "closing"
hit_msg = (
"盈亏比达标限价平仓"
if (rr is not None and rr > 0)
else "目标位触发限价平仓"
)
mark_monitor(
conn,
int(mon["id"]),
status=status,
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=hit_msg if done else "已挂买一限价,等待成交",
)
_commit_monitor(conn)
triggered += 1
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
profit_rr=rr,
idx=idx,
result=result,
conn=conn,
)
return triggered