Enable hedge-plan live opens with path validation, DB, and monitor.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Vendored
+1
-1
@@ -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,保险腿不平"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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")}
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user