a1abe159fa
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>
528 lines
18 KiB
Python
528 lines
18 KiB
Python
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import os
|
||
from typing import Any, Optional
|
||
|
||
PREMIUM_EXEC_FACTOR = 0.95
|
||
DEFAULT_MIN_HOURS = 36.0
|
||
DEFAULT_STRIKE_INTERVAL = 15.0
|
||
DEFAULT_PERP_LEVERAGE = 100
|
||
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
|
||
DEFAULT_OPT_LEVERAGE_OTM = 200.0
|
||
DEFAULT_RATIO_ITM_ATM = 2.0
|
||
DEFAULT_RATIO_OTM = 4.0
|
||
OTM_LEV_FLOOR = 180.0
|
||
|
||
|
||
def _sf(v: Any) -> Optional[float]:
|
||
if v is None or v == "":
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
|
||
if not body_or_plan:
|
||
return False
|
||
v = body_or_plan.get("option_primary")
|
||
if v in (True, 1, "1", "true", "yes", "on"):
|
||
return True
|
||
try:
|
||
return int(v or 0) == 1
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
|
||
def fee_rate() -> float:
|
||
try:
|
||
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
|
||
except (TypeError, ValueError):
|
||
return 0.0005
|
||
|
||
|
||
def floor2(v: float) -> float:
|
||
"""ETH 数量向下取两位小数."""
|
||
if v <= 0:
|
||
return 0.0
|
||
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
|
||
|
||
|
||
def opt_type_for_view(direction: str) -> str:
|
||
"""看法做多→Call,做空→Put."""
|
||
return "P" if str(direction or "").strip().lower() == "short" else "C"
|
||
|
||
|
||
def perp_direction_for_view(direction: str) -> str:
|
||
"""看法做多→永续空,做空→永续多."""
|
||
return "long" if str(direction or "").strip().lower() == "short" else "short"
|
||
|
||
|
||
def default_opt_leverage(moneyness: str) -> float:
|
||
m = (moneyness or "").strip().lower()
|
||
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
|
||
|
||
|
||
def default_ratio(moneyness: str) -> float:
|
||
m = (moneyness or "").strip().lower()
|
||
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
|
||
|
||
|
||
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
|
||
cfg = _sf(configured)
|
||
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
|
||
if (moneyness or "").strip().lower() == "otm":
|
||
return max(base, OTM_LEV_FLOOR)
|
||
return base
|
||
|
||
|
||
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
|
||
exp = _sf(exp_ms)
|
||
if exp is None or exp <= 0:
|
||
return None
|
||
# OKX exp 多为毫秒
|
||
if exp < 1e12:
|
||
exp *= 1000.0
|
||
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
|
||
return (exp - now) / 3600000.0
|
||
|
||
|
||
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
|
||
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0."""
|
||
n = float(points or 0)
|
||
k = float(strike)
|
||
s = float(index_px)
|
||
if n <= 0 or k <= 0 or s <= 0:
|
||
return False
|
||
side = str(view_side or "").strip().lower()
|
||
if side == "short":
|
||
return s <= (k - n)
|
||
return s >= (k + n)
|
||
|
||
|
||
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
|
||
b = _sf(bid)
|
||
if b is None or b <= 0:
|
||
return False, "暂无买一报价,无法平期权"
|
||
sz = _sf(bid_sz)
|
||
if sz is not None and sz <= 0:
|
||
return False, "买一深度为 0,无法平期权"
|
||
need = float(need_sheets or 0)
|
||
if need > 0 and sz is not None and sz + 1e-12 < need:
|
||
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
|
||
return True, ""
|
||
|
||
|
||
def size_from_premium(
|
||
*,
|
||
premium_budget: float,
|
||
ask: float,
|
||
ct_mult: float,
|
||
ratio: float,
|
||
contract_size: float,
|
||
exec_factor: float = PREMIUM_EXEC_FACTOR,
|
||
) -> dict[str, Any]:
|
||
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
|
||
budget = float(premium_budget or 0)
|
||
a = float(ask or 0)
|
||
ct = float(ct_mult or 0.01)
|
||
r = float(ratio or 0)
|
||
cs = float(contract_size or 0.01)
|
||
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
|
||
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
|
||
return {
|
||
"ok": False,
|
||
"msg": "定仓参数无效",
|
||
"usable_premium": round(usable, 4),
|
||
"eth_qty": 0.0,
|
||
"sheets": 0.0,
|
||
"perp_eth": 0.0,
|
||
"contracts": 0.0,
|
||
}
|
||
# ask 为每 1 币权利金;ETH 数量 = usable / ask
|
||
eth_qty = floor2(usable / a)
|
||
if eth_qty <= 0:
|
||
return {
|
||
"ok": False,
|
||
"msg": "权利金不足以买入 0.01 ETH 名义期权",
|
||
"usable_premium": round(usable, 4),
|
||
"eth_qty": 0.0,
|
||
"sheets": 0.0,
|
||
"perp_eth": 0.0,
|
||
"contracts": 0.0,
|
||
}
|
||
sheets = eth_qty / ct
|
||
# 张数向下取整到整数张(OKX 期权常见整张)
|
||
sheets_i = float(math.floor(sheets + 1e-12))
|
||
if sheets_i <= 0:
|
||
return {
|
||
"ok": False,
|
||
"msg": "换算期权张数不足 1 张",
|
||
"usable_premium": round(usable, 4),
|
||
"eth_qty": eth_qty,
|
||
"sheets": 0.0,
|
||
"perp_eth": 0.0,
|
||
"contracts": 0.0,
|
||
}
|
||
# 用整张回写 ETH,保持与下单一致
|
||
eth_qty = round(sheets_i * ct, 2)
|
||
perp_eth = eth_qty / r
|
||
contracts = perp_eth / cs
|
||
premium_est = a * sheets_i * ct
|
||
return {
|
||
"ok": True,
|
||
"msg": "",
|
||
"usable_premium": round(usable, 4),
|
||
"eth_qty": eth_qty,
|
||
"sheets": sheets_i,
|
||
"perp_eth": round(perp_eth, 6),
|
||
"contracts": contracts,
|
||
"premium_est": round(premium_est, 4),
|
||
"ratio": r,
|
||
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
|
||
}
|
||
|
||
|
||
def estimate_combo_net_pnl(
|
||
*,
|
||
view_side: str,
|
||
strike: float,
|
||
index_px: float,
|
||
ask_open: float,
|
||
bid: float,
|
||
sheets: float,
|
||
ct_mult: float,
|
||
perp_direction: str,
|
||
perp_entry: float,
|
||
perp_mark: float,
|
||
contracts: float,
|
||
contract_size: float,
|
||
fee: Optional[float] = None,
|
||
) -> dict[str, Any]:
|
||
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
|
||
fr = fee if fee is not None else fee_rate()
|
||
ct = float(ct_mult or 0.01)
|
||
sh = float(sheets or 0)
|
||
a = float(ask_open or 0)
|
||
b = float(bid or 0)
|
||
premium = a * sh * ct
|
||
opt_proceeds = b * sh * ct
|
||
opt_open_fee = premium * fr
|
||
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
|
||
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
|
||
|
||
coins = float(contracts or 0) * float(contract_size or 0.01)
|
||
entry = float(perp_entry or 0)
|
||
mark = float(perp_mark or 0)
|
||
pd = str(perp_direction or "").strip().lower()
|
||
if pd == "short":
|
||
perp_gross = (entry - mark) * coins
|
||
else:
|
||
perp_gross = (mark - entry) * coins
|
||
perp_notional_open = abs(entry * coins)
|
||
perp_notional_close = abs(mark * coins)
|
||
perp_open_fee = perp_notional_open * fr
|
||
perp_close_fee = perp_notional_close * fr
|
||
perp_net = perp_gross - perp_open_fee - perp_close_fee
|
||
total = opt_net + perp_net
|
||
return {
|
||
"opt_net": round(opt_net, 4),
|
||
"perp_net": round(perp_net, 4),
|
||
"net": round(total, 4),
|
||
"fee_rate": fr,
|
||
"premium": round(premium, 4),
|
||
"opt_proceeds": round(opt_proceeds, 4),
|
||
}
|
||
|
||
|
||
def validate_option_primary_moneyness(
|
||
*,
|
||
opt_type: str,
|
||
strike: Any,
|
||
index_px: Any,
|
||
ask: Any = None,
|
||
moneyness: str = "atm",
|
||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
||
min_hours: Any = DEFAULT_MIN_HOURS,
|
||
hours_to_expiry: Any = None,
|
||
min_opt_leverage: Any = None,
|
||
) -> Optional[str]:
|
||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||
classify_moneyness,
|
||
is_atm_or_otm,
|
||
is_itm_or_atm,
|
||
normalize_opt_type,
|
||
)
|
||
|
||
o = normalize_opt_type(opt_type)
|
||
k = _sf(strike)
|
||
s = _sf(index_px)
|
||
if o not in ("C", "P"):
|
||
return "期权类型无效"
|
||
if k is None or s is None or s <= 0:
|
||
return "行权价或指数无效"
|
||
m_want = (moneyness or "atm").strip().lower()
|
||
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
||
if m_want == "itm":
|
||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
||
return "所选须为实值或平值"
|
||
elif m_want == "atm":
|
||
# 平值:距指数在间隔内即可(不强制 classify==atm)
|
||
pass
|
||
elif m_want == "otm":
|
||
if m_got == "itm":
|
||
return "虚值模式不可选实值"
|
||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
||
return "虚值模式须选虚值或平值档"
|
||
else:
|
||
return "期权类型(实/平/虚)无效"
|
||
|
||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
||
if interval > 0 and abs(k - s) > interval + 1e-9:
|
||
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
|
||
|
||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
||
h = _sf(hours_to_expiry)
|
||
if min_h > 0 and h is not None and h < min_h:
|
||
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
|
||
|
||
a = _sf(ask)
|
||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
|
||
if min_lev > 0 and a is not None and a > 0:
|
||
lev = s / a
|
||
if lev < min_lev:
|
||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
||
return None
|
||
|
||
|
||
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
|
||
"""盯盘启动校验:只要参数,不要求已选具体合约."""
|
||
need = (
|
||
"direction",
|
||
"exchange_symbol",
|
||
"premium_budget",
|
||
"option_target_points",
|
||
"perp_target_points",
|
||
"option_perp_ratio",
|
||
"option_leverage",
|
||
)
|
||
for k in need:
|
||
if body.get(k) in (None, ""):
|
||
return f"缺少字段: {k}"
|
||
try:
|
||
if float(body["premium_budget"]) <= 0:
|
||
return "权利金须大于 0"
|
||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
||
return "目标位点数须大于 0"
|
||
if float(body["option_perp_ratio"]) <= 0:
|
||
return "期权永续比例须大于 0"
|
||
if float(body["option_leverage"]) <= 0:
|
||
return "期权杠杆须大于 0"
|
||
lev_perp = _sf(body.get("leverage"))
|
||
if lev_perp is not None and lev_perp <= 0:
|
||
return "永续杠杆须大于 0"
|
||
except (TypeError, ValueError):
|
||
return "数值字段无效"
|
||
direction = str(body.get("direction") or "").strip().lower()
|
||
if direction not in ("long", "short"):
|
||
return "方向须为 long 或 short"
|
||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||
if moneyness not in ("itm", "atm", "otm"):
|
||
return "期权类型(实/平/虚)无效"
|
||
return None
|
||
|
||
|
||
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
|
||
need = (
|
||
"direction",
|
||
"contracts",
|
||
"opt_inst_id",
|
||
"sheets",
|
||
"exchange_symbol",
|
||
"premium_budget",
|
||
"option_target_points",
|
||
"perp_target_points",
|
||
"option_perp_ratio",
|
||
)
|
||
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"
|
||
if float(body["premium_budget"]) <= 0:
|
||
return "权利金须大于 0"
|
||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
||
return "目标位点数须大于 0"
|
||
if float(body["option_perp_ratio"]) <= 0:
|
||
return "期权永续比例须大于 0"
|
||
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:
|
||
inst = str(body.get("opt_inst_id") or "")
|
||
if inst.upper().endswith("-P"):
|
||
opt_type = "P"
|
||
elif inst.upper().endswith("-C"):
|
||
opt_type = "C"
|
||
want = opt_type_for_view(direction)
|
||
if opt_type != want:
|
||
return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}"
|
||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
||
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
|
||
|
||
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") or body.get("entry")
|
||
return validate_option_primary_moneyness(
|
||
opt_type=opt_type,
|
||
strike=strike,
|
||
index_px=index_px,
|
||
ask=body.get("ask"),
|
||
moneyness=moneyness,
|
||
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
|
||
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
|
||
hours_to_expiry=body.get("hours_to_expiry"),
|
||
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
|
||
)
|
||
|
||
|
||
def pick_option_primary_candidate(
|
||
chain: dict[str, Any],
|
||
*,
|
||
direction: str,
|
||
moneyness: str = "otm",
|
||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
||
min_hours: Any = DEFAULT_MIN_HOURS,
|
||
min_opt_leverage: Any = None,
|
||
) -> Optional[dict[str, Any]]:
|
||
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
|
||
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
|
||
|
||
want = opt_type_for_view(direction)
|
||
m_want = (moneyness or "otm").strip().lower()
|
||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
||
try:
|
||
idx = float(chain.get("index_px") or 0)
|
||
except (TypeError, ValueError):
|
||
idx = 0.0
|
||
if idx <= 0:
|
||
return None
|
||
|
||
best: Optional[dict[str, Any]] = None
|
||
best_dist: Optional[float] = None
|
||
for exp in chain.get("expiries") or []:
|
||
h = hours_to_expiry_from_ms(exp.get("exp_time"))
|
||
if min_h > 0 and h is not None and h < min_h:
|
||
continue
|
||
for c in exp.get("contracts") or []:
|
||
if str(c.get("opt_type") or "").upper() != want:
|
||
continue
|
||
try:
|
||
k = float(c.get("strike") or 0)
|
||
ask = float(c.get("ask") or 0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if k <= 0 or ask <= 0:
|
||
continue
|
||
if interval > 0 and abs(k - idx) > interval + 1e-9:
|
||
continue
|
||
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
|
||
if m_want == "itm" and m_got not in ("itm", "atm"):
|
||
continue
|
||
if m_want == "atm" and m_got != "atm":
|
||
continue
|
||
if m_want == "otm" and m_got == "itm":
|
||
continue
|
||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
|
||
if min_lev > 0 and idx / ask < min_lev - 1e-9:
|
||
continue
|
||
dist = abs(k - idx)
|
||
if best is None or best_dist is None or dist < best_dist:
|
||
best = {
|
||
**dict(c),
|
||
"hours_to_expiry": h,
|
||
"exp_time": exp.get("exp_time"),
|
||
"moneyness": m_got,
|
||
"index_px": idx,
|
||
"leverage": round(idx / ask, 1),
|
||
}
|
||
best_dist = dist
|
||
return best
|
||
|
||
|
||
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
|
||
"""情景:期权目标 / 永续目标粗估净利."""
|
||
view = str(body.get("direction") or "long").lower()
|
||
strike = float(body["strike"])
|
||
n = float(body.get("option_target_points") or 0)
|
||
m = float(body.get("perp_target_points") or 0)
|
||
ask = float(body.get("ask") or 0)
|
||
sheets = float(body.get("sheets") or 0)
|
||
ct = float(body.get("ct_mult") or 0.01)
|
||
contracts = float(body.get("contracts") or 0)
|
||
cs = float(body.get("contract_size") or 0.01)
|
||
entry = float(body.get("entry") or body.get("index_px") or 0)
|
||
perp_dir = perp_direction_for_view(view)
|
||
# 粗估到点时期权卖价:按内在价值近似(下限 0)
|
||
def intrinsic(spot: float) -> float:
|
||
o = opt_type_for_view(view)
|
||
if o == "C":
|
||
return max(0.0, spot - strike)
|
||
return max(0.0, strike - spot)
|
||
|
||
scenarios = []
|
||
for label, pts, reason in (
|
||
("期权目标", n, "opt_target_points"),
|
||
("永续目标", m, "perp_target_points"),
|
||
):
|
||
spot = strike + pts if view != "short" else strike - pts
|
||
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
|
||
net = estimate_combo_net_pnl(
|
||
view_side=view,
|
||
strike=strike,
|
||
index_px=spot,
|
||
ask_open=ask,
|
||
bid=bid_est,
|
||
sheets=sheets,
|
||
ct_mult=ct,
|
||
perp_direction=perp_dir,
|
||
perp_entry=entry,
|
||
perp_mark=spot,
|
||
contracts=contracts,
|
||
contract_size=cs,
|
||
)
|
||
scenarios.append(
|
||
{
|
||
"label": label,
|
||
"reason": reason,
|
||
"index": spot,
|
||
"perp_pnl": net["perp_net"],
|
||
"options_pnl": net["opt_net"],
|
||
"total": net["net"],
|
||
"note": "扣费净利估价;平仓费按买入费率",
|
||
}
|
||
)
|
||
premium = ask * sheets * ct
|
||
return {
|
||
"plan_type": "perp_options",
|
||
"option_primary": True,
|
||
"summary": {
|
||
"premium_paid": round(premium, 4),
|
||
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
|
||
"opt_target_total": scenarios[0]["total"] if scenarios else None,
|
||
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
|
||
"perp_direction": perp_dir,
|
||
"opt_type": opt_type_for_view(view),
|
||
},
|
||
"scenarios": scenarios,
|
||
}
|