Files
crypto_okx/lib/hedge_plan/hedge_plan_orders_lib.py
dekun a1abe159fa Initial standalone crypto_okx with one-click deploy.
Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:00:59 +08:00

1347 lines
48 KiB
Python

"""对冲计划开仓/平仓编排(可 dry_run 校验下单路径)."""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from typing import Any, Callable, Optional
def _now() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _env_bool(key: str, default: bool = False) -> bool:
raw = (os.getenv(key) or "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def open_order_mode() -> str:
v = (os.getenv("HEDGE_PLAN_OPEN_ORDER") or "options_first").strip().lower()
return v if v in ("options_first", "perp_first") else "options_first"
def manual_complete_on_partial() -> bool:
"""半腿失败后挂 partial 并手动补开(默认 true)."""
return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True)
def partial_auto_close_enabled() -> bool:
"""手动补开开启时强制关闭自动平,避免吃买卖价差."""
if manual_complete_on_partial():
return False
return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True)
def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易)."""
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
perp_direction_for_view,
)
opt_primary = is_option_primary(body)
mode = "options_first" if opt_primary else open_order_mode()
view = str(body.get("direction") or "long")
perp_dir = perp_direction_for_view(view) if opt_primary else view
opt = {
"step": "options_buy_limit",
"account": "options",
"inst_id": body.get("opt_inst_id"),
"sheets": float(body.get("sheets") or 1),
"side": "buy",
"price_hint": "ask",
}
perp = {
"step": "perp_market_open",
"account": "swap",
"symbol": body.get("exchange_symbol"),
"direction": perp_dir,
"contracts": float(body.get("contracts") or 0),
"tp": None if opt_primary else body.get("tp"),
"sl": None if opt_primary else body.get("sl"),
"attach_tpsl": False if opt_primary else True,
"option_primary": opt_primary,
"view_side": view,
}
return [opt, perp] if mode == "options_first" else [perp, opt]
def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
return [
{
"step": "options_buy_limit",
"account": "options",
"leg": "a",
"inst_id": (body.get("leg_a") or {}).get("inst_id"),
"sheets": float((body.get("leg_a") or {}).get("sheets") or 1),
"side": "buy",
"price_hint": "ask",
},
{
"step": "options_buy_limit",
"account": "options",
"leg": "b",
"inst_id": (body.get("leg_b") or {}).get("inst_id"),
"sheets": float((body.get("leg_b") or {}).get("sheets") or 1),
"side": "buy",
"price_hint": "ask",
},
]
def _option_open_fill_timeout_sec() -> float:
try:
return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
return 12.0
def _buy_option(
cfg: dict[str, Any],
*,
inst_id: str,
sheets: float,
dry_run: bool,
) -> dict[str, Any]:
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
wait_option_order_full_fill,
)
ex = cfg.get("exchange_options")
quote_fn = cfg.get("quote_option_contract")
place_fn = cfg.get("place_option_limit_order")
td_buy = cfg.get("td_mode_for_option_buy")
if not inst_id:
return {"ok": False, "msg": "缺少期权合约"}
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
q = quote_fn(ex, inst_id)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q}
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
if not can_open:
return {
"ok": False,
"msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入",
"quote": q,
"mark": q.get("mark"),
"ref_ask": q.get("ref_ask"),
"can_open": False,
}
from lib.options.options_position_limit_lib import option_position_limit_block_msg
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False}
sheets_i = max(1, int(round(float(sheets))))
requested_sheets = sheets_i
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1)
if capped is None:
return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q}
if int(capped) < requested_sheets:
return {
"ok": False,
"msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交",
"quote": q,
"can_open": False,
"requested_sheets": requested_sheets,
"ask_sz": ask_sz,
}
sheets_i = int(capped)
ct_mult = float(q.get("ct_mult") or 0.01)
premium = float(ask) * sheets_i * ct_mult
if dry_run:
return {
"ok": True,
"dry_run": True,
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"ask_sz": float(ask_sz),
"premium": premium,
"ct_mult": ct_mult,
"tick_sz": q.get("tick_sz"),
"meta": q.get("meta") or {},
"strike": q.get("strike"),
"exp_time": q.get("exp_time"),
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
"can_open": True,
}
if not callable(place_fn):
return {"ok": False, "msg": "期权限价下单未注入"}
td = "isolated"
if callable(td_buy):
td = td_buy(cfg.get("options_td_mode") or "isolated")
# IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交
order = place_fn(
ex,
inst_id=inst_id,
side="buy",
sheets=sheets_i,
price=float(ask),
td_mode=td,
tick_sz=q.get("tick_sz"),
ord_type="ioc",
)
if not order.get("ok"):
return order
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
return {"ok": False, "msg": "下单成功但未返回订单号", "order": order}
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=sheets_i,
timeout_sec=_option_open_fill_timeout_sec(),
cancel_on_timeout=True,
)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
orphan_close = None
if filled_n > 0 and not dry_run:
# 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头
try:
orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n))
except Exception as e:
orphan_close = {"ok": False, "msg": str(e)}
return {
"ok": False,
"msg": fill.get("msg") or "未完全成交,开仓失败",
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"exchange_ord_id": ord_id,
"filled_sheets": filled_n,
"orphan_close": orphan_close,
"order": order,
"fill": fill,
"can_open": False,
}
fill_px = float(fill.get("avg_px") or ask)
filled_n = int(fill.get("filled_sheets") or sheets_i)
premium = fill_px * filled_n * ct_mult
return {
"ok": True,
"inst_id": inst_id,
"sheets": filled_n,
"ask": fill_px,
"ask_sz": float(ask_sz),
"premium": premium,
"ct_mult": ct_mult,
"tick_sz": q.get("tick_sz"),
"meta": q.get("meta") or {},
"strike": q.get("strike"),
"exp_time": q.get("exp_time"),
"opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"),
"exchange_ord_id": ord_id,
"order": order,
"fill": fill,
"can_open": True,
}
def _open_perp(
cfg: dict[str, Any],
*,
symbol: str,
direction: str,
contracts: float,
leverage: int,
tp: Optional[float],
sl: Optional[float],
dry_run: bool,
attach_tpsl: bool = True,
) -> dict[str, Any]:
if not symbol or contracts <= 0:
return {"ok": False, "msg": "永续符号或张数无效"}
amount = float(contracts)
to_prec = cfg.get("amount_to_precision")
ex = cfg.get("exchange")
if callable(to_prec) and ex is not None:
try:
amount = float(to_prec(symbol, amount))
except Exception:
pass
if amount <= 0:
return {"ok": False, "msg": "张数经精度舍入后为 0"}
use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None
tp_v = float(tp) if use_tpsl else None
sl_v = float(sl) if use_tpsl else None
if dry_run:
return {
"ok": True,
"dry_run": True,
"symbol": symbol,
"direction": direction,
"contracts": amount,
"leverage": leverage,
"tp": tp_v,
"sl": sl_v,
"attach_tpsl": use_tpsl,
}
ensure = cfg.get("ensure_okx_live_ready")
if callable(ensure):
ok, msg = ensure()
if not ok:
return {"ok": False, "msg": msg or "实盘未就绪"}
place = cfg.get("place_exchange_order")
if not callable(place):
return {"ok": False, "msg": "永续下单函数未注入"}
try:
order = place(
symbol,
direction,
amount,
leverage,
stop_loss=sl_v,
take_profit=tp_v,
)
except Exception as e:
return {"ok": False, "msg": f"永续开仓失败: {e}"}
return {
"ok": True,
"symbol": symbol,
"direction": direction,
"contracts": amount,
"leverage": leverage,
"tp": tp_v,
"sl": sl_v,
"attach_tpsl": use_tpsl,
"order": order,
"exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""),
}
def _close_perp(
cfg: dict[str, Any],
*,
symbol: str,
direction: str,
contracts: float,
dry_run: bool = False,
) -> dict[str, Any]:
"""市价平永续(reduce-only);优先用注入的 close_exchange_order."""
if not symbol:
return {"ok": False, "msg": "永续符号无效"}
if dry_run:
return {
"ok": True,
"dry_run": True,
"symbol": symbol,
"direction": direction,
"contracts": float(contracts or 0),
}
close_fn = cfg.get("close_exchange_order")
if callable(close_fn):
try:
order = close_fn(
{
"exchange_symbol": symbol,
"direction": direction,
"order_amount": float(contracts or 0),
"symbol": symbol,
}
)
return {"ok": True, "symbol": symbol, "direction": direction, "order": order}
except Exception as e:
return {"ok": False, "msg": f"永续平仓失败: {e}"}
# 回退:对向市价 reduce-only(若注入了 place + 支持)
place = cfg.get("place_exchange_order")
if not callable(place):
return {"ok": False, "msg": "永续平仓函数未注入"}
try:
# 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order
side_dir = "short" if str(direction).lower() == "long" else "long"
order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None)
return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"}
except Exception as e:
return {"ok": False, "msg": f"永续平仓失败: {e}"}
def _sell_option(
cfg: dict[str, Any],
*,
inst_id: str,
sheets: float,
dry_run: bool = False,
) -> dict[str, Any]:
"""平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功.
对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False).
"""
from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions
from lib.options.options_close_exec_lib import close_option_by_bid1
ex = cfg.get("exchange_options")
quote_fn = cfg.get("quote_option_contract")
if not inst_id:
return {"ok": False, "msg": "缺少期权合约"}
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
q = quote_fn(ex, inst_id)
bid = q.get("bid") if q.get("ok") else None
if bid is None or float(bid) <= 0:
return {"ok": False, "msg": "暂无买一价,无法平期权"}
sheets_i = max(1, int(round(float(sheets))))
if dry_run:
return {
"ok": True,
"dry_run": True,
"inst_id": inst_id,
"sheets": sheets_i,
"bid": float(bid),
"fully_closed": True,
}
if not callable(cfg.get("place_option_limit_order")):
return {"ok": False, "msg": "期权平仓未注入"}
close_cfg = dict(cfg)
if not callable(close_cfg.get("fetch_option_positions")):
close_cfg["fetch_option_positions"] = fetch_option_positions
if not callable(close_cfg.get("fetch_option_book_depth")):
close_cfg["fetch_option_book_depth"] = fetch_option_book_depth
if "td_mode" not in close_cfg:
close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated"
result = close_option_by_bid1(
close_cfg,
ex,
inst_id,
sheets=sheets_i,
require_recycle_gate=False,
)
out = dict(result or {})
if out.get("already_flat"):
# 二次验仓,避免一次空列表误判已平
import time as _time
_time.sleep(0.35)
try:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache()
except Exception:
pass
rows2 = close_cfg["fetch_option_positions"](ex)
if rows2 is None:
return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False}
still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None)
still_sz = 0.0
if still is not None:
try:
still_sz = abs(float(still.get("availPos") or still.get("pos") or 0))
except (TypeError, ValueError):
still_sz = 0.0
if still is not None and still_sz >= 1:
return {
"ok": False,
"msg": "二次验仓仍有持仓,拒绝 already_flat",
"fully_closed": False,
}
out["ok"] = True
out["fully_closed"] = True
out.setdefault("bid", float(bid))
return out
if not out.get("ok"):
out.setdefault("bid", float(bid))
return out
if not out.get("fully_closed"):
return {
"ok": False,
"msg": out.get("msg") or "期权尚未完全平仓,将下轮重试",
"bid": out.get("locked_bid_px") or float(bid),
"fully_closed": False,
"partial": True,
"close": out,
}
out["bid"] = out.get("locked_bid_px") or float(bid)
out["fully_closed"] = True
return out
def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None:
try:
from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail
notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results)
except Exception:
pass
def _park_partial(
cfg: dict[str, Any],
*,
plan_type: str,
body: dict[str, Any],
missing_leg: str,
msg: str,
path: list[dict[str, Any]],
results: list[dict[str, Any]],
persist: Optional[Callable[..., Any]],
dry_run: bool,
**filled: Any,
) -> dict[str, Any]:
"""半腿失败:保留已成腿,挂 partial 供手动补开."""
if not dry_run:
_notify_partial(cfg, plan_type, msg, results)
out: dict[str, Any] = {
"ok": True,
"partial": True,
"status": "partial",
"dry_run": dry_run,
"plan_type": plan_type,
"missing_leg": missing_leg,
"msg": msg,
"path": path,
"results": results,
"opened_at": _now(),
**filled,
}
if persist and not dry_run:
out["plan_id"] = persist(out, body)
return out
def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float:
"""对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立."""
raw = None
if cfg is not None:
raw = cfg.get("budget_buffer")
if raw is None or raw == "":
raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"
try:
buf = float(raw)
except (TypeError, ValueError):
buf = 0.95
if buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
return float(buf)
def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]:
from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by
split = None
ratio = None
if cfg is not None:
split = cfg.get("oo_bias_split_by")
ratio = cfg.get("oo_bias_ratio")
if split in (None, ""):
split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget"
if ratio in (None, ""):
ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7"
return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio)
def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*.
方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额.
"""
from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok
from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets
leg_a = dict(body.get("leg_a") or {})
leg_b = dict(body.get("leg_b") or {})
inst_a = str(leg_a.get("inst_id") or "").strip()
inst_b = str(leg_b.get("inst_id") or "").strip()
if not inst_a or not inst_b:
return {"ok": False, "msg": "缺少期权合约"}
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
qa = quote_fn(ex, inst_a)
if not qa.get("ok"):
return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa}
qb = quote_fn(ex, inst_b)
if not qb.get("ok"):
return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb}
for tag, q in (("A", qa), ("B", qb)):
can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not can_open:
return {
"ok": False,
"msg": f"{tag}: {block_msg or '暂无卖一深度,无法买入'}",
"quote_a": qa,
"quote_b": qb,
}
trading = fetch_options_trading_usdc(ex)
buf = _hedge_budget_buffer(cfg)
budget_info = resolve_oo_budget_usdc(
trading_usdc=trading,
trade_budget_usdc=cfg.get("trade_budget_usdc"),
buffer_ratio=buf,
)
if not budget_info.get("ok"):
return {
"ok": False,
"msg": budget_info.get("msg") or "可用预算不足",
"budget": budget_info,
"quote_a": qa,
"quote_b": qb,
}
mode = str(body.get("oo_sheets_mode") or "same_sheets")
split_by, bias_ratio = _oo_bias_settings(cfg)
opt_a = str(
leg_a.get("opt_type")
or (qa.get("meta") or {}).get("optType")
or qa.get("opt_type")
or ""
)
opt_b = str(
leg_b.get("opt_type")
or (qb.get("meta") or {}).get("optType")
or qb.get("opt_type")
or ""
)
sug = suggest_oo_sheets(
mode=mode,
budget_usdc=float(budget_info["budget_usdc"]),
ask_a=float(qa["ask"]),
ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01),
ask_sz_a=qa.get("ask_sz"),
opt_type_a=opt_a,
ask_b=float(qb["ask"]),
ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01),
ask_sz_b=qb.get("ask_sz"),
opt_type_b=opt_b,
bias_split_by=split_by,
bias_ratio=bias_ratio,
)
if not sug.get("ok"):
return {
"ok": False,
"msg": sug.get("msg") or "按最新卖一无法建议张数",
"sizing": sug,
"budget": budget_info,
"quote_a": qa,
"quote_b": qb,
}
prev_a = leg_a.get("sheets")
prev_b = leg_b.get("sheets")
leg_a["sheets"] = int(sug["sheets_a"])
leg_a["ask"] = float(qa["ask"])
leg_a["ask_sz"] = qa.get("ask_sz")
leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01)
if opt_a:
leg_a["opt_type"] = opt_a
leg_b["sheets"] = int(sug["sheets_b"])
leg_b["ask"] = float(qb["ask"])
leg_b["ask_sz"] = qb.get("ask_sz")
leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01)
if opt_b:
leg_b["opt_type"] = opt_b
body["leg_a"] = leg_a
body["leg_b"] = leg_b
return {
"ok": True,
"buffer_ratio": buf,
"budget": budget_info,
"sizing": sug,
"quote_a": qa,
"quote_b": qb,
"prev_sheets_a": prev_a,
"prev_sheets_b": prev_b,
"sheets_a": int(sug["sheets_a"]),
"sheets_b": int(sug["sheets_b"]),
"ask_a": float(qa["ask"]),
"ask_b": float(qb["ask"]),
"premium_est": sug.get("premium_est"),
"msg": (
f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + "
f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U"
),
}
def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓."""
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium
inst = str(body.get("opt_inst_id") or "").strip()
if not inst:
return {"ok": False, "msg": "缺少期权合约"}
quote_fn = cfg.get("quote_option_contract")
ex = cfg.get("exchange_options")
if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"}
q = quote_fn(ex, inst)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q}
can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"quote": q,
}
body["ask"] = float(q["ask"])
body["ask_sz"] = q.get("ask_sz")
if q.get("ct_mult") is not None:
body["ct_mult"] = float(q.get("ct_mult") or 0.01)
if is_option_primary(body):
cs = float(body.get("contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
sym = str(body.get("exchange_symbol") or "")
if callable(get_cs) and sym:
try:
cs = float(get_cs(sym) or cs)
except Exception:
pass
sized = size_from_premium(
premium_budget=float(body.get("premium_budget") or 0),
ask=float(body["ask"]),
ct_mult=float(body.get("ct_mult") or 0.01),
ratio=float(body.get("option_perp_ratio") or 2),
contract_size=cs,
)
if not sized.get("ok"):
return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized}
body["sheets"] = sized["sheets"]
body["contracts"] = sized["contracts"]
body["eth_qty"] = sized["eth_qty"]
body["contract_size"] = cs
# 深度不足则缩量
ask_sz = float(q.get("ask_sz") or 0)
if ask_sz > 0 and float(body["sheets"]) > ask_sz:
body["sheets"] = float(int(ask_sz))
if body["sheets"] <= 0:
return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized}
eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2)
body["eth_qty"] = eth
body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs
return {
"ok": True,
"ask": float(q["ask"]),
"ask_sz": q.get("ask_sz"),
"sheets": body.get("sheets"),
"contracts": body.get("contracts"),
"eth_qty": body.get("eth_qty"),
"sizing": sized,
"quote": q,
"msg": (
f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / "
f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}"
),
}
return {
"ok": True,
"ask": float(q["ask"]),
"ask_sz": q.get("ask_sz"),
"sheets": body.get("sheets"),
"quote": q,
"msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}",
}
def execute_perp_options_start(
cfg: dict[str, Any],
body: dict[str, Any],
*,
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
refresh = refresh_po_option_quote_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh}
path = build_po_path_plan(body)
results: list[dict[str, Any]] = []
opt_res: Optional[dict[str, Any]] = None
perp_res: Optional[dict[str, Any]] = None
for step in path:
if step["step"] == "options_buy_limit":
opt_res = _buy_option(
cfg,
inst_id=str(body.get("opt_inst_id") or ""),
sheets=float(body.get("sheets") or 1),
dry_run=dry_run,
)
results.append({"step": step["step"], **opt_res})
if not opt_res.get("ok"):
# 永续已成、期权失败 → 可挂 partial 等补开期权
if (
perp_res
and perp_res.get("ok")
and not dry_run
and manual_complete_on_partial()
and persist
):
return _park_partial(
cfg,
plan_type="perp_options",
body=body,
missing_leg="option_hedge",
msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
option=None,
perp=perp_res,
)
return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else:
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
perp_direction_for_view,
)
opt_primary = is_option_primary(body)
view = str(body.get("direction") or "long")
perp_dir = str(step.get("direction") or (
perp_direction_for_view(view) if opt_primary else view
))
attach = bool(step.get("attach_tpsl", not opt_primary))
tp_v = None if not attach else body.get("tp")
sl_v = None if not attach else body.get("sl")
if attach:
tp_v = float(body["tp"])
sl_v = float(body["sl"])
perp_res = _open_perp(
cfg,
symbol=str(body.get("exchange_symbol") or ""),
direction=perp_dir,
contracts=float(body.get("contracts") or 0),
leverage=int(body.get("leverage") or (100 if opt_primary else 10)),
tp=tp_v,
sl=sl_v,
dry_run=dry_run,
attach_tpsl=attach,
)
results.append({"step": step["step"], **perp_res})
if not perp_res.get("ok"):
if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled():
close_r = _sell_option(
cfg,
inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""),
sheets=float(opt_res.get("sheets") or body.get("sheets") or 1),
)
results.append({"step": "options_auto_close_on_perp_fail", **close_r})
msg = perp_res.get("msg") or "永续开仓失败"
_notify_partial(cfg, "perp_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
}
if (
opt_res
and opt_res.get("ok")
and not dry_run
and manual_complete_on_partial()
and persist
):
return _park_partial(
cfg,
plan_type="perp_options",
body=body,
missing_leg="perp",
msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
option=opt_res,
perp=None,
)
msg = perp_res.get("msg") or "永续开仓失败"
if not dry_run:
_notify_partial(cfg, "perp_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
}
out = {
"ok": True,
"dry_run": dry_run,
"plan_type": "perp_options",
"path": path,
"results": results,
"option": opt_res,
"perp": perp_res,
"refresh": refresh,
"opened_at": _now(),
}
if persist and not dry_run:
out["plan_id"] = persist(out, body)
return out
def execute_options_options_start(
cfg: dict[str, Any],
body: dict[str, Any],
*,
dry_run: bool = False,
persist: Optional[Callable[..., Any]] = None,
) -> dict[str, Any]:
refresh = refresh_oo_sizing_before_start(cfg, body)
if not refresh.get("ok"):
return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh}
path = build_oo_path_plan(body)
results: list[dict[str, Any]] = []
leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {}
inst_a = str(leg_a.get("inst_id") or "")
inst_b = str(leg_b.get("inst_id") or "")
from lib.options.options_position_limit_lib import option_position_limit_block_msg
pos_limit_msg = option_position_limit_block_msg(
cfg.get("exchange_options"),
opening_inst_ids=[inst_a, inst_b],
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {
"ok": False,
"msg": pos_limit_msg,
"path": path,
"results": [],
"refresh": refresh,
}
a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "a", **a_res})
if not a_res.get("ok"):
return {
"ok": False,
"msg": a_res.get("msg") or "腿A开仓失败",
"path": path,
"results": results,
"refresh": refresh,
}
b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "b", **b_res})
if not b_res.get("ok"):
if not dry_run and partial_auto_close_enabled():
close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1))
results.append({"step": "options_auto_close_leg_a", **close_r})
msg = b_res.get("msg") or "腿B开仓失败"
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
}
if not dry_run and manual_complete_on_partial() and persist:
out_p = _park_partial(
cfg,
plan_type="options_options",
body=body,
missing_leg="option_b",
msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B",
path=path,
results=results,
persist=persist,
dry_run=dry_run,
leg_a=a_res,
leg_b=None,
)
out_p["refresh"] = refresh
return out_p
msg = b_res.get("msg") or "腿B开仓失败"
if not dry_run:
_notify_partial(cfg, "options_options", msg, results)
return {
"ok": False,
"msg": msg,
"path": path,
"results": results,
"partial": True,
"refresh": refresh,
}
out = {
"ok": True,
"dry_run": dry_run,
"plan_type": "options_options",
"path": path,
"results": results,
"leg_a": a_res,
"leg_b": b_res,
"refresh": refresh,
"opened_at": _now(),
}
if persist and not dry_run:
out["plan_id"] = persist(out, body)
return out
def execute_complete_missing_leg(
cfg: dict[str, Any],
plan: dict[str, Any],
legs: list[dict[str, Any]],
start_body: dict[str, Any],
*,
dry_run: bool = False,
) -> dict[str, Any]:
"""对 partial 计划补开缺失腿;成功后由调用方把计划升为 active."""
missing = None
for leg in legs:
if str(leg.get("status") or "").lower() == "pending":
missing = leg
break
if not missing:
return {"ok": False, "msg": "没有待补开的腿"}
role = str(missing.get("leg_role") or "")
results: list[dict[str, Any]] = []
if role == "perp":
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
perp_direction_for_view,
)
opt_primary = is_option_primary(start_body)
view = str(start_body.get("direction") or "long")
perp_dir = perp_direction_for_view(view) if opt_primary else view
attach = not opt_primary
res = _open_perp(
cfg,
symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""),
direction=perp_dir,
contracts=float(start_body.get("contracts") or missing.get("size") or 0),
leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)),
tp=None if not attach else float(start_body["tp"]),
sl=None if not attach else float(start_body["sl"]),
dry_run=dry_run,
attach_tpsl=attach,
)
results.append({"step": "perp_market_open", "complete": True, **res})
if not res.get("ok"):
return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role}
return {
"ok": True,
"leg_role": role,
"leg_id": missing.get("id"),
"results": results,
"fill": res,
"opened_at": _now(),
}
if role in ("option_hedge", "option_b", "option_a"):
if role == "option_b":
src = start_body.get("leg_b") or {}
inst = str(src.get("inst_id") or missing.get("inst_id") or "")
sheets = float(src.get("sheets") or missing.get("size") or 1)
elif role == "option_a":
src = start_body.get("leg_a") or {}
inst = str(src.get("inst_id") or missing.get("inst_id") or "")
sheets = float(src.get("sheets") or missing.get("size") or 1)
else:
inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "")
sheets = float(start_body.get("sheets") or missing.get("size") or 1)
res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run)
results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res})
if not res.get("ok"):
return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role}
return {
"ok": True,
"leg_role": role,
"leg_id": missing.get("id"),
"results": results,
"fill": res,
"opened_at": _now(),
}
return {"ok": False, "msg": f"未知待补腿: {role}"}
def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower()
if pt == "perp_options":
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
validate_option_primary_start,
)
if is_option_primary(body):
from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch
# 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验
watch = body.get("watch_entry")
if watch in (None, "", True, 1, "1", "true", "yes", "on"):
return validate_option_primary_watch(body)
return validate_option_primary_start(body)
need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol")
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
return "张数必须大于 0"
entry = float(body["entry"])
tp = float(body["tp"])
sl = float(body["sl"])
if tp <= 0 or sl <= 0 or entry <= 0:
return "止盈/止损/入场无效"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
opt_type = str(body.get("opt_type") or "").strip().upper()
if not opt_type:
# 允许从合约名推断 ETH-USD-...-P / -C
inst = str(body.get("opt_inst_id") or "")
if inst.upper().endswith("-P"):
opt_type = "P"
elif inst.upper().endswith("-C"):
opt_type = "C"
if opt_type not in ("P", "C"):
return "缺少期权类型(Put/Call)"
if direction == "long" and opt_type != "P":
return "做多永期对冲须用 Put"
if direction == "short" and opt_type != "C":
return "做空永期对冲须用 Call"
if direction == "long" and not (sl < entry < tp):
return "做多须满足 止损 < 入场 < 止盈"
if direction == "short" and not (tp < entry < sl):
return "做空须满足 止盈 < 入场 < 止损"
from lib.hedge_plan.hedge_plan_moneyness_lib import (
parse_strike_from_inst,
validate_po_option_moneyness,
)
strike = body.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
index_px = body.get("index_px")
if index_px in (None, ""):
index_px = entry
money_err = validate_po_option_moneyness(
opt_type=opt_type,
strike=strike,
index_px=index_px,
ask=body.get("ask"),
hours_to_expiry=body.get("hours_to_expiry"),
)
if money_err:
return money_err
return None
if pt == "options_options":
a = body.get("leg_a") or {}
b = body.get("leg_b") or {}
if not a.get("inst_id") or not b.get("inst_id"):
return "请选用两条期权腿"
rr_raw = body.get("profit_rr")
if rr_raw not in (None, ""):
try:
rr = float(rr_raw)
except (TypeError, ValueError):
return "盈亏比无效"
if rr <= 0:
return "盈亏比须大于0"
else:
# 兼容旧上/下破
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, ""):
return "请填写盈亏比"
try:
if float(up) <= float(down):
return "上破目标价必须大于下破目标价"
except (TypeError, ValueError):
return "目标价无效"
from lib.hedge_plan.hedge_plan_moneyness_lib import (
parse_strike_from_inst,
validate_oo_legs_moneyness,
)
def _leg_for_money(leg: dict) -> dict:
strike = leg.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(leg.get("inst_id") or ""))
opt_type = leg.get("opt_type")
if not opt_type:
inst = str(leg.get("inst_id") or "").upper()
if inst.endswith("-C"):
opt_type = "C"
elif inst.endswith("-P"):
opt_type = "P"
return {"opt_type": opt_type, "strike": strike}
index_px = body.get("index_px")
money_err = validate_oo_legs_moneyness(
_leg_for_money(a),
_leg_for_money(b),
index_px=index_px,
)
if money_err:
return money_err
return None
return "未知计划类型"
def dump_preview(preview: Any) -> str:
try:
return json.dumps(preview, ensure_ascii=False)[:8000]
except Exception:
return ""
def _live_option_pos_sheets(ex: Any, inst_id: str) -> float:
from lib.exchange.okx_options_lib import fetch_option_positions
inst_id = (inst_id or "").strip()
if not inst_id or ex is None:
return 0.0
rows = fetch_option_positions(ex)
if rows is None:
return -1.0 # API 失败:未知
for r in rows:
if str(r.get("instId") or "").strip() != inst_id:
continue
try:
return abs(float(r.get("pos") or 0))
except (TypeError, ValueError):
return 0.0
return 0.0
def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None:
"""腿状态校正后:有 open + pending → partial."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
plan = get_plan(conn, int(plan_id))
if not plan:
return
pst = str(plan.get("status") or "")
if pst not in ("opening", "active", "partial"):
return
legs = get_plan_legs(conn, int(plan_id))
statuses = [str(l.get("status") or "").lower() for l in legs]
n_open = sum(1 for s in statuses if s == "open")
n_pending = sum(1 for s in statuses if s == "pending")
if n_pending and n_open:
update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail")
def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]:
"""未成交却标 open 的期权腿 → pending(可补开);不显示成持仓."""
from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg
from lib.exchange.okx_options_lib import fetch_option_order
ex = cfg.get("exchange_options")
notes: list[str] = []
legs = get_plan_legs(conn, int(plan_id))
for leg in legs:
role = str(leg.get("leg_role") or "")
if not role.startswith("option"):
continue
st = str(leg.get("status") or "").lower()
if st != "open":
continue
inst = str(leg.get("inst_id") or "").strip()
oid = str(leg.get("exchange_ord_id") or "").strip()
leg_id = int(leg["id"])
sheets = _live_option_pos_sheets(ex, inst)
if sheets < 0:
continue # 查仓失败不改
if sheets >= 1:
continue
# 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单)
if ex is not None and inst and oid:
od = fetch_option_order(ex, inst_id=inst, ord_id=oid)
if od.get("ok"):
acc = float(od.get("acc_fill_sz") or 0)
ostate = str(od.get("state") or "")
if acc >= 1 or ostate == "filled":
continue # 有成交但仓位暂未同步,暂不改
update_leg(
conn,
leg_id,
status="pending",
close_reason=None,
closed_at=None,
avg_open=None,
premium=0,
)
notes.append(f"{inst} 无成交却标open→pending")
if notes:
_sync_plan_status_after_leg_fix(conn, int(plan_id))
return notes
def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]:
"""人工结束进行中计划:不自动平仓;未成交腿标 cancelled."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end
from lib.exchange.okx_options_lib import cancel_option_order
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
if st not in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": f"当前状态 {st or ''} 不可结束"}
notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
ex = cfg.get("exchange_options")
legs = get_plan_legs(conn, int(plan_id))
for leg in legs:
lst = str(leg.get("status") or "").lower()
inst = str(leg.get("inst_id") or "").strip()
oid = str(leg.get("exchange_ord_id") or "").strip()
if lst == "pending":
if ex is not None and inst and oid:
cancel_option_order(ex, inst_id=inst, ord_id=oid)
update_leg(
conn,
int(leg["id"]),
status="cancelled",
close_reason="manual_end",
closed_at=_now(),
avg_open=None,
premium=0,
)
notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled")
update_plan(
conn,
int(plan_id),
status="closed",
close_reason="manual",
closed_at=_now(),
note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500],
)
plan2 = get_plan(conn, int(plan_id))
if plan2:
try:
notify_plan_end(cfg, conn, plan2)
except Exception:
pass
return {
"ok": True,
"plan_id": int(plan_id),
"msg": "计划已结束(未自动平仓;有持仓请自行平掉)",
"notes": notes,
}