Enable hedge-plan live opens with path validation, DB, and monitor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-14 13:15:10 +08:00
parent 5fcf649c79
commit 982497d65a
10 changed files with 1474 additions and 45 deletions
+2
View File
@@ -1499,9 +1499,11 @@ def init_db():
from lib.strategy.strategy_db import init_strategy_tables
from lib.options.options_db import init_options_tables
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
init_strategy_tables(conn)
init_options_tables(conn)
init_hedge_plan_tables(conn)
from lib.trade.account_risk_lib import ensure_account_risk_schema
ensure_account_risk_schema(conn)
+146 -7
View File
@@ -135,7 +135,12 @@
if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; "));
el.textContent = parts.join(" · ");
const start = $("hp-start-btn");
if (start) start.disabled = !gates.can_start;
const startOo = $("hp-start-btn-oo");
if ((gates.plan_type || state.mode) === "options_options") {
if (startOo) startOo.disabled = !gates.can_start;
} else {
if (start) start.disabled = !gates.can_start;
}
}
function setOptionsBalance(chain) {
@@ -702,14 +707,13 @@
syncTabUI();
if (state.tab === "perp_options" || state.tab === "options_options") {
void loadGates();
} else if (state.tab === "history") {
void loadHistory();
} else if (state.tab === "stats") {
void loadStats();
} else {
const el = $("hp-gate-line");
if (el) {
el.textContent =
state.tab === "history"
? "历史记录:独立对冲表,与普通交易记录分离"
: "统计:止盈=盈利−保费;止损=期权盈利−永续亏损";
}
if (el) el.textContent = "";
}
});
});
@@ -768,6 +772,141 @@
state.mode = "options_options";
void runPreview();
});
if ($("hp-start-btn"))
$("hp-start-btn").addEventListener("click", function () {
void startPlan("perp_options");
});
if ($("hp-start-btn-oo"))
$("hp-start-btn-oo").addEventListener("click", function () {
void startPlan("options_options");
});
}
async function loadHistory() {
const tbody = $("hp-history-tbody");
if (!tbody) return;
try {
const d = await apiJson("/api/hedge-plan/history");
const rows = d.plans || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无已结束计划</td></tr>';
return;
}
tbody.innerHTML = "";
rows.forEach(function (p) {
const tr = document.createElement("tr");
tr.innerHTML =
"<td>" +
p.id +
"</td><td>" +
(p.plan_type === "perp_options" ? "永期" : "期期") +
"</td><td>" +
(p.underlying || "") +
"</td><td>" +
(p.status || "") +
"</td><td>" +
fmt(p.realized_pnl_total) +
"</td><td>" +
(p.close_reason || "—") +
"</td><td>" +
(p.opened_at || "—") +
"</td><td>" +
(p.closed_at || "—") +
"</td>";
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>";
}
}
async function loadStats() {
const box = $("hp-stats-box");
if (!box) return;
try {
const d = await apiJson("/api/hedge-plan/stats");
const parts = [
"活跃计划 <strong>" + (d.active || 0) + "</strong>",
"已结笔数 <strong>" + (d.closed_count || 0) + "</strong>",
"已结合计 <strong>" + fmt(d.closed_pnl_total) + "</strong> ≈U",
];
const by = d.by_reason || [];
if (by.length) {
parts.push(
"<br/>按原因: " +
by
.map(function (r) {
return (
(r.plan_type || "") +
"/" +
(r.close_reason || "") +
" ×" +
r.n +
" pnl=" +
fmt(r.pnl)
);
})
.join(" · ")
);
}
box.innerHTML = parts.join(" · ");
} catch (e) {
box.textContent = e.message || String(e);
}
}
async function startPlan(planType) {
const isOo = planType === "options_options";
try {
let body;
if (isOo) {
if (!state.legA || !state.legB) throw new Error("请选用两条期权腿");
const target = Number(($("hp-target") && $("hp-target").value) || 0);
if (!target) throw new Error("请填写目标价");
body = {
plan_type: "options_options",
underlying: state.underlying,
target_price: target,
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
};
} else {
if (!state.selected) throw new Error("请选用期权腿");
const entry = Number(($("hp-entry") && $("hp-entry").value) || 0);
const tp = Number(($("hp-tp") && $("hp-tp").value) || 0);
const sl = Number(($("hp-sl") && $("hp-sl").value) || 0);
const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0);
const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1);
if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数");
body = {
plan_type: "perp_options",
underlying: state.underlying,
direction: ($("hp-direction") && $("hp-direction").value) || "long",
entry: entry,
tp: tp,
sl: sl,
contracts: contracts,
sheets: sheets,
opt_inst_id: state.selected.inst_id,
opt_type: state.selected.opt_type,
strike: state.selected.strike,
exchange_symbol: (state.market && state.market.exchange_symbol) || "",
leverage: 10,
margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital,
};
}
if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return;
const d = await apiJson("/api/hedge-plan/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setGateLine(d.gates);
alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : ""));
void loadGates();
} catch (e) {
alert(e.message || String(e));
}
}
async function refreshAll() {
+1 -1
View File
@@ -130,7 +130,7 @@ _HEDGE_PLAN_SECTION: dict[str, Any] = {
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"),
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘开关与;P0 仅测算"),
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
+23 -6
View File
@@ -299,13 +299,17 @@ def gate_status(
sizing_mode: str,
plan_type: str,
options_enabled: bool,
live_order: bool = False,
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
full = is_full_margin_mode(sizing_mode)
pt = (plan_type or "").strip().lower()
can_preview = True
can_start = False
can_start = True
reasons: list[str] = []
if not hedge_enabled:
can_start = False
@@ -314,23 +318,36 @@ def gate_status(
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if pt == "perp_options":
if not full:
can_start = False
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
elif hedge_enabled and options_enabled:
if not live_trading:
can_start = False
reasons.append("P0 仅测算,真实开仓将在后续版本开放")
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
elif pt == "options_options":
if hedge_enabled and options_enabled:
can_start = False
reasons.append("P0 仅测算,真实开仓将在后续版本开放")
pass
else:
can_start = False
reasons.append("未知计划类型")
if can_start:
reasons = []
return {
"hedge_enabled": hedge_enabled,
"options_enabled": options_enabled,
"sizing_mode": sizing_mode,
"is_full_margin": full,
"plan_type": pt,
"live_order": live_order,
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
+173
View File
@@ -0,0 +1,173 @@
"""对冲计划 SQLite 表."""
from __future__ import annotations
import sqlite3
from typing import Any, Optional
def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_type TEXT NOT NULL,
status TEXT NOT NULL,
underlying TEXT NOT NULL,
direction TEXT,
entry_mark REAL,
tp REAL,
sl REAL,
target_price REAL,
sizing_mode_at_open TEXT,
perp_size REAL,
margin REAL,
leverage REAL,
premium_total REAL,
realized_pnl_perp REAL,
realized_pnl_options REAL,
realized_pnl_total REAL,
stats_bucket TEXT,
close_reason TEXT,
wechat_start_sent INTEGER DEFAULT 0,
wechat_end_sent INTEGER DEFAULT 0,
note TEXT,
preview_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
opened_at TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plan_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
leg_role TEXT NOT NULL,
symbol TEXT,
inst_id TEXT,
opt_type TEXT,
strike REAL,
side TEXT,
size REAL,
avg_open REAL,
premium REAL,
status TEXT,
linked_monitor_id INTEGER,
options_trade_id INTEGER,
exchange_ord_id TEXT,
realized_pnl REAL,
close_reason TEXT,
opened_at TIMESTAMP,
closed_at TIMESTAMP,
FOREIGN KEY(plan_id) REFERENCES hedge_plans(id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)"
)
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
if plan_type:
row = conn.execute(
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?",
(plan_type,),
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')"
).fetchone()
return int((row["c"] if row else 0) or 0)
def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
def list_plans(
conn: sqlite3.Connection,
*,
status: Optional[str] = None,
plan_type: Optional[str] = None,
underlying: Optional[str] = None,
limit: int = 50,
) -> list[dict[str, Any]]:
wheres: list[str] = []
args: list[Any] = []
if status:
wheres.append("status=?")
args.append(status)
if plan_type:
wheres.append("plan_type=?")
args.append(plan_type)
if underlying:
wheres.append("underlying=?")
args.append(underlying)
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
rows = conn.execute(
f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?",
[*args, int(limit)],
).fetchall()
return [dict(r) for r in rows]
def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]:
row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone()
return dict(row) if row else None
def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,)
).fetchall()
return [dict(r) for r in rows]
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
rows = conn.execute(
"""
SELECT plan_type, close_reason, COUNT(1) AS n,
COALESCE(SUM(realized_pnl_total), 0) AS pnl
FROM hedge_plans
WHERE status='closed'
GROUP BY plan_type, close_reason
"""
).fetchall()
closed = conn.execute(
"SELECT COUNT(1) AS c, COALESCE(SUM(realized_pnl_total),0) AS pnl FROM hedge_plans WHERE status='closed'"
).fetchone()
active = count_active_plans(conn)
return {
"active": active,
"closed_count": int((closed["c"] if closed else 0) or 0),
"closed_pnl_total": float((closed["pnl"] if closed else 0) or 0),
"by_reason": [dict(r) for r in rows],
}
+233
View File
@@ -0,0 +1,233 @@
"""对冲计划监控:永期 TP/SL 与期期目标价/到期收口."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any, Optional
from lib.hedge_plan.hedge_plan_db import get_plan_legs, list_plans, update_plan
from lib.hedge_plan.hedge_plan_orders_lib import _sell_option
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 _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _perp_live_contracts(cfg: dict[str, Any], symbol: str, direction: str) -> Optional[float]:
fn = cfg.get("get_live_position_contracts")
if not callable(fn):
return None
try:
return fn(symbol, direction)
except Exception:
return None
def _index_px(cfg: dict[str, Any], underlying: str) -> Optional[float]:
ex = cfg.get("exchange_options")
fn = cfg.get("fetch_index_price")
if callable(fn) and ex is not None:
try:
return fn(ex, underlying)
except Exception:
return None
return None
def tick_active_plans(cfg: dict[str, Any]) -> dict[str, Any]:
"""扫描 active 计划并按规则收口.返回处理摘要."""
get_db = cfg.get("get_db")
if not callable(get_db):
return {"ok": False, "msg": "get_db missing"}
conn = get_db()
acted: list[dict[str, Any]] = []
try:
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
init_hedge_plan_tables(conn)
plans = list_plans(conn, status="active", limit=20)
for plan in plans:
r = _tick_one(cfg, conn, plan)
if r:
acted.append(r)
conn.commit()
finally:
conn.close()
return {"ok": True, "acted": acted}
def _tick_one(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> Optional[dict[str, Any]]:
pt = plan.get("plan_type")
legs = get_plan_legs(conn, int(plan["id"]))
if pt == "perp_options":
return _tick_po(cfg, conn, plan, legs)
if pt == "options_options":
return _tick_oo(cfg, conn, plan, legs)
return None
def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
perp = next((x for x in legs if x.get("leg_role") == "perp"), None)
opt = next((x for x in legs if x.get("leg_role") == "option_hedge"), None)
if not perp:
return None
symbol = perp.get("symbol") or ""
direction = (plan.get("direction") or "long").lower()
live = _perp_live_contracts(cfg, symbol, direction)
# 仍有仓 → 未触达交易所 TP/SL
if live is not None and live > 0:
return None
# 仓已平:用标记/最新粗判 TP or SL
entry = _sf(plan.get("entry_mark")) or _sf(perp.get("avg_open")) or 0
tp = _sf(plan.get("tp"))
sl = _sf(plan.get("sl"))
mark = None
ex = cfg.get("exchange")
if ex is not None and symbol:
try:
t = ex.fetch_ticker(symbol)
mark = _sf((t.get("info") or {}).get("markPx")) or _sf(t.get("last"))
except Exception:
mark = None
reason = "perp_tp"
if mark is not None and sl is not None and entry:
if direction == "long" and mark <= sl:
reason = "perp_sl"
elif direction == "short" and mark >= sl:
reason = "perp_sl"
elif tp is not None:
if direction == "long" and mark >= tp:
reason = "perp_tp"
elif direction == "short" and mark <= tp:
reason = "perp_tp"
premium = float(plan.get("premium_total") or 0)
# 粗算永续已实现
cs = float(cfg.get("default_contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
if callable(get_cs) and symbol:
try:
cs = float(get_cs(symbol) or cs)
except Exception:
pass
size = float(perp.get("size") or 0)
exit_px = mark or (tp if reason == "perp_tp" else sl) or entry
coins = size * cs
if direction == "short":
perp_pnl = (entry - exit_px) * coins
else:
perp_pnl = (exit_px - entry) * coins
opt_pnl = -premium
if reason == "perp_sl" and opt and _env_bool("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", True):
close_r = _sell_option(
cfg,
inst_id=str(opt.get("inst_id") or ""),
sheets=float(opt.get("size") or 1),
)
# 简化:平仓失败仍结束计划并记 −权利金
if close_r.get("ok"):
# 无法精确拿到卖出价差时仍用 −premium 作为下限;有 bid 则近似
bid = _sf(close_r.get("bid"))
ask_open = _sf(opt.get("avg_open"))
if bid is not None and ask_open is not None:
ct = 0.01
opt_pnl = (bid - ask_open) * float(opt.get("size") or 1) * ct
else:
opt_pnl = -premium
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), opt_pnl, opt["id"]),
)
elif reason == "perp_tp" and opt:
# 止盈默认不平期权
if _env_bool("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", False):
_sell_option(cfg, inst_id=str(opt.get("inst_id") or ""), sheets=float(opt.get("size") or 1))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=? WHERE id=?",
("closed", reason, _now(), opt["id"]),
)
else:
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=? WHERE id=?",
("hold_to_expiry", "orphaned_after_tp", opt["id"]),
)
opt_pnl = -premium
if reason == "perp_tp":
total = perp_pnl + opt_pnl # = 止盈盈利 权利金
else:
total = opt_pnl + perp_pnl # 期权盈亏 + 永续盈亏
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), perp_pnl, perp["id"]),
)
update_plan(
conn,
int(plan["id"]),
status="closed",
close_reason=reason,
realized_pnl_perp=round(perp_pnl, 4),
realized_pnl_options=round(opt_pnl, 4),
realized_pnl_total=round(total, 4),
stats_bucket="tp" if reason == "perp_tp" else "sl",
closed_at=_now(),
)
return {"plan_id": plan["id"], "close_reason": reason, "total": total}
def _tick_oo(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
target = _sf(plan.get("target_price"))
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if target is None or idx is None:
return None
# 简化:接近目标价(相对 0.15%)时平盈利腿
if abs(idx - target) / max(target, 1) > 0.0015 and not (idx >= target or idx <= target):
pass
near = abs(idx - target) / max(abs(target), 1.0) <= 0.002
if not near:
return None
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = [x for x in legs if x.get("status") == "open" and x.get("leg_role", "").startswith("option")]
if len(open_legs) < 2:
return None
# 用内在价值粗判盈利腿
winners = []
for leg in open_legs:
strike = _sf(leg.get("strike")) or 0
o = (leg.get("opt_type") or "").upper()
intrinsic = max(0.0, idx - strike) if o == "C" else max(0.0, strike - idx)
premium = float(leg.get("premium") or 0)
pnl = intrinsic * float(leg.get("size") or 1) * 0.01 - premium
winners.append((pnl, leg))
winners.sort(key=lambda x: x[0], reverse=True)
best_pnl, best = winners[0]
if best_pnl <= 0:
return None
close_r = _sell_option(cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1))
if not close_r.get("ok"):
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", "target_win_leg", _now(), best_pnl, best["id"]),
)
# 计划暂不 closed,等另一腿到期;先标 note
update_plan(conn, int(plan["id"]), close_reason="target_win_leg")
return {"plan_id": plan["id"], "close_reason": "target_win_leg", "closed_leg": best.get("id")}
+377
View File
@@ -0,0 +1,377 @@
"""对冲计划开仓/平仓编排(可 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 build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易)."""
mode = open_order_mode()
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": body.get("direction") or "long",
"contracts": float(body.get("contracts") or 0),
"tp": body.get("tp"),
"sl": body.get("sl"),
"attach_tpsl": True,
}
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 _buy_option(
cfg: dict[str, Any],
*,
inst_id: str,
sheets: float,
dry_run: bool,
) -> dict[str, Any]:
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")
if ask is None or float(ask) <= 0:
return {"ok": False, "msg": "暂无卖一价,无法买入"}
sheets_i = max(1, int(round(float(sheets))))
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),
"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"),
}
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")
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"),
)
if not order.get("ok"):
return order
return {
"ok": True,
"inst_id": inst_id,
"sheets": sheets_i,
"ask": float(ask),
"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": (order.get("data") or {}).get("ordId"),
"order": order,
}
def _open_perp(
cfg: dict[str, Any],
*,
symbol: str,
direction: str,
contracts: float,
leverage: int,
tp: float,
sl: float,
dry_run: bool,
) -> 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"}
if dry_run:
return {
"ok": True,
"dry_run": True,
"symbol": symbol,
"direction": direction,
"contracts": amount,
"leverage": leverage,
"tp": tp,
"sl": sl,
}
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, take_profit=tp)
except Exception as e:
return {"ok": False, "msg": f"永续开仓失败: {e}"}
return {
"ok": True,
"symbol": symbol,
"direction": direction,
"contracts": amount,
"leverage": leverage,
"tp": tp,
"sl": sl,
"order": order,
"exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""),
}
def _sell_option(
cfg: dict[str, Any],
*,
inst_id: str,
sheets: float,
dry_run: bool = False,
) -> dict[str, Any]:
ex = cfg.get("exchange_options")
quote_fn = cfg.get("quote_option_contract")
place_fn = cfg.get("place_option_limit_order")
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)}
if not callable(place_fn):
return {"ok": False, "msg": "期权平仓未注入"}
order = place_fn(
ex,
inst_id=inst_id,
side="sell",
sheets=sheets_i,
price=float(bid),
td_mode="isolated",
tick_sz=q.get("tick_sz"),
reduce_only=True,
)
return order if order.get("ok") else order
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]:
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"):
return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else:
perp_res = _open_perp(
cfg,
symbol=str(body.get("exchange_symbol") or ""),
direction=str(body.get("direction") or "long"),
contracts=float(body.get("contracts") or 0),
leverage=int(body.get("leverage") or 10),
tp=float(body["tp"]),
sl=float(body["sl"]),
dry_run=dry_run,
)
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 _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
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})
return {
"ok": False,
"msg": perp_res.get("msg") or "永续开仓失败",
"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,
"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]:
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 {}
a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), 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}
b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), 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 _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True):
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})
return {
"ok": False,
"msg": b_res.get("msg") or "腿B开仓失败",
"path": path,
"results": results,
"partial": True,
}
out = {
"ok": True,
"dry_run": dry_run,
"plan_type": "options_options",
"path": path,
"results": results,
"leg_a": a_res,
"leg_b": b_res,
"opened_at": _now(),
}
if persist and not dry_run:
out["plan_id"] = persist(out, body)
return out
def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower()
if pt == "perp_options":
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"
if float(body["tp"]) <= 0 or float(body["sl"]) <= 0:
return "止盈/止损无效"
except (TypeError, ValueError):
return "数值字段无效"
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 "请选用两条期权腿"
if body.get("target_price") in (None, ""):
return "缺少目标价"
return None
return "未知计划类型"
def dump_preview(preview: Any) -> str:
try:
return json.dumps(preview, ensure_ascii=False)[:8000]
except Exception:
return ""
+340 -24
View File
@@ -48,10 +48,24 @@ def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None:
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, options_header_balances
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,
@@ -63,8 +77,17 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"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),
@@ -74,6 +97,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"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),
}
@@ -81,6 +105,180 @@ 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 init_hedge_plan_tables, insert_leg, insert_plan
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()
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 init_hedge_plan_tables, insert_leg, insert_plan
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()
return plan_id
finally:
conn.close()
def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
lr = cfg["login_required"]
@@ -98,17 +296,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
@lr
def api_hedge_gates():
plan_type = (request.args.get("plan_type") or "perp_options").strip()
return jsonify(
{
"ok": True,
**gate_status(
hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(),
plan_type=plan_type,
options_enabled=bool(cfg.get("options_enabled")),
),
}
)
return jsonify({"ok": True, **_gates_dict(cfg, plan_type)})
@app.route("/api/hedge-plan/market")
@lr
@@ -123,12 +311,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
if err:
return jsonify({"ok": False, "msg": err}), 400
sizing_mode = load_position_sizing_mode()
gates = gate_status(
hedge_enabled=_hedge_enabled(),
sizing_mode=sizing_mode,
plan_type="perp_options",
options_enabled=bool(cfg.get("options_enabled")),
)
gates = _gates_dict(cfg, "perp_options")
out = {
"ok": True,
"base": base,
@@ -181,12 +364,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
def api_hedge_preview():
body = request.get_json(silent=True) or {}
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
gates = gate_status(
hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(),
plan_type=plan_type,
options_enabled=bool(cfg.get("options_enabled")),
)
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:
@@ -200,6 +378,144 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
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()
+19 -7
View File
@@ -13,7 +13,7 @@
<div class="card hp-head-card">
<div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">P0 测算</span></h2>
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span></h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
@@ -84,7 +84,7 @@
<div id="hp-opt-bal-line" class="muted hp-quote-line hp-opt-bal-line"></div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn">计算</button>
<button type="button" class="btn-secondary" id="hp-start-btn" disabled title="P0 不开仓">启动计划(P0禁用)</button>
<button type="button" class="btn-secondary" id="hp-start-btn" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
@@ -166,6 +166,7 @@
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
<button type="button" class="btn-secondary" id="hp-start-btn-oo" title="需开启 HEDGE_PLAN_LIVE_ORDER">启动计划</button>
</div>
</div>
</div>
@@ -195,17 +196,28 @@
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>历史记录</h2>
<p class="muted">独立对冲计划历史(永期 / 期期)将在此展示合计盈亏、平仓原因与复盘短评.</p>
<p class="hp-placeholder">P1–P5 落地后可查;当前无计划记录.</p>
<p class="muted">独立对冲计划历史(与普通交易记录分离)</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th>
</tr>
</thead>
<tbody id="hp-history-tbody">
<tr><td colspan="8" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>统计分析</h2>
<p class="muted">止盈口径:止盈盈利 − 权利金;止损口径:期权盈利 − 永续亏损;期期到期无盈利记总亏损.</p>
<p class="hp-placeholder">统计看板随历史表上线后提供.</p>
<p class="muted">止盈=盈利−保费;止损=期权盈亏+永续盈亏;期期到期无盈利记总亏损</p>
<div id="hp-stats-box" class="muted">加载中…</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=9"></script>
<script src="/static/hedge_plan.js?v=10"></script>
+160
View File
@@ -0,0 +1,160 @@
"""对冲计划下单路径校验(dry_run + 门禁)."""
import unittest
from unittest.mock import MagicMock
from lib.hedge_plan.hedge_plan_calc_lib import gate_status
from lib.hedge_plan.hedge_plan_orders_lib import (
build_oo_path_plan,
build_po_path_plan,
execute_options_options_start,
execute_perp_options_start,
validate_start_body,
)
class TestHedgePlanOrderPath(unittest.TestCase):
def test_po_path_options_first(self):
body = {
"opt_inst_id": "ETH-USD-260731-1800-P",
"sheets": 2,
"exchange_symbol": "ETH/USDT:USDT",
"direction": "long",
"contracts": 4.5,
"tp": 1900,
"sl": 1700,
}
path = build_po_path_plan(body)
self.assertEqual(path[0]["step"], "options_buy_limit")
self.assertEqual(path[0]["account"], "options")
self.assertEqual(path[1]["step"], "perp_market_open")
self.assertEqual(path[1]["account"], "swap")
self.assertTrue(path[1]["attach_tpsl"])
def test_oo_path_two_option_buys(self):
body = {
"leg_a": {"inst_id": "ETH-USD-260731-1800-C", "sheets": 1},
"leg_b": {"inst_id": "ETH-USD-260731-1700-P", "sheets": 3},
}
path = build_oo_path_plan(body)
self.assertEqual(len(path), 2)
self.assertEqual(path[0]["leg"], "a")
self.assertEqual(path[1]["sheets"], 3)
def test_validate_body(self):
self.assertIsNotNone(validate_start_body("perp_options", {}))
ok = validate_start_body(
"perp_options",
{
"direction": "long",
"entry": 1800,
"tp": 1900,
"sl": 1700,
"contracts": 1,
"opt_inst_id": "X",
"sheets": 1,
"exchange_symbol": "ETH/USDT:USDT",
},
)
self.assertIsNone(ok)
def test_gate_can_start_when_live(self):
g = gate_status(
hedge_enabled=True,
sizing_mode="full_margin",
plan_type="perp_options",
options_enabled=True,
live_order=True,
live_trading=True,
active_count=0,
max_active=1,
)
self.assertTrue(g["can_start"])
self.assertEqual(g["reasons"], [])
def test_gate_oo_without_live_trading(self):
g = gate_status(
hedge_enabled=True,
sizing_mode="risk",
plan_type="options_options",
options_enabled=True,
live_order=True,
live_trading=False,
active_count=0,
max_active=1,
)
self.assertTrue(g["can_start"])
def test_dry_run_po_calls_quote_not_place(self):
quote = MagicMock(
return_value={
"ok": True,
"ask": 12.5,
"ct_mult": 0.01,
"tick_sz": "0.1",
"strike": 1800,
"exp_time": 1,
"meta": {"optType": "P"},
}
)
place_opt = MagicMock()
place_perp = MagicMock()
cfg = {
"exchange_options": object(),
"exchange": object(),
"quote_option_contract": quote,
"place_option_limit_order": place_opt,
"place_exchange_order": place_perp,
"td_mode_for_option_buy": lambda x: "isolated",
"amount_to_precision": lambda s, a: a,
"ensure_okx_live_ready": lambda: (True, ""),
}
body = {
"direction": "long",
"entry": 1800,
"tp": 1900,
"sl": 1700,
"contracts": 4.5,
"opt_inst_id": "ETH-USD-260731-1800-P",
"sheets": 2,
"exchange_symbol": "ETH/USDT:USDT",
"leverage": 10,
"underlying": "ETH",
}
out = execute_perp_options_start(cfg, body, dry_run=True)
self.assertTrue(out["ok"])
self.assertTrue(out["dry_run"])
place_opt.assert_not_called()
place_perp.assert_not_called()
quote.assert_called()
self.assertEqual(out["path"][0]["account"], "options")
self.assertEqual(out["path"][1]["account"], "swap")
def test_dry_run_oo(self):
quote = MagicMock(
return_value={
"ok": True,
"ask": 10,
"ct_mult": 0.01,
"tick_sz": "0.1",
"strike": 1800,
"meta": {"optType": "C"},
}
)
cfg = {
"exchange_options": object(),
"quote_option_contract": quote,
"place_option_limit_order": MagicMock(),
"td_mode_for_option_buy": lambda x: "isolated",
}
body = {
"target_price": 1900,
"leg_a": {"inst_id": "A", "sheets": 1},
"leg_b": {"inst_id": "B", "sheets": 1},
}
out = execute_options_options_start(cfg, body, dry_run=True)
self.assertTrue(out["ok"])
self.assertEqual(len(out["results"]), 2)
if __name__ == "__main__":
unittest.main()