e6907dfbbe
Wire idempotent notify on open/close/partial fail, settle OO at expiry, and close orphaned TP option legs without rewriting plan totals. Co-authored-by: Cursor <cursoragent@cursor.com>
735 lines
27 KiB
Python
735 lines
27 KiB
Python
"""OKX 对冲计划:P0 测算页与 API 注册."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any
|
|
|
|
from flask import Flask, jsonify, request
|
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
|
|
|
from lib.hedge_plan.hedge_plan_calc_lib import (
|
|
build_options_options_preview,
|
|
build_perp_options_preview,
|
|
floor_contracts_to_precision,
|
|
gate_status,
|
|
option_premium_total,
|
|
suggest_contracts_from_notional,
|
|
)
|
|
from lib.hub.hub_calculator_market_lib import amount_decimals_from_exchange
|
|
from lib.trade.position_sizing_lib import (
|
|
compute_full_margin_sizing,
|
|
load_position_sizing_mode,
|
|
)
|
|
|
|
|
|
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 attach_hedge_plan_templates(app: Flask, repo_root: str) -> None:
|
|
tpl_dir = os.path.join(repo_root, "lib", "hedge_plan", "templates")
|
|
if not os.path.isdir(tpl_dir):
|
|
return
|
|
existing = app.jinja_loader
|
|
loaders = [FileSystemLoader(tpl_dir)]
|
|
if existing is not None:
|
|
if isinstance(existing, ChoiceLoader):
|
|
loaders = list(existing.loaders) + loaders
|
|
else:
|
|
loaders.insert(0, existing)
|
|
app.jinja_loader = ChoiceLoader(loaders)
|
|
|
|
|
|
def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None:
|
|
attach_hedge_plan_templates(app, repo_root)
|
|
cfg = _build_cfg(app_module)
|
|
app.extensions["hedge_plan_cfg"] = cfg
|
|
register_hedge_plan_routes(app, cfg)
|
|
_maybe_start_monitor(cfg)
|
|
|
|
|
|
def _build_cfg(app_module: Any) -> dict[str, Any]:
|
|
from lib.exchange.okx_options_lib import (
|
|
build_option_chain,
|
|
fetch_index_price,
|
|
options_header_balances,
|
|
place_option_limit_order,
|
|
quote_option_contract,
|
|
td_mode_for_option_buy,
|
|
)
|
|
|
|
def _amount_to_precision(sym: str, amt: float) -> float:
|
|
ex = getattr(app_module, "exchange", None)
|
|
if ex is None:
|
|
return float(amt)
|
|
return float(ex.amount_to_precision(sym, amt))
|
|
|
|
return {
|
|
"get_db": app_module.get_db,
|
|
"login_required": app_module.login_required,
|
|
"render_main_page": app_module.render_main_page,
|
|
"exchange": getattr(app_module, "exchange", None),
|
|
"exchange_options": getattr(app_module, "exchange_options", None),
|
|
"get_available_trading_usdt": getattr(app_module, "get_available_trading_usdt", None),
|
|
"get_contract_size": getattr(app_module, "get_contract_size", None),
|
|
"normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None),
|
|
"ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None),
|
|
"ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None),
|
|
"place_exchange_order": getattr(app_module, "place_exchange_order", None),
|
|
"get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None),
|
|
"amount_to_precision": _amount_to_precision,
|
|
"build_option_chain": build_option_chain,
|
|
"options_header_balances": options_header_balances,
|
|
"quote_option_contract": quote_option_contract,
|
|
"place_option_limit_order": place_option_limit_order,
|
|
"td_mode_for_option_buy": td_mode_for_option_buy,
|
|
"fetch_index_price": fetch_index_price,
|
|
"options_td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(),
|
|
"btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10),
|
|
"alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5),
|
|
"full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98),
|
|
"funds_decimals": int(getattr(app_module, "FUNDS_DECIMALS", 2) or 2),
|
|
"options_enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
|
|
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
|
"chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"),
|
|
"perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(),
|
|
"options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(),
|
|
"live_trading": _env_bool("LIVE_TRADING_ENABLED", False),
|
|
"send_wechat": getattr(app_module, "send_wechat_msg", None),
|
|
}
|
|
|
|
|
|
def _hedge_enabled() -> bool:
|
|
return _env_bool("HEDGE_PLAN_ENABLED", False)
|
|
|
|
|
|
def _live_order() -> bool:
|
|
return _env_bool("HEDGE_PLAN_LIVE_ORDER", False)
|
|
|
|
|
|
def _max_active() -> int:
|
|
try:
|
|
return max(1, int(os.getenv("MAX_ACTIVE_HEDGE_PLANS") or "1"))
|
|
except ValueError:
|
|
return 1
|
|
|
|
|
|
def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
|
|
active = 0
|
|
try:
|
|
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
|
|
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
active = count_active_plans(conn)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
except Exception:
|
|
active = 0
|
|
return gate_status(
|
|
hedge_enabled=_hedge_enabled(),
|
|
sizing_mode=load_position_sizing_mode(),
|
|
plan_type=plan_type,
|
|
options_enabled=bool(cfg.get("options_enabled")),
|
|
live_order=_live_order(),
|
|
live_trading=bool(cfg.get("live_trading")) or _env_bool("LIVE_TRADING_ENABLED", False),
|
|
active_count=active,
|
|
max_active=_max_active(),
|
|
)
|
|
|
|
|
|
def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
|
|
if not _hedge_enabled():
|
|
return
|
|
try:
|
|
secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15")
|
|
except ValueError:
|
|
secs = 15.0
|
|
secs = max(5.0, secs)
|
|
|
|
def _loop() -> None:
|
|
import time
|
|
|
|
from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans
|
|
|
|
while True:
|
|
try:
|
|
tick_active_plans(cfg)
|
|
except Exception:
|
|
pass
|
|
time.sleep(secs)
|
|
|
|
import threading
|
|
|
|
t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True)
|
|
t.start()
|
|
cfg["hedge_monitor_thread"] = t
|
|
|
|
|
|
def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
|
|
from lib.hedge_plan.hedge_plan_db import (
|
|
get_plan,
|
|
get_plan_legs,
|
|
init_hedge_plan_tables,
|
|
insert_leg,
|
|
insert_plan,
|
|
)
|
|
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
|
|
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
opt = result.get("option") or {}
|
|
perp = result.get("perp") or {}
|
|
premium = float(opt.get("premium") or 0)
|
|
plan_id = insert_plan(
|
|
conn,
|
|
{
|
|
"plan_type": "perp_options",
|
|
"status": "active",
|
|
"underlying": str(body.get("underlying") or "ETH").upper(),
|
|
"direction": str(body.get("direction") or "long"),
|
|
"entry_mark": float(body.get("entry") or 0),
|
|
"tp": float(body.get("tp") or 0),
|
|
"sl": float(body.get("sl") or 0),
|
|
"sizing_mode_at_open": load_position_sizing_mode(),
|
|
"perp_size": float(perp.get("contracts") or body.get("contracts") or 0),
|
|
"margin": body.get("margin"),
|
|
"leverage": float(body.get("leverage") or 10),
|
|
"premium_total": premium,
|
|
"opened_at": result.get("opened_at"),
|
|
},
|
|
)
|
|
insert_leg(
|
|
conn,
|
|
{
|
|
"plan_id": plan_id,
|
|
"leg_role": "perp",
|
|
"symbol": str(body.get("exchange_symbol") or ""),
|
|
"side": str(body.get("direction") or "long"),
|
|
"size": float(perp.get("contracts") or body.get("contracts") or 0),
|
|
"avg_open": float(body.get("entry") or 0),
|
|
"status": "open",
|
|
"exchange_ord_id": str(perp.get("exchange_ord_id") or ""),
|
|
"opened_at": result.get("opened_at"),
|
|
},
|
|
)
|
|
insert_leg(
|
|
conn,
|
|
{
|
|
"plan_id": plan_id,
|
|
"leg_role": "option_hedge",
|
|
"inst_id": str(opt.get("inst_id") or body.get("opt_inst_id") or ""),
|
|
"opt_type": str(opt.get("opt_type") or body.get("opt_type") or ""),
|
|
"strike": opt.get("strike") or body.get("strike"),
|
|
"side": "buy",
|
|
"size": float(opt.get("sheets") or body.get("sheets") or 1),
|
|
"avg_open": float(opt.get("ask") or 0),
|
|
"premium": premium,
|
|
"status": "open",
|
|
"exchange_ord_id": str(opt.get("exchange_ord_id") or ""),
|
|
"opened_at": result.get("opened_at"),
|
|
},
|
|
)
|
|
conn.commit()
|
|
plan = get_plan(conn, plan_id)
|
|
legs = get_plan_legs(conn, plan_id)
|
|
if plan:
|
|
notify_plan_start(cfg, conn, plan, legs)
|
|
conn.commit()
|
|
return plan_id
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
|
|
from lib.hedge_plan.hedge_plan_db import (
|
|
get_plan,
|
|
get_plan_legs,
|
|
init_hedge_plan_tables,
|
|
insert_leg,
|
|
insert_plan,
|
|
)
|
|
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
|
|
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
a = result.get("leg_a") or {}
|
|
b = result.get("leg_b") or {}
|
|
premium = float(a.get("premium") or 0) + float(b.get("premium") or 0)
|
|
plan_id = insert_plan(
|
|
conn,
|
|
{
|
|
"plan_type": "options_options",
|
|
"status": "active",
|
|
"underlying": str(body.get("underlying") or "ETH").upper(),
|
|
"target_price": float(body.get("target_price") or 0),
|
|
"sizing_mode_at_open": load_position_sizing_mode(),
|
|
"premium_total": premium,
|
|
"opened_at": result.get("opened_at"),
|
|
},
|
|
)
|
|
for role, res, src in (("option_a", a, body.get("leg_a") or {}), ("option_b", b, body.get("leg_b") or {})):
|
|
insert_leg(
|
|
conn,
|
|
{
|
|
"plan_id": plan_id,
|
|
"leg_role": role,
|
|
"inst_id": str(res.get("inst_id") or src.get("inst_id") or ""),
|
|
"opt_type": str(res.get("opt_type") or src.get("opt_type") or ""),
|
|
"strike": res.get("strike") or src.get("strike"),
|
|
"side": "buy",
|
|
"size": float(res.get("sheets") or src.get("sheets") or 1),
|
|
"avg_open": float(res.get("ask") or 0),
|
|
"premium": float(res.get("premium") or 0),
|
|
"status": "open",
|
|
"exchange_ord_id": str(res.get("exchange_ord_id") or ""),
|
|
"opened_at": result.get("opened_at"),
|
|
},
|
|
)
|
|
conn.commit()
|
|
plan = get_plan(conn, plan_id)
|
|
legs = get_plan_legs(conn, plan_id)
|
|
if plan:
|
|
notify_plan_start(cfg, conn, plan, legs)
|
|
conn.commit()
|
|
return plan_id
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|
lr = cfg["login_required"]
|
|
|
|
@app.route("/hedge-plan")
|
|
@lr
|
|
def page_hedge_plan():
|
|
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
|
|
|
|
redir = redirect_to_embed_shell_if_enabled("hedge_plan")
|
|
if redir is not None:
|
|
return redir
|
|
return cfg["render_main_page"]("hedge_plan")
|
|
|
|
@app.route("/api/hedge-plan/gates")
|
|
@lr
|
|
def api_hedge_gates():
|
|
plan_type = (request.args.get("plan_type") or "perp_options").strip()
|
|
return jsonify({"ok": True, **_gates_dict(cfg, plan_type)})
|
|
|
|
@app.route("/api/hedge-plan/market")
|
|
@lr
|
|
def api_hedge_market():
|
|
base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper()
|
|
if base not in ("BTC", "ETH"):
|
|
return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400
|
|
direction = (request.args.get("direction") or "long").strip().lower()
|
|
if direction not in ("long", "short"):
|
|
direction = "long"
|
|
data, err = _fetch_perp_market(cfg, base)
|
|
if err:
|
|
return jsonify({"ok": False, "msg": err}), 400
|
|
sizing_mode = load_position_sizing_mode()
|
|
gates = _gates_dict(cfg, "perp_options")
|
|
out = {
|
|
"ok": True,
|
|
"base": base,
|
|
"direction": direction,
|
|
"suggested_opt_type": "P" if direction == "long" else "C",
|
|
**data,
|
|
"gates": gates,
|
|
"sizing_mode": sizing_mode,
|
|
"account_kind": "perp",
|
|
"account_label": cfg.get("perp_account_label") or "合约账户",
|
|
"account_note": "永续腿使用合约(交易)账户可用 USDT",
|
|
}
|
|
return jsonify(out)
|
|
|
|
@app.route("/api/hedge-plan/options-chain")
|
|
@lr
|
|
def api_hedge_options_chain():
|
|
if not cfg.get("options_enabled"):
|
|
return jsonify({"ok": False, "msg": "期权模块未启用"}), 400
|
|
ex = cfg.get("exchange_options")
|
|
if ex is None:
|
|
return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400
|
|
u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper()
|
|
try:
|
|
chain = cfg["build_option_chain"](
|
|
ex,
|
|
u,
|
|
max_dte_days=float(cfg.get("chain_max_dte") or 14),
|
|
itm_only=False,
|
|
itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"),
|
|
)
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500
|
|
opt_acct = _options_account_snapshot(cfg)
|
|
return jsonify(
|
|
{
|
|
"ok": True,
|
|
**chain,
|
|
"underlying": u,
|
|
"chain_max_dte_days": cfg.get("chain_max_dte"),
|
|
"account_kind": "options",
|
|
"account_label": cfg.get("options_account_label") or "期权账户",
|
|
"account_note": "期权腿使用期权账户(交易 USDC)",
|
|
"options_account": opt_acct,
|
|
}
|
|
)
|
|
|
|
@app.route("/api/hedge-plan/preview", methods=["POST"])
|
|
@lr
|
|
def api_hedge_preview():
|
|
body = request.get_json(silent=True) or {}
|
|
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
|
|
gates = _gates_dict(cfg, plan_type)
|
|
if not gates.get("can_preview"):
|
|
return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400
|
|
try:
|
|
if plan_type == "options_options":
|
|
data = _preview_oo(body)
|
|
else:
|
|
data = _preview_po(body)
|
|
except ValueError as e:
|
|
return jsonify({"ok": False, "msg": str(e)}), 400
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500
|
|
return jsonify({"ok": True, "gates": gates, **data})
|
|
|
|
@app.route("/api/hedge-plan/validate-path", methods=["POST"])
|
|
@lr
|
|
def api_hedge_validate_path():
|
|
"""只校验下单路径(强制 dry_run),不真实成交."""
|
|
from lib.hedge_plan.hedge_plan_orders_lib import (
|
|
execute_options_options_start,
|
|
execute_perp_options_start,
|
|
validate_start_body,
|
|
)
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
|
|
err = validate_start_body(plan_type, body)
|
|
if err:
|
|
return jsonify({"ok": False, "msg": err}), 400
|
|
if plan_type == "options_options":
|
|
out = execute_options_options_start(cfg, body, dry_run=True)
|
|
else:
|
|
out = execute_perp_options_start(cfg, body, dry_run=True)
|
|
return jsonify(out), (200 if out.get("ok") else 400)
|
|
|
|
@app.route("/api/hedge-plan/start", methods=["POST"])
|
|
@lr
|
|
def api_hedge_start():
|
|
from lib.hedge_plan.hedge_plan_orders_lib import (
|
|
execute_options_options_start,
|
|
execute_perp_options_start,
|
|
validate_start_body,
|
|
)
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
|
|
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
|
|
gates = _gates_dict(cfg, plan_type)
|
|
if not dry_run and not gates.get("can_start"):
|
|
return jsonify(
|
|
{"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates}
|
|
), 400
|
|
err = validate_start_body(plan_type, body)
|
|
if err:
|
|
return jsonify({"ok": False, "msg": err, "gates": gates}), 400
|
|
# 补齐永续杠杆
|
|
if plan_type == "perp_options" and not body.get("leverage"):
|
|
base = str(body.get("underlying") or "ETH").upper()
|
|
body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10)
|
|
# ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH
|
|
if base in ("BTC", "ETH"):
|
|
body["leverage"] = int(cfg.get("btc_leverage") or 10)
|
|
if plan_type == "options_options":
|
|
out = execute_options_options_start(
|
|
cfg,
|
|
body,
|
|
dry_run=dry_run,
|
|
persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))),
|
|
)
|
|
else:
|
|
out = execute_perp_options_start(
|
|
cfg,
|
|
body,
|
|
dry_run=dry_run,
|
|
persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))),
|
|
)
|
|
out["gates"] = gates
|
|
return jsonify(out), (200 if out.get("ok") else 400)
|
|
|
|
@app.route("/api/hedge-plan/list")
|
|
@lr
|
|
def api_hedge_list():
|
|
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans
|
|
|
|
status = (request.args.get("status") or "").strip() or None
|
|
plan_type = (request.args.get("plan_type") or "").strip() or None
|
|
underlying = (request.args.get("underlying") or "").strip() or None
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
rows = list_plans(
|
|
conn, status=status, plan_type=plan_type, underlying=underlying, limit=80
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return jsonify({"ok": True, "plans": rows})
|
|
|
|
@app.route("/api/hedge-plan/history")
|
|
@lr
|
|
def api_hedge_history():
|
|
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, list_plans
|
|
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
rows = list_plans(conn, status="closed", limit=100)
|
|
failed = list_plans(conn, status="failed", limit=50)
|
|
cancelled = list_plans(conn, status="cancelled", limit=50)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return jsonify({"ok": True, "plans": rows + failed + cancelled})
|
|
|
|
@app.route("/api/hedge-plan/stats")
|
|
@lr
|
|
def api_hedge_stats():
|
|
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, stats_summary
|
|
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
s = stats_summary(conn)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return jsonify({"ok": True, **s})
|
|
|
|
@app.route("/api/hedge-plan/<int:plan_id>")
|
|
@lr
|
|
def api_hedge_detail(plan_id: int):
|
|
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, init_hedge_plan_tables
|
|
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
init_hedge_plan_tables(conn)
|
|
plan = get_plan(conn, plan_id)
|
|
if not plan:
|
|
return jsonify({"ok": False, "msg": "计划不存在"}), 404
|
|
legs = get_plan_legs(conn, plan_id)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return jsonify({"ok": True, "plan": plan, "legs": legs})
|
|
|
|
@app.route("/api/hedge-plan/monitor-tick", methods=["POST"])
|
|
@lr
|
|
def api_hedge_monitor_tick():
|
|
from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans
|
|
|
|
return jsonify(tick_active_plans(cfg))
|
|
|
|
|
|
def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
|
direction = str(body.get("direction") or "long").lower()
|
|
entry = float(body["entry"])
|
|
tp = float(body["tp"])
|
|
sl = float(body["sl"])
|
|
contracts = float(body["contracts"])
|
|
contract_size = float(body.get("contract_size") or 0.01)
|
|
opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C"))
|
|
strike = float(body["strike"])
|
|
sheets = float(body.get("sheets") or 1)
|
|
ct_mult = float(body.get("ct_mult") or 0.01)
|
|
ask = body.get("ask")
|
|
premium = body.get("premium_paid")
|
|
if premium is None:
|
|
if ask is None:
|
|
raise ValueError("缺少权利金或卖一价")
|
|
premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult)
|
|
index_px = body.get("index_px")
|
|
return build_perp_options_preview(
|
|
direction=direction,
|
|
entry=entry,
|
|
tp=tp,
|
|
sl=sl,
|
|
contracts=contracts,
|
|
contract_size=contract_size,
|
|
opt_type=opt_type,
|
|
strike=strike,
|
|
sheets=sheets,
|
|
ct_mult=ct_mult,
|
|
premium_paid=float(premium),
|
|
index_px=float(index_px) if index_px is not None else None,
|
|
)
|
|
|
|
|
|
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
|
target = float(body["target_price"])
|
|
index_px = float(body.get("index_px") or target)
|
|
leg_a = body.get("leg_a") or {}
|
|
leg_b = body.get("leg_b") or {}
|
|
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
|
|
if not leg.get("strike"):
|
|
raise ValueError(f"缺少 {name} 行权价")
|
|
if leg.get("premium_paid") is None and leg.get("ask") is not None:
|
|
leg["premium_paid"] = option_premium_total(
|
|
ask=float(leg["ask"]),
|
|
sheets=float(leg.get("sheets") or 1),
|
|
ct_mult=float(leg.get("ct_mult") or 0.01),
|
|
)
|
|
if leg.get("premium_paid") is None:
|
|
raise ValueError(f"缺少 {name} 权利金")
|
|
return build_options_options_preview(
|
|
target_price=target,
|
|
index_px=index_px,
|
|
leg_a=leg_a,
|
|
leg_b=leg_b,
|
|
)
|
|
|
|
|
|
def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]:
|
|
ex = cfg.get("exchange")
|
|
if ex is None:
|
|
return {}, "永续交易所未初始化"
|
|
ensure = cfg.get("ensure_markets_loaded")
|
|
if callable(ensure):
|
|
try:
|
|
ensure()
|
|
except Exception as e:
|
|
return {}, f"加载市场失败: {e}"
|
|
norm = cfg.get("normalize_exchange_symbol")
|
|
sym = f"{base}/USDT:USDT"
|
|
if callable(norm):
|
|
try:
|
|
sym = norm(f"{base}/USDT")
|
|
except Exception:
|
|
sym = f"{base}/USDT:USDT"
|
|
mark = bid = ask = last = None
|
|
try:
|
|
t = ex.fetch_ticker(sym)
|
|
last = _sf(t.get("last"))
|
|
mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None
|
|
if mark is None:
|
|
mark = _sf(t.get("mark")) or last
|
|
bid = _sf(t.get("bid"))
|
|
ask = _sf(t.get("ask"))
|
|
except Exception as e:
|
|
return {}, f"拉永续行情失败: {e}"
|
|
|
|
cs = 0.01
|
|
get_cs = cfg.get("get_contract_size")
|
|
if callable(get_cs):
|
|
try:
|
|
cs = float(get_cs(sym) or 0.01)
|
|
except Exception:
|
|
cs = 0.01
|
|
|
|
available = None
|
|
get_av = cfg.get("get_available_trading_usdt")
|
|
if callable(get_av):
|
|
try:
|
|
available = get_av()
|
|
except Exception:
|
|
available = None
|
|
|
|
entry = float(mark or last or 0)
|
|
sizing = None
|
|
suggest_contracts = None
|
|
amount_precision = 4
|
|
try:
|
|
amount_precision = int(amount_decimals_from_exchange(ex, sym))
|
|
except Exception:
|
|
amount_precision = 4
|
|
if available is not None and entry > 0:
|
|
sizing, _serr = compute_full_margin_sizing(
|
|
symbol=sym,
|
|
available_usdt=float(available),
|
|
capital_base=float(available),
|
|
buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98),
|
|
btc_leverage=int(cfg.get("btc_leverage") or 10),
|
|
alt_leverage=int(cfg.get("alt_leverage") or 5),
|
|
funds_decimals=int(cfg.get("funds_decimals") or 2),
|
|
)
|
|
if sizing:
|
|
raw_contracts = suggest_contracts_from_notional(
|
|
notional=float(sizing["notional_value"]),
|
|
entry=entry,
|
|
contract_size=cs,
|
|
)
|
|
# 优先走交易所 amount_to_precision;失败则按精度位数向下取整
|
|
suggest_contracts = None
|
|
try:
|
|
precise = float(ex.amount_to_precision(sym, raw_contracts))
|
|
if precise > raw_contracts + 1e-12:
|
|
precise = floor_contracts_to_precision(raw_contracts, amount_precision)
|
|
suggest_contracts = precise
|
|
except Exception:
|
|
suggest_contracts = floor_contracts_to_precision(raw_contracts, amount_precision)
|
|
|
|
return {
|
|
"exchange_symbol": sym,
|
|
"mark": mark,
|
|
"last": last,
|
|
"bid": bid,
|
|
"ask": ask,
|
|
"contract_size": cs,
|
|
"available_usdt": available,
|
|
"full_margin_sizing": sizing,
|
|
"suggest_contracts": suggest_contracts,
|
|
"amount_precision": amount_precision,
|
|
"unit_quote": "USDT",
|
|
"unit_contracts": "合约张",
|
|
"unit_note": "价格单位 USDT;张数=交易所永续合约张(与下单精度一致);名义≈张数×面值×价格",
|
|
"entry_ref": entry or None,
|
|
}, None
|
|
|
|
|
|
def _options_account_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
"""期权账户资金快照(与期权页同源: exchange_options)."""
|
|
out: dict[str, Any] = {
|
|
"label": cfg.get("options_account_label") or "期权账户",
|
|
"trading_usdc": None,
|
|
"funding_usdc": None,
|
|
"trading_usdt": None,
|
|
"funding_usdt": None,
|
|
}
|
|
ex = cfg.get("exchange_options")
|
|
hdr = cfg.get("options_header_balances")
|
|
if ex is None or not callable(hdr):
|
|
return out
|
|
try:
|
|
trading_usdc, funding_usdc, funding_usdt, trading_usdt = hdr(ex, force=False)
|
|
out.update(
|
|
{
|
|
"trading_usdc": trading_usdc,
|
|
"funding_usdc": funding_usdc,
|
|
"trading_usdt": trading_usdt,
|
|
"funding_usdt": funding_usdt,
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _sf(v: Any) -> float | None:
|
|
if v is None or v == "":
|
|
return None
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|