Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0acba5e351 | |||
| 8a62cfbb48 | |||
| 41c85c36a9 | |||
| 06f9b23f44 | |||
| a03c208a5f | |||
| a7015ec5c8 | |||
| f5104fe68b | |||
| c5cfbb34e4 | |||
| ea58a4778e | |||
| d16821c7b7 |
@@ -20,7 +20,7 @@ curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.
|
|||||||
|
|
||||||
安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**.
|
安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**.
|
||||||
|
|
||||||
详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**.
|
详见 **[部署文档.md](./部署文档.md)**、**[新机纯实盘部署.md](./新机纯实盘部署.md)**、**[deploy/README.md](./deploy/README.md)**.
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1674,6 +1674,21 @@
|
|||||||
return fmt(n, 2) + "倍";
|
return fmt(n, 2) + "倍";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profitExitHint(mult, req, premCcy) {
|
||||||
|
const n = Number(mult);
|
||||||
|
const m = Number.isFinite(n) && n > 0 ? n : 1;
|
||||||
|
const needFactor = 1 + m;
|
||||||
|
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
|
||||||
|
? String(Math.round(needFactor))
|
||||||
|
: fmt(needFactor, 2);
|
||||||
|
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
|
||||||
|
+ " → 需回收≥权利金×" + factorTxt;
|
||||||
|
if (req != null) {
|
||||||
|
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
|
||||||
|
}
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
|
|
||||||
function renderProfitExitRow(p) {
|
function renderProfitExitRow(p) {
|
||||||
const inst = p.inst_id || "";
|
const inst = p.inst_id || "";
|
||||||
if (p.hedge_plan_target) {
|
if (p.hedge_plan_target) {
|
||||||
@@ -1694,6 +1709,7 @@
|
|||||||
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
||||||
const req = p.profit_exit_required_recycle;
|
const req = p.profit_exit_required_recycle;
|
||||||
const premCcy = posPremiumCcy(p);
|
const premCcy = posPremiumCcy(p);
|
||||||
|
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
|
||||||
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
||||||
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
||||||
return (
|
return (
|
||||||
@@ -1710,7 +1726,7 @@
|
|||||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
||||||
'<span class="muted opt-target-row-hint">' +
|
'<span class="muted opt-target-row-hint">' +
|
||||||
(enabled
|
(enabled
|
||||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
|
? profitExitHint(hintMult, req, premCcy)
|
||||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
||||||
"</span>" +
|
"</span>" +
|
||||||
"</div>"
|
"</div>"
|
||||||
|
|||||||
Vendored
+4
-4
@@ -307,11 +307,11 @@ def _effective_env_value(key: str, file_values: dict[str, str], schema_default:
|
|||||||
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
|
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
|
||||||
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
|
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
|
||||||
if file_val:
|
if file_val:
|
||||||
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
|
from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||||
|
|
||||||
return normalize_okx_trade_mode(file_val) or file_val
|
return normalize_okx_trade_mode(file_val) or file_val
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
|
||||||
|
|
||||||
return get_okx_trade_mode()
|
return get_okx_trade_mode()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -398,7 +398,7 @@ def _build_field(
|
|||||||
|
|
||||||
def _okx_mode_for_env_ui() -> str:
|
def _okx_mode_for_env_ui() -> str:
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
|
||||||
|
|
||||||
return get_okx_trade_mode()
|
return get_okx_trade_mode()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -431,7 +431,7 @@ def ui_sections_for_exchange(
|
|||||||
sections.append({"title": "交易所与实盘", "fields": live_fields})
|
sections.append({"title": "交易所与实盘", "fields": live_fields})
|
||||||
sections.extend(_SHARED_SECTIONS)
|
sections.extend(_SHARED_SECTIONS)
|
||||||
if ex in _MODE_SECTION.get("exchanges", frozenset()):
|
if ex in _MODE_SECTION.get("exchanges", frozenset()):
|
||||||
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
|
from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode
|
||||||
|
|
||||||
m = normalize_okx_trade_mode(mode) if mode else ""
|
m = normalize_okx_trade_mode(mode) if mode else ""
|
||||||
if not m:
|
if not m:
|
||||||
|
|||||||
@@ -1595,6 +1595,9 @@ def resolve_option_close_from_history(
|
|||||||
u_i, row = item
|
u_i, row = item
|
||||||
c_ms = _safe_float(row.get("cTime"))
|
c_ms = _safe_float(row.get("cTime"))
|
||||||
parts: list[float] = []
|
parts: list[float] = []
|
||||||
|
# 全平/强平/ADL 优先于部分平仓(同合约多条时避免只吃到 type=1)
|
||||||
|
close_type = str(row.get("type") or "").strip()
|
||||||
|
parts.append(0.0 if close_type in ("2", "3", "6") else 1.0)
|
||||||
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
|
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
|
||||||
if want_sheets is not None:
|
if want_sheets is not None:
|
||||||
hist_sheets = _safe_float(row.get("closeTotalPos"))
|
hist_sheets = _safe_float(row.get("closeTotalPos"))
|
||||||
@@ -1609,7 +1612,7 @@ def resolve_option_close_from_history(
|
|||||||
parts.append(float(abs(int(c_ms) - int(open_ms))))
|
parts.append(float(abs(int(c_ms) - int(open_ms))))
|
||||||
if close_ms is not None:
|
if close_ms is not None:
|
||||||
parts.append(float(abs(u_i - int(close_ms))))
|
parts.append(float(abs(u_i - int(close_ms))))
|
||||||
if not parts:
|
if len(parts) <= 1:
|
||||||
parts.append(float(-u_i))
|
parts.append(float(-u_i))
|
||||||
# 同距时偏向更晚平仓
|
# 同距时偏向更晚平仓
|
||||||
parts.append(float(-u_i))
|
parts.append(float(-u_i))
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# hedge_plan package
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""对冲计划与单独期权开仓互斥门控.
|
|
||||||
|
|
||||||
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
|
|
||||||
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(key: str, default: bool = False) -> bool:
|
|
||||||
v = (os.getenv(key) or "").strip().lower()
|
|
||||||
if not v:
|
|
||||||
return default
|
|
||||||
return v in ("1", "true", "yes", "on")
|
|
||||||
|
|
||||||
|
|
||||||
def mutual_exclusive_enabled() -> bool:
|
|
||||||
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
|
|
||||||
|
|
||||||
|
|
||||||
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
|
|
||||||
"""若应拦截单独开期权,返回中文原因;否则 None."""
|
|
||||||
if not mutual_exclusive_enabled():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
|
|
||||||
|
|
||||||
init_hedge_plan_tables(conn)
|
|
||||||
if count_active_plans(conn) > 0:
|
|
||||||
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
|
|
||||||
except Exception:
|
|
||||||
return "互斥门控校验失败,暂禁止单独开期权"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _pos_nonzero(raw: dict[str, Any]) -> bool:
|
|
||||||
try:
|
|
||||||
return abs(float(raw.get("pos") or 0)) > 1e-12
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
|
|
||||||
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
|
|
||||||
if not raw_positions:
|
|
||||||
return False
|
|
||||||
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
|
||||||
|
|
||||||
for p in raw_positions:
|
|
||||||
if not isinstance(p, dict) or not _pos_nonzero(p):
|
|
||||||
continue
|
|
||||||
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
|
|
||||||
if not inst:
|
|
||||||
continue
|
|
||||||
source, _, _ = _resolve_options_source(conn, inst)
|
|
||||||
if source == "option":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def block_hedge_plan_start_msg(
|
|
||||||
conn: Any,
|
|
||||||
*,
|
|
||||||
fetch_positions: Optional[Callable[[Any], Any]] = None,
|
|
||||||
exchange: Any = None,
|
|
||||||
raw_positions: list[dict[str, Any]] | None = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
|
|
||||||
if not mutual_exclusive_enabled():
|
|
||||||
return None
|
|
||||||
rows = raw_positions
|
|
||||||
if rows is None:
|
|
||||||
if fetch_positions is None or exchange is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
rows = fetch_positions(exchange) or []
|
|
||||||
except Exception:
|
|
||||||
return "获取期权持仓失败,暂禁止启动对冲计划"
|
|
||||||
try:
|
|
||||||
if has_standalone_option_position(conn, rows):
|
|
||||||
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
|
|
||||||
except Exception:
|
|
||||||
return "互斥门控校验失败,暂禁止启动对冲计划"
|
|
||||||
return None
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _f(v: Any) -> Optional[float]:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def perp_coin_amount(*, contracts: float, contract_size: float) -> float:
|
|
||||||
return float(contracts) * float(contract_size or 1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def perp_pnl(
|
|
||||||
*,
|
|
||||||
direction: str,
|
|
||||||
entry: float,
|
|
||||||
exit_px: float,
|
|
||||||
contracts: float,
|
|
||||||
contract_size: float,
|
|
||||||
) -> float:
|
|
||||||
coins = perp_coin_amount(contracts=contracts, contract_size=contract_size)
|
|
||||||
d = (direction or "long").strip().lower()
|
|
||||||
if d == "short":
|
|
||||||
return (float(entry) - float(exit_px)) * coins
|
|
||||||
return (float(exit_px) - float(entry)) * coins
|
|
||||||
|
|
||||||
|
|
||||||
def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float:
|
|
||||||
"""卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult."""
|
|
||||||
return float(ask) * float(sheets) * float(ct_mult or 0.01)
|
|
||||||
|
|
||||||
|
|
||||||
def option_expiry_pnl(
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
strike: float,
|
|
||||||
spot: float,
|
|
||||||
sheets: float,
|
|
||||||
ct_mult: float,
|
|
||||||
premium_paid: float,
|
|
||||||
) -> float:
|
|
||||||
o = (opt_type or "").strip().upper()
|
|
||||||
intrinsic_per_coin = 0.0
|
|
||||||
if o in ("C", "CALL"):
|
|
||||||
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
|
|
||||||
elif o in ("P", "PUT"):
|
|
||||||
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
|
|
||||||
else:
|
|
||||||
return -float(premium_paid)
|
|
||||||
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
|
|
||||||
return value - float(premium_paid)
|
|
||||||
|
|
||||||
|
|
||||||
def spot_from_expiry_intrinsic_profit(
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
strike: float,
|
|
||||||
sheets: float,
|
|
||||||
ct_mult: float,
|
|
||||||
premium_paid: float,
|
|
||||||
profit: float,
|
|
||||||
) -> float | None:
|
|
||||||
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
|
|
||||||
|
|
||||||
到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数).
|
|
||||||
Call: spot=K+实值/币; Put: spot=K−实值/币.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
k = float(strike)
|
|
||||||
n = float(sheets or 0)
|
|
||||||
ct = float(ct_mult or 0.01)
|
|
||||||
prem = float(premium_paid or 0)
|
|
||||||
pnl = float(profit)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
denom = n * ct
|
|
||||||
if denom <= 0:
|
|
||||||
return None
|
|
||||||
need = (pnl + prem) / denom
|
|
||||||
if need < 0:
|
|
||||||
need = 0.0
|
|
||||||
o = (opt_type or "").strip().upper()
|
|
||||||
if o in ("C", "CALL"):
|
|
||||||
return round(k + need, 2)
|
|
||||||
if o in ("P", "PUT"):
|
|
||||||
return round(k - need, 2)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def suggest_contracts_from_notional(
|
|
||||||
*,
|
|
||||||
notional: float,
|
|
||||||
entry: float,
|
|
||||||
contract_size: float,
|
|
||||||
) -> float:
|
|
||||||
if entry <= 0 or contract_size <= 0 or notional <= 0:
|
|
||||||
return 0.0
|
|
||||||
return float(notional) / (float(entry) * float(contract_size))
|
|
||||||
|
|
||||||
|
|
||||||
def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
|
|
||||||
"""按交易所张数精度向下取整,避免建议张数超过可用保证金."""
|
|
||||||
import math
|
|
||||||
|
|
||||||
raw = float(contracts or 0.0)
|
|
||||||
if raw <= 0:
|
|
||||||
return 0.0
|
|
||||||
try:
|
|
||||||
d = int(decimals)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
d = 0
|
|
||||||
if d <= 0:
|
|
||||||
return float(math.floor(raw + 1e-12))
|
|
||||||
scale = 10**d
|
|
||||||
return math.floor(raw * scale + 1e-12) / scale
|
|
||||||
|
|
||||||
|
|
||||||
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
|
|
||||||
"""单张权利金(USDC) = 卖一价 × ct_mult."""
|
|
||||||
a = _f(ask)
|
|
||||||
if a is None or a <= 0:
|
|
||||||
return 0.0
|
|
||||||
return float(a) * float(ct_mult or 0.01)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_oo_budget_usdc(
|
|
||||||
*,
|
|
||||||
trading_usdc: Any,
|
|
||||||
trade_budget_usdc: Any,
|
|
||||||
buffer_ratio: Any = 0.95,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
|
|
||||||
import math
|
|
||||||
|
|
||||||
trading = _f(trading_usdc)
|
|
||||||
cap = _f(trade_budget_usdc)
|
|
||||||
buf = _f(buffer_ratio)
|
|
||||||
if buf is None or buf <= 0:
|
|
||||||
buf = 0.95
|
|
||||||
if buf > 1:
|
|
||||||
buf = 1.0
|
|
||||||
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
|
|
||||||
trade_cap = None if cap is None else max(0.0, float(cap))
|
|
||||||
if trading_cap is None and trade_cap is None:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"budget_usdc": 0.0,
|
|
||||||
"trading_cap": None,
|
|
||||||
"trade_budget_cap": None,
|
|
||||||
"buffer_ratio": float(buf),
|
|
||||||
"msg": "缺少交易户余额与单笔预算",
|
|
||||||
}
|
|
||||||
if trading_cap is None:
|
|
||||||
budget = float(trade_cap or 0.0)
|
|
||||||
elif trade_cap is None:
|
|
||||||
budget = float(trading_cap)
|
|
||||||
else:
|
|
||||||
budget = min(float(trading_cap), float(trade_cap))
|
|
||||||
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
|
|
||||||
return {
|
|
||||||
"ok": budget > 0,
|
|
||||||
"budget_usdc": budget,
|
|
||||||
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
|
|
||||||
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
|
|
||||||
"buffer_ratio": float(buf),
|
|
||||||
"msg": "" if budget > 0 else "可用预算为 0",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
|
|
||||||
import math
|
|
||||||
|
|
||||||
n = max(0, int(sheets))
|
|
||||||
depth = _f(ask_sz)
|
|
||||||
if depth is None:
|
|
||||||
return n
|
|
||||||
if depth <= 0:
|
|
||||||
return 0
|
|
||||||
return min(n, int(math.floor(float(depth) + 1e-12)))
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_oo_sheets_mode(mode: str) -> str:
|
|
||||||
m = (mode or "same_sheets").strip().lower()
|
|
||||||
if m in ("long_bias", "bias_long", "long", "做多"):
|
|
||||||
return "long_bias"
|
|
||||||
if m in ("short_bias", "bias_short", "short", "做空"):
|
|
||||||
return "short_bias"
|
|
||||||
# 旧「均分」兼容:按预算 50/50(页面已移除)
|
|
||||||
if m in ("split", "equal_budget", "split_budget", "均分"):
|
|
||||||
return "split_budget"
|
|
||||||
return "same_sheets"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_oo_bias_split_by(raw: Any) -> str:
|
|
||||||
v = str(raw or "budget").strip().lower()
|
|
||||||
if v in ("sheets", "qty", "quantity", "张数"):
|
|
||||||
return "sheets"
|
|
||||||
return "budget"
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
|
|
||||||
try:
|
|
||||||
r = float(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
r = float(default)
|
|
||||||
if r <= 0 or r >= 1:
|
|
||||||
r = float(default)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
|
|
||||||
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
|
|
||||||
a = (opt_type_a or "").strip().upper()
|
|
||||||
b = (opt_type_b or "").strip().upper()
|
|
||||||
if a.startswith("C"):
|
|
||||||
a = "C"
|
|
||||||
elif a.startswith("P"):
|
|
||||||
a = "P"
|
|
||||||
if b.startswith("C"):
|
|
||||||
b = "C"
|
|
||||||
elif b.startswith("P"):
|
|
||||||
b = "P"
|
|
||||||
if {a, b} != {"C", "P"}:
|
|
||||||
return None, None, "做多/做空需一腿 Call、一腿 Put"
|
|
||||||
call_side = "a" if a == "C" else "b"
|
|
||||||
put_side = "b" if call_side == "a" else "a"
|
|
||||||
return call_side, put_side, ""
|
|
||||||
|
|
||||||
|
|
||||||
def suggest_oo_sheets(
|
|
||||||
*,
|
|
||||||
mode: str,
|
|
||||||
budget_usdc: float,
|
|
||||||
ask_a: float,
|
|
||||||
ct_mult_a: float = 0.01,
|
|
||||||
ask_sz_a: Any = None,
|
|
||||||
opt_type_a: str = "",
|
|
||||||
ask_b: float,
|
|
||||||
ct_mult_b: float = 0.01,
|
|
||||||
ask_sz_b: Any = None,
|
|
||||||
opt_type_b: str = "",
|
|
||||||
bias_split_by: str = "budget",
|
|
||||||
bias_ratio: float = 0.7,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
|
|
||||||
import math
|
|
||||||
|
|
||||||
m = _normalize_oo_sheets_mode(mode)
|
|
||||||
split_by = _normalize_oo_bias_split_by(bias_split_by)
|
|
||||||
ratio = _clamp_oo_bias_ratio(bias_ratio)
|
|
||||||
budget = max(0.0, float(budget_usdc or 0.0))
|
|
||||||
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
|
|
||||||
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
|
|
||||||
|
|
||||||
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"mode": m,
|
|
||||||
"sheets_a": n_a,
|
|
||||||
"sheets_b": n_b,
|
|
||||||
"cost_a": round(cost_a, 8),
|
|
||||||
"cost_b": round(cost_b, 8),
|
|
||||||
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
|
|
||||||
"ok": False,
|
|
||||||
"msg": msg,
|
|
||||||
"bias_split_by": split_by,
|
|
||||||
"bias_ratio": ratio,
|
|
||||||
}
|
|
||||||
|
|
||||||
if budget <= 0:
|
|
||||||
return _fail("可用预算为 0")
|
|
||||||
if cost_a <= 0 or cost_b <= 0:
|
|
||||||
return _fail("缺少有效卖一价,无法建议张数")
|
|
||||||
|
|
||||||
pair = cost_a + cost_b
|
|
||||||
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
|
|
||||||
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
|
|
||||||
n_same = min(
|
|
||||||
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
|
|
||||||
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
|
|
||||||
)
|
|
||||||
|
|
||||||
if m == "same_sheets":
|
|
||||||
n_a = n_same
|
|
||||||
n_b = n_same
|
|
||||||
elif m == "split_budget":
|
|
||||||
half = budget / 2.0
|
|
||||||
n_a = int(math.floor(half / cost_a + 1e-12))
|
|
||||||
n_b = int(math.floor(half / cost_b + 1e-12))
|
|
||||||
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
|
|
||||||
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
|
|
||||||
else:
|
|
||||||
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
|
|
||||||
if err:
|
|
||||||
return _fail(err)
|
|
||||||
major_is_call = m == "long_bias"
|
|
||||||
if split_by == "sheets":
|
|
||||||
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
|
|
||||||
total = int(n_same) * 2
|
|
||||||
if total < 2:
|
|
||||||
return _fail("同张数总规模不足 2,无法按比例拆分")
|
|
||||||
major_n = int(round(total * ratio))
|
|
||||||
major_n = max(1, min(major_n, total - 1))
|
|
||||||
minor_n = total - major_n
|
|
||||||
n_call = major_n if major_is_call else minor_n
|
|
||||||
n_put = minor_n if major_is_call else major_n
|
|
||||||
else:
|
|
||||||
maj_budget = budget * ratio
|
|
||||||
min_budget = budget * (1.0 - ratio)
|
|
||||||
cost_call = cost_a if call_side == "a" else cost_b
|
|
||||||
cost_put = cost_b if call_side == "a" else cost_a
|
|
||||||
if major_is_call:
|
|
||||||
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
|
|
||||||
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
|
|
||||||
else:
|
|
||||||
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
|
|
||||||
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
|
|
||||||
n_a = n_call if call_side == "a" else n_put
|
|
||||||
n_b = n_put if call_side == "a" else n_call
|
|
||||||
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
|
|
||||||
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
|
|
||||||
|
|
||||||
prem = cost_a * n_a + cost_b * n_b
|
|
||||||
ok = n_a >= 1 and n_b >= 1
|
|
||||||
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
|
|
||||||
return {
|
|
||||||
"mode": m,
|
|
||||||
"sheets_a": n_a,
|
|
||||||
"sheets_b": n_b,
|
|
||||||
"cost_a": round(cost_a, 8),
|
|
||||||
"cost_b": round(cost_b, 8),
|
|
||||||
"premium_est": round(prem, 6),
|
|
||||||
"ok": ok,
|
|
||||||
"msg": msg,
|
|
||||||
"bias_split_by": split_by,
|
|
||||||
"bias_ratio": ratio,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_perp_options_preview(
|
|
||||||
*,
|
|
||||||
direction: str,
|
|
||||||
entry: float,
|
|
||||||
tp: float,
|
|
||||||
sl: float,
|
|
||||||
contracts: float,
|
|
||||||
contract_size: float,
|
|
||||||
opt_type: str,
|
|
||||||
strike: float,
|
|
||||||
sheets: float,
|
|
||||||
ct_mult: float,
|
|
||||||
premium_paid: float,
|
|
||||||
index_px: Optional[float] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
永期情景.
|
|
||||||
止盈账:永续止盈盈利 - 权利金.
|
|
||||||
止损账:期权到期内在(按 SL 价) - 永续止损亏损额.
|
|
||||||
"""
|
|
||||||
d = (direction or "long").strip().lower()
|
|
||||||
pnl_tp_perp = perp_pnl(
|
|
||||||
direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size
|
|
||||||
)
|
|
||||||
pnl_sl_perp = perp_pnl(
|
|
||||||
direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size
|
|
||||||
)
|
|
||||||
# 止盈统计口径
|
|
||||||
tp_total = float(pnl_tp_perp) - float(premium_paid)
|
|
||||||
# 止损:期权按 SL 价结算内在 - |永续亏损|
|
|
||||||
opt_at_sl = option_expiry_pnl(
|
|
||||||
opt_type=opt_type,
|
|
||||||
strike=strike,
|
|
||||||
spot=sl,
|
|
||||||
sheets=sheets,
|
|
||||||
ct_mult=ct_mult,
|
|
||||||
premium_paid=premium_paid,
|
|
||||||
)
|
|
||||||
sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float(
|
|
||||||
pnl_sl_perp
|
|
||||||
)
|
|
||||||
# 有符号相加更稳:期权盈亏 + 永续盈亏
|
|
||||||
sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp)
|
|
||||||
|
|
||||||
spot = float(index_px) if index_px is not None else float(entry)
|
|
||||||
opt_flat = option_expiry_pnl(
|
|
||||||
opt_type=opt_type,
|
|
||||||
strike=strike,
|
|
||||||
spot=spot,
|
|
||||||
sheets=sheets,
|
|
||||||
ct_mult=ct_mult,
|
|
||||||
premium_paid=premium_paid,
|
|
||||||
)
|
|
||||||
flat_total = 0.0 + float(opt_flat)
|
|
||||||
|
|
||||||
opt_at_tp = option_expiry_pnl(
|
|
||||||
opt_type=opt_type,
|
|
||||||
strike=strike,
|
|
||||||
spot=tp,
|
|
||||||
sheets=sheets,
|
|
||||||
ct_mult=ct_mult,
|
|
||||||
premium_paid=premium_paid,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"plan_type": "perp_options",
|
|
||||||
"direction": d,
|
|
||||||
"contracts": contracts,
|
|
||||||
"coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size),
|
|
||||||
"premium_paid": round(float(premium_paid), 6),
|
|
||||||
"scenarios": [
|
|
||||||
{
|
|
||||||
"id": "tp",
|
|
||||||
"label": "止盈(计划结束口径)",
|
|
||||||
"spot": tp,
|
|
||||||
"perp_pnl": round(pnl_tp_perp, 4),
|
|
||||||
"options_pnl": round(-float(premium_paid), 4),
|
|
||||||
"total": round(tp_total, 4),
|
|
||||||
"note": "止盈盈利 − 权利金;期权可不强平",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sl",
|
|
||||||
"label": "止损(计划结束口径)",
|
|
||||||
"spot": sl,
|
|
||||||
"perp_pnl": round(pnl_sl_perp, 4),
|
|
||||||
"options_pnl": round(opt_at_sl, 4),
|
|
||||||
"total": round(sl_total_signed, 4),
|
|
||||||
"note": "期权盈利 − 永续亏损(有符号相加);期权须强平",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "flat",
|
|
||||||
"label": "到期·现价附近",
|
|
||||||
"spot": spot,
|
|
||||||
"perp_pnl": 0.0,
|
|
||||||
"options_pnl": round(opt_flat, 4),
|
|
||||||
"total": round(flat_total, 4),
|
|
||||||
"note": "示意:永续未动,期权按到期内在",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "expiry_tp",
|
|
||||||
"label": "到期·止盈价",
|
|
||||||
"spot": tp,
|
|
||||||
"perp_pnl": round(pnl_tp_perp, 4),
|
|
||||||
"options_pnl": round(opt_at_tp, 4),
|
|
||||||
"total": round(pnl_tp_perp + opt_at_tp, 4),
|
|
||||||
"note": "若期权拿到 TP 价到期(参考)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "expiry_sl",
|
|
||||||
"label": "到期·止损价",
|
|
||||||
"spot": sl,
|
|
||||||
"perp_pnl": round(pnl_sl_perp, 4),
|
|
||||||
"options_pnl": round(opt_at_sl, 4),
|
|
||||||
"total": round(pnl_sl_perp + opt_at_sl, 4),
|
|
||||||
"note": "与止损口径相近(期权用内在)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"summary": {
|
|
||||||
"tp_total": round(tp_total, 4),
|
|
||||||
"sl_total": round(sl_total_signed, 4),
|
|
||||||
"premium_paid": round(float(premium_paid), 4),
|
|
||||||
"hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
|
|
||||||
loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0
|
|
||||||
if loss <= 1e-12:
|
|
||||||
return None
|
|
||||||
if float(opt_pnl) <= 0:
|
|
||||||
return 0.0
|
|
||||||
return round(float(opt_pnl) / loss * 100.0, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def build_options_options_preview(
|
|
||||||
*,
|
|
||||||
target_price: float | None = None,
|
|
||||||
target_price_up: float | None = None,
|
|
||||||
target_price_down: float | None = None,
|
|
||||||
profit_rr: float | None = None,
|
|
||||||
index_px: float,
|
|
||||||
leg_a: dict[str, Any],
|
|
||||||
leg_b: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
|
||||||
|
|
||||||
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
|
|
||||||
残值按亏损腿本合约权利金的 20% 计.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
|
||||||
return option_expiry_pnl(
|
|
||||||
opt_type=str(leg.get("opt_type") or ""),
|
|
||||||
strike=float(leg["strike"]),
|
|
||||||
spot=spot,
|
|
||||||
sheets=float(leg.get("sheets") or 0),
|
|
||||||
ct_mult=float(leg.get("ct_mult") or 0.01),
|
|
||||||
premium_paid=float(leg.get("premium_paid") or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
prem_a = float(leg_a.get("premium_paid") or 0)
|
|
||||||
prem_b = float(leg_b.get("premium_paid") or 0)
|
|
||||||
prem = prem_a + prem_b
|
|
||||||
rr = float(profit_rr) if profit_rr is not None else None
|
|
||||||
|
|
||||||
# 新:盈亏比情景(不依赖指数上下破价)
|
|
||||||
if rr is not None and rr > 0:
|
|
||||||
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
|
|
||||||
win_profit = rr * prem
|
|
||||||
a_at_a = win_profit
|
|
||||||
b_at_a_full = -prem_b
|
|
||||||
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
|
|
||||||
b_at_b = win_profit
|
|
||||||
a_at_b_full = -prem_a
|
|
||||||
a_at_b_res = -prem_a * 0.8
|
|
||||||
|
|
||||||
spot_a = spot_from_expiry_intrinsic_profit(
|
|
||||||
opt_type=str(leg_a.get("opt_type") or ""),
|
|
||||||
strike=float(leg_a["strike"]),
|
|
||||||
sheets=float(leg_a.get("sheets") or 0),
|
|
||||||
ct_mult=float(leg_a.get("ct_mult") or 0.01),
|
|
||||||
premium_paid=prem_a,
|
|
||||||
profit=win_profit,
|
|
||||||
)
|
|
||||||
spot_b = spot_from_expiry_intrinsic_profit(
|
|
||||||
opt_type=str(leg_b.get("opt_type") or ""),
|
|
||||||
strike=float(leg_b["strike"]),
|
|
||||||
sheets=float(leg_b.get("sheets") or 0),
|
|
||||||
ct_mult=float(leg_b.get("ct_mult") or 0.01),
|
|
||||||
premium_paid=prem_b,
|
|
||||||
profit=win_profit,
|
|
||||||
)
|
|
||||||
|
|
||||||
a_flat = _leg_pnl(leg_a, index_px)
|
|
||||||
b_flat = _leg_pnl(leg_b, index_px)
|
|
||||||
flat_total = a_flat + b_flat
|
|
||||||
|
|
||||||
return {
|
|
||||||
"plan_type": "options_options",
|
|
||||||
"premium_paid": round(prem, 6),
|
|
||||||
"profit_rr": rr,
|
|
||||||
"target_price": None,
|
|
||||||
"target_price_up": None,
|
|
||||||
"target_price_down": None,
|
|
||||||
"winner_at_up": "a",
|
|
||||||
"winner_at_down": "b",
|
|
||||||
"winner_at_target": "a",
|
|
||||||
"scenarios": [
|
|
||||||
{
|
|
||||||
"id": "rr_leg_a_full",
|
|
||||||
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
|
|
||||||
"spot": spot_a,
|
|
||||||
"leg_a_pnl": round(a_at_a, 4),
|
|
||||||
"leg_b_pnl": round(b_at_a_full, 4),
|
|
||||||
"total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rr_leg_b_full",
|
|
||||||
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
|
|
||||||
"spot": spot_b,
|
|
||||||
"leg_a_pnl": round(a_at_b_full, 4),
|
|
||||||
"leg_b_pnl": round(b_at_b, 4),
|
|
||||||
"total": round(a_at_b_full + b_at_b, 4),
|
|
||||||
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rr_leg_a_residual",
|
|
||||||
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
|
|
||||||
"spot": spot_a,
|
|
||||||
"leg_a_pnl": round(a_at_a, 4),
|
|
||||||
"leg_b_pnl": round(b_at_a_res, 4),
|
|
||||||
"total": round(a_at_a + b_at_a_res, 4),
|
|
||||||
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "expiry_flat",
|
|
||||||
"label": "到期·现价",
|
|
||||||
"spot": index_px,
|
|
||||||
"leg_a_pnl": round(a_flat, 4),
|
|
||||||
"leg_b_pnl": round(b_flat, 4),
|
|
||||||
"total": round(flat_total, 4),
|
|
||||||
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "max_premium_loss",
|
|
||||||
"label": "最大保费损耗",
|
|
||||||
"spot": None,
|
|
||||||
"leg_a_pnl": round(-prem_a, 4),
|
|
||||||
"leg_b_pnl": round(-prem_b, 4),
|
|
||||||
"total": round(-prem, 4),
|
|
||||||
"note": "双腿权利金全部损失",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"summary": {
|
|
||||||
"profit_rr": rr,
|
|
||||||
"spot_at_rr_a": spot_a,
|
|
||||||
"spot_at_rr_b": spot_b,
|
|
||||||
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
|
|
||||||
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
|
|
||||||
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
|
|
||||||
"at_target_total": round(a_at_a + b_at_a_full, 4),
|
|
||||||
"expiry_flat_total": round(flat_total, 4),
|
|
||||||
"premium_paid": round(prem, 6),
|
|
||||||
"expiry_is_loss": flat_total <= 0,
|
|
||||||
"rr_risk_premium": round(prem, 6),
|
|
||||||
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
|
|
||||||
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
|
|
||||||
up = target_price_up if target_price_up is not None else target_price
|
|
||||||
down = target_price_down if target_price_down is not None else target_price
|
|
||||||
if up is None or down is None:
|
|
||||||
raise ValueError("缺少盈亏比或上破/下破目标价")
|
|
||||||
up_f = float(up)
|
|
||||||
down_f = float(down)
|
|
||||||
|
|
||||||
a_up = _leg_pnl(leg_a, up_f)
|
|
||||||
b_up = _leg_pnl(leg_b, up_f)
|
|
||||||
at_up = a_up + b_up
|
|
||||||
win_up = "a" if a_up >= b_up else "b"
|
|
||||||
|
|
||||||
a_dn = _leg_pnl(leg_a, down_f)
|
|
||||||
b_dn = _leg_pnl(leg_b, down_f)
|
|
||||||
at_dn = a_dn + b_dn
|
|
||||||
win_dn = "a" if a_dn >= b_dn else "b"
|
|
||||||
|
|
||||||
a_flat = _leg_pnl(leg_a, index_px)
|
|
||||||
b_flat = _leg_pnl(leg_b, index_px)
|
|
||||||
flat_total = a_flat + b_flat
|
|
||||||
expiry_loss = flat_total if flat_total <= 0 else flat_total
|
|
||||||
|
|
||||||
return {
|
|
||||||
"plan_type": "options_options",
|
|
||||||
"premium_paid": round(prem, 6),
|
|
||||||
"target_price": up_f, # 兼容旧字段,取上破
|
|
||||||
"target_price_up": up_f,
|
|
||||||
"target_price_down": down_f,
|
|
||||||
"winner_at_up": win_up,
|
|
||||||
"winner_at_down": win_dn,
|
|
||||||
"winner_at_target": win_up,
|
|
||||||
"scenarios": [
|
|
||||||
{
|
|
||||||
"id": "target_up",
|
|
||||||
"label": "上破目标",
|
|
||||||
"spot": up_f,
|
|
||||||
"leg_a_pnl": round(a_up, 4),
|
|
||||||
"leg_b_pnl": round(b_up, 4),
|
|
||||||
"total": round(at_up, 4),
|
|
||||||
"note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "target_down",
|
|
||||||
"label": "下破目标",
|
|
||||||
"spot": down_f,
|
|
||||||
"leg_a_pnl": round(a_dn, 4),
|
|
||||||
"leg_b_pnl": round(b_dn, 4),
|
|
||||||
"total": round(at_dn, 4),
|
|
||||||
"note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "expiry_flat",
|
|
||||||
"label": "到期·现价(无突破)",
|
|
||||||
"spot": index_px,
|
|
||||||
"leg_a_pnl": round(a_flat, 4),
|
|
||||||
"leg_b_pnl": round(b_flat, 4),
|
|
||||||
"total": round(flat_total, 4),
|
|
||||||
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "max_premium_loss",
|
|
||||||
"label": "最大保费损耗",
|
|
||||||
"spot": None,
|
|
||||||
"leg_a_pnl": round(-prem_a, 4),
|
|
||||||
"leg_b_pnl": round(-prem_b, 4),
|
|
||||||
"total": round(-prem, 4),
|
|
||||||
"note": "双腿权利金全部损失",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"summary": {
|
|
||||||
"at_target_up_total": round(at_up, 4),
|
|
||||||
"at_target_down_total": round(at_dn, 4),
|
|
||||||
"at_target_total": round(at_up, 4),
|
|
||||||
"expiry_flat_total": round(expiry_loss, 4),
|
|
||||||
"premium_paid": round(prem, 6),
|
|
||||||
"expiry_is_loss": flat_total <= 0,
|
|
||||||
# 盈亏比:盈利/全亏保费(风险=权利金全损)
|
|
||||||
"rr_risk_premium": round(prem, 6),
|
|
||||||
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
|
|
||||||
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def gate_status(
|
|
||||||
*,
|
|
||||||
hedge_enabled: bool,
|
|
||||||
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,
|
|
||||||
show_perp_options: bool = True,
|
|
||||||
show_options_options: bool = True,
|
|
||||||
mutual_exclusive: bool = True,
|
|
||||||
has_standalone_option: bool = False,
|
|
||||||
) -> 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 = True
|
|
||||||
reasons: list[str] = []
|
|
||||||
if not hedge_enabled:
|
|
||||||
can_start = False
|
|
||||||
reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)")
|
|
||||||
if not options_enabled:
|
|
||||||
can_preview = False
|
|
||||||
can_start = False
|
|
||||||
reasons.append("期权模块未启用")
|
|
||||||
if pt == "perp_options" and not show_perp_options:
|
|
||||||
can_preview = False
|
|
||||||
can_start = False
|
|
||||||
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
|
|
||||||
if pt == "options_options" and not show_options_options:
|
|
||||||
can_preview = False
|
|
||||||
can_start = False
|
|
||||||
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
|
|
||||||
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 mutual_exclusive and has_standalone_option:
|
|
||||||
can_start = False
|
|
||||||
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
|
|
||||||
if pt == "perp_options":
|
|
||||||
if not full:
|
|
||||||
can_start = False
|
|
||||||
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
|
|
||||||
if not live_trading:
|
|
||||||
can_start = False
|
|
||||||
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
|
|
||||||
elif pt == "options_options":
|
|
||||||
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,
|
|
||||||
"show_perp_options": bool(show_perp_options),
|
|
||||||
"show_options_options": bool(show_options_options),
|
|
||||||
"mutual_exclusive": bool(mutual_exclusive),
|
|
||||||
"has_standalone_option": bool(has_standalone_option),
|
|
||||||
"can_preview": can_preview,
|
|
||||||
"can_start": can_start,
|
|
||||||
"reasons": reasons,
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
"""对冲计划 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)"
|
|
||||||
)
|
|
||||||
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
|
||||||
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
|
|
||||||
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
|
|
||||||
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
|
|
||||||
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
|
||||||
# 永期「以期权为主」
|
|
||||||
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
|
|
||||||
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
|
|
||||||
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
|
|
||||||
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
|
|
||||||
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
|
||||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
||||||
names: set[str] = set()
|
|
||||||
for r in rows:
|
|
||||||
try:
|
|
||||||
names.add(str(r["name"]))
|
|
||||||
except (TypeError, KeyError, IndexError):
|
|
||||||
names.add(str(r[1]))
|
|
||||||
if col not in names:
|
|
||||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
|
||||||
|
|
||||||
|
|
||||||
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
|
|
||||||
|
|
||||||
|
|
||||||
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
|
|
||||||
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
|
|
||||||
if plan_type:
|
|
||||||
row = conn.execute(
|
|
||||||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
|
|
||||||
(plan_type,),
|
|
||||||
).fetchone()
|
|
||||||
else:
|
|
||||||
row = conn.execute(
|
|
||||||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
|
|
||||||
).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 update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
|
|
||||||
if not fields:
|
|
||||||
return
|
|
||||||
sets = ", ".join(f"{k}=?" for k in fields)
|
|
||||||
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
|
|
||||||
|
|
||||||
|
|
||||||
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
|
|
||||||
for leg in legs or []:
|
|
||||||
if str(leg.get("status") or "").strip().lower() == "pending":
|
|
||||||
role = str(leg.get("leg_role") or "").strip()
|
|
||||||
if role:
|
|
||||||
return role
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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 delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
|
|
||||||
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
|
|
||||||
plan = get_plan(conn, int(plan_id))
|
|
||||||
if not plan:
|
|
||||||
return {"ok": False, "msg": "计划不存在"}
|
|
||||||
st = str(plan.get("status") or "")
|
|
||||||
if st in ("opening", "active", "partial", "watching"):
|
|
||||||
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
|
|
||||||
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
|
|
||||||
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
|
|
||||||
return {"ok": True, "deleted_id": int(plan_id)}
|
|
||||||
|
|
||||||
|
|
||||||
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
for leg in legs:
|
|
||||||
role = str(leg.get("leg_role") or "")
|
|
||||||
st = str(leg.get("status") or "").strip().lower()
|
|
||||||
if st == "pending":
|
|
||||||
suffix = "(待补)"
|
|
||||||
elif st in ("cancelled", "canceled"):
|
|
||||||
suffix = "(未成交)"
|
|
||||||
else:
|
|
||||||
suffix = ""
|
|
||||||
if role == "perp":
|
|
||||||
name = str(leg.get("symbol") or "永续")
|
|
||||||
parts.append(f"永续 {name}{suffix}")
|
|
||||||
else:
|
|
||||||
inst = str(leg.get("inst_id") or "")
|
|
||||||
ot = str(leg.get("opt_type") or "").upper()
|
|
||||||
strike = leg.get("strike")
|
|
||||||
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
|
|
||||||
parts.append(f"{label}{suffix}")
|
|
||||||
return " · ".join(parts) if parts else "—"
|
|
||||||
|
|
||||||
|
|
||||||
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for p in plans:
|
|
||||||
legs = get_plan_legs(conn, int(p["id"]))
|
|
||||||
row = dict(p)
|
|
||||||
row["legs"] = legs
|
|
||||||
summary = legs_contract_summary(legs)
|
|
||||||
if str(p.get("status") or "") == "watching" and (not legs or summary == "—"):
|
|
||||||
money = str(p.get("option_moneyness") or "otm")
|
|
||||||
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
|
|
||||||
parts = [f"盯盘·{money_lab}"]
|
|
||||||
try:
|
|
||||||
if p.get("strike_interval") not in (None, ""):
|
|
||||||
parts.append(f"间隔{float(p.get('strike_interval')):g}")
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
if p.get("option_leverage") not in (None, ""):
|
|
||||||
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
summary = "·".join(parts)
|
|
||||||
row["contracts_summary"] = summary
|
|
||||||
row["missing_leg"] = missing_leg_role(legs)
|
|
||||||
out.append(row)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
|
||||||
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
|
|
||||||
|
|
||||||
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
|
|
||||||
否则两套监控会同时尝试平掉同一条期权腿。
|
|
||||||
"""
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
|
||||||
p.profit_rr, l.inst_id, l.opt_type
|
|
||||||
FROM hedge_plans p
|
|
||||||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
|
||||||
WHERE p.plan_type = 'options_options'
|
|
||||||
AND p.status IN ('opening', 'active', 'partial')
|
|
||||||
AND l.status = 'open'
|
|
||||||
AND l.inst_id IS NOT NULL
|
|
||||||
AND l.inst_id != ''
|
|
||||||
ORDER BY p.id DESC, l.id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
out: dict[str, dict[str, Any]] = {}
|
|
||||||
for raw in rows:
|
|
||||||
row = dict(raw)
|
|
||||||
inst_id = str(row.get("inst_id") or "")
|
|
||||||
opt_type = str(row.get("opt_type") or "").upper()
|
|
||||||
if not inst_id or inst_id in out:
|
|
||||||
continue
|
|
||||||
profit_rr = _sf(row.get("profit_rr"))
|
|
||||||
if profit_rr is not None and profit_rr > 0:
|
|
||||||
out[inst_id] = {
|
|
||||||
"plan_id": int(row["plan_id"]),
|
|
||||||
"inst_id": inst_id,
|
|
||||||
"underlying": row.get("underlying"),
|
|
||||||
"opt_type": opt_type,
|
|
||||||
"profit_rr": profit_rr,
|
|
||||||
"target_index": None,
|
|
||||||
"exit_mode": "profit_rr",
|
|
||||||
"managed_by": "hedge_plan",
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
|
||||||
target_f = _sf(target)
|
|
||||||
if target_f is None or target_f <= 0:
|
|
||||||
continue
|
|
||||||
out[inst_id] = {
|
|
||||||
"plan_id": int(row["plan_id"]),
|
|
||||||
"inst_id": inst_id,
|
|
||||||
"underlying": row.get("underlying"),
|
|
||||||
"opt_type": opt_type,
|
|
||||||
"target_index": target_f,
|
|
||||||
"plan_type": "options_options",
|
|
||||||
"managed_by": "hedge_plan",
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
|
|
||||||
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT l.inst_id
|
|
||||||
FROM hedge_plan_legs l
|
|
||||||
JOIN hedge_plans p ON p.id = l.plan_id
|
|
||||||
WHERE p.status IN ('opening', 'active', 'partial')
|
|
||||||
AND l.status IN ('open', 'hold_to_expiry')
|
|
||||||
AND l.inst_id IS NOT NULL
|
|
||||||
AND TRIM(l.inst_id) != ''
|
|
||||||
AND (
|
|
||||||
l.leg_role LIKE 'option%'
|
|
||||||
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
return {str(r[0]).strip() for r in rows if r and r[0]}
|
|
||||||
|
|
||||||
|
|
||||||
def _sf(v: Any) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
||||||
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
|
|
||||||
pnls: list[float] = []
|
|
||||||
timed: list[tuple[str, float]] = []
|
|
||||||
for r in rows:
|
|
||||||
pnl = _sf(r.get("realized_pnl_total"))
|
|
||||||
if pnl is None:
|
|
||||||
continue
|
|
||||||
pnls.append(pnl)
|
|
||||||
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
|
|
||||||
timed.append((t, pnl))
|
|
||||||
n = len(pnls)
|
|
||||||
if n == 0:
|
|
||||||
return {
|
|
||||||
"count": 0,
|
|
||||||
"wins": 0,
|
|
||||||
"losses": 0,
|
|
||||||
"win_rate": None,
|
|
||||||
"net_pnl": 0.0,
|
|
||||||
"avg_pnl": None,
|
|
||||||
"avg_premium": None,
|
|
||||||
"profit_factor": None,
|
|
||||||
"max_profit": None,
|
|
||||||
"max_loss": None,
|
|
||||||
"max_drawdown": None,
|
|
||||||
}
|
|
||||||
wins = [x for x in pnls if x > 0]
|
|
||||||
losses = [x for x in pnls if x < 0]
|
|
||||||
gross_win = sum(wins)
|
|
||||||
gross_loss = abs(sum(losses))
|
|
||||||
if gross_loss > 0:
|
|
||||||
profit_factor = round(gross_win / gross_loss, 4)
|
|
||||||
elif gross_win > 0:
|
|
||||||
profit_factor = None # 全胜,标无限
|
|
||||||
else:
|
|
||||||
profit_factor = 0.0
|
|
||||||
|
|
||||||
timed.sort(key=lambda x: x[0] or "")
|
|
||||||
cum = 0.0
|
|
||||||
peak = 0.0
|
|
||||||
mdd = 0.0
|
|
||||||
for _, p in timed:
|
|
||||||
cum += p
|
|
||||||
if cum > peak:
|
|
||||||
peak = cum
|
|
||||||
dd = peak - cum
|
|
||||||
if dd > mdd:
|
|
||||||
mdd = dd
|
|
||||||
|
|
||||||
premiums = [_sf(r.get("premium_total")) for r in rows]
|
|
||||||
premiums_f = [x for x in premiums if x is not None]
|
|
||||||
return {
|
|
||||||
"count": n,
|
|
||||||
"wins": len(wins),
|
|
||||||
"losses": len(losses),
|
|
||||||
"win_rate": round(len(wins) / n, 4),
|
|
||||||
"net_pnl": round(sum(pnls), 4),
|
|
||||||
"avg_pnl": round(sum(pnls) / n, 4),
|
|
||||||
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
|
|
||||||
"profit_factor": profit_factor,
|
|
||||||
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
|
|
||||||
"max_profit": round(max(pnls), 4),
|
|
||||||
"max_loss": round(min(pnls), 4),
|
|
||||||
"max_drawdown": round(mdd, 4),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
||||||
reason_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_rows = [
|
|
||||||
dict(r)
|
|
||||||
for r in conn.execute(
|
|
||||||
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
|
|
||||||
).fetchall()
|
|
||||||
]
|
|
||||||
active = count_active_plans(conn)
|
|
||||||
overall = _metrics_from_pnls(closed_rows)
|
|
||||||
by_type = {
|
|
||||||
"perp_options": _metrics_from_pnls(
|
|
||||||
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
|
||||||
),
|
|
||||||
"options_options": _metrics_from_pnls(
|
|
||||||
[r for r in closed_rows if r.get("plan_type") == "options_options"]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
# 永期止盈/止损分桶
|
|
||||||
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
|
||||||
by_type["perp_options"]["buckets"] = {
|
|
||||||
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
|
|
||||||
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
|
|
||||||
}
|
|
||||||
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
|
|
||||||
by_type["options_options"]["buckets"] = {
|
|
||||||
"expiry_loss": _metrics_from_pnls(
|
|
||||||
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
|
|
||||||
),
|
|
||||||
"expiry_win": _metrics_from_pnls(
|
|
||||||
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"active": active,
|
|
||||||
"closed_count": overall["count"],
|
|
||||||
"closed_pnl_total": overall["net_pnl"],
|
|
||||||
"overall": overall,
|
|
||||||
"by_type": by_type,
|
|
||||||
"by_reason": [dict(r) for r in reason_rows],
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
"""对冲计划虚实值选约与校验.
|
|
||||||
|
|
||||||
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
|
|
||||||
期期(options_options):两腿仅允许平值或虚值(禁实值).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _env_float(name: str, default: float) -> float:
|
|
||||||
try:
|
|
||||||
return float(os.getenv(name) or default)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return float(default)
|
|
||||||
|
|
||||||
|
|
||||||
def itm_max_dist_usd() -> float:
|
|
||||||
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
|
|
||||||
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
return max(0.0, float(raw))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
|
|
||||||
|
|
||||||
|
|
||||||
def min_option_hours() -> float:
|
|
||||||
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
|
|
||||||
|
|
||||||
|
|
||||||
def min_option_leverage() -> float:
|
|
||||||
"""指数/卖一 最低杠杆门槛;0=不启用."""
|
|
||||||
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
|
|
||||||
|
|
||||||
|
|
||||||
def _sf(v: Any) -> Optional[float]:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
|
|
||||||
o = str(opt_type or "").strip().upper()
|
|
||||||
if o in ("C", "CALL"):
|
|
||||||
return "C"
|
|
||||||
if o in ("P", "PUT"):
|
|
||||||
return "P"
|
|
||||||
inst = str(inst_id or "").upper()
|
|
||||||
if inst.endswith("-C") or inst.endswith("-CALL"):
|
|
||||||
return "C"
|
|
||||||
if inst.endswith("-P") or inst.endswith("-PUT"):
|
|
||||||
return "P"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
|
|
||||||
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
|
|
||||||
from lib.options.options_pricing_lib import option_moneyness
|
|
||||||
|
|
||||||
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
|
||||||
|
|
||||||
|
|
||||||
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
|
||||||
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
|
|
||||||
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
|
||||||
if m in ("itm", "atm"):
|
|
||||||
return True
|
|
||||||
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
|
|
||||||
o = normalize_opt_type(opt_type)
|
|
||||||
k = float(strike)
|
|
||||||
s = float(index_px)
|
|
||||||
if o == "C":
|
|
||||||
return k <= s + 1e-9
|
|
||||||
if o == "P":
|
|
||||||
return k >= s - 1e-9
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
|
|
||||||
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
|
|
||||||
if m in ("atm", "otm"):
|
|
||||||
return True
|
|
||||||
o = normalize_opt_type(opt_type)
|
|
||||||
k = float(strike)
|
|
||||||
s = float(index_px)
|
|
||||||
if o == "C":
|
|
||||||
return k >= s - 1e-9 # 平值带内或虚值
|
|
||||||
if o == "P":
|
|
||||||
return k <= s + 1e-9
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
|
|
||||||
o = normalize_opt_type(opt_type)
|
|
||||||
k = float(strike)
|
|
||||||
s = float(index_px)
|
|
||||||
if o == "C" and k < s:
|
|
||||||
return s - k
|
|
||||||
if o == "P" and k > s:
|
|
||||||
return k - s
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
|
|
||||||
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
|
|
||||||
parts = str(inst_id or "").strip().upper().split("-")
|
|
||||||
if len(parts) < 5:
|
|
||||||
return None
|
|
||||||
return _sf(parts[-2])
|
|
||||||
|
|
||||||
|
|
||||||
def pick_itm_or_atm_contract(
|
|
||||||
contracts: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
index_px: float,
|
|
||||||
itm_max_dist: Optional[float] = None,
|
|
||||||
) -> Optional[dict[str, Any]]:
|
|
||||||
"""在合约列表中选距标的最近的实值/平值腿."""
|
|
||||||
want = normalize_opt_type(opt_type)
|
|
||||||
if not want or index_px <= 0:
|
|
||||||
return None
|
|
||||||
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
|
|
||||||
cands: list[tuple[float, float, dict[str, Any]]] = []
|
|
||||||
for c in contracts or []:
|
|
||||||
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
|
||||||
continue
|
|
||||||
k = _sf(c.get("strike"))
|
|
||||||
if k is None:
|
|
||||||
continue
|
|
||||||
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
|
|
||||||
continue
|
|
||||||
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
|
|
||||||
if max_dist > 0 and depth > max_dist:
|
|
||||||
continue
|
|
||||||
cands.append((abs(k - index_px), k, c))
|
|
||||||
if not cands:
|
|
||||||
return None
|
|
||||||
cands.sort(key=lambda x: (x[0], x[1]))
|
|
||||||
return cands[0][2]
|
|
||||||
|
|
||||||
|
|
||||||
def pick_atm_or_otm_contract(
|
|
||||||
contracts: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
index_px: float,
|
|
||||||
prefer: str = "atm",
|
|
||||||
) -> Optional[dict[str, Any]]:
|
|
||||||
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
|
|
||||||
want = normalize_opt_type(opt_type)
|
|
||||||
if not want or index_px <= 0:
|
|
||||||
return None
|
|
||||||
prefer_l = (prefer or "atm").strip().lower()
|
|
||||||
cands: list[tuple[float, float, dict[str, Any]]] = []
|
|
||||||
for c in contracts or []:
|
|
||||||
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
|
|
||||||
continue
|
|
||||||
k = _sf(c.get("strike"))
|
|
||||||
if k is None:
|
|
||||||
continue
|
|
||||||
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
|
|
||||||
continue
|
|
||||||
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
|
|
||||||
if prefer_l == "otm" and m != "otm":
|
|
||||||
continue
|
|
||||||
if prefer_l == "atm" and m == "otm":
|
|
||||||
# 仍可入选,但排序靠后(先 atm)
|
|
||||||
cands.append((1_000_000 + abs(k - index_px), k, c))
|
|
||||||
else:
|
|
||||||
cands.append((abs(k - index_px), k, c))
|
|
||||||
if not cands:
|
|
||||||
return None
|
|
||||||
cands.sort(key=lambda x: (x[0], x[1]))
|
|
||||||
return cands[0][2]
|
|
||||||
|
|
||||||
|
|
||||||
def recommend_oo_legs(
|
|
||||||
contracts: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
index_px: float,
|
|
||||||
template: str = "atm_straddle",
|
|
||||||
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
|
|
||||||
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
|
|
||||||
tpl = (template or "atm_straddle").strip().lower()
|
|
||||||
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
|
|
||||||
call = pick_atm_or_otm_contract(
|
|
||||||
contracts, opt_type="C", index_px=index_px, prefer=prefer
|
|
||||||
)
|
|
||||||
put = pick_atm_or_otm_contract(
|
|
||||||
contracts, opt_type="P", index_px=index_px, prefer=prefer
|
|
||||||
)
|
|
||||||
if not call or not put:
|
|
||||||
return None
|
|
||||||
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
|
|
||||||
return None
|
|
||||||
return call, put
|
|
||||||
|
|
||||||
|
|
||||||
def validate_po_option_moneyness(
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
strike: Any,
|
|
||||||
index_px: Any,
|
|
||||||
ask: Any = None,
|
|
||||||
hours_to_expiry: Any = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""永期保险腿校验;返回错误文案或 None."""
|
|
||||||
o = normalize_opt_type(opt_type)
|
|
||||||
k = _sf(strike)
|
|
||||||
s = _sf(index_px)
|
|
||||||
if o not in ("C", "P"):
|
|
||||||
return "期权类型无效"
|
|
||||||
if k is None or s is None or s <= 0:
|
|
||||||
return "行权价或指数无效,无法校验虚实值"
|
|
||||||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
|
||||||
return "永期保险腿须为实值或平值,不可选虚值"
|
|
||||||
max_dist = itm_max_dist_usd()
|
|
||||||
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
|
|
||||||
if max_dist > 0 and depth > max_dist:
|
|
||||||
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
|
|
||||||
min_h = min_option_hours()
|
|
||||||
h = _sf(hours_to_expiry)
|
|
||||||
if min_h > 0 and h is not None and h < min_h:
|
|
||||||
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
|
|
||||||
min_lev = min_option_leverage()
|
|
||||||
a = _sf(ask)
|
|
||||||
if min_lev > 0 and a is not None and a > 0:
|
|
||||||
lev = s / a
|
|
||||||
if lev < min_lev:
|
|
||||||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_oo_leg_moneyness(
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
strike: Any,
|
|
||||||
index_px: Any,
|
|
||||||
role: str = "腿",
|
|
||||||
) -> Optional[str]:
|
|
||||||
o = normalize_opt_type(opt_type)
|
|
||||||
k = _sf(strike)
|
|
||||||
s = _sf(index_px)
|
|
||||||
if o not in ("C", "P"):
|
|
||||||
return f"{role}期权类型无效"
|
|
||||||
if k is None or s is None or s <= 0:
|
|
||||||
return f"{role}行权价或指数无效,无法校验虚实值"
|
|
||||||
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
|
||||||
if m == "itm":
|
|
||||||
return f"{role}须为平值或虚值,不可选实值"
|
|
||||||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
|
||||||
return f"{role}须为平值或虚值"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_oo_legs_moneyness(
|
|
||||||
leg_a: dict[str, Any],
|
|
||||||
leg_b: dict[str, Any],
|
|
||||||
*,
|
|
||||||
index_px: Any,
|
|
||||||
) -> Optional[str]:
|
|
||||||
err = validate_oo_leg_moneyness(
|
|
||||||
opt_type=leg_a.get("opt_type"),
|
|
||||||
strike=leg_a.get("strike"),
|
|
||||||
index_px=index_px,
|
|
||||||
role="腿A",
|
|
||||||
)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
err = validate_oo_leg_moneyness(
|
|
||||||
opt_type=leg_b.get("opt_type"),
|
|
||||||
strike=leg_b.get("strike"),
|
|
||||||
index_px=index_px,
|
|
||||||
role="腿B",
|
|
||||||
)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
return None
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
|||||||
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
from lib.hedge_plan.hedge_plan_db import update_plan
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt(v: Any, d: int = 2) -> str:
|
|
||||||
try:
|
|
||||||
if v is None or v == "":
|
|
||||||
return "—"
|
|
||||||
return f"{float(v):.{d}f}"
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return str(v)
|
|
||||||
|
|
||||||
|
|
||||||
def _type_label(plan_type: str) -> str:
|
|
||||||
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
|
|
||||||
|
|
||||||
|
|
||||||
def _dir_label(direction: str) -> str:
|
|
||||||
d = (direction or "").lower()
|
|
||||||
if d == "long":
|
|
||||||
return "做多"
|
|
||||||
if d == "short":
|
|
||||||
return "做空"
|
|
||||||
return "—"
|
|
||||||
|
|
||||||
|
|
||||||
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
|
|
||||||
pt = plan.get("plan_type") or ""
|
|
||||||
lines = [
|
|
||||||
f"🟢 对冲计划启动 #{plan.get('id')}",
|
|
||||||
f"📌 类型:{_type_label(pt)}",
|
|
||||||
f"🪙 标的:{plan.get('underlying') or '—'}",
|
|
||||||
]
|
|
||||||
if pt == "perp_options":
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
|
|
||||||
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
|
|
||||||
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
|
|
||||||
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
|
|
||||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rr = plan.get("profit_rr")
|
|
||||||
if rr not in (None, ""):
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
|
|
||||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
|
||||||
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
|
|
||||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if legs:
|
|
||||||
for leg in legs:
|
|
||||||
role = leg.get("leg_role") or ""
|
|
||||||
if role == "perp":
|
|
||||||
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
|
|
||||||
else:
|
|
||||||
lines.append(
|
|
||||||
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
|
|
||||||
f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}"
|
|
||||||
)
|
|
||||||
lines.append("📎 独立模块推送,不进普通交易复盘")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
|
||||||
reason = plan.get("close_reason") or "—"
|
|
||||||
total = plan.get("realized_pnl_total")
|
|
||||||
try:
|
|
||||||
tv = float(total) if total is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
tv = None
|
|
||||||
head = "🔴" if (tv is not None and tv < 0) else "🟢"
|
|
||||||
reason_map = {
|
|
||||||
"perp_tp": "永续止盈(期权默认不平)",
|
|
||||||
"perp_sl": "永续止损(期权强制平)",
|
|
||||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
|
||||||
"target_up_win_leg": "期期上破·已平盈利腿",
|
|
||||||
"target_down_win_leg": "期期下破·已平盈利腿",
|
|
||||||
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
|
|
||||||
"oo_rest_closing": "期期残值平·清亏损腿中",
|
|
||||||
"oo_rest_closed": "期期残值平·两腿已平",
|
|
||||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
|
||||||
"oo_expiry_win": "期期到期仍盈利",
|
|
||||||
"expiry": "到期收口",
|
|
||||||
"manual": "人工结束",
|
|
||||||
"partial_fail": "半腿失败收尾",
|
|
||||||
"cancelled": "已取消",
|
|
||||||
}
|
|
||||||
lines = [
|
|
||||||
f"{head} 对冲计划结束 #{plan.get('id')}",
|
|
||||||
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
|
|
||||||
f"🪙 标的:{plan.get('underlying') or '—'}",
|
|
||||||
f"📎 原因:{reason_map.get(reason, reason)}",
|
|
||||||
f"💰 合计≈U:{_fmt(total)}",
|
|
||||||
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
|
|
||||||
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
|
|
||||||
f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}",
|
|
||||||
]
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_hedge_alert_message(
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
plan_id: Any = None,
|
|
||||||
detail: str = "",
|
|
||||||
) -> str:
|
|
||||||
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
|
|
||||||
if detail:
|
|
||||||
lines.append(str(detail)[:800])
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def notify_hedge(
|
|
||||||
cfg: dict[str, Any],
|
|
||||||
content: str,
|
|
||||||
) -> bool:
|
|
||||||
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
|
|
||||||
if not callable(send):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
send(content)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def notify_plan_start(
|
|
||||||
cfg: dict[str, Any],
|
|
||||||
conn: Any,
|
|
||||||
plan: dict[str, Any],
|
|
||||||
legs: Optional[list[dict[str, Any]]] = None,
|
|
||||||
) -> bool:
|
|
||||||
if int(plan.get("wechat_start_sent") or 0):
|
|
||||||
return False
|
|
||||||
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
|
|
||||||
if ok and plan.get("id") is not None:
|
|
||||||
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
|
|
||||||
plan["wechat_start_sent"] = 1
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
|
|
||||||
if int(plan.get("wechat_end_sent") or 0):
|
|
||||||
return False
|
|
||||||
# 中间态 target_win_leg 不算正式结束推送(用告警)
|
|
||||||
if (plan.get("close_reason") or "") in (
|
|
||||||
"target_win_leg",
|
|
||||||
"target_up_win_leg",
|
|
||||||
"target_down_win_leg",
|
|
||||||
"profit_rr_win_leg",
|
|
||||||
"oo_rest_closing",
|
|
||||||
) and (plan.get("status") or "") != "closed":
|
|
||||||
cr = str(plan.get("close_reason") or "")
|
|
||||||
if "profit_rr" in cr:
|
|
||||||
side = "盈亏比达标"
|
|
||||||
elif "up" in cr:
|
|
||||||
side = "上破"
|
|
||||||
elif "down" in cr:
|
|
||||||
side = "下破"
|
|
||||||
else:
|
|
||||||
side = "目标"
|
|
||||||
mode = (plan.get("oo_close_mode") or "").strip().lower()
|
|
||||||
if mode in ("close_all", "全平", "残值平"):
|
|
||||||
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
|
|
||||||
else:
|
|
||||||
rest_txt = "另一腿到期平(持有至到期结算)"
|
|
||||||
rr = plan.get("profit_rr")
|
|
||||||
if rr not in (None, ""):
|
|
||||||
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
|
|
||||||
else:
|
|
||||||
detail = (
|
|
||||||
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
|
|
||||||
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
|
|
||||||
)
|
|
||||||
notify_hedge(
|
|
||||||
cfg,
|
|
||||||
build_hedge_alert_message(
|
|
||||||
title=f"期期{side}已平盈利腿 · {rest_txt}",
|
|
||||||
plan_id=plan.get("id"),
|
|
||||||
detail=detail,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
ok = notify_hedge(cfg, build_hedge_end_message(plan))
|
|
||||||
if ok and plan.get("id") is not None:
|
|
||||||
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
|
|
||||||
plan["wechat_end_sent"] = 1
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
|
|
||||||
detail = msg
|
|
||||||
if results:
|
|
||||||
try:
|
|
||||||
detail = f"{msg}\n路径结果:{results}"[:800]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return notify_hedge(
|
|
||||||
cfg,
|
|
||||||
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
|
|
||||||
)
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
PREMIUM_EXEC_FACTOR = 0.95
|
|
||||||
DEFAULT_MIN_HOURS = 36.0
|
|
||||||
DEFAULT_STRIKE_INTERVAL = 15.0
|
|
||||||
DEFAULT_PERP_LEVERAGE = 100
|
|
||||||
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
|
|
||||||
DEFAULT_OPT_LEVERAGE_OTM = 200.0
|
|
||||||
DEFAULT_RATIO_ITM_ATM = 2.0
|
|
||||||
DEFAULT_RATIO_OTM = 4.0
|
|
||||||
OTM_LEV_FLOOR = 180.0
|
|
||||||
|
|
||||||
|
|
||||||
def _sf(v: Any) -> Optional[float]:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
|
|
||||||
if not body_or_plan:
|
|
||||||
return False
|
|
||||||
v = body_or_plan.get("option_primary")
|
|
||||||
if v in (True, 1, "1", "true", "yes", "on"):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return int(v or 0) == 1
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def fee_rate() -> float:
|
|
||||||
try:
|
|
||||||
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 0.0005
|
|
||||||
|
|
||||||
|
|
||||||
def floor2(v: float) -> float:
|
|
||||||
"""ETH 数量向下取两位小数."""
|
|
||||||
if v <= 0:
|
|
||||||
return 0.0
|
|
||||||
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
|
|
||||||
|
|
||||||
|
|
||||||
def opt_type_for_view(direction: str) -> str:
|
|
||||||
"""看法做多→Call,做空→Put."""
|
|
||||||
return "P" if str(direction or "").strip().lower() == "short" else "C"
|
|
||||||
|
|
||||||
|
|
||||||
def perp_direction_for_view(direction: str) -> str:
|
|
||||||
"""看法做多→永续空,做空→永续多."""
|
|
||||||
return "long" if str(direction or "").strip().lower() == "short" else "short"
|
|
||||||
|
|
||||||
|
|
||||||
def default_opt_leverage(moneyness: str) -> float:
|
|
||||||
m = (moneyness or "").strip().lower()
|
|
||||||
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
|
|
||||||
|
|
||||||
|
|
||||||
def default_ratio(moneyness: str) -> float:
|
|
||||||
m = (moneyness or "").strip().lower()
|
|
||||||
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
|
|
||||||
|
|
||||||
|
|
||||||
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
|
|
||||||
cfg = _sf(configured)
|
|
||||||
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
|
|
||||||
if (moneyness or "").strip().lower() == "otm":
|
|
||||||
return max(base, OTM_LEV_FLOOR)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
|
|
||||||
exp = _sf(exp_ms)
|
|
||||||
if exp is None or exp <= 0:
|
|
||||||
return None
|
|
||||||
# OKX exp 多为毫秒
|
|
||||||
if exp < 1e12:
|
|
||||||
exp *= 1000.0
|
|
||||||
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
|
|
||||||
return (exp - now) / 3600000.0
|
|
||||||
|
|
||||||
|
|
||||||
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
|
|
||||||
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0."""
|
|
||||||
n = float(points or 0)
|
|
||||||
k = float(strike)
|
|
||||||
s = float(index_px)
|
|
||||||
if n <= 0 or k <= 0 or s <= 0:
|
|
||||||
return False
|
|
||||||
side = str(view_side or "").strip().lower()
|
|
||||||
if side == "short":
|
|
||||||
return s <= (k - n)
|
|
||||||
return s >= (k + n)
|
|
||||||
|
|
||||||
|
|
||||||
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
|
|
||||||
b = _sf(bid)
|
|
||||||
if b is None or b <= 0:
|
|
||||||
return False, "暂无买一报价,无法平期权"
|
|
||||||
sz = _sf(bid_sz)
|
|
||||||
if sz is not None and sz <= 0:
|
|
||||||
return False, "买一深度为 0,无法平期权"
|
|
||||||
need = float(need_sheets or 0)
|
|
||||||
if need > 0 and sz is not None and sz + 1e-12 < need:
|
|
||||||
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
def size_from_premium(
|
|
||||||
*,
|
|
||||||
premium_budget: float,
|
|
||||||
ask: float,
|
|
||||||
ct_mult: float,
|
|
||||||
ratio: float,
|
|
||||||
contract_size: float,
|
|
||||||
exec_factor: float = PREMIUM_EXEC_FACTOR,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
|
|
||||||
budget = float(premium_budget or 0)
|
|
||||||
a = float(ask or 0)
|
|
||||||
ct = float(ct_mult or 0.01)
|
|
||||||
r = float(ratio or 0)
|
|
||||||
cs = float(contract_size or 0.01)
|
|
||||||
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
|
|
||||||
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"msg": "定仓参数无效",
|
|
||||||
"usable_premium": round(usable, 4),
|
|
||||||
"eth_qty": 0.0,
|
|
||||||
"sheets": 0.0,
|
|
||||||
"perp_eth": 0.0,
|
|
||||||
"contracts": 0.0,
|
|
||||||
}
|
|
||||||
# ask 为每 1 币权利金;ETH 数量 = usable / ask
|
|
||||||
eth_qty = floor2(usable / a)
|
|
||||||
if eth_qty <= 0:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"msg": "权利金不足以买入 0.01 ETH 名义期权",
|
|
||||||
"usable_premium": round(usable, 4),
|
|
||||||
"eth_qty": 0.0,
|
|
||||||
"sheets": 0.0,
|
|
||||||
"perp_eth": 0.0,
|
|
||||||
"contracts": 0.0,
|
|
||||||
}
|
|
||||||
sheets = eth_qty / ct
|
|
||||||
# 张数向下取整到整数张(OKX 期权常见整张)
|
|
||||||
sheets_i = float(math.floor(sheets + 1e-12))
|
|
||||||
if sheets_i <= 0:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"msg": "换算期权张数不足 1 张",
|
|
||||||
"usable_premium": round(usable, 4),
|
|
||||||
"eth_qty": eth_qty,
|
|
||||||
"sheets": 0.0,
|
|
||||||
"perp_eth": 0.0,
|
|
||||||
"contracts": 0.0,
|
|
||||||
}
|
|
||||||
# 用整张回写 ETH,保持与下单一致
|
|
||||||
eth_qty = round(sheets_i * ct, 2)
|
|
||||||
perp_eth = eth_qty / r
|
|
||||||
contracts = perp_eth / cs
|
|
||||||
premium_est = a * sheets_i * ct
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"msg": "",
|
|
||||||
"usable_premium": round(usable, 4),
|
|
||||||
"eth_qty": eth_qty,
|
|
||||||
"sheets": sheets_i,
|
|
||||||
"perp_eth": round(perp_eth, 6),
|
|
||||||
"contracts": contracts,
|
|
||||||
"premium_est": round(premium_est, 4),
|
|
||||||
"ratio": r,
|
|
||||||
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_combo_net_pnl(
|
|
||||||
*,
|
|
||||||
view_side: str,
|
|
||||||
strike: float,
|
|
||||||
index_px: float,
|
|
||||||
ask_open: float,
|
|
||||||
bid: float,
|
|
||||||
sheets: float,
|
|
||||||
ct_mult: float,
|
|
||||||
perp_direction: str,
|
|
||||||
perp_entry: float,
|
|
||||||
perp_mark: float,
|
|
||||||
contracts: float,
|
|
||||||
contract_size: float,
|
|
||||||
fee: Optional[float] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
|
|
||||||
fr = fee if fee is not None else fee_rate()
|
|
||||||
ct = float(ct_mult or 0.01)
|
|
||||||
sh = float(sheets or 0)
|
|
||||||
a = float(ask_open or 0)
|
|
||||||
b = float(bid or 0)
|
|
||||||
premium = a * sh * ct
|
|
||||||
opt_proceeds = b * sh * ct
|
|
||||||
opt_open_fee = premium * fr
|
|
||||||
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
|
|
||||||
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
|
|
||||||
|
|
||||||
coins = float(contracts or 0) * float(contract_size or 0.01)
|
|
||||||
entry = float(perp_entry or 0)
|
|
||||||
mark = float(perp_mark or 0)
|
|
||||||
pd = str(perp_direction or "").strip().lower()
|
|
||||||
if pd == "short":
|
|
||||||
perp_gross = (entry - mark) * coins
|
|
||||||
else:
|
|
||||||
perp_gross = (mark - entry) * coins
|
|
||||||
perp_notional_open = abs(entry * coins)
|
|
||||||
perp_notional_close = abs(mark * coins)
|
|
||||||
perp_open_fee = perp_notional_open * fr
|
|
||||||
perp_close_fee = perp_notional_close * fr
|
|
||||||
perp_net = perp_gross - perp_open_fee - perp_close_fee
|
|
||||||
total = opt_net + perp_net
|
|
||||||
return {
|
|
||||||
"opt_net": round(opt_net, 4),
|
|
||||||
"perp_net": round(perp_net, 4),
|
|
||||||
"net": round(total, 4),
|
|
||||||
"fee_rate": fr,
|
|
||||||
"premium": round(premium, 4),
|
|
||||||
"opt_proceeds": round(opt_proceeds, 4),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_option_primary_moneyness(
|
|
||||||
*,
|
|
||||||
opt_type: str,
|
|
||||||
strike: Any,
|
|
||||||
index_px: Any,
|
|
||||||
ask: Any = None,
|
|
||||||
moneyness: str = "atm",
|
|
||||||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
|
||||||
min_hours: Any = DEFAULT_MIN_HOURS,
|
|
||||||
hours_to_expiry: Any = None,
|
|
||||||
min_opt_leverage: Any = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
|
||||||
classify_moneyness,
|
|
||||||
is_atm_or_otm,
|
|
||||||
is_itm_or_atm,
|
|
||||||
normalize_opt_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
o = normalize_opt_type(opt_type)
|
|
||||||
k = _sf(strike)
|
|
||||||
s = _sf(index_px)
|
|
||||||
if o not in ("C", "P"):
|
|
||||||
return "期权类型无效"
|
|
||||||
if k is None or s is None or s <= 0:
|
|
||||||
return "行权价或指数无效"
|
|
||||||
m_want = (moneyness or "atm").strip().lower()
|
|
||||||
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
|
|
||||||
if m_want == "itm":
|
|
||||||
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
|
|
||||||
return "所选须为实值或平值"
|
|
||||||
elif m_want == "atm":
|
|
||||||
# 平值:距指数在间隔内即可(不强制 classify==atm)
|
|
||||||
pass
|
|
||||||
elif m_want == "otm":
|
|
||||||
if m_got == "itm":
|
|
||||||
return "虚值模式不可选实值"
|
|
||||||
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
|
|
||||||
return "虚值模式须选虚值或平值档"
|
|
||||||
else:
|
|
||||||
return "期权类型(实/平/虚)无效"
|
|
||||||
|
|
||||||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
|
||||||
if interval > 0 and abs(k - s) > interval + 1e-9:
|
|
||||||
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
|
|
||||||
|
|
||||||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
|
||||||
h = _sf(hours_to_expiry)
|
|
||||||
if min_h > 0 and h is not None and h < min_h:
|
|
||||||
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
|
|
||||||
|
|
||||||
a = _sf(ask)
|
|
||||||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
|
|
||||||
if min_lev > 0 and a is not None and a > 0:
|
|
||||||
lev = s / a
|
|
||||||
if lev < min_lev:
|
|
||||||
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
|
|
||||||
"""盯盘启动校验:只要参数,不要求已选具体合约."""
|
|
||||||
need = (
|
|
||||||
"direction",
|
|
||||||
"exchange_symbol",
|
|
||||||
"premium_budget",
|
|
||||||
"option_target_points",
|
|
||||||
"perp_target_points",
|
|
||||||
"option_perp_ratio",
|
|
||||||
"option_leverage",
|
|
||||||
)
|
|
||||||
for k in need:
|
|
||||||
if body.get(k) in (None, ""):
|
|
||||||
return f"缺少字段: {k}"
|
|
||||||
try:
|
|
||||||
if float(body["premium_budget"]) <= 0:
|
|
||||||
return "权利金须大于 0"
|
|
||||||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
|
||||||
return "目标位点数须大于 0"
|
|
||||||
if float(body["option_perp_ratio"]) <= 0:
|
|
||||||
return "期权永续比例须大于 0"
|
|
||||||
if float(body["option_leverage"]) <= 0:
|
|
||||||
return "期权杠杆须大于 0"
|
|
||||||
lev_perp = _sf(body.get("leverage"))
|
|
||||||
if lev_perp is not None and lev_perp <= 0:
|
|
||||||
return "永续杠杆须大于 0"
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return "数值字段无效"
|
|
||||||
direction = str(body.get("direction") or "").strip().lower()
|
|
||||||
if direction not in ("long", "short"):
|
|
||||||
return "方向须为 long 或 short"
|
|
||||||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
|
||||||
if moneyness not in ("itm", "atm", "otm"):
|
|
||||||
return "期权类型(实/平/虚)无效"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
|
|
||||||
need = (
|
|
||||||
"direction",
|
|
||||||
"contracts",
|
|
||||||
"opt_inst_id",
|
|
||||||
"sheets",
|
|
||||||
"exchange_symbol",
|
|
||||||
"premium_budget",
|
|
||||||
"option_target_points",
|
|
||||||
"perp_target_points",
|
|
||||||
"option_perp_ratio",
|
|
||||||
)
|
|
||||||
for k in need:
|
|
||||||
if body.get(k) in (None, ""):
|
|
||||||
return f"缺少字段: {k}"
|
|
||||||
try:
|
|
||||||
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
|
|
||||||
return "张数必须大于 0"
|
|
||||||
if float(body["premium_budget"]) <= 0:
|
|
||||||
return "权利金须大于 0"
|
|
||||||
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
|
|
||||||
return "目标位点数须大于 0"
|
|
||||||
if float(body["option_perp_ratio"]) <= 0:
|
|
||||||
return "期权永续比例须大于 0"
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return "数值字段无效"
|
|
||||||
direction = str(body.get("direction") or "").strip().lower()
|
|
||||||
if direction not in ("long", "short"):
|
|
||||||
return "方向须为 long 或 short"
|
|
||||||
opt_type = str(body.get("opt_type") or "").strip().upper()
|
|
||||||
if not opt_type:
|
|
||||||
inst = str(body.get("opt_inst_id") or "")
|
|
||||||
if inst.upper().endswith("-P"):
|
|
||||||
opt_type = "P"
|
|
||||||
elif inst.upper().endswith("-C"):
|
|
||||||
opt_type = "C"
|
|
||||||
want = opt_type_for_view(direction)
|
|
||||||
if opt_type != want:
|
|
||||||
return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}"
|
|
||||||
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
|
|
||||||
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
|
|
||||||
|
|
||||||
strike = body.get("strike")
|
|
||||||
if strike in (None, ""):
|
|
||||||
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
|
|
||||||
index_px = body.get("index_px") or body.get("entry")
|
|
||||||
return validate_option_primary_moneyness(
|
|
||||||
opt_type=opt_type,
|
|
||||||
strike=strike,
|
|
||||||
index_px=index_px,
|
|
||||||
ask=body.get("ask"),
|
|
||||||
moneyness=moneyness,
|
|
||||||
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
|
|
||||||
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
|
|
||||||
hours_to_expiry=body.get("hours_to_expiry"),
|
|
||||||
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pick_option_primary_candidate(
|
|
||||||
chain: dict[str, Any],
|
|
||||||
*,
|
|
||||||
direction: str,
|
|
||||||
moneyness: str = "otm",
|
|
||||||
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
|
|
||||||
min_hours: Any = DEFAULT_MIN_HOURS,
|
|
||||||
min_opt_leverage: Any = None,
|
|
||||||
) -> Optional[dict[str, Any]]:
|
|
||||||
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
|
|
||||||
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
|
|
||||||
|
|
||||||
want = opt_type_for_view(direction)
|
|
||||||
m_want = (moneyness or "otm").strip().lower()
|
|
||||||
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
|
|
||||||
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
|
|
||||||
try:
|
|
||||||
idx = float(chain.get("index_px") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
idx = 0.0
|
|
||||||
if idx <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
best: Optional[dict[str, Any]] = None
|
|
||||||
best_dist: Optional[float] = None
|
|
||||||
for exp in chain.get("expiries") or []:
|
|
||||||
h = hours_to_expiry_from_ms(exp.get("exp_time"))
|
|
||||||
if min_h > 0 and h is not None and h < min_h:
|
|
||||||
continue
|
|
||||||
for c in exp.get("contracts") or []:
|
|
||||||
if str(c.get("opt_type") or "").upper() != want:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
k = float(c.get("strike") or 0)
|
|
||||||
ask = float(c.get("ask") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if k <= 0 or ask <= 0:
|
|
||||||
continue
|
|
||||||
if interval > 0 and abs(k - idx) > interval + 1e-9:
|
|
||||||
continue
|
|
||||||
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
|
|
||||||
if m_want == "itm" and m_got not in ("itm", "atm"):
|
|
||||||
continue
|
|
||||||
if m_want == "atm" and m_got != "atm":
|
|
||||||
continue
|
|
||||||
if m_want == "otm" and m_got == "itm":
|
|
||||||
continue
|
|
||||||
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
|
|
||||||
if min_lev > 0 and idx / ask < min_lev - 1e-9:
|
|
||||||
continue
|
|
||||||
dist = abs(k - idx)
|
|
||||||
if best is None or best_dist is None or dist < best_dist:
|
|
||||||
best = {
|
|
||||||
**dict(c),
|
|
||||||
"hours_to_expiry": h,
|
|
||||||
"exp_time": exp.get("exp_time"),
|
|
||||||
"moneyness": m_got,
|
|
||||||
"index_px": idx,
|
|
||||||
"leverage": round(idx / ask, 1),
|
|
||||||
}
|
|
||||||
best_dist = dist
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""情景:期权目标 / 永续目标粗估净利."""
|
|
||||||
view = str(body.get("direction") or "long").lower()
|
|
||||||
strike = float(body["strike"])
|
|
||||||
n = float(body.get("option_target_points") or 0)
|
|
||||||
m = float(body.get("perp_target_points") or 0)
|
|
||||||
ask = float(body.get("ask") or 0)
|
|
||||||
sheets = float(body.get("sheets") or 0)
|
|
||||||
ct = float(body.get("ct_mult") or 0.01)
|
|
||||||
contracts = float(body.get("contracts") or 0)
|
|
||||||
cs = float(body.get("contract_size") or 0.01)
|
|
||||||
entry = float(body.get("entry") or body.get("index_px") or 0)
|
|
||||||
perp_dir = perp_direction_for_view(view)
|
|
||||||
# 粗估到点时期权卖价:按内在价值近似(下限 0)
|
|
||||||
def intrinsic(spot: float) -> float:
|
|
||||||
o = opt_type_for_view(view)
|
|
||||||
if o == "C":
|
|
||||||
return max(0.0, spot - strike)
|
|
||||||
return max(0.0, strike - spot)
|
|
||||||
|
|
||||||
scenarios = []
|
|
||||||
for label, pts, reason in (
|
|
||||||
("期权目标", n, "opt_target_points"),
|
|
||||||
("永续目标", m, "perp_target_points"),
|
|
||||||
):
|
|
||||||
spot = strike + pts if view != "short" else strike - pts
|
|
||||||
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
|
|
||||||
net = estimate_combo_net_pnl(
|
|
||||||
view_side=view,
|
|
||||||
strike=strike,
|
|
||||||
index_px=spot,
|
|
||||||
ask_open=ask,
|
|
||||||
bid=bid_est,
|
|
||||||
sheets=sheets,
|
|
||||||
ct_mult=ct,
|
|
||||||
perp_direction=perp_dir,
|
|
||||||
perp_entry=entry,
|
|
||||||
perp_mark=spot,
|
|
||||||
contracts=contracts,
|
|
||||||
contract_size=cs,
|
|
||||||
)
|
|
||||||
scenarios.append(
|
|
||||||
{
|
|
||||||
"label": label,
|
|
||||||
"reason": reason,
|
|
||||||
"index": spot,
|
|
||||||
"perp_pnl": net["perp_net"],
|
|
||||||
"options_pnl": net["opt_net"],
|
|
||||||
"total": net["net"],
|
|
||||||
"note": "扣费净利估价;平仓费按买入费率",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
premium = ask * sheets * ct
|
|
||||||
return {
|
|
||||||
"plan_type": "perp_options",
|
|
||||||
"option_primary": True,
|
|
||||||
"summary": {
|
|
||||||
"premium_paid": round(premium, 4),
|
|
||||||
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
|
|
||||||
"opt_target_total": scenarios[0]["total"] if scenarios else None,
|
|
||||||
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
|
|
||||||
"perp_direction": perp_dir,
|
|
||||||
"opt_type": opt_type_for_view(view),
|
|
||||||
},
|
|
||||||
"scenarios": scenarios,
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,217 +0,0 @@
|
|||||||
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
|
|
||||||
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
|
|
||||||
|
|
||||||
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
|
|
||||||
|
|
||||||
|
|
||||||
def _sf(v: Any) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
return float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
|
|
||||||
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
|
|
||||||
|
|
||||||
|
|
||||||
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
|
|
||||||
exp = leg_exp_ms(leg)
|
|
||||||
if exp is None:
|
|
||||||
return False
|
|
||||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
|
||||||
return now >= int(exp)
|
|
||||||
|
|
||||||
|
|
||||||
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
|
|
||||||
"""按到期结算口径估算腿盈亏(USDC)."""
|
|
||||||
premium = float(leg.get("premium") or 0)
|
|
||||||
strike = _sf(leg.get("strike"))
|
|
||||||
if strike is None:
|
|
||||||
return -premium
|
|
||||||
sheets = float(leg.get("size") or 1)
|
|
||||||
# ct_mult 未入库时默认 0.01
|
|
||||||
ct = float(leg.get("ct_mult") or 0.01)
|
|
||||||
return float(
|
|
||||||
option_expiry_pnl(
|
|
||||||
opt_type=str(leg.get("opt_type") or "P"),
|
|
||||||
strike=float(strike),
|
|
||||||
spot=float(spot),
|
|
||||||
sheets=sheets,
|
|
||||||
ct_mult=ct,
|
|
||||||
premium_paid=premium,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
|
|
||||||
opts = [
|
|
||||||
x
|
|
||||||
for x in legs
|
|
||||||
if str(x.get("leg_role") or "").startswith("option")
|
|
||||||
and str(x.get("status") or "") in ("open", "hold_to_expiry")
|
|
||||||
]
|
|
||||||
if not opts:
|
|
||||||
return False
|
|
||||||
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_opened_ms(raw: Any) -> Optional[int]:
|
|
||||||
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
|
|
||||||
if raw is None or raw == "":
|
|
||||||
return None
|
|
||||||
s = str(raw).strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
|
|
||||||
return int(dt.timestamp() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_option_leg_realized_pnl(
|
|
||||||
*,
|
|
||||||
ex: Any = None,
|
|
||||||
leg: dict[str, Any],
|
|
||||||
fallback: Optional[float] = None,
|
|
||||||
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
|
|
||||||
hist_rows: Optional[list[dict[str, Any]]] = None,
|
|
||||||
) -> tuple[Optional[float], str]:
|
|
||||||
"""
|
|
||||||
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
|
|
||||||
返回 (pnl, source) source=exchange|fallback|none.
|
|
||||||
"""
|
|
||||||
inst_id = str(leg.get("inst_id") or "").strip()
|
|
||||||
open_ms = _parse_opened_ms(leg.get("opened_at"))
|
|
||||||
rows = hist_rows
|
|
||||||
if rows is None and inst_id:
|
|
||||||
try:
|
|
||||||
if callable(fetch_history_fn):
|
|
||||||
rows = fetch_history_fn(inst_id)
|
|
||||||
elif ex is not None:
|
|
||||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
|
||||||
|
|
||||||
rows = fetch_option_position_history(ex, inst_id)
|
|
||||||
except Exception:
|
|
||||||
rows = None
|
|
||||||
if rows:
|
|
||||||
close_ms = _parse_opened_ms(leg.get("closed_at"))
|
|
||||||
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
|
|
||||||
info = resolve_option_close_from_history(
|
|
||||||
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
|
|
||||||
)
|
|
||||||
pnl = _sf((info or {}).get("realized_pnl")) if info else None
|
|
||||||
if pnl is not None:
|
|
||||||
return round(float(pnl), 4), "exchange"
|
|
||||||
if fallback is not None:
|
|
||||||
return round(float(fallback), 4), "fallback"
|
|
||||||
return None, "none"
|
|
||||||
|
|
||||||
|
|
||||||
def backfill_hedge_option_legs_realized_pnl(
|
|
||||||
conn: Any,
|
|
||||||
hist_rows: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
update_plan_fn: Optional[Callable[..., Any]] = None,
|
|
||||||
) -> dict[str, int]:
|
|
||||||
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
|
|
||||||
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
|
|
||||||
|
|
||||||
by_inst: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
for raw in hist_rows or []:
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
continue
|
|
||||||
inst = str(raw.get("instId") or "").strip()
|
|
||||||
if inst:
|
|
||||||
by_inst.setdefault(inst, []).append(raw)
|
|
||||||
|
|
||||||
legs = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM hedge_plan_legs
|
|
||||||
WHERE status = 'closed'
|
|
||||||
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
|
|
||||||
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 400
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
updated_legs = 0
|
|
||||||
touched_plans: set[int] = set()
|
|
||||||
for row in legs:
|
|
||||||
leg = dict(row)
|
|
||||||
inst = str(leg.get("inst_id") or "").strip()
|
|
||||||
if not inst or inst not in by_inst:
|
|
||||||
continue
|
|
||||||
pnl, src = resolve_option_leg_realized_pnl(
|
|
||||||
leg=leg,
|
|
||||||
hist_rows=by_inst[inst],
|
|
||||||
fallback=None,
|
|
||||||
)
|
|
||||||
if src != "exchange" or pnl is None:
|
|
||||||
continue
|
|
||||||
local = _sf(leg.get("realized_pnl"))
|
|
||||||
if local is not None and abs(local - pnl) < 1e-6:
|
|
||||||
continue
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
|
|
||||||
(pnl, int(leg["id"])),
|
|
||||||
)
|
|
||||||
updated_legs += 1
|
|
||||||
touched_plans.add(int(leg["plan_id"]))
|
|
||||||
|
|
||||||
updated_plans = 0
|
|
||||||
updater = update_plan_fn or update_plan
|
|
||||||
for pid in touched_plans:
|
|
||||||
plan = get_plan(conn, pid)
|
|
||||||
if not plan or str(plan.get("status") or "") != "closed":
|
|
||||||
continue
|
|
||||||
plan_legs = get_plan_legs(conn, pid)
|
|
||||||
opt_sum = 0.0
|
|
||||||
for lg in plan_legs:
|
|
||||||
role = str(lg.get("leg_role") or "")
|
|
||||||
if not (role.startswith("option") or lg.get("opt_type")):
|
|
||||||
continue
|
|
||||||
if str(lg.get("status") or "") != "closed":
|
|
||||||
continue
|
|
||||||
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
|
|
||||||
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
|
|
||||||
ptype = str(plan.get("plan_type") or "")
|
|
||||||
if ptype == "options_options":
|
|
||||||
total = opt_sum
|
|
||||||
kwargs: dict[str, Any] = {
|
|
||||||
"realized_pnl_options": round(opt_sum, 4),
|
|
||||||
"realized_pnl_total": round(total, 4),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
total = perp + opt_sum
|
|
||||||
kwargs = {
|
|
||||||
"realized_pnl_perp": round(perp, 4),
|
|
||||||
"realized_pnl_options": round(opt_sum, 4),
|
|
||||||
"realized_pnl_total": round(total, 4),
|
|
||||||
}
|
|
||||||
old_total = _sf(plan.get("realized_pnl_total"))
|
|
||||||
old_opts = _sf(plan.get("realized_pnl_options"))
|
|
||||||
if (
|
|
||||||
old_total is not None
|
|
||||||
and abs(old_total - total) < 1e-6
|
|
||||||
and old_opts is not None
|
|
||||||
and abs(old_opts - opt_sum) < 1e-6
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
updater(conn, pid, **kwargs)
|
|
||||||
updated_plans += 1
|
|
||||||
return {"legs": updated_legs, "plans": updated_plans}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"""OKX 期权/对冲模式(env: OKX_TRADE_MODE).
|
|
||||||
|
|
||||||
options → 仅单独期权(隐藏对冲导航与对冲 env 配置)
|
|
||||||
options_options → 仅期期对冲(不可单独开期权)
|
|
||||||
perp_options → 永期对冲(含合约腿);产品 UI 已隐藏,仅兼容旧 .env / 历史计划
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
MODE_OPTIONS = "options"
|
|
||||||
MODE_PERP = "perp_options"
|
|
||||||
MODE_OO = "options_options"
|
|
||||||
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
|
|
||||||
# 配置页可选模式(不含永期)
|
|
||||||
UI_MODES = frozenset({MODE_OPTIONS, MODE_OO})
|
|
||||||
|
|
||||||
_ALIASES = {
|
|
||||||
"option": MODE_OPTIONS,
|
|
||||||
"standalone": MODE_OPTIONS,
|
|
||||||
"期权": MODE_OPTIONS,
|
|
||||||
"单独期权": MODE_OPTIONS,
|
|
||||||
"po": MODE_PERP,
|
|
||||||
"perp": MODE_PERP,
|
|
||||||
"永期": MODE_PERP,
|
|
||||||
"永期对冲": MODE_PERP,
|
|
||||||
"oo": MODE_OO,
|
|
||||||
"期期": MODE_OO,
|
|
||||||
"期期对冲": MODE_OO,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool = False) -> bool:
|
|
||||||
raw = os.getenv(name)
|
|
||||||
if raw is None or str(raw).strip() == "":
|
|
||||||
return default
|
|
||||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
|
|
||||||
s = str(raw or "").strip().lower()
|
|
||||||
if s in VALID_MODES:
|
|
||||||
return s
|
|
||||||
if s in _ALIASES:
|
|
||||||
return _ALIASES[s]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def legacy_infer_okx_trade_mode() -> str:
|
|
||||||
"""未配置 OKX_TRADE_MODE 时默认单独期权(对冲关闭).
|
|
||||||
|
|
||||||
旧 HEDGE_PLAN_* 开关不再自动打开对冲,避免误开;需显式设 OKX_TRADE_MODE=options_options.
|
|
||||||
"""
|
|
||||||
return MODE_OPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
def get_okx_trade_mode() -> str:
|
|
||||||
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
|
|
||||||
if m:
|
|
||||||
return m
|
|
||||||
return legacy_infer_okx_trade_mode()
|
|
||||||
|
|
||||||
|
|
||||||
def hedge_module_enabled() -> bool:
|
|
||||||
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
|
|
||||||
|
|
||||||
|
|
||||||
def show_perp_options() -> bool:
|
|
||||||
return get_okx_trade_mode() == MODE_PERP
|
|
||||||
|
|
||||||
|
|
||||||
def show_options_options() -> bool:
|
|
||||||
return get_okx_trade_mode() == MODE_OO
|
|
||||||
|
|
||||||
|
|
||||||
def standalone_options_open_allowed() -> bool:
|
|
||||||
return get_okx_trade_mode() == MODE_OPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
def mode_label(mode: Optional[str] = None) -> str:
|
|
||||||
m = mode or get_okx_trade_mode()
|
|
||||||
return {
|
|
||||||
MODE_OPTIONS: "单独期权",
|
|
||||||
MODE_PERP: "永期对冲",
|
|
||||||
MODE_OO: "期期对冲",
|
|
||||||
}.get(m, m or "—")
|
|
||||||
|
|
||||||
|
|
||||||
def block_standalone_open_by_mode_msg() -> Optional[str]:
|
|
||||||
if standalone_options_open_allowed():
|
|
||||||
return None
|
|
||||||
return (
|
|
||||||
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
|
|
||||||
"请在 env「交易模式」切换为「单独期权」"
|
|
||||||
)
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
|
|
||||||
data-default-underly="{{ options_default_underly | default('ETH') }}"
|
|
||||||
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
|
|
||||||
data-options-enabled="{{ '1' if options_enabled else '0' }}"
|
|
||||||
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
|
|
||||||
data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
|
|
||||||
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
|
|
||||||
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
|
|
||||||
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
|
|
||||||
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
|
|
||||||
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
|
|
||||||
{% if not hedge_plan_enabled %}
|
|
||||||
<div class="flash" style="margin-bottom:12px">对冲计划未启用:请在 <code>env配置 → 对冲计划</code> 打开 <code>HEDGE_PLAN_ENABLED</code>(可热更).</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if not options_enabled %}
|
|
||||||
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
|
|
||||||
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card hp-head-card">
|
|
||||||
<div class="hp-head-row">
|
|
||||||
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
|
|
||||||
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
|
|
||||||
</h2>
|
|
||||||
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
|
|
||||||
</div>
|
|
||||||
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
|
|
||||||
{% if hedge_plan_show_perp_options %}
|
|
||||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
|
|
||||||
{% endif %}
|
|
||||||
{% if hedge_plan_show_options_options %}
|
|
||||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
|
|
||||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
|
|
||||||
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" id="hp-gate-line"></p>
|
|
||||||
<p class="muted hp-acct-hint" id="hp-acct-hint">永续腿→合约账户 · 期权腿→期权账户</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
|
|
||||||
<div class="options-dual-grid" id="hp-po-layout">
|
|
||||||
<div class="card hp-po-perp-card">
|
|
||||||
<h2>
|
|
||||||
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
|
|
||||||
<span id="hp-po-card-title">执行参数</span>
|
|
||||||
· <span id="hp-perp-uly-label">ETH</span>
|
|
||||||
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
|
|
||||||
</h2>
|
|
||||||
<details class="tip-collapse hp-rule-collapse">
|
|
||||||
<summary class="tip-collapse-summary">规则说明</summary>
|
|
||||||
<div class="tip-collapse-body rule-tip">
|
|
||||||
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
|
|
||||||
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
|
|
||||||
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
|
|
||||||
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<div class="form-row hp-uly-row">
|
|
||||||
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
|
|
||||||
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
|
|
||||||
</div>
|
|
||||||
<div class="hp-po-top">
|
|
||||||
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
|
|
||||||
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续空"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
|
|
||||||
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续多"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
|
|
||||||
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
|
|
||||||
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field hp-po-field--tp">
|
|
||||||
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
|
|
||||||
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field hp-po-field--sl">
|
|
||||||
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
|
|
||||||
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="hp-po-fields-option-primary">
|
|
||||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
|
|
||||||
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
|
|
||||||
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
|
|
||||||
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">永续杠杆</span>
|
|
||||||
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
|
|
||||||
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
|
|
||||||
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
|
|
||||||
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
|
|
||||||
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">期权间隔 <em>点</em></span>
|
|
||||||
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field hp-po-field--type">
|
|
||||||
<span class="hp-po-field-lab">类型</span>
|
|
||||||
<select id="hp-money-select" aria-label="虚实值类型">
|
|
||||||
<option value="otm" selected>虚值</option>
|
|
||||||
<option value="itm">实值/平值</option>
|
|
||||||
<option value="atm">仅平值</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
|
|
||||||
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
|
|
||||||
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
|
|
||||||
<div class="hp-po-fields hp-po-fields--section">
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
|
|
||||||
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
<label class="hp-po-field">
|
|
||||||
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
|
|
||||||
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div class="hp-po-summary">
|
|
||||||
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
|
|
||||||
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card hp-po-right-card">
|
|
||||||
<div class="hp-po-right-stack">
|
|
||||||
<div class="hp-po-inner-card hp-po-perp-quote-card">
|
|
||||||
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
|
|
||||||
<div class="hp-po-quote-head">
|
|
||||||
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
|
|
||||||
</div>
|
|
||||||
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
|
|
||||||
<p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
|
|
||||||
</div>
|
|
||||||
<div class="hp-po-inner-card hp-opt-card">
|
|
||||||
<h2>期权 · <span id="hp-opt-type-label">Call</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
|
|
||||||
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
|
|
||||||
<select id="hp-exp-select"><option value="">选择到期日</option></select>
|
|
||||||
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
|
|
||||||
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
|
|
||||||
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
|
|
||||||
</span>
|
|
||||||
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="按当前类型自动匹配最近合约">自动匹配</button>
|
|
||||||
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
|
|
||||||
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
|
|
||||||
</div>
|
|
||||||
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
|
|
||||||
<table class="options-strike-table" id="hp-strike-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>行权价</th>
|
|
||||||
<th>实虚值</th>
|
|
||||||
<th title="指数÷卖一">杠杆</th>
|
|
||||||
<th>卖一/张</th>
|
|
||||||
<th>买一/张</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="hp-strike-tbody">
|
|
||||||
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="form-row hp-pick-row">
|
|
||||||
<label>已选 <code id="hp-sel-inst">—</code></label>
|
|
||||||
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
|
||||||
<span class="muted" id="hp-premium-line"></span>
|
|
||||||
</div>
|
|
||||||
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row hp-action-row hp-po-action-row">
|
|
||||||
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
|
|
||||||
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
|
||||||
<div class="options-dual-grid" id="hp-oo-layout">
|
|
||||||
<div class="card">
|
|
||||||
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
|
|
||||||
<details class="tip-collapse hp-rule-collapse">
|
|
||||||
<summary class="tip-collapse-summary">规则说明</summary>
|
|
||||||
<div class="tip-collapse-body rule-tip">
|
|
||||||
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
|
|
||||||
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
|
||||||
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<div class="form-row hp-uly-row">
|
|
||||||
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
|
|
||||||
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-row hp-target-row hp-oo-target-row">
|
|
||||||
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
|
||||||
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
|
|
||||||
</div>
|
|
||||||
<div class="hp-oo-controls">
|
|
||||||
<div class="hp-oo-ctrl">
|
|
||||||
<span class="hp-oo-ctrl-lab">张数</span>
|
|
||||||
<div class="hp-oo-seg" role="group" aria-label="自动张数">
|
|
||||||
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true">✓</span>同张数</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
|
|
||||||
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
|
|
||||||
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
|
|
||||||
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true">✓</span>残值平</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true">✓</span>到期平</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
|
|
||||||
<div id="hp-oo-legs" class="hp-oo-legs">
|
|
||||||
<div class="hp-oo-leg-row" data-leg="a">
|
|
||||||
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
|
|
||||||
<label>张数 <span class="hp-unit">期权张</span>
|
|
||||||
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="hp-oo-leg-row" data-leg="b">
|
|
||||||
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
|
|
||||||
<label>张数 <span class="hp-unit">期权张</span>
|
|
||||||
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="muted" id="hp-oo-prem-line"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card hp-opt-card">
|
|
||||||
<h2>期权 T 型报价</h2>
|
|
||||||
<div class="form-row">
|
|
||||||
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-money-btn is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true">✓</span>平/虚</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅平值</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅虚值</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐跨式</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐双虚</button>
|
|
||||||
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
|
|
||||||
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>显示全部</button>
|
|
||||||
</div>
|
|
||||||
<div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
|
|
||||||
<table class="options-strike-table options-strike-table--t" id="hp-oo-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th colspan="4" class="opt-t-head-call">Call</th>
|
|
||||||
<th class="opt-t-head-mid">行权</th>
|
|
||||||
<th colspan="4" class="opt-t-head-put">Put</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
|
|
||||||
<th>K</th>
|
|
||||||
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="hp-oo-tbody">
|
|
||||||
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
|
|
||||||
<div class="hp-oo-transfer-bals muted">
|
|
||||||
<span>资金 <strong id="hp-oo-funding-usdc">—</strong></span>
|
|
||||||
<span class="hp-oo-transfer-sep">·</span>
|
|
||||||
<span>交易 <strong id="hp-oo-trading-usdc">—</strong></span>
|
|
||||||
<span class="hp-oo-transfer-unit">USDC</span>
|
|
||||||
<span class="muted" id="hp-oo-xfer-msg"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-row hp-oo-transfer-form" autocomplete="off">
|
|
||||||
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
|
|
||||||
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
|
|
||||||
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
|
|
||||||
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
|
|
||||||
<option value="funding_to_trading" selected>资金 → 交易</option>
|
|
||||||
<option value="trading_to_funding">交易 → 资金</option>
|
|
||||||
</select>
|
|
||||||
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
|
|
||||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
|
|
||||||
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
|
|
||||||
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row hp-action-row">
|
|
||||||
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
|
||||||
<div class="card">
|
|
||||||
<h2>进行中的计划</h2>
|
|
||||||
<p class="muted">仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。</p>
|
|
||||||
<div class="options-strike-table-wrap">
|
|
||||||
<table class="options-strike-table hp-history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>目标/止盈止损</th><th>开仓</th><th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="hp-active-tbody">
|
|
||||||
<tr><td colspan="8" class="muted">加载中…</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
|
|
||||||
<div class="card">
|
|
||||||
<h2>历史记录</h2>
|
|
||||||
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
|
|
||||||
<div class="options-strike-table-wrap">
|
|
||||||
<table class="options-strike-table hp-history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="hp-history-tbody">
|
|
||||||
<tr><td colspan="10" 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>
|
|
||||||
<div id="hp-stats-box" class="muted">加载中…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
|
|
||||||
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
|
|
||||||
<div class="hp-modal-head">
|
|
||||||
<h3 id="hp-detail-title">成交细节</h3>
|
|
||||||
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
|
|
||||||
</div>
|
|
||||||
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
|
|
||||||
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
|
|
||||||
<div class="hp-modal-head">
|
|
||||||
<h3 id="hp-preview-title">情景测算</h3>
|
|
||||||
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
|
|
||||||
</div>
|
|
||||||
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
|
|
||||||
<div class="options-strike-table-wrap">
|
|
||||||
<table class="options-strike-table" id="hp-result-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>情景</th>
|
|
||||||
<th>现货价</th>
|
|
||||||
<th id="hp-preview-mid-th">永续/腿盈亏</th>
|
|
||||||
<th>期权盈亏</th>
|
|
||||||
<th>合计≈U</th>
|
|
||||||
<th>说明</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="hp-result-tbody">
|
|
||||||
<tr><td colspan="6" class="muted">计算中…</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="form-row hp-preview-actions">
|
|
||||||
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
|
|
||||||
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="/static/hedge_plan.js?v=46"></script>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,144 +1,144 @@
|
|||||||
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
|
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
|
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
|
||||||
|
|
||||||
DISPLAY_RUNTIME_PREFIX = "display."
|
DISPLAY_RUNTIME_PREFIX = "display."
|
||||||
|
|
||||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||||
"show_nav_dashboard": False,
|
"show_nav_dashboard": False,
|
||||||
"show_nav_account_ledger": False,
|
"show_nav_account_ledger": False,
|
||||||
"show_nav_key_monitor": True,
|
"show_nav_key_monitor": True,
|
||||||
"show_nav_trade": False, # 实盘下单界面已移除
|
"show_nav_trade": False, # 实盘下单界面已移除
|
||||||
"show_nav_strategy": False,
|
"show_nav_strategy": False,
|
||||||
"show_nav_strategy_records": False,
|
"show_nav_strategy_records": False,
|
||||||
"show_nav_records": False, # 永续交易记录与复盘已移除
|
"show_nav_records": False, # 永续交易记录与复盘已移除
|
||||||
"show_nav_stats": False,
|
"show_nav_stats": False,
|
||||||
"show_nav_risk_policy": True,
|
"show_nav_risk_policy": True,
|
||||||
"show_nav_system_guide": False,
|
"show_nav_system_guide": False,
|
||||||
"show_nav_env_config": True,
|
"show_nav_env_config": True,
|
||||||
"show_nav_options": True,
|
"show_nav_options": True,
|
||||||
"show_nav_options_review": True,
|
"show_nav_options_review": True,
|
||||||
"show_nav_hedge_plan": True,
|
"show_nav_hedge_plan": False,
|
||||||
"show_settings_transfer": False, # 永续资金划转默认隐藏
|
"show_settings_transfer": False, # 永续资金划转默认隐藏
|
||||||
"show_settings_export": True,
|
"show_settings_export": True,
|
||||||
"show_settings_password": True,
|
"show_settings_password": True,
|
||||||
"show_settings_options_swap": True,
|
"show_settings_options_swap": True,
|
||||||
"show_settings_options_transfer": True,
|
"show_settings_options_transfer": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
DISPLAY_LABELS: dict[str, str] = {
|
DISPLAY_LABELS: dict[str, str] = {
|
||||||
"show_nav_dashboard": "数据看板",
|
"show_nav_dashboard": "数据看板",
|
||||||
"show_nav_account_ledger": "账户流水",
|
"show_nav_account_ledger": "账户流水",
|
||||||
"show_nav_key_monitor": "关键位监控",
|
"show_nav_key_monitor": "关键位监控",
|
||||||
"show_nav_trade": "实盘下单",
|
"show_nav_trade": "实盘下单",
|
||||||
"show_nav_strategy": "策略交易",
|
"show_nav_strategy": "策略交易",
|
||||||
"show_nav_strategy_records": "策略交易记录",
|
"show_nav_strategy_records": "策略交易记录",
|
||||||
"show_nav_records": "交易记录与复盘",
|
"show_nav_records": "交易记录与复盘",
|
||||||
"show_nav_stats": "统计分析",
|
"show_nav_stats": "统计分析",
|
||||||
"show_nav_risk_policy": "风控说明",
|
"show_nav_risk_policy": "风控说明",
|
||||||
"show_nav_system_guide": "系统说明",
|
"show_nav_system_guide": "系统说明",
|
||||||
"show_nav_env_config": "env配置",
|
"show_nav_env_config": "env配置",
|
||||||
"show_nav_options": "期权",
|
"show_nav_options": "期权",
|
||||||
"show_nav_options_review": "期权复盘",
|
"show_nav_options_review": "期权复盘",
|
||||||
"show_nav_hedge_plan": "对冲计划",
|
"show_nav_hedge_plan": "对冲计划",
|
||||||
"show_settings_transfer": "永续资金划转",
|
"show_settings_transfer": "永续资金划转",
|
||||||
"show_settings_export": "数据导出",
|
"show_settings_export": "数据导出",
|
||||||
"show_settings_password": "账户密码修改",
|
"show_settings_password": "账户密码修改",
|
||||||
"show_settings_options_swap": "期权币种兑换",
|
"show_settings_options_swap": "期权币种兑换",
|
||||||
"show_settings_options_transfer": "期权资金划转",
|
"show_settings_options_transfer": "期权资金划转",
|
||||||
}
|
}
|
||||||
|
|
||||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||||
"dashboard": "show_nav_dashboard",
|
"dashboard": "show_nav_dashboard",
|
||||||
"account_ledger": "show_nav_account_ledger",
|
"account_ledger": "show_nav_account_ledger",
|
||||||
"key_monitor": "show_nav_key_monitor",
|
"key_monitor": "show_nav_key_monitor",
|
||||||
"trade": "show_nav_trade",
|
"trade": "show_nav_trade",
|
||||||
"strategy": "show_nav_strategy",
|
"strategy": "show_nav_strategy",
|
||||||
"strategy_records": "show_nav_strategy_records",
|
"strategy_records": "show_nav_strategy_records",
|
||||||
"records": "show_nav_records",
|
"records": "show_nav_records",
|
||||||
"stats": "show_nav_stats",
|
"stats": "show_nav_stats",
|
||||||
"risk_policy": "show_nav_risk_policy",
|
"risk_policy": "show_nav_risk_policy",
|
||||||
"system_guide": "show_nav_system_guide",
|
"system_guide": "show_nav_system_guide",
|
||||||
"env_config": "show_nav_env_config",
|
"env_config": "show_nav_env_config",
|
||||||
"options": "show_nav_options",
|
"options": "show_nav_options",
|
||||||
"options_review": "show_nav_options_review",
|
"options_review": "show_nav_options_review",
|
||||||
"hedge_plan": "show_nav_hedge_plan",
|
"hedge_plan": "show_nav_hedge_plan",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
|
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
|
||||||
out = dict(DEFAULT_INSTANCE_DISPLAY)
|
out = dict(DEFAULT_INSTANCE_DISPLAY)
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||||
if key in raw:
|
if key in raw:
|
||||||
out[key] = bool(raw[key])
|
out[key] = bool(raw[key])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _load_from_conn(conn) -> dict[str, bool]:
|
def _load_from_conn(conn) -> dict[str, bool]:
|
||||||
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
|
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
|
||||||
merged: dict[str, Any] = {}
|
merged: dict[str, Any] = {}
|
||||||
for key in DEFAULT_INSTANCE_DISPLAY:
|
for key in DEFAULT_INSTANCE_DISPLAY:
|
||||||
sk = key
|
sk = key
|
||||||
if sk in stored:
|
if sk in stored:
|
||||||
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
|
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
|
||||||
return normalize_display_prefs(merged)
|
return normalize_display_prefs(merged)
|
||||||
|
|
||||||
|
|
||||||
def get_display_prefs(get_db: Callable) -> dict[str, bool]:
|
def get_display_prefs(get_db: Callable) -> dict[str, bool]:
|
||||||
return with_db(get_db, _load_from_conn)
|
return with_db(get_db, _load_from_conn)
|
||||||
|
|
||||||
|
|
||||||
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
|
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
|
||||||
normalized = normalize_display_prefs(prefs)
|
normalized = normalize_display_prefs(prefs)
|
||||||
|
|
||||||
def _save(conn):
|
def _save(conn):
|
||||||
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
|
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
|
||||||
runtime_set_many(conn, mapping)
|
runtime_set_many(conn, mapping)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
return with_db(get_db, _save)
|
return with_db(get_db, _save)
|
||||||
|
|
||||||
|
|
||||||
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
||||||
prefs = get_display_prefs(get_db)
|
prefs = get_display_prefs(get_db)
|
||||||
return {"display": prefs, "display_meta": display_meta_for_ui()}
|
return {"display": prefs, "display_meta": display_meta_for_ui()}
|
||||||
|
|
||||||
|
|
||||||
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||||
prefs = normalize_display_prefs(display or {})
|
prefs = normalize_display_prefs(display or {})
|
||||||
t = (tab or "").strip()
|
t = (tab or "").strip()
|
||||||
if t in ("trade", "records", "stats"):
|
if t in ("trade", "records", "stats"):
|
||||||
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
|
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
|
||||||
key = NAV_TAB_ALLOWED.get(t)
|
key = NAV_TAB_ALLOWED.get(t)
|
||||||
if not key:
|
if not key:
|
||||||
return True
|
return True
|
||||||
return bool(prefs.get(key, True))
|
return bool(prefs.get(key, True))
|
||||||
|
|
||||||
|
|
||||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||||
nav_keys = [
|
nav_keys = [
|
||||||
"show_nav_dashboard",
|
"show_nav_dashboard",
|
||||||
"show_nav_account_ledger",
|
"show_nav_account_ledger",
|
||||||
"show_nav_key_monitor",
|
"show_nav_key_monitor",
|
||||||
"show_nav_risk_policy",
|
"show_nav_risk_policy",
|
||||||
"show_nav_system_guide",
|
"show_nav_system_guide",
|
||||||
"show_nav_env_config",
|
"show_nav_env_config",
|
||||||
"show_nav_options",
|
"show_nav_options",
|
||||||
"show_nav_options_review",
|
"show_nav_options_review",
|
||||||
"show_nav_hedge_plan",
|
"show_nav_hedge_plan",
|
||||||
]
|
]
|
||||||
settings_keys = [
|
settings_keys = [
|
||||||
"show_settings_transfer",
|
"show_settings_transfer",
|
||||||
"show_settings_export",
|
"show_settings_export",
|
||||||
"show_settings_password",
|
"show_settings_password",
|
||||||
"show_settings_options_swap",
|
"show_settings_options_swap",
|
||||||
"show_settings_options_transfer",
|
"show_settings_options_transfer",
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
|
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
|
||||||
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
|
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
|
|||||||
"options_settings.js": "application/javascript; charset=utf-8",
|
"options_settings.js": "application/javascript; charset=utf-8",
|
||||||
"options_position_cards.js": "application/javascript; charset=utf-8",
|
"options_position_cards.js": "application/javascript; charset=utf-8",
|
||||||
"options_review.js": "application/javascript; charset=utf-8",
|
"options_review.js": "application/javascript; charset=utf-8",
|
||||||
"hedge_plan.js": "application/javascript; charset=utf-8",
|
|
||||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.css": "text/css; charset=utf-8",
|
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""OKX 交易模式(币本位期权专用机:仅单独期权).
|
||||||
|
|
||||||
|
历史 .env 若仍为 perp_options / options_options,读取时归一为 options,
|
||||||
|
对冲模块已移除,不可再启用.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
MODE_OPTIONS = "options"
|
||||||
|
# 兼容旧配置名,不再作为可选模式
|
||||||
|
MODE_PERP = "perp_options"
|
||||||
|
MODE_OO = "options_options"
|
||||||
|
VALID_MODES = frozenset({MODE_OPTIONS})
|
||||||
|
UI_MODES = frozenset({MODE_OPTIONS})
|
||||||
|
|
||||||
|
_ALIASES = {
|
||||||
|
"option": MODE_OPTIONS,
|
||||||
|
"standalone": MODE_OPTIONS,
|
||||||
|
"期权": MODE_OPTIONS,
|
||||||
|
"单独期权": MODE_OPTIONS,
|
||||||
|
"po": MODE_OPTIONS,
|
||||||
|
"perp": MODE_OPTIONS,
|
||||||
|
"perp_options": MODE_OPTIONS,
|
||||||
|
"永期": MODE_OPTIONS,
|
||||||
|
"永期对冲": MODE_OPTIONS,
|
||||||
|
"oo": MODE_OPTIONS,
|
||||||
|
"options_options": MODE_OPTIONS,
|
||||||
|
"期期": MODE_OPTIONS,
|
||||||
|
"期期对冲": MODE_OPTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_okx_trade_mode(raw: Optional[str] = None) -> str:
|
||||||
|
s = str(raw or "").strip().lower()
|
||||||
|
if s in VALID_MODES:
|
||||||
|
return s
|
||||||
|
if s in _ALIASES:
|
||||||
|
return _ALIASES[s]
|
||||||
|
# 未知或空 → 单独期权
|
||||||
|
return MODE_OPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_infer_okx_trade_mode() -> str:
|
||||||
|
return MODE_OPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
def get_okx_trade_mode() -> str:
|
||||||
|
return normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
|
||||||
|
|
||||||
|
|
||||||
|
def hedge_module_enabled() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def show_perp_options() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def show_options_options() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def standalone_options_open_allowed() -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mode_label(mode: Optional[str] = None) -> str:
|
||||||
|
return "单独期权"
|
||||||
|
|
||||||
|
|
||||||
|
def block_standalone_open_by_mode_msg() -> Optional[str]:
|
||||||
|
return None
|
||||||
@@ -306,7 +306,8 @@ def close_option_by_bid1(
|
|||||||
|
|
||||||
px = float(order.get("px", locked_bid_px))
|
px = float(order.get("px", locked_bid_px))
|
||||||
oid = str((order.get("data") or {}).get("ordId") or "")
|
oid = str((order.get("data") or {}).get("ordId") or "")
|
||||||
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4)
|
# 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
|
||||||
|
prem_recv_submit = round(total_premium(px, level_sheets * ct_mult), 4)
|
||||||
time.sleep(0.6)
|
time.sleep(0.6)
|
||||||
invalidate_option_positions_cache()
|
invalidate_option_positions_cache()
|
||||||
raw2 = cfg["fetch_option_positions"](ex)
|
raw2 = cfg["fetch_option_positions"](ex)
|
||||||
@@ -325,14 +326,47 @@ def close_option_by_bid1(
|
|||||||
reduced = max(0, before_avail - after_avail)
|
reduced = max(0, before_avail - after_avail)
|
||||||
remaining_pos = after_avail
|
remaining_pos = after_avail
|
||||||
fully_closed = remaining_pos < 1
|
fully_closed = remaining_pos < 1
|
||||||
|
filled_sheets = min(reduced, level_sheets) if reduced > 0 else 0
|
||||||
|
# 全平但减仓读数异常时回退挂单张数,避免漏记本批
|
||||||
|
if fully_closed and filled_sheets < 1:
|
||||||
|
filled_sheets = level_sheets
|
||||||
|
batch_recv = (
|
||||||
|
round(total_premium(px, filled_sheets * ct_mult), 4) if filled_sheets > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
total_recv = batch_recv
|
||||||
|
close_px = px
|
||||||
|
merged_ord = oid or None
|
||||||
|
|
||||||
if fully_closed:
|
if fully_closed:
|
||||||
clear_close_gate(inst_id)
|
clear_close_gate(inst_id)
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
from lib.options.options_db import init_options_tables
|
from lib.options.options_db import (
|
||||||
|
add_open_close_accum,
|
||||||
|
get_open_close_accum,
|
||||||
|
init_options_tables,
|
||||||
|
)
|
||||||
|
|
||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
|
# 先并入本批,再读累计(含此前分批)
|
||||||
|
if filled_sheets > 0:
|
||||||
|
add_open_close_accum(
|
||||||
|
conn,
|
||||||
|
inst_id,
|
||||||
|
recv=batch_recv,
|
||||||
|
sheets=filled_sheets,
|
||||||
|
px=px,
|
||||||
|
ord_id=oid or None,
|
||||||
|
)
|
||||||
|
accum = get_open_close_accum(conn, inst_id)
|
||||||
|
total_recv = round(float(accum.get("recv") or 0), 4)
|
||||||
|
accum_sheets = int(accum.get("sheets") or 0)
|
||||||
|
accum_notional = float(accum.get("notional") or 0)
|
||||||
|
if accum_sheets > 0 and accum_notional > 0:
|
||||||
|
close_px = round(accum_notional / accum_sheets, 8)
|
||||||
|
merged_ord = str(accum.get("close_ord_id") or "").strip() or (oid or None)
|
||||||
|
|
||||||
open_rows = conn.execute(
|
open_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, premium_paid FROM options_trades
|
SELECT id, premium_paid FROM options_trades
|
||||||
@@ -346,16 +380,25 @@ def close_option_by_bid1(
|
|||||||
for i, row in enumerate(open_rows):
|
for i, row in enumerate(open_rows):
|
||||||
paid = float(row["premium_paid"] or 0)
|
paid = float(row["premium_paid"] or 0)
|
||||||
if i == len(open_rows) - 1:
|
if i == len(open_rows) - 1:
|
||||||
recv = round(prem_recv - allocated, 4)
|
recv = round(total_recv - allocated, 4)
|
||||||
elif total_paid > 0:
|
elif total_paid > 0:
|
||||||
recv = round(prem_recv * (paid / total_paid), 4)
|
recv = round(total_recv * (paid / total_paid), 4)
|
||||||
allocated += recv
|
allocated += recv
|
||||||
else:
|
else:
|
||||||
recv = round(prem_recv / len(open_rows), 4)
|
recv = round(total_recv / len(open_rows), 4)
|
||||||
allocated += recv
|
allocated += recv
|
||||||
pnl = round(recv - paid, 4)
|
pnl = round(recv - paid, 4)
|
||||||
note_sql = ""
|
note_sql = ""
|
||||||
params: list[Any] = [px, recv, pnl, oid or None]
|
from lib.options.options_time_lib import options_app_now_str
|
||||||
|
|
||||||
|
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
|
||||||
|
params: list[Any] = [
|
||||||
|
close_px,
|
||||||
|
recv,
|
||||||
|
pnl,
|
||||||
|
merged_ord,
|
||||||
|
options_app_now_str(),
|
||||||
|
]
|
||||||
if signal_note and i == len(open_rows) - 1:
|
if signal_note and i == len(open_rows) - 1:
|
||||||
note_sql = """,
|
note_sql = """,
|
||||||
signal_note = CASE
|
signal_note = CASE
|
||||||
@@ -363,15 +406,14 @@ def close_option_by_bid1(
|
|||||||
ELSE signal_note
|
ELSE signal_note
|
||||||
END"""
|
END"""
|
||||||
params.append(signal_note)
|
params.append(signal_note)
|
||||||
from lib.options.options_time_lib import options_app_now_str
|
|
||||||
|
|
||||||
params.append(options_app_now_str())
|
|
||||||
params.append(int(row["id"]))
|
params.append(int(row["id"]))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE options_trades
|
UPDATE options_trades
|
||||||
SET status = 'closed', close_quote = ?, premium_received = ?,
|
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||||
realized_pnl = ?, close_ord_id = ?, closed_at = ?
|
realized_pnl = ?, close_ord_id = ?, closed_at = ?,
|
||||||
|
close_recv_accum = 0, close_sheets_accum = 0,
|
||||||
|
close_notional_accum = 0
|
||||||
{note_sql}
|
{note_sql}
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
@@ -380,6 +422,27 @@ def close_option_by_bid1(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
elif filled_sheets > 0:
|
||||||
|
# 部分成交:持久化本批收回,下次全平合计
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
from lib.options.options_db import add_open_close_accum, init_options_tables
|
||||||
|
|
||||||
|
init_options_tables(conn)
|
||||||
|
add_open_close_accum(
|
||||||
|
conn,
|
||||||
|
inst_id,
|
||||||
|
recv=batch_recv,
|
||||||
|
sheets=filled_sheets,
|
||||||
|
px=px,
|
||||||
|
ord_id=oid or None,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if require_recycle_gate:
|
||||||
|
# 自动平已挂过单:同仓续批只验流动性
|
||||||
|
mark_close_gate_passed(inst_id)
|
||||||
elif require_recycle_gate:
|
elif require_recycle_gate:
|
||||||
# 自动平已挂过单:同仓续批只验流动性
|
# 自动平已挂过单:同仓续批只验流动性
|
||||||
mark_close_gate_passed(inst_id)
|
mark_close_gate_passed(inst_id)
|
||||||
@@ -391,11 +454,12 @@ def close_option_by_bid1(
|
|||||||
"bid": px,
|
"bid": px,
|
||||||
"locked_bid_px": locked_bid_px,
|
"locked_bid_px": locked_bid_px,
|
||||||
"submitted_sheets": level_sheets,
|
"submitted_sheets": level_sheets,
|
||||||
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
|
"filled_or_reduced_sheets": filled_sheets,
|
||||||
"remaining_sheets": remaining_pos,
|
"remaining_sheets": remaining_pos,
|
||||||
"premium_received": prem_recv,
|
"premium_received": total_recv if fully_closed else (batch_recv or prem_recv_submit),
|
||||||
|
"batch_premium_received": batch_recv,
|
||||||
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
|
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
|
||||||
"close_ord_id": oid or None,
|
"close_ord_id": merged_ord if fully_closed else (oid or None),
|
||||||
"fully_closed": fully_closed,
|
"fully_closed": fully_closed,
|
||||||
"msg": (
|
"msg": (
|
||||||
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
|
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
|
||||||
|
|||||||
@@ -1,85 +1,77 @@
|
|||||||
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
|
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
|
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
|
||||||
不走 options dashboard snapshot(避免余额/历史).
|
不走 options dashboard snapshot(避免余额/历史).
|
||||||
"""
|
"""
|
||||||
if not cfg.get("enabled"):
|
if not cfg.get("enabled"):
|
||||||
return []
|
return []
|
||||||
ex = cfg.get("exchange_options")
|
ex = cfg.get("exchange_options")
|
||||||
ready_fn = cfg.get("options_api_ready")
|
ready_fn = cfg.get("options_api_ready")
|
||||||
if not callable(ready_fn):
|
if not callable(ready_fn):
|
||||||
return []
|
return []
|
||||||
ok, _reason = ready_fn(ex)
|
ok, _reason = ready_fn(ex)
|
||||||
if not ok:
|
if not ok:
|
||||||
return []
|
return []
|
||||||
fetch_fn = cfg.get("fetch_option_positions")
|
fetch_fn = cfg.get("fetch_option_positions")
|
||||||
if not callable(fetch_fn):
|
if not callable(fetch_fn):
|
||||||
return []
|
return []
|
||||||
raw = fetch_fn(ex)
|
raw = fetch_fn(ex)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return []
|
return []
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||||
from lib.options.options_history_lib import enrich_position_row_display
|
from lib.options.options_history_lib import enrich_position_row_display
|
||||||
from lib.options.options_positions_lib import attach_close_preview
|
from lib.options.options_positions_lib import attach_close_preview
|
||||||
|
|
||||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
get_db = cfg.get("get_db")
|
get_db = cfg.get("get_db")
|
||||||
if not callable(get_db):
|
if not callable(get_db):
|
||||||
for p in raw:
|
for p in raw:
|
||||||
if not isinstance(p, dict):
|
if not isinstance(p, dict):
|
||||||
continue
|
continue
|
||||||
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
|
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
|
||||||
attach_close_preview(cfg, ex, row)
|
attach_close_preview(cfg, ex, row)
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
from lib.options.options_target_lib import targets_by_inst
|
||||||
from lib.options.options_target_lib import targets_by_inst
|
|
||||||
|
tgt_map = targets_by_inst(conn)
|
||||||
tgt_map = targets_by_inst(conn)
|
except Exception:
|
||||||
hedge_target_map = active_options_targets_by_inst(conn)
|
tgt_map = {}
|
||||||
except Exception:
|
|
||||||
tgt_map = {}
|
for p in raw:
|
||||||
hedge_target_map = {}
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
for p in raw:
|
inst = str(p.get("instId") or "").strip()
|
||||||
if not isinstance(p, dict):
|
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||||
continue
|
row = enrich_position_row_display(
|
||||||
inst = str(p.get("instId") or "").strip()
|
cfg,
|
||||||
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
ex,
|
||||||
row = enrich_position_row_display(
|
p,
|
||||||
cfg,
|
meta_cache=meta_cache,
|
||||||
ex,
|
premium_override=premium_override,
|
||||||
p,
|
)
|
||||||
meta_cache=meta_cache,
|
attach_close_preview(cfg, ex, row, premium_paid=premium_override)
|
||||||
premium_override=premium_override,
|
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||||
)
|
if mon:
|
||||||
attach_close_preview(cfg, ex, row, premium_paid=premium_override)
|
row["target_index"] = mon.get("target_index")
|
||||||
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
row["target_monitor_id"] = mon.get("id")
|
||||||
if mon:
|
row["target_monitor"] = mon
|
||||||
row["target_index"] = mon.get("target_index")
|
rows.append(row)
|
||||||
row["target_monitor_id"] = mon.get("id")
|
finally:
|
||||||
row["target_monitor"] = mon
|
conn.close()
|
||||||
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
return rows
|
||||||
if hedge_target:
|
|
||||||
row["hedge_plan_target"] = hedge_target
|
|
||||||
if not mon:
|
|
||||||
row["target_index"] = hedge_target.get("target_index")
|
|
||||||
rows.append(row)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return rows
|
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
|
|||||||
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
|
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
|
||||||
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
|
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
|
||||||
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
|
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
|
||||||
|
# 分批平仓累计:全平前写在最早一笔 open 上,全平后并入 premium_received
|
||||||
|
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
|
||||||
|
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
|
||||||
|
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
conn.execute(ddl)
|
conn.execute(ddl)
|
||||||
@@ -154,3 +158,86 @@ def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
|
|||||||
if not row or int(row["n"] or 0) < 1:
|
if not row or int(row["n"] or 0) < 1:
|
||||||
return None
|
return None
|
||||||
return int(row["total"] or 0)
|
return int(row["total"] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _earliest_open_trade_id(conn: sqlite3.Connection, inst_id: str) -> int | None:
|
||||||
|
inst = (inst_id or "").strip()
|
||||||
|
if not inst:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM options_trades
|
||||||
|
WHERE inst_id = ? AND status = 'open'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(inst,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_close_accum(conn: sqlite3.Connection, inst_id: str) -> dict[str, float | int | str | None]:
|
||||||
|
"""读取分批平仓累计(挂在最早一笔 open 上)."""
|
||||||
|
tid = _earliest_open_trade_id(conn, inst_id)
|
||||||
|
if tid is None:
|
||||||
|
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT close_recv_accum, close_sheets_accum, close_notional_accum, close_ord_id
|
||||||
|
FROM options_trades WHERE id = ?
|
||||||
|
""",
|
||||||
|
(tid,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
|
||||||
|
return {
|
||||||
|
"recv": float(row["close_recv_accum"] or 0),
|
||||||
|
"sheets": int(row["close_sheets_accum"] or 0),
|
||||||
|
"notional": float(row["close_notional_accum"] or 0),
|
||||||
|
"close_ord_id": str(row["close_ord_id"] or "").strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_open_close_accum(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
inst_id: str,
|
||||||
|
*,
|
||||||
|
recv: float,
|
||||||
|
sheets: int,
|
||||||
|
px: float,
|
||||||
|
ord_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""分批平仓成交后累加收回权利金/张数/名义(供全平 VWAP 与总盈亏)."""
|
||||||
|
tid = _earliest_open_trade_id(conn, inst_id)
|
||||||
|
if tid is None:
|
||||||
|
return False
|
||||||
|
sheets_i = max(0, int(sheets or 0))
|
||||||
|
recv_f = float(recv or 0)
|
||||||
|
px_f = float(px or 0)
|
||||||
|
if sheets_i < 1 and abs(recv_f) < 1e-15:
|
||||||
|
return False
|
||||||
|
notional_add = px_f * sheets_i if sheets_i > 0 and px_f > 0 else 0.0
|
||||||
|
oid = str(ord_id or "").strip()
|
||||||
|
prev = conn.execute(
|
||||||
|
"SELECT close_ord_id FROM options_trades WHERE id = ?", (tid,)
|
||||||
|
).fetchone()
|
||||||
|
prev_oids = str((prev["close_ord_id"] if prev else None) or "").strip()
|
||||||
|
if oid:
|
||||||
|
parts = [p for p in prev_oids.split(",") if p] if prev_oids else []
|
||||||
|
if oid not in parts:
|
||||||
|
parts.append(oid)
|
||||||
|
merged_oid = ",".join(parts)
|
||||||
|
else:
|
||||||
|
merged_oid = prev_oids or None
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_trades
|
||||||
|
SET close_recv_accum = COALESCE(close_recv_accum, 0) + ?,
|
||||||
|
close_sheets_accum = COALESCE(close_sheets_accum, 0) + ?,
|
||||||
|
close_notional_accum = COALESCE(close_notional_accum, 0) + ?,
|
||||||
|
close_ord_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(recv_f, sheets_i, notional_add, merged_oid, tid),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -28,15 +28,12 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
|
|
||||||
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
from lib.options.options_profit_exit_lib import profit_exit_by_inst
|
||||||
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
|
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
|
||||||
|
|
||||||
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
|
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
|
||||||
tgt_map = targets_by_inst(conn)
|
tgt_map = targets_by_inst(conn)
|
||||||
hedge_target_map = active_options_targets_by_inst(conn)
|
|
||||||
profit_exit_map = profit_exit_by_inst(conn)
|
profit_exit_map = profit_exit_by_inst(conn)
|
||||||
target_monitors.extend(hedge_target_map.values())
|
|
||||||
for pe in profit_exit_map.values():
|
for pe in profit_exit_map.values():
|
||||||
if pe.get("profit_exit_enabled"):
|
if pe.get("profit_exit_enabled"):
|
||||||
target_monitors.append(
|
target_monitors.append(
|
||||||
@@ -59,12 +56,6 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
p["profit_exit_mult"] = pe.get("profit_exit_mult")
|
p["profit_exit_mult"] = pe.get("profit_exit_mult")
|
||||||
p["profit_exit_state"] = pe.get("profit_exit_state")
|
p["profit_exit_state"] = pe.get("profit_exit_state")
|
||||||
p["profit_exit_required_recycle"] = pe.get("required_recycle")
|
p["profit_exit_required_recycle"] = pe.get("required_recycle")
|
||||||
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
|
||||||
if hedge_target:
|
|
||||||
p["hedge_plan_target"] = hedge_target
|
|
||||||
if not mon:
|
|
||||||
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
|
|
||||||
p["target_index"] = hedge_target.get("target_index")
|
|
||||||
try:
|
try:
|
||||||
from lib.instance.instance_dashboard_lib import (
|
from lib.instance.instance_dashboard_lib import (
|
||||||
_format_options_target,
|
_format_options_target,
|
||||||
@@ -85,13 +76,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
target_monitors = []
|
target_monitors = []
|
||||||
from lib.options.options_positions_lib import display_pnl_from_option_row
|
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
|
||||||
|
|
||||||
upl_total = 0.0
|
upl_total = 0.0
|
||||||
has_upl = False
|
has_upl = False
|
||||||
for p in positions:
|
for p in positions:
|
||||||
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
|
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
|
||||||
pnl = display_pnl_from_option_row(p)
|
pnl = display_pnl_usdt_from_option_row(p)
|
||||||
if pnl is None:
|
if pnl is None:
|
||||||
continue
|
continue
|
||||||
has_upl = True
|
has_upl = True
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def option_position_limit_block_msg(
|
|||||||
- 拉持仓失败:拒绝开仓(避免绕过上限)
|
- 拉持仓失败:拒绝开仓(避免绕过上限)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed
|
from lib.options.okx_trade_mode_lib import standalone_options_open_allowed
|
||||||
|
|
||||||
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
|
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
|
||||||
if max_active is None and not standalone_options_open_allowed():
|
if max_active is None and not standalone_options_open_allowed():
|
||||||
|
|||||||
@@ -125,11 +125,24 @@ def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
|
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
|
||||||
"""顶栏「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样."""
|
"""顶栏/看板「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.
|
||||||
|
|
||||||
|
BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱).
|
||||||
|
"""
|
||||||
pnl = display_pnl_from_option_row(row)
|
pnl = display_pnl_from_option_row(row)
|
||||||
if pnl is None:
|
if pnl is None:
|
||||||
return None
|
return None
|
||||||
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
|
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
|
||||||
|
if not ccy or ccy == "USDC":
|
||||||
|
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
|
||||||
|
inst = str(row.get("inst_id") or row.get("instId") or "")
|
||||||
|
mode = str(row.get("margin_mode") or "").strip().lower()
|
||||||
|
if mode == "coin" or (
|
||||||
|
"-USD-" in inst.upper() and "_UM" not in inst.upper()
|
||||||
|
):
|
||||||
|
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "") or "").upper()
|
||||||
|
if underly in ("ETH", "BTC"):
|
||||||
|
ccy = underly
|
||||||
if ccy in ("ETH", "BTC"):
|
if ccy in ("ETH", "BTC"):
|
||||||
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
|
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
|
||||||
if idx is None or idx <= 0:
|
if idx is None or idx <= 0:
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
|
|||||||
return float(quote_per_unit) * float(ct_mult)
|
return float(quote_per_unit) * float(ct_mult)
|
||||||
|
|
||||||
|
|
||||||
|
def option_expiry_pnl(
|
||||||
|
*,
|
||||||
|
opt_type: str,
|
||||||
|
strike: float,
|
||||||
|
spot: float,
|
||||||
|
sheets: float,
|
||||||
|
ct_mult: float,
|
||||||
|
premium_paid: float,
|
||||||
|
) -> float:
|
||||||
|
"""到期按实值估算盈亏(权利金币种口径);模拟盘到期结算等复用."""
|
||||||
|
o = (opt_type or "").strip().upper()
|
||||||
|
intrinsic_per_coin = 0.0
|
||||||
|
if o in ("C", "CALL"):
|
||||||
|
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
|
||||||
|
elif o in ("P", "PUT"):
|
||||||
|
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
|
||||||
|
else:
|
||||||
|
return -float(premium_paid)
|
||||||
|
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
|
||||||
|
return value - float(premium_paid)
|
||||||
|
|
||||||
|
|
||||||
def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None:
|
def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None:
|
||||||
"""盘口展示:价格/张数,如 17.2/150."""
|
"""盘口展示:价格/张数,如 17.2/150."""
|
||||||
if px is None:
|
if px is None:
|
||||||
|
|||||||
@@ -211,13 +211,34 @@ def _estimate_recycle(
|
|||||||
pos: dict[str, Any],
|
pos: dict[str, Any],
|
||||||
premium_paid: float | None,
|
premium_paid: float | None,
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
|
"""翻倍出场用可回收估算.
|
||||||
|
|
||||||
|
用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓;
|
||||||
|
残档/无效买一则返回 None(禁止按假买盘自动平).
|
||||||
|
"""
|
||||||
from lib.options.options_positions_lib import attach_close_preview
|
from lib.options.options_positions_lib import attach_close_preview
|
||||||
|
from lib.options.options_pricing_lib import total_premium
|
||||||
|
|
||||||
row = dict(pos)
|
row = dict(pos)
|
||||||
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
|
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
|
||||||
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
||||||
if preview.get("bid_invalid"):
|
if preview.get("bid_invalid"):
|
||||||
return None
|
return None
|
||||||
|
levels = preview.get("levels") or []
|
||||||
|
ct_mult = float(row.get("ct_mult") or 0.01)
|
||||||
|
sheets = int(
|
||||||
|
abs(
|
||||||
|
_safe_float(row.get("avail_pos") if row.get("avail_pos") is not None else row.get("pos"))
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if levels and sheets > 0 and ct_mult > 0:
|
||||||
|
try:
|
||||||
|
px = float(levels[0].get("px") or 0)
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
px = 0.0
|
||||||
|
if px > 0:
|
||||||
|
return round(total_premium(px, sheets * ct_mult), 8)
|
||||||
return _safe_float(preview.get("total_received"))
|
return _safe_float(preview.get("total_received"))
|
||||||
|
|
||||||
|
|
||||||
@@ -289,33 +310,12 @@ def run_options_profit_exits(
|
|||||||
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
|
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
|
||||||
ensure_profit_exit_columns(conn)
|
ensure_profit_exit_columns(conn)
|
||||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||||
hedge_managed: set[str] = set()
|
|
||||||
try:
|
|
||||||
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
|
|
||||||
|
|
||||||
init_hedge_plan_tables(conn)
|
|
||||||
hedge_managed = active_hedge_option_inst_ids(conn)
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
rules = profit_exit_by_inst(conn)
|
rules = profit_exit_by_inst(conn)
|
||||||
triggered = 0
|
triggered = 0
|
||||||
|
|
||||||
for inst_id, info in list(rules.items()):
|
for inst_id, info in list(rules.items()):
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
continue
|
continue
|
||||||
if inst_id in hedge_managed:
|
|
||||||
_mark_state(conn, inst_id, "idle")
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE options_trades
|
|
||||||
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
|
|
||||||
WHERE inst_id = ? AND status = 'open'
|
|
||||||
""",
|
|
||||||
(inst_id,),
|
|
||||||
)
|
|
||||||
_commit(conn)
|
|
||||||
continue
|
|
||||||
pos = pos_by_inst.get(inst_id)
|
pos = pos_by_inst.get(inst_id)
|
||||||
if not pos:
|
if not pos:
|
||||||
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
|
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
|
||||||
|
|||||||
+1997
-2108
File diff suppressed because it is too large
Load Diff
@@ -586,32 +586,11 @@ def _mark_option_legs_excluded(
|
|||||||
|
|
||||||
|
|
||||||
def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]:
|
def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||||
"""从本地 hedge_plans 导入已结束计划(计划级)."""
|
"""对冲模块已移除:不再导入对冲计划到复盘."""
|
||||||
init_options_review_tables(conn)
|
init_options_review_tables(conn)
|
||||||
from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans
|
return {"ok": True, "fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "removed": True}
|
||||||
|
|
||||||
|
|
||||||
init_hedge_plan_tables(conn)
|
|
||||||
plans = list_plans(conn, status="closed", limit=500)
|
|
||||||
inserted = updated = skipped = 0
|
|
||||||
for plan in plans:
|
|
||||||
legs = get_plan_legs(conn, int(plan["id"]))
|
|
||||||
action = upsert_hedge_plan_row(conn, plan, legs)
|
|
||||||
if action == "inserted":
|
|
||||||
inserted += 1
|
|
||||||
elif action == "updated":
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
last_id = max((int(p["id"]) for p in plans), default=0)
|
|
||||||
set_sync_state(conn, "hedge_last_sync_at", _now_str())
|
|
||||||
set_sync_state(conn, "hedge_last_plan_id", str(last_id))
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"fetched": len(plans),
|
|
||||||
"inserted": inserted,
|
|
||||||
"updated": updated,
|
|
||||||
"skipped": skipped,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sync_all_review_sources(
|
def sync_all_review_sources(
|
||||||
@@ -643,27 +622,28 @@ def ensure_local_review_synced(
|
|||||||
backfill_exchange_pnl: bool = True,
|
backfill_exchange_pnl: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
|
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
|
||||||
from lib.options.options_time_lib import migrate_options_utc_wall_clock_to_app
|
from lib.options.options_time_lib import (
|
||||||
|
migrate_options_utc_wall_clock_to_app,
|
||||||
|
repair_swapped_closed_at_signal_note,
|
||||||
|
)
|
||||||
|
|
||||||
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
|
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
|
||||||
|
note_swap = repair_swapped_closed_at_signal_note(conn)
|
||||||
if backfill_exchange_pnl and ex is not None:
|
if backfill_exchange_pnl and ex is not None:
|
||||||
try:
|
try:
|
||||||
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
|
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
|
||||||
from lib.hedge_plan.hedge_plan_settle_lib import (
|
|
||||||
backfill_hedge_option_legs_realized_pnl,
|
|
||||||
)
|
|
||||||
from lib.options.options_monitor_lib import (
|
from lib.options.options_monitor_lib import (
|
||||||
backfill_closed_options_realized_pnl_from_history,
|
backfill_closed_options_realized_pnl_from_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
hist = fetch_all_option_positions_history(ex, limit=200)
|
hist = fetch_all_option_positions_history(ex, limit=200)
|
||||||
backfill_closed_options_realized_pnl_from_history(conn, hist)
|
backfill_closed_options_realized_pnl_from_history(conn, hist)
|
||||||
backfill_hedge_option_legs_realized_pnl(conn, hist)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
|
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
|
||||||
if isinstance(out, dict):
|
if isinstance(out, dict):
|
||||||
out["ts_migrate"] = ts_migrate
|
out["ts_migrate"] = ts_migrate
|
||||||
|
out["note_swap"] = note_swap
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,452 +1,452 @@
|
|||||||
"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘)."""
|
"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, send_file
|
from flask import Flask, jsonify, request, send_file
|
||||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables
|
from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables
|
||||||
from lib.options.options_review_images_lib import (
|
from lib.options.options_review_images_lib import (
|
||||||
OPTIONS_REVIEW_UPLOAD_TFS,
|
OPTIONS_REVIEW_UPLOAD_TFS,
|
||||||
normalize_options_review_draft_id,
|
normalize_options_review_draft_id,
|
||||||
options_review_image_paths,
|
options_review_image_paths,
|
||||||
options_review_upload_dir,
|
options_review_upload_dir,
|
||||||
save_options_review_slot_file,
|
save_options_review_slot_file,
|
||||||
)
|
)
|
||||||
from lib.options.options_review_lib import (
|
from lib.options.options_review_lib import (
|
||||||
SOURCE_LABELS,
|
SOURCE_LABELS,
|
||||||
compute_review_stats,
|
compute_review_stats,
|
||||||
count_review_trades,
|
count_review_trades,
|
||||||
delete_review_entry,
|
delete_review_entry,
|
||||||
ensure_local_review_synced,
|
ensure_local_review_synced,
|
||||||
get_review_trade,
|
get_review_trade,
|
||||||
hide_review_trade,
|
hide_review_trade,
|
||||||
list_review_trades,
|
list_review_trades,
|
||||||
save_review_entry,
|
save_review_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _review_source_for_mode(requested: str | None) -> str | None:
|
def _review_source_for_mode(requested: str | None) -> str | None:
|
||||||
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
|
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
|
||||||
try:
|
try:
|
||||||
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
|
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
|
||||||
|
|
||||||
mode = get_okx_trade_mode()
|
mode = get_okx_trade_mode()
|
||||||
except Exception:
|
except Exception:
|
||||||
mode = "options"
|
mode = "options"
|
||||||
allowed = {
|
allowed = {
|
||||||
"options": "option_spot",
|
"options": "option_spot",
|
||||||
"perp_options": "perp_options",
|
"perp_options": "perp_options",
|
||||||
"options_options": "options_options",
|
"options_options": "options_options",
|
||||||
}.get(mode, "option_spot")
|
}.get(mode, "option_spot")
|
||||||
req = (requested or "").strip()
|
req = (requested or "").strip()
|
||||||
if not req:
|
if not req:
|
||||||
return allowed
|
return allowed
|
||||||
if req == allowed:
|
if req == allowed:
|
||||||
return allowed
|
return allowed
|
||||||
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
|
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
def attach_options_review_templates(app: Flask, repo_root: str) -> None:
|
def attach_options_review_templates(app: Flask, repo_root: str) -> None:
|
||||||
tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
|
tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
|
||||||
if not os.path.isdir(tpl_dir):
|
if not os.path.isdir(tpl_dir):
|
||||||
return
|
return
|
||||||
existing = app.jinja_loader
|
existing = app.jinja_loader
|
||||||
loaders = [FileSystemLoader(tpl_dir)]
|
loaders = [FileSystemLoader(tpl_dir)]
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if isinstance(existing, ChoiceLoader):
|
if isinstance(existing, ChoiceLoader):
|
||||||
loaders = list(existing.loaders) + loaders
|
loaders = list(existing.loaders) + loaders
|
||||||
else:
|
else:
|
||||||
loaders.insert(0, existing)
|
loaders.insert(0, existing)
|
||||||
app.jinja_loader = ChoiceLoader(loaders)
|
app.jinja_loader = ChoiceLoader(loaders)
|
||||||
|
|
||||||
|
|
||||||
def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None:
|
def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None:
|
||||||
attach_options_review_templates(app, repo_root)
|
attach_options_review_templates(app, repo_root)
|
||||||
cfg = {
|
cfg = {
|
||||||
"get_db": app_module.get_db,
|
"get_db": app_module.get_db,
|
||||||
"login_required": app_module.login_required,
|
"login_required": app_module.login_required,
|
||||||
"exchange_options": getattr(app_module, "exchange_options", None),
|
"exchange_options": getattr(app_module, "exchange_options", None),
|
||||||
"render_main_page": app_module.render_main_page,
|
"render_main_page": app_module.render_main_page,
|
||||||
"upload_folder": getattr(app_module, "UPLOAD_FOLDER", None)
|
"upload_folder": getattr(app_module, "UPLOAD_FOLDER", None)
|
||||||
or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"),
|
or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"),
|
||||||
"options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)),
|
"options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)),
|
||||||
"app_module": app_module,
|
"app_module": app_module,
|
||||||
}
|
}
|
||||||
app.extensions["options_review_cfg"] = cfg
|
app.extensions["options_review_cfg"] = cfg
|
||||||
register_options_review_routes(app, cfg, repo_root)
|
register_options_review_routes(app, cfg, repo_root)
|
||||||
|
|
||||||
|
|
||||||
def _require_ex(cfg: dict[str, Any]):
|
def _require_ex(cfg: dict[str, Any]):
|
||||||
from lib.exchange.okx_options_lib import options_api_ready
|
from lib.exchange.okx_options_lib import options_api_ready
|
||||||
|
|
||||||
if not cfg.get("options_enabled"):
|
if not cfg.get("options_enabled"):
|
||||||
return None, "期权模块未启用"
|
return None, "期权模块未启用"
|
||||||
ex = cfg.get("exchange_options")
|
ex = cfg.get("exchange_options")
|
||||||
ok, reason = options_api_ready(ex)
|
ok, reason = options_api_ready(ex)
|
||||||
if not ok:
|
if not ok:
|
||||||
return None, reason or "期权 API 未配置"
|
return None, reason or "期权 API 未配置"
|
||||||
return ex, ""
|
return ex, ""
|
||||||
|
|
||||||
|
|
||||||
def _truthy(raw: Any) -> bool:
|
def _truthy(raw: Any) -> bool:
|
||||||
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
|
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
|
||||||
|
|
||||||
|
|
||||||
def _prepare_review_entry_images(
|
def _prepare_review_entry_images(
|
||||||
conn: Any,
|
conn: Any,
|
||||||
trade_id: int,
|
trade_id: int,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
|
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
|
||||||
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
|
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
|
||||||
from lib.options.options_review_images_lib import parse_options_review_images_json
|
from lib.options.options_review_images_lib import parse_options_review_images_json
|
||||||
|
|
||||||
images = data.get("images") if isinstance(data.get("images"), list) else []
|
images = data.get("images") if isinstance(data.get("images"), list) else []
|
||||||
cleaned: list[dict[str, str]] = []
|
cleaned: list[dict[str, str]] = []
|
||||||
for item in images:
|
for item in images:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
tf = str(item.get("tf") or "").strip()
|
tf = str(item.get("tf") or "").strip()
|
||||||
file = str(item.get("file") or "").strip()
|
file = str(item.get("file") or "").strip()
|
||||||
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
|
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||||
continue
|
continue
|
||||||
cleaned.append({"tf": tf, "file": file})
|
cleaned.append({"tf": tf, "file": file})
|
||||||
|
|
||||||
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
|
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
|
||||||
if not want_chart:
|
if not want_chart:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
|
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
|
||||||
(int(trade_id),),
|
(int(trade_id),),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
for item in parse_options_review_images_json(row["images_json"]):
|
for item in parse_options_review_images_json(row["images_json"]):
|
||||||
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
|
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||||
cleaned.insert(0, dict(item))
|
cleaned.insert(0, dict(item))
|
||||||
break
|
break
|
||||||
|
|
||||||
out = dict(data)
|
out = dict(data)
|
||||||
out["images"] = cleaned
|
out["images"] = cleaned
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _maybe_schedule_options_review_chart(
|
def _maybe_schedule_options_review_chart(
|
||||||
cfg: dict[str, Any],
|
cfg: dict[str, Any],
|
||||||
trade: dict[str, Any],
|
trade: dict[str, Any],
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
|
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
|
||||||
if str(trade.get("source_type") or "").strip() != "option_spot":
|
if str(trade.get("source_type") or "").strip() != "option_spot":
|
||||||
return False
|
return False
|
||||||
images = data.get("images") if isinstance(data.get("images"), list) else []
|
images = data.get("images") if isinstance(data.get("images"), list) else []
|
||||||
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
|
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
|
||||||
if has_manual or not _truthy(data.get("journal_exchange_chart")):
|
if has_manual or not _truthy(data.get("journal_exchange_chart")):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
app_module = cfg.get("app_module")
|
app_module = cfg.get("app_module")
|
||||||
if app_module is None:
|
if app_module is None:
|
||||||
return False
|
return False
|
||||||
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
|
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
|
||||||
return False
|
return False
|
||||||
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
|
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
|
||||||
if not callable(generate_fn):
|
if not callable(generate_fn):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from lib.instance.journal_chart_lib import (
|
from lib.instance.journal_chart_lib import (
|
||||||
parse_journal_chart_anchor,
|
parse_journal_chart_anchor,
|
||||||
parse_journal_chart_limit,
|
parse_journal_chart_limit,
|
||||||
parse_journal_chart_timeframes,
|
parse_journal_chart_timeframes,
|
||||||
)
|
)
|
||||||
from lib.options.options_review_chart_async_lib import (
|
from lib.options.options_review_chart_async_lib import (
|
||||||
schedule_options_review_exchange_chart,
|
schedule_options_review_exchange_chart,
|
||||||
underlying_to_okx_perp_symbol,
|
underlying_to_okx_perp_symbol,
|
||||||
)
|
)
|
||||||
from lib.options.options_review_images_lib import options_review_upload_dir
|
from lib.options.options_review_images_lib import options_review_upload_dir
|
||||||
from lib.options.options_review_lib import _now_str
|
from lib.options.options_review_lib import _now_str
|
||||||
|
|
||||||
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
|
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
|
||||||
journal_tfs = parse_journal_chart_timeframes(
|
journal_tfs = parse_journal_chart_timeframes(
|
||||||
data.get("journal_chart_tf1"),
|
data.get("journal_chart_tf1"),
|
||||||
data.get("journal_chart_tf2"),
|
data.get("journal_chart_tf2"),
|
||||||
fallback_tfs or None,
|
fallback_tfs or None,
|
||||||
)
|
)
|
||||||
journal_limit = parse_journal_chart_limit(
|
journal_limit = parse_journal_chart_limit(
|
||||||
data.get("journal_chart_limit"),
|
data.get("journal_chart_limit"),
|
||||||
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
|
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
|
||||||
)
|
)
|
||||||
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
|
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
|
||||||
|
|
||||||
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
|
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
|
||||||
exchange_symbol = underlying_to_okx_perp_symbol(
|
exchange_symbol = underlying_to_okx_perp_symbol(
|
||||||
underlying,
|
underlying,
|
||||||
normalize_symbol_input_fn=app_module.normalize_symbol_input,
|
normalize_symbol_input_fn=app_module.normalize_symbol_input,
|
||||||
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
|
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
|
||||||
)
|
)
|
||||||
if not exchange_symbol:
|
if not exchange_symbol:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
|
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
|
||||||
if not callable(to_ms):
|
if not callable(to_ms):
|
||||||
return False
|
return False
|
||||||
entry_ts = to_ms(trade.get("opened_at"))
|
entry_ts = to_ms(trade.get("opened_at"))
|
||||||
exit_ts = to_ms(trade.get("closed_at"))
|
exit_ts = to_ms(trade.get("closed_at"))
|
||||||
if entry_ts is None and exit_ts is None:
|
if entry_ts is None and exit_ts is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now_ms = None
|
now_ms = None
|
||||||
app_now = getattr(app_module, "app_now", None)
|
app_now = getattr(app_module, "app_now", None)
|
||||||
if callable(app_now):
|
if callable(app_now):
|
||||||
try:
|
try:
|
||||||
now_ms = int(app_now().timestamp() * 1000)
|
now_ms = int(app_now().timestamp() * 1000)
|
||||||
except Exception:
|
except Exception:
|
||||||
now_ms = None
|
now_ms = None
|
||||||
|
|
||||||
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
|
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
|
||||||
upload_folder = options_review_upload_dir(cfg["upload_folder"])
|
upload_folder = options_review_upload_dir(cfg["upload_folder"])
|
||||||
schedule_options_review_exchange_chart(
|
schedule_options_review_exchange_chart(
|
||||||
trade_id=int(trade["id"]),
|
trade_id=int(trade["id"]),
|
||||||
exchange_symbol=exchange_symbol,
|
exchange_symbol=exchange_symbol,
|
||||||
title_prefix=f"{underlying or 'OPT'} {inst} review",
|
title_prefix=f"{underlying or 'OPT'} {inst} review",
|
||||||
journal_tfs=journal_tfs,
|
journal_tfs=journal_tfs,
|
||||||
journal_limit=journal_limit,
|
journal_limit=journal_limit,
|
||||||
marker_payload={
|
marker_payload={
|
||||||
"entry_ts_ms": entry_ts,
|
"entry_ts_ms": entry_ts,
|
||||||
"exit_ts_ms": exit_ts,
|
"exit_ts_ms": exit_ts,
|
||||||
# 只按时间钉进/出场,不传价格与止损
|
# 只按时间钉进/出场,不传价格与止损
|
||||||
"chart_anchor": chart_anchor,
|
"chart_anchor": chart_anchor,
|
||||||
"now_ts_ms": now_ms,
|
"now_ts_ms": now_ms,
|
||||||
},
|
},
|
||||||
upload_folder=upload_folder,
|
upload_folder=upload_folder,
|
||||||
generate_chart_fn=generate_fn,
|
generate_chart_fn=generate_fn,
|
||||||
get_db_fn=cfg["get_db"],
|
get_db_fn=cfg["get_db"],
|
||||||
now_str_fn=_now_str,
|
now_str_fn=_now_str,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
|
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
|
||||||
lr = cfg["login_required"]
|
lr = cfg["login_required"]
|
||||||
|
|
||||||
@app.route("/options/review")
|
@app.route("/options/review")
|
||||||
@lr
|
@lr
|
||||||
def options_review_page():
|
def options_review_page():
|
||||||
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
|
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
|
||||||
|
|
||||||
redir = redirect_to_embed_shell_if_enabled("options_review")
|
redir = redirect_to_embed_shell_if_enabled("options_review")
|
||||||
if redir is not None:
|
if redir is not None:
|
||||||
return redir
|
return redir
|
||||||
return cfg["render_main_page"]("options_review")
|
return cfg["render_main_page"]("options_review")
|
||||||
|
|
||||||
@app.route("/static/options_review.js")
|
@app.route("/static/options_review.js")
|
||||||
@lr
|
@lr
|
||||||
def static_options_review_js():
|
def static_options_review_js():
|
||||||
path = os.path.join(repo_root, "lib", "common", "static", "options_review.js")
|
path = os.path.join(repo_root, "lib", "common", "static", "options_review.js")
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
return ("not found", 404)
|
return ("not found", 404)
|
||||||
return send_file(path, mimetype="application/javascript; charset=utf-8")
|
return send_file(path, mimetype="application/javascript; charset=utf-8")
|
||||||
|
|
||||||
@app.route("/static/images/options_journal/<path:filename>")
|
@app.route("/static/images/options_journal/<path:filename>")
|
||||||
def static_options_review_image(filename: str):
|
def static_options_review_image(filename: str):
|
||||||
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
|
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
|
||||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||||
safe = os.path.basename(filename or "")
|
safe = os.path.basename(filename or "")
|
||||||
path = os.path.join(folder, safe)
|
path = os.path.join(folder, safe)
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
|
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
|
||||||
root = os.path.abspath(cfg["upload_folder"] or "")
|
root = os.path.abspath(cfg["upload_folder"] or "")
|
||||||
alt = os.path.join(root, safe)
|
alt = os.path.join(root, safe)
|
||||||
if os.path.isfile(alt):
|
if os.path.isfile(alt):
|
||||||
path = alt
|
path = alt
|
||||||
else:
|
else:
|
||||||
return ("not found", 404)
|
return ("not found", 404)
|
||||||
return send_file(path)
|
return send_file(path)
|
||||||
|
|
||||||
@app.route("/api/options/review/sync", methods=["POST"])
|
@app.route("/api/options/review/sync", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_sync():
|
def api_options_review_sync():
|
||||||
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
|
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
init_options_review_tables(conn)
|
init_options_review_tables(conn)
|
||||||
ex, _err = _require_ex(cfg)
|
ex, _err = _require_ex(cfg)
|
||||||
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/review/trades")
|
@app.route("/api/options/review/trades")
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_trades():
|
def api_options_review_trades():
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
# 翻页可跳过同步,仅刷新当前卡片列表
|
# 翻页可跳过同步,仅刷新当前卡片列表
|
||||||
do_sync = (request.args.get("sync") or "1").strip().lower() not in (
|
do_sync = (request.args.get("sync") or "1").strip().lower() not in (
|
||||||
"0",
|
"0",
|
||||||
"false",
|
"false",
|
||||||
"no",
|
"no",
|
||||||
)
|
)
|
||||||
if do_sync:
|
if do_sync:
|
||||||
ex, _err = _require_ex(cfg)
|
ex, _err = _require_ex(cfg)
|
||||||
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
filt = dict(
|
filt = dict(
|
||||||
source_type=_review_source_for_mode(request.args.get("source_type")),
|
source_type=_review_source_for_mode(request.args.get("source_type")),
|
||||||
underlying=(request.args.get("underlying") or "").strip() or None,
|
underlying=(request.args.get("underlying") or "").strip() or None,
|
||||||
opt_type=(request.args.get("opt_type") or "").strip() or None,
|
opt_type=(request.args.get("opt_type") or "").strip() or None,
|
||||||
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
|
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
|
||||||
q=(request.args.get("q") or "").strip() or None,
|
q=(request.args.get("q") or "").strip() or None,
|
||||||
reviewed=(request.args.get("reviewed") or "").strip() or None,
|
reviewed=(request.args.get("reviewed") or "").strip() or None,
|
||||||
include_hedge_legs=(request.args.get("include_hedge_legs") or "")
|
include_hedge_legs=(request.args.get("include_hedge_legs") or "")
|
||||||
.strip()
|
.strip()
|
||||||
.lower()
|
.lower()
|
||||||
in ("1", "true", "yes"),
|
in ("1", "true", "yes"),
|
||||||
closed_from=(request.args.get("closed_from") or "").strip() or None,
|
closed_from=(request.args.get("closed_from") or "").strip() or None,
|
||||||
closed_to=(request.args.get("closed_to") or "").strip() or None,
|
closed_to=(request.args.get("closed_to") or "").strip() or None,
|
||||||
)
|
)
|
||||||
limit = min(500, max(1, int(request.args.get("limit") or 200)))
|
limit = min(500, max(1, int(request.args.get("limit") or 200)))
|
||||||
offset = max(0, int(request.args.get("offset") or 0))
|
offset = max(0, int(request.args.get("offset") or 0))
|
||||||
total = count_review_trades(conn, **filt)
|
total = count_review_trades(conn, **filt)
|
||||||
items = list_review_trades(conn, **filt, limit=limit, offset=offset)
|
items = list_review_trades(conn, **filt, limit=limit, offset=offset)
|
||||||
pages = max(1, (total + limit - 1) // limit) if total else 1
|
pages = max(1, (total + limit - 1) // limit) if total else 1
|
||||||
page = (offset // limit) + 1 if limit else 1
|
page = (offset // limit) + 1 if limit else 1
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"trades": items,
|
"trades": items,
|
||||||
"source_labels": SOURCE_LABELS,
|
"source_labels": SOURCE_LABELS,
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
"page": page,
|
"page": page,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/review/trades/<int:trade_id>")
|
@app.route("/api/options/review/trades/<int:trade_id>")
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_trade_detail(trade_id: int):
|
def api_options_review_trade_detail(trade_id: int):
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
item = get_review_trade(conn, trade_id)
|
item = get_review_trade(conn, trade_id)
|
||||||
if not item:
|
if not item:
|
||||||
return jsonify({"ok": False, "msg": "未找到"}), 404
|
return jsonify({"ok": False, "msg": "未找到"}), 404
|
||||||
return jsonify({"ok": True, "trade": item})
|
return jsonify({"ok": True, "trade": item})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/review/entry", methods=["POST"])
|
@app.route("/api/options/review/entry", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_entry_save():
|
def api_options_review_entry_save():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
try:
|
try:
|
||||||
trade_id = int(data.get("trade_id"))
|
trade_id = int(data.get("trade_id"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
|
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
data = _prepare_review_entry_images(conn, trade_id, data)
|
data = _prepare_review_entry_images(conn, trade_id, data)
|
||||||
out = save_review_entry(conn, trade_id, data)
|
out = save_review_entry(conn, trade_id, data)
|
||||||
if out.get("ok"):
|
if out.get("ok"):
|
||||||
conn.commit()
|
conn.commit()
|
||||||
chart_queued = _maybe_schedule_options_review_chart(
|
chart_queued = _maybe_schedule_options_review_chart(
|
||||||
cfg, out.get("trade") or {}, data
|
cfg, out.get("trade") or {}, data
|
||||||
)
|
)
|
||||||
if chart_queued:
|
if chart_queued:
|
||||||
out = dict(out)
|
out = dict(out)
|
||||||
out["chart_queued"] = True
|
out["chart_queued"] = True
|
||||||
return jsonify(out), (200 if out.get("ok") else 400)
|
return jsonify(out), (200 if out.get("ok") else 400)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/review/trades/<int:trade_id>", methods=["DELETE"])
|
@app.route("/api/options/review/trades/<int:trade_id>", methods=["DELETE"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_trade_hide(trade_id: int):
|
def api_options_review_trade_hide(trade_id: int):
|
||||||
"""从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
|
"""从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
out = hide_review_trade(conn, trade_id)
|
out = hide_review_trade(conn, trade_id)
|
||||||
if out.get("ok"):
|
if out.get("ok"):
|
||||||
entry = out.get("entry") or {}
|
entry = out.get("entry") or {}
|
||||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||||
for path in options_review_image_paths(entry, folder):
|
for path in options_review_image_paths(entry, folder):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify(out), (200 if out.get("ok") else 400)
|
return jsonify(out), (200 if out.get("ok") else 400)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/review/entry/<int:trade_id>", methods=["DELETE"])
|
@app.route("/api/options/review/entry/<int:trade_id>", methods=["DELETE"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_entry_delete(trade_id: int):
|
def api_options_review_entry_delete(trade_id: int):
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
out = delete_review_entry(conn, trade_id)
|
out = delete_review_entry(conn, trade_id)
|
||||||
if out.get("ok"):
|
if out.get("ok"):
|
||||||
entry = out.get("entry") or {}
|
entry = out.get("entry") or {}
|
||||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||||
for path in options_review_image_paths(entry, folder):
|
for path in options_review_image_paths(entry, folder):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify(out), (200 if out.get("ok") else 400)
|
return jsonify(out), (200 if out.get("ok") else 400)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/options/review/upload_slot", methods=["POST"])
|
@app.route("/api/options/review/upload_slot", methods=["POST"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_upload_slot():
|
def api_options_review_upload_slot():
|
||||||
draft_id = normalize_options_review_draft_id(
|
draft_id = normalize_options_review_draft_id(
|
||||||
request.form.get("draft_id") if request.form else None
|
request.form.get("draft_id") if request.form else None
|
||||||
)
|
)
|
||||||
tf = str((request.form.get("tf") if request.form else None) or "").strip()
|
tf = str((request.form.get("tf") if request.form else None) or "").strip()
|
||||||
if not draft_id:
|
if not draft_id:
|
||||||
return jsonify({"ok": False, "error": "invalid draft_id"}), 400
|
return jsonify({"ok": False, "error": "invalid draft_id"}), 400
|
||||||
if tf not in OPTIONS_REVIEW_UPLOAD_TFS:
|
if tf not in OPTIONS_REVIEW_UPLOAD_TFS:
|
||||||
return jsonify({"ok": False, "error": "invalid tf"}), 400
|
return jsonify({"ok": False, "error": "invalid tf"}), 400
|
||||||
f = request.files.get("file") if request.files else None
|
f = request.files.get("file") if request.files else None
|
||||||
if not f or not getattr(f, "filename", None):
|
if not f or not getattr(f, "filename", None):
|
||||||
return jsonify({"ok": False, "error": "no file"}), 400
|
return jsonify({"ok": False, "error": "no file"}), 400
|
||||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||||
item = save_options_review_slot_file(
|
item = save_options_review_slot_file(
|
||||||
f, draft_id, tf, folder, secure_filename_fn=secure_filename
|
f, draft_id, tf, folder, secure_filename_fn=secure_filename
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
return jsonify({"ok": False, "error": "save failed"}), 500
|
return jsonify({"ok": False, "error": "save failed"}), 500
|
||||||
return jsonify({"ok": True, "tf": tf, "file": item["file"]})
|
return jsonify({"ok": True, "tf": tf, "file": item["file"]})
|
||||||
|
|
||||||
@app.route("/api/options/review/stats")
|
@app.route("/api/options/review/stats")
|
||||||
@lr
|
@lr
|
||||||
def api_options_review_stats():
|
def api_options_review_stats():
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
ex, _err = _require_ex(cfg)
|
ex, _err = _require_ex(cfg)
|
||||||
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
stats = compute_review_stats(
|
stats = compute_review_stats(
|
||||||
conn,
|
conn,
|
||||||
source_type=_review_source_for_mode(request.args.get("source_type")),
|
source_type=_review_source_for_mode(request.args.get("source_type")),
|
||||||
underlying=(request.args.get("underlying") or "").strip() or None,
|
underlying=(request.args.get("underlying") or "").strip() or None,
|
||||||
include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower()
|
include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower()
|
||||||
in ("1", "true", "yes"),
|
in ("1", "true", "yes"),
|
||||||
closed_from=(request.args.get("closed_from") or "").strip() or None,
|
closed_from=(request.args.get("closed_from") or "").strip() or None,
|
||||||
closed_to=(request.args.get("closed_to") or "").strip() or None,
|
closed_to=(request.args.get("closed_to") or "").strip() or None,
|
||||||
require_strategy=(request.args.get("require_strategy") or "").strip().lower()
|
require_strategy=(request.args.get("require_strategy") or "").strip().lower()
|
||||||
in ("1", "true", "yes"),
|
in ("1", "true", "yes"),
|
||||||
)
|
)
|
||||||
stats["source_types"] = list(SOURCE_TYPES)
|
stats["source_types"] = list(SOURCE_TYPES)
|
||||||
stats["source_labels"] = SOURCE_LABELS
|
stats["source_labels"] = SOURCE_LABELS
|
||||||
return jsonify(stats)
|
return jsonify(stats)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
+474
-501
@@ -1,501 +1,474 @@
|
|||||||
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
|
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables
|
from lib.options.options_db import init_options_tables
|
||||||
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: Any) -> float | None:
|
def _safe_float(v: Any) -> float | None:
|
||||||
if v is None or v == "":
|
if v is None or v == "":
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return float(v)
|
return float(v)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
||||||
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
||||||
|
|
||||||
inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
|
inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
|
||||||
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
|
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
|
||||||
if mark is None:
|
if mark is None:
|
||||||
mark = fetch_option_mark_px(ex, inst_id)
|
mark = fetch_option_mark_px(ex, inst_id)
|
||||||
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
|
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
|
||||||
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
|
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
|
||||||
if not opt_type or strike is None:
|
if not opt_type or strike is None:
|
||||||
pt, ps = option_fields_from_inst_id(inst_id)
|
pt, ps = option_fields_from_inst_id(inst_id)
|
||||||
opt_type = opt_type or pt
|
opt_type = opt_type or pt
|
||||||
if strike is None:
|
if strike is None:
|
||||||
strike = ps
|
strike = ps
|
||||||
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
|
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
|
||||||
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
|
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
|
||||||
|
|
||||||
|
|
||||||
def ensure_target_tables(conn: sqlite3.Connection) -> None:
|
def ensure_target_tables(conn: sqlite3.Connection) -> None:
|
||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS options_target_monitors (
|
CREATE TABLE IF NOT EXISTS options_target_monitors (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
inst_id TEXT NOT NULL,
|
inst_id TEXT NOT NULL,
|
||||||
underlying TEXT,
|
underlying TEXT,
|
||||||
opt_type TEXT,
|
opt_type TEXT,
|
||||||
target_index REAL NOT NULL,
|
target_index REAL NOT NULL,
|
||||||
trade_id INTEGER,
|
trade_id INTEGER,
|
||||||
sheets INTEGER,
|
sheets INTEGER,
|
||||||
status TEXT DEFAULT 'active',
|
status TEXT DEFAULT 'active',
|
||||||
trigger_idx REAL,
|
trigger_idx REAL,
|
||||||
close_ord_id TEXT,
|
close_ord_id TEXT,
|
||||||
message TEXT,
|
message TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
triggered_at TIMESTAMP
|
triggered_at TIMESTAMP
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
|
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
|
||||||
ON options_target_monitors(status)
|
ON options_target_monitors(status)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
||||||
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
|
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
|
||||||
ot = (opt_type or "").strip().upper()
|
ot = (opt_type or "").strip().upper()
|
||||||
if ot == "P":
|
if ot == "P":
|
||||||
return index_px <= target_index
|
return index_px <= target_index
|
||||||
return index_px >= target_index
|
return index_px >= target_index
|
||||||
|
|
||||||
|
|
||||||
def upsert_target_monitor(
|
def upsert_target_monitor(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
target_index: float,
|
target_index: float,
|
||||||
underlying: str | None = None,
|
underlying: str | None = None,
|
||||||
opt_type: str | None = None,
|
opt_type: str | None = None,
|
||||||
trade_id: int | None = None,
|
trade_id: int | None = None,
|
||||||
sheets: int | None = None,
|
sheets: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
inst_id = (inst_id or "").strip()
|
inst_id = (inst_id or "").strip()
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
return {"ok": False, "msg": "缺少 inst_id"}
|
return {"ok": False, "msg": "缺少 inst_id"}
|
||||||
if target_index is None or float(target_index) <= 0:
|
if target_index is None or float(target_index) <= 0:
|
||||||
return {"ok": False, "msg": "目标位无效"}
|
return {"ok": False, "msg": "目标位无效"}
|
||||||
target_index = float(target_index)
|
target_index = float(target_index)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM options_target_monitors
|
SELECT id FROM options_target_monitors
|
||||||
WHERE inst_id = ? AND status IN ('active', 'closing')
|
WHERE inst_id = ? AND status IN ('active', 'closing')
|
||||||
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
|
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(inst_id,),
|
(inst_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET target_index = ?,
|
SET target_index = ?,
|
||||||
underlying = COALESCE(?, underlying),
|
underlying = COALESCE(?, underlying),
|
||||||
opt_type = COALESCE(?, opt_type),
|
opt_type = COALESCE(?, opt_type),
|
||||||
trade_id = COALESCE(?, trade_id),
|
trade_id = COALESCE(?, trade_id),
|
||||||
sheets = COALESCE(?, sheets),
|
sheets = COALESCE(?, sheets),
|
||||||
status = 'active',
|
status = 'active',
|
||||||
trigger_idx = NULL,
|
trigger_idx = NULL,
|
||||||
close_ord_id = NULL,
|
close_ord_id = NULL,
|
||||||
message = NULL,
|
message = NULL,
|
||||||
triggered_at = NULL
|
triggered_at = NULL
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
||||||
)
|
)
|
||||||
mon_id = int(row["id"])
|
mon_id = int(row["id"])
|
||||||
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
|
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET status = 'cancelled', message = '被新目标位覆盖'
|
SET status = 'cancelled', message = '被新目标位覆盖'
|
||||||
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
||||||
""",
|
""",
|
||||||
(inst_id, mon_id),
|
(inst_id, mon_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO options_target_monitors
|
INSERT INTO options_target_monitors
|
||||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
|
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||||
""",
|
""",
|
||||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
|
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
|
||||||
)
|
)
|
||||||
mon_id = int(cur.lastrowid)
|
mon_id = int(cur.lastrowid)
|
||||||
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
|
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
|
||||||
|
|
||||||
|
|
||||||
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
if monitor_id is not None:
|
if monitor_id is not None:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET status = 'cancelled', message = '手动取消'
|
SET status = 'cancelled', message = '手动取消'
|
||||||
WHERE id = ? AND status IN ('active', 'closing')
|
WHERE id = ? AND status IN ('active', 'closing')
|
||||||
""",
|
""",
|
||||||
(int(monitor_id),),
|
(int(monitor_id),),
|
||||||
)
|
)
|
||||||
return int(cur.rowcount or 0)
|
return int(cur.rowcount or 0)
|
||||||
if inst_id:
|
if inst_id:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET status = 'cancelled', message = '手动取消'
|
SET status = 'cancelled', message = '手动取消'
|
||||||
WHERE inst_id = ? AND status IN ('active', 'closing')
|
WHERE inst_id = ? AND status IN ('active', 'closing')
|
||||||
""",
|
""",
|
||||||
(inst_id.strip(),),
|
(inst_id.strip(),),
|
||||||
)
|
)
|
||||||
return int(cur.rowcount or 0)
|
return int(cur.rowcount or 0)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": int(r["id"]),
|
"id": int(r["id"]),
|
||||||
"inst_id": r["inst_id"],
|
"inst_id": r["inst_id"],
|
||||||
"underlying": r["underlying"],
|
"underlying": r["underlying"],
|
||||||
"opt_type": r["opt_type"],
|
"opt_type": r["opt_type"],
|
||||||
"target_index": _safe_float(r["target_index"]),
|
"target_index": _safe_float(r["target_index"]),
|
||||||
"trade_id": r["trade_id"],
|
"trade_id": r["trade_id"],
|
||||||
"sheets": r["sheets"],
|
"sheets": r["sheets"],
|
||||||
"status": r["status"],
|
"status": r["status"],
|
||||||
"message": r["message"],
|
"message": r["message"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
||||||
status, message, created_at
|
status, message, created_at
|
||||||
FROM options_target_monitors
|
FROM options_target_monitors
|
||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_target(r) for r in rows]
|
return [_row_to_target(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
||||||
status, message, created_at
|
status, message, created_at
|
||||||
FROM options_target_monitors
|
FROM options_target_monitors
|
||||||
WHERE status = 'closing'
|
WHERE status = 'closing'
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_target(r) for r in rows]
|
return [_row_to_target(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||||
"""UI/持仓挂载:active 与 closing 都算进行中."""
|
"""UI/持仓挂载:active 与 closing 都算进行中."""
|
||||||
out: dict[str, dict[str, Any]] = {}
|
out: dict[str, dict[str, Any]] = {}
|
||||||
for t in list_closing_targets(conn) + list_active_targets(conn):
|
for t in list_closing_targets(conn) + list_active_targets(conn):
|
||||||
inst = str(t.get("inst_id") or "")
|
inst = str(t.get("inst_id") or "")
|
||||||
if inst and inst not in out:
|
if inst and inst not in out:
|
||||||
out[inst] = t
|
out[inst] = t
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def mark_monitor(
|
def mark_monitor(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
monitor_id: int,
|
monitor_id: int,
|
||||||
*,
|
*,
|
||||||
status: str,
|
status: str,
|
||||||
trigger_idx: float | None = None,
|
trigger_idx: float | None = None,
|
||||||
close_ord_id: str | None = None,
|
close_ord_id: str | None = None,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET status = ?,
|
SET status = ?,
|
||||||
trigger_idx = COALESCE(?, trigger_idx),
|
trigger_idx = COALESCE(?, trigger_idx),
|
||||||
close_ord_id = COALESCE(?, close_ord_id),
|
close_ord_id = COALESCE(?, close_ord_id),
|
||||||
message = COALESCE(?, message),
|
message = COALESCE(?, message),
|
||||||
triggered_at = CASE
|
triggered_at = CASE
|
||||||
WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
|
WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
|
||||||
ELSE triggered_at
|
ELSE triggered_at
|
||||||
END
|
END
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
|
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def cancel_orphans_without_position(
|
def cancel_orphans_without_position(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
live_inst_ids: set[str],
|
live_inst_ids: set[str],
|
||||||
) -> int:
|
) -> int:
|
||||||
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
|
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = list_active_targets(conn) + list_closing_targets(conn)
|
rows = list_active_targets(conn) + list_closing_targets(conn)
|
||||||
n = 0
|
n = 0
|
||||||
for t in rows:
|
for t in rows:
|
||||||
inst = str(t.get("inst_id") or "")
|
inst = str(t.get("inst_id") or "")
|
||||||
if inst and inst not in live_inst_ids:
|
if inst and inst not in live_inst_ids:
|
||||||
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
|
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _commit_monitor(conn: sqlite3.Connection) -> None:
|
def _commit_monitor(conn: sqlite3.Connection) -> None:
|
||||||
"""状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
|
"""状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
|
||||||
try:
|
try:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def close_option_by_bid_depth(
|
def close_option_by_bid_depth(
|
||||||
cfg: dict[str, Any],
|
cfg: dict[str, Any],
|
||||||
ex: Any,
|
ex: Any,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
*,
|
*,
|
||||||
sheets: int | None = None,
|
sheets: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
|
"""目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
|
||||||
from lib.options.options_close_exec_lib import close_option_by_bid1
|
from lib.options.options_close_exec_lib import close_option_by_bid1
|
||||||
|
|
||||||
return close_option_by_bid1(
|
return close_option_by_bid1(
|
||||||
cfg,
|
cfg,
|
||||||
ex,
|
ex,
|
||||||
inst_id,
|
inst_id,
|
||||||
sheets=sheets,
|
sheets=sheets,
|
||||||
require_recycle_gate=True,
|
require_recycle_gate=True,
|
||||||
signal_note="目标位平仓",
|
signal_note="目标位平仓",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_target_close(
|
def _notify_target_close(
|
||||||
cfg: dict[str, Any] | None,
|
cfg: dict[str, Any] | None,
|
||||||
send_wechat: Callable[[str], None] | None,
|
send_wechat: Callable[[str], None] | None,
|
||||||
*,
|
*,
|
||||||
account_label: str,
|
account_label: str,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
target: float,
|
target: float,
|
||||||
idx: float,
|
idx: float,
|
||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
conn: Any = None,
|
conn: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
||||||
from lib.options.options_notify_lib import resolve_premium_ccy
|
from lib.options.options_notify_lib import resolve_premium_ccy
|
||||||
|
|
||||||
ccy = resolve_premium_ccy(inst_id=inst_id)
|
ccy = resolve_premium_ccy(inst_id=inst_id)
|
||||||
if result.get("fully_closed") or result.get("already_flat"):
|
if result.get("fully_closed") or result.get("already_flat"):
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
try:
|
try:
|
||||||
from lib.options.options_notify_lib import notify_options_close
|
from lib.options.options_notify_lib import notify_options_close
|
||||||
|
|
||||||
notify_options_close(
|
notify_options_close(
|
||||||
cfg,
|
cfg,
|
||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
reason="目标位平仓",
|
reason="目标位平仓",
|
||||||
sheets=result.get("submitted_sheets"),
|
sheets=result.get("submitted_sheets"),
|
||||||
premium_received=result.get("premium_received"),
|
premium_received=result.get("premium_received"),
|
||||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||||
target_index=target,
|
target_index=target,
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
premium_ccy=ccy,
|
premium_ccy=ccy,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not send_wechat:
|
if not send_wechat:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
recv = result.get("premium_received")
|
recv = result.get("premium_received")
|
||||||
send_wechat(
|
send_wechat(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
"【OKX期权·目标位平仓】",
|
"【OKX期权·目标位平仓】",
|
||||||
f"账户:{account_label}",
|
f"账户:{account_label}",
|
||||||
f"合约:{inst_id}",
|
f"合约:{inst_id}",
|
||||||
f"目标指数:{target:g}",
|
f"目标指数:{target:g}",
|
||||||
f"触发指数:{idx:g}",
|
f"触发指数:{idx:g}",
|
||||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||||
f"预估收回:{recv if recv is not None else '—'} {ccy}",
|
f"预估收回:{recv if recv is not None else '—'} {ccy}",
|
||||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _result_fully_done(result: dict[str, Any]) -> bool:
|
def _result_fully_done(result: dict[str, Any]) -> bool:
|
||||||
if result.get("already_flat"):
|
if result.get("already_flat"):
|
||||||
return True
|
return True
|
||||||
if result.get("fully_closed"):
|
if result.get("fully_closed"):
|
||||||
return True
|
return True
|
||||||
remaining = result.get("remaining_sheets")
|
remaining = result.get("remaining_sheets")
|
||||||
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
|
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def run_options_target_closes(
|
def run_options_target_closes(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
positions: list[dict[str, Any]],
|
positions: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
close_fn: Callable[[str], dict[str, Any]],
|
close_fn: Callable[[str], dict[str, Any]],
|
||||||
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
||||||
send_wechat: Callable[[str], None] | None = None,
|
send_wechat: Callable[[str], None] | None = None,
|
||||||
account_label: str = "OKX期权",
|
account_label: str = "OKX期权",
|
||||||
cfg: dict[str, Any] | None = None,
|
cfg: dict[str, Any] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
扫描 active 目标委托;指数到位后限价平仓.
|
扫描 active 目标委托;指数到位后限价平仓.
|
||||||
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
||||||
未完全成交进入 closing,仅重试平仓不再推送.
|
未完全成交进入 closing,仅重试平仓不再推送.
|
||||||
返回本次新触发(并推送)的条数.
|
返回本次新触发(并推送)的条数.
|
||||||
"""
|
"""
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||||
live_ids = {k for k in pos_by_inst if k}
|
live_ids = {k for k in pos_by_inst if k}
|
||||||
hedge_managed: set[str] = set()
|
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
|
||||||
try:
|
_commit_monitor(conn)
|
||||||
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
|
|
||||||
|
# 先处理已挂单等待成交的,绝不再发微信
|
||||||
init_hedge_plan_tables(conn)
|
for mon in list_closing_targets(conn):
|
||||||
hedge_managed = active_hedge_option_inst_ids(conn)
|
inst_id = str(mon.get("inst_id") or "")
|
||||||
except Exception:
|
if not inst_id:
|
||||||
# fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿
|
continue
|
||||||
return 0
|
if inst_id not in pos_by_inst:
|
||||||
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
|
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
|
continue
|
||||||
# 先处理已挂单等待成交的,绝不再发微信
|
result = close_fn(inst_id)
|
||||||
for mon in list_closing_targets(conn):
|
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
|
||||||
inst_id = str(mon.get("inst_id") or "")
|
if result.get("already_flat") or _result_fully_done(result):
|
||||||
if not inst_id:
|
mark_monitor(
|
||||||
continue
|
conn,
|
||||||
if inst_id in hedge_managed:
|
int(mon["id"]),
|
||||||
mark_monitor(
|
status="triggered",
|
||||||
conn,
|
trigger_idx=idx,
|
||||||
int(mon["id"]),
|
close_ord_id=result.get("close_ord_id"),
|
||||||
status="expired",
|
message="目标位限价平仓完成",
|
||||||
message="已移交对冲计划托管,跳过单独目标平仓",
|
)
|
||||||
)
|
_commit_monitor(conn)
|
||||||
_commit_monitor(conn)
|
continue
|
||||||
continue
|
mark_monitor(
|
||||||
if inst_id not in pos_by_inst:
|
conn,
|
||||||
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
|
int(mon["id"]),
|
||||||
_commit_monitor(conn)
|
status="closing",
|
||||||
continue
|
trigger_idx=idx,
|
||||||
result = close_fn(inst_id)
|
close_ord_id=result.get("close_ord_id"),
|
||||||
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
|
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
|
||||||
if result.get("already_flat") or _result_fully_done(result):
|
)
|
||||||
mark_monitor(
|
_commit_monitor(conn)
|
||||||
conn,
|
|
||||||
int(mon["id"]),
|
triggered = 0
|
||||||
status="triggered",
|
for mon in list_active_targets(conn):
|
||||||
trigger_idx=idx,
|
inst_id = str(mon.get("inst_id") or "")
|
||||||
close_ord_id=result.get("close_ord_id"),
|
target = _safe_float(mon.get("target_index"))
|
||||||
message="目标位限价平仓完成",
|
if not inst_id or target is None:
|
||||||
)
|
continue
|
||||||
_commit_monitor(conn)
|
pos = pos_by_inst.get(inst_id)
|
||||||
continue
|
if not pos:
|
||||||
mark_monitor(
|
continue
|
||||||
conn,
|
if index_fn is not None:
|
||||||
int(mon["id"]),
|
idx = index_fn(pos)
|
||||||
status="closing",
|
else:
|
||||||
trigger_idx=idx,
|
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
||||||
close_ord_id=result.get("close_ord_id"),
|
if idx is None:
|
||||||
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
|
continue
|
||||||
)
|
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||||
_commit_monitor(conn)
|
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
||||||
|
continue
|
||||||
triggered = 0
|
|
||||||
for mon in list_active_targets(conn):
|
result = close_fn(inst_id)
|
||||||
inst_id = str(mon.get("inst_id") or "")
|
if result.get("already_flat"):
|
||||||
target = _safe_float(mon.get("target_index"))
|
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||||
if not inst_id or target is None:
|
_commit_monitor(conn)
|
||||||
continue
|
continue
|
||||||
if inst_id in hedge_managed:
|
if not result.get("ok"):
|
||||||
mark_monitor(
|
mark_monitor(
|
||||||
conn,
|
conn,
|
||||||
int(mon["id"]),
|
int(mon["id"]),
|
||||||
status="expired",
|
status="active",
|
||||||
message="已移交对冲计划托管,跳过单独目标平仓",
|
trigger_idx=idx,
|
||||||
)
|
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
|
||||||
_commit_monitor(conn)
|
)
|
||||||
continue
|
_commit_monitor(conn)
|
||||||
pos = pos_by_inst.get(inst_id)
|
continue
|
||||||
if not pos:
|
|
||||||
continue
|
done = _result_fully_done(result)
|
||||||
if index_fn is not None:
|
status = "triggered" if done else "closing"
|
||||||
idx = index_fn(pos)
|
mark_monitor(
|
||||||
else:
|
conn,
|
||||||
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
int(mon["id"]),
|
||||||
if idx is None:
|
status=status,
|
||||||
continue
|
trigger_idx=idx,
|
||||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
close_ord_id=result.get("close_ord_id"),
|
||||||
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
|
||||||
continue
|
)
|
||||||
|
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
|
||||||
result = close_fn(inst_id)
|
_commit_monitor(conn)
|
||||||
if result.get("already_flat"):
|
triggered += 1
|
||||||
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
_notify_target_close(
|
||||||
_commit_monitor(conn)
|
cfg,
|
||||||
continue
|
send_wechat,
|
||||||
if not result.get("ok"):
|
account_label=account_label,
|
||||||
mark_monitor(
|
inst_id=inst_id,
|
||||||
conn,
|
target=target,
|
||||||
int(mon["id"]),
|
idx=idx,
|
||||||
status="active",
|
result=result,
|
||||||
trigger_idx=idx,
|
conn=conn,
|
||||||
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
|
)
|
||||||
)
|
return triggered
|
||||||
_commit_monitor(conn)
|
|
||||||
continue
|
|
||||||
|
|
||||||
done = _result_fully_done(result)
|
|
||||||
status = "triggered" if done else "closing"
|
|
||||||
mark_monitor(
|
|
||||||
conn,
|
|
||||||
int(mon["id"]),
|
|
||||||
status=status,
|
|
||||||
trigger_idx=idx,
|
|
||||||
close_ord_id=result.get("close_ord_id"),
|
|
||||||
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
|
|
||||||
)
|
|
||||||
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
|
|
||||||
_commit_monitor(conn)
|
|
||||||
triggered += 1
|
|
||||||
_notify_target_close(
|
|
||||||
cfg,
|
|
||||||
send_wechat,
|
|
||||||
account_label=account_label,
|
|
||||||
inst_id=inst_id,
|
|
||||||
target=target,
|
|
||||||
idx=idx,
|
|
||||||
result=result,
|
|
||||||
conn=conn,
|
|
||||||
)
|
|
||||||
return triggered
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ _APP_TZ = ZoneInfo(
|
|||||||
or "Asia/Shanghai"
|
or "Asia/Shanghai"
|
||||||
)
|
)
|
||||||
_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done"
|
_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done"
|
||||||
|
_SWAP_REPAIR_FLAG = "options_closed_at_note_swap_done"
|
||||||
|
|
||||||
|
|
||||||
def options_app_tz() -> ZoneInfo:
|
def options_app_tz() -> ZoneInfo:
|
||||||
@@ -119,3 +120,63 @@ def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str,
|
|||||||
|
|
||||||
set_sync_state(conn, _MIGRATE_FLAG, "1")
|
set_sync_state(conn, _MIGRATE_FLAG, "1")
|
||||||
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
|
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
|
||||||
|
|
||||||
|
|
||||||
|
def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str, int]:
|
||||||
|
"""修复 close_option_by_bid1 曾把 signal_note 写入 closed_at 的坏行.
|
||||||
|
|
||||||
|
典型表现: closed_at='翻倍出场'/'目标位平仓', signal_note 反而是时间戳.
|
||||||
|
幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修).
|
||||||
|
"""
|
||||||
|
from lib.options.options_review_db import init_options_review_tables
|
||||||
|
from lib.options.options_review_lib import set_sync_state
|
||||||
|
|
||||||
|
init_options_review_tables(conn)
|
||||||
|
trades_fixed = 0
|
||||||
|
review_fixed = 0
|
||||||
|
try:
|
||||||
|
from lib.options.options_db import init_options_tables
|
||||||
|
|
||||||
|
init_options_tables(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, closed_at, signal_note
|
||||||
|
FROM options_trades
|
||||||
|
WHERE status = 'closed'
|
||||||
|
AND closed_at IS NOT NULL
|
||||||
|
AND TRIM(closed_at) != ''
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
closed = str(row["closed_at"] or "").strip()
|
||||||
|
note = str(row["signal_note"] or "").strip()
|
||||||
|
if options_wall_clock_to_ms(closed) is not None:
|
||||||
|
continue
|
||||||
|
if options_wall_clock_to_ms(note) is None:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_trades
|
||||||
|
SET closed_at = ?, signal_note = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(note, closed, int(row["id"])),
|
||||||
|
)
|
||||||
|
trades_fixed += 1
|
||||||
|
hk = f"local_opt:{int(row['id'])}"
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_review_trades
|
||||||
|
SET closed_at = ?
|
||||||
|
WHERE history_key = ?
|
||||||
|
AND (closed_at IS NULL OR TRIM(closed_at) = '' OR closed_at = ?)
|
||||||
|
""",
|
||||||
|
(note, hk, closed),
|
||||||
|
)
|
||||||
|
review_fixed += int(cur.rowcount or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 复盘表里仍残留非时间戳 closed_at 的,若能从 local_opt 源修好则已处理;否则仅标记完成
|
||||||
|
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
|
||||||
|
return {"trades": trades_fixed, "review": review_fixed}
|
||||||
|
|||||||
+214
-2
@@ -12,7 +12,19 @@ from lib.sim.wallets_lib import InsufficientFunds, SimWallets
|
|||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
"""业务时区墙钟(默认 Asia/Shanghai);勿用 utcnow,否则复盘K线标记会偏 8 小时."""
|
||||||
|
try:
|
||||||
|
from lib.options.options_time_lib import options_app_now_str
|
||||||
|
|
||||||
|
return options_app_now_str()
|
||||||
|
except Exception:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz = ZoneInfo(
|
||||||
|
(os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
|
||||||
|
or "Asia/Shanghai"
|
||||||
|
)
|
||||||
|
return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]:
|
def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]:
|
||||||
@@ -692,7 +704,7 @@ class SimBroker:
|
|||||||
expiry_ms_from_inst_id,
|
expiry_ms_from_inst_id,
|
||||||
option_fields_from_inst_id,
|
option_fields_from_inst_id,
|
||||||
)
|
)
|
||||||
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
|
from lib.options.options_pricing_lib import option_expiry_pnl
|
||||||
|
|
||||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
settled: list[dict[str, Any]] = []
|
settled: list[dict[str, Any]] = []
|
||||||
@@ -890,6 +902,200 @@ class SimBroker:
|
|||||||
rows.append(row)
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sells_from_ledger(
|
||||||
|
conn: Any,
|
||||||
|
*,
|
||||||
|
inst: str,
|
||||||
|
opened: str,
|
||||||
|
closed: str,
|
||||||
|
) -> list[Any]:
|
||||||
|
"""从 sim_ledger_entries 的 option_close 备注解析卖平成交."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
params: list[Any] = [f"%sell {inst} %"]
|
||||||
|
sql = """
|
||||||
|
SELECT note, ts AS created_at
|
||||||
|
FROM sim_ledger_entries
|
||||||
|
WHERE kind = 'option_close' AND note LIKE ?
|
||||||
|
"""
|
||||||
|
if opened:
|
||||||
|
sql += " AND ts >= ?"
|
||||||
|
params.append(opened[:19])
|
||||||
|
if closed:
|
||||||
|
sql += " AND ts <= ?"
|
||||||
|
params.append((closed[:16] + ":59") if len(closed) >= 16 else closed)
|
||||||
|
sql += " ORDER BY ts ASC, id ASC"
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
pat = re.compile(
|
||||||
|
rf"sell\s+{re.escape(inst)}\s+x(?P<sheets>[0-9.]+)@(?P<px>[0-9.]+)",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
note = str(r["note"] or "")
|
||||||
|
m = pat.search(note)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sh = float(m.group("sheets"))
|
||||||
|
px = float(m.group("px"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if sh <= 0 or px <= 0:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"ord_id": f"ledger-{i}",
|
||||||
|
"sheets": sh,
|
||||||
|
"avg_px": px,
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def repair_closed_options_pnl_from_sim_orders(self, *, limit: int = 300) -> int:
|
||||||
|
"""用 sim_option_orders 卖平成交重算已平仓收回(修复分批平仓只记最后一笔).
|
||||||
|
|
||||||
|
返回更新条数.历史页/复盘合成前调用,使旧数据也能自愈.
|
||||||
|
"""
|
||||||
|
from lib.options.options_db import init_options_tables
|
||||||
|
from lib.options.options_pricing_lib import total_premium
|
||||||
|
from lib.sim.db_lib import init_sim_tables
|
||||||
|
|
||||||
|
conn = self.get_db()
|
||||||
|
updated = 0
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
init_sim_tables(conn)
|
||||||
|
# 表不存在时跳过(极旧库)
|
||||||
|
try:
|
||||||
|
conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone()
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
trades = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, inst_id, sheets, eth_amount, premium_paid, premium_received,
|
||||||
|
realized_pnl, close_quote, created_at, closed_at, close_ord_id
|
||||||
|
FROM options_trades
|
||||||
|
WHERE status = 'closed'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(int(max(1, min(int(limit), 500))),),
|
||||||
|
).fetchall()
|
||||||
|
for tr in trades:
|
||||||
|
inst = str(tr["inst_id"] or "").strip()
|
||||||
|
if not inst:
|
||||||
|
continue
|
||||||
|
want_sheets = float(tr["sheets"] or 0)
|
||||||
|
if want_sheets <= 0:
|
||||||
|
continue
|
||||||
|
eth_amt = float(tr["eth_amount"] or 0)
|
||||||
|
ct_mult = (eth_amt / want_sheets) if eth_amt > 0 else 0.01
|
||||||
|
if ct_mult <= 0:
|
||||||
|
ct_mult = 0.01
|
||||||
|
opened = str(tr["created_at"] or "").strip()
|
||||||
|
closed = str(tr["closed_at"] or "").strip()
|
||||||
|
# 时间窗:开仓后、平仓后 2 分钟内(含下单延迟)
|
||||||
|
params: list[Any] = [inst]
|
||||||
|
sql = """
|
||||||
|
SELECT ord_id, sheets, avg_px, created_at
|
||||||
|
FROM sim_option_orders
|
||||||
|
WHERE inst_id = ? AND lower(side) = 'sell'
|
||||||
|
"""
|
||||||
|
if opened:
|
||||||
|
sql += " AND created_at >= ?"
|
||||||
|
params.append(opened[:19])
|
||||||
|
if closed:
|
||||||
|
# 字符串时间比较;闭区间放宽到同分钟及之后的短尾
|
||||||
|
sql += " AND created_at <= ?"
|
||||||
|
params.append((closed[:16] + ":59") if len(closed) >= 16 else closed)
|
||||||
|
sql += " ORDER BY created_at ASC, ord_id ASC"
|
||||||
|
sells = conn.execute(sql, params).fetchall()
|
||||||
|
if not sells:
|
||||||
|
# 回退:从钱包流水 note 解析 sell INST x张数@价
|
||||||
|
sells = self._sells_from_ledger(
|
||||||
|
conn, inst=inst, opened=opened, closed=closed
|
||||||
|
)
|
||||||
|
if not sells:
|
||||||
|
continue
|
||||||
|
selected: list[Any] = []
|
||||||
|
sheets_sum = 0.0
|
||||||
|
for o in sells:
|
||||||
|
selected.append(o)
|
||||||
|
sheets_sum += float(o["sheets"] or 0)
|
||||||
|
if sheets_sum + 1e-9 >= want_sheets:
|
||||||
|
break
|
||||||
|
if sheets_sum + 1e-6 < want_sheets * 0.5:
|
||||||
|
# 成交张数明显不足,不瞎改
|
||||||
|
continue
|
||||||
|
recv = 0.0
|
||||||
|
notional = 0.0
|
||||||
|
oids: list[str] = []
|
||||||
|
for o in selected:
|
||||||
|
sh = float(o["sheets"] or 0)
|
||||||
|
px = float(o["avg_px"] or 0)
|
||||||
|
if sh <= 0 or px <= 0:
|
||||||
|
continue
|
||||||
|
recv += total_premium(px, sh * ct_mult)
|
||||||
|
notional += px * sh
|
||||||
|
oid = str(o["ord_id"] or "").strip()
|
||||||
|
if oid and oid not in oids:
|
||||||
|
oids.append(oid)
|
||||||
|
if not oids:
|
||||||
|
continue
|
||||||
|
total_recv = round(recv, 8)
|
||||||
|
paid = float(tr["premium_paid"] or 0) if tr["premium_paid"] is not None else None
|
||||||
|
if paid is None:
|
||||||
|
continue
|
||||||
|
new_pnl = round(total_recv - paid, 8)
|
||||||
|
close_px = round(notional / sheets_sum, 8) if sheets_sum > 0 else None
|
||||||
|
old_recv = (
|
||||||
|
float(tr["premium_received"])
|
||||||
|
if tr["premium_received"] is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
old_pnl = float(tr["realized_pnl"]) if tr["realized_pnl"] is not None else None
|
||||||
|
# 已与合计一致则跳过
|
||||||
|
if (
|
||||||
|
old_recv is not None
|
||||||
|
and old_pnl is not None
|
||||||
|
and abs(old_recv - total_recv) < 1e-8
|
||||||
|
and abs(old_pnl - new_pnl) < 1e-8
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
# 仅当卖笔数≥2 或合计收回明显高于本地(漏记)时覆盖
|
||||||
|
if len(selected) < 2 and old_recv is not None and total_recv <= old_recv + 1e-10:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_trades
|
||||||
|
SET premium_received = ?,
|
||||||
|
realized_pnl = ?,
|
||||||
|
close_quote = COALESCE(?, close_quote),
|
||||||
|
close_ord_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
total_recv,
|
||||||
|
new_pnl,
|
||||||
|
close_px,
|
||||||
|
",".join(oids) if oids else tr["close_ord_id"],
|
||||||
|
int(tr["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
if updated:
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return updated
|
||||||
|
|
||||||
def option_positions_history_okx_rows(
|
def option_positions_history_okx_rows(
|
||||||
self,
|
self,
|
||||||
exchange: Any = None,
|
exchange: Any = None,
|
||||||
@@ -901,6 +1107,12 @@ class SimBroker:
|
|||||||
|
|
||||||
实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据.
|
实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据.
|
||||||
"""
|
"""
|
||||||
|
# 先按卖平成交修复分批漏计,再合成展示
|
||||||
|
try:
|
||||||
|
self.repair_closed_options_pnl_from_sim_orders(limit=max(int(limit), 200))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ def install_sim_trading(app: Flask, repo_root: str, app_module: Any = None) -> N
|
|||||||
apply_sim_hooks(app_module)
|
apply_sim_hooks(app_module)
|
||||||
|
|
||||||
# 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用
|
# 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用
|
||||||
for key in ("options_cfg", "hedge_plan_cfg"):
|
for key in ("options_cfg",):
|
||||||
cfg = app.extensions.get(key)
|
cfg = app.extensions.get(key)
|
||||||
if isinstance(cfg, dict):
|
if isinstance(cfg, dict):
|
||||||
patch_options_cfg(cfg)
|
patch_options_cfg(cfg)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""OKX 公开 K 线小测(无网络):instId / bar 映射."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class OkxPublicOhlcvMappingTests(unittest.TestCase):
|
||||||
|
def test_swap_inst_and_bar(self):
|
||||||
|
# 避免 import 整站 app:直接复刻映射逻辑做契约校验
|
||||||
|
def swap_inst(exchange_symbol: str) -> str:
|
||||||
|
sym = str(exchange_symbol or "").strip().upper()
|
||||||
|
if not sym:
|
||||||
|
return ""
|
||||||
|
if "/" in sym:
|
||||||
|
base, rest = sym.split("/", 1)
|
||||||
|
quote = rest.split(":", 1)[0].strip() or "USDT"
|
||||||
|
return f"{base.strip()}-{quote}-SWAP"
|
||||||
|
if sym.endswith("-SWAP"):
|
||||||
|
return sym
|
||||||
|
return sym
|
||||||
|
|
||||||
|
def public_bar(timeframe: str) -> str:
|
||||||
|
low = str(timeframe or "").strip().lower()
|
||||||
|
if low.endswith("m") and low[:-1].isdigit():
|
||||||
|
return low
|
||||||
|
if low.endswith("h") and low[:-1].isdigit():
|
||||||
|
return f"{low[:-1]}H"
|
||||||
|
if low.endswith("d") and low[:-1].isdigit():
|
||||||
|
return f"{low[:-1]}D"
|
||||||
|
return low or "15m"
|
||||||
|
|
||||||
|
self.assertEqual(swap_inst("ETH/USDT:USDT"), "ETH-USDT-SWAP")
|
||||||
|
self.assertEqual(swap_inst("BTC/USDT:USDT"), "BTC-USDT-SWAP")
|
||||||
|
self.assertEqual(public_bar("15m"), "15m")
|
||||||
|
self.assertEqual(public_bar("1h"), "1H")
|
||||||
|
self.assertEqual(public_bar("4h"), "4H")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayPnlUsdtTests(unittest.TestCase):
|
||||||
|
def test_btc_coin_pnl_times_index(self):
|
||||||
|
row = {
|
||||||
|
"inst_id": "BTC-USD-260327-100000-P",
|
||||||
|
"premium_ccy": "BTC",
|
||||||
|
"margin_mode": "coin",
|
||||||
|
"idx_px": 100000.0,
|
||||||
|
"upl": -0.001,
|
||||||
|
"close_preview": {"bid_invalid": True},
|
||||||
|
}
|
||||||
|
# 残档回退 upl: -0.001 BTC × 100000 = -100 U
|
||||||
|
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4)
|
||||||
|
|
||||||
|
def test_btc_infers_ccy_when_premium_ccy_missing(self):
|
||||||
|
row = {
|
||||||
|
"inst_id": "BTC-USD-260327-100000-C",
|
||||||
|
"margin_mode": "coin",
|
||||||
|
"underlying": "BTC",
|
||||||
|
"idx_px": 95000.0,
|
||||||
|
"upl": 0.002,
|
||||||
|
"close_preview": {"bid_invalid": True},
|
||||||
|
}
|
||||||
|
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4)
|
||||||
|
|
||||||
|
def test_usdc_unchanged(self):
|
||||||
|
row = {
|
||||||
|
"inst_id": "BTC-USD_UM-260327-100000-C",
|
||||||
|
"premium_ccy": "USDC",
|
||||||
|
"margin_mode": "usdc",
|
||||||
|
"upl": -12.5,
|
||||||
|
"close_preview": {"bid_invalid": True},
|
||||||
|
}
|
||||||
|
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""分批平仓:累计各批收回权利金后再算盈亏."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from lib.options.options_close_exec_lib import close_option_by_bid1
|
||||||
|
from lib.options.options_db import (
|
||||||
|
add_open_close_accum,
|
||||||
|
get_open_close_accum,
|
||||||
|
init_options_tables,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mem_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
init_options_tables(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
class CloseAccumDbTests(unittest.TestCase):
|
||||||
|
def test_accum_on_earliest_open(self):
|
||||||
|
conn = _mem_db()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_trades
|
||||||
|
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
||||||
|
VALUES ('ETH-USD-260927-2700-P', 'ETH', 'P', 10, 0.1, 0.02, 'open')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
self.assertTrue(
|
||||||
|
add_open_close_accum(
|
||||||
|
conn, "ETH-USD-260927-2700-P", recv=0.005, sheets=5, px=0.05, ord_id="a1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
add_open_close_accum(
|
||||||
|
conn, "ETH-USD-260927-2700-P", recv=0.004, sheets=5, px=0.04, ord_id="a2"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
acc = get_open_close_accum(conn, "ETH-USD-260927-2700-P")
|
||||||
|
self.assertAlmostEqual(float(acc["recv"]), 0.009, places=6)
|
||||||
|
self.assertEqual(int(acc["sheets"]), 10)
|
||||||
|
self.assertAlmostEqual(float(acc["notional"]), 0.05 * 5 + 0.04 * 5, places=6)
|
||||||
|
self.assertEqual(acc["close_ord_id"], "a1,a2")
|
||||||
|
|
||||||
|
|
||||||
|
class TwoStepCloseExecTests(unittest.TestCase):
|
||||||
|
def test_two_partial_closes_sum_premium_received(self):
|
||||||
|
conn = _mem_db()
|
||||||
|
inst = "ETH-USD-260927-2700-P"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_trades
|
||||||
|
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
||||||
|
open_quote, premium_paid, status, premium_ccy, margin_mode)
|
||||||
|
VALUES (?, 'ETH', 'P', 2700, 10, 0.1, 0.2, 0.02, 'open', 'ETH', 'coin')
|
||||||
|
""",
|
||||||
|
(inst,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 持仓:第一次 10→5,第二次 5→0
|
||||||
|
pos_seq = [
|
||||||
|
[{"instId": inst, "availPos": "10", "pos": "10", "mgnMode": "isolated", "idxPx": "2500"}],
|
||||||
|
[{"instId": inst, "availPos": "5", "pos": "5", "mgnMode": "isolated", "idxPx": "2500"}],
|
||||||
|
[{"instId": inst, "availPos": "5", "pos": "5", "mgnMode": "isolated", "idxPx": "2500"}],
|
||||||
|
[], # 第二次下单后已平
|
||||||
|
]
|
||||||
|
pos_i = {"n": 0}
|
||||||
|
|
||||||
|
def fetch_positions(_ex):
|
||||||
|
i = pos_i["n"]
|
||||||
|
pos_i["n"] = min(i + 1, len(pos_seq) - 1)
|
||||||
|
return pos_seq[i]
|
||||||
|
|
||||||
|
order_px = {"n": 0}
|
||||||
|
|
||||||
|
def place_limit(_ex, **kwargs):
|
||||||
|
order_px["n"] += 1
|
||||||
|
px = 0.05 if order_px["n"] == 1 else 0.04
|
||||||
|
return {"ok": True, "px": px, "data": {"ordId": f"ord{order_px['n']}"}}
|
||||||
|
|
||||||
|
def quote(_ex, _inst):
|
||||||
|
return {"ok": True, "tick_sz": "0.0001", "ct_mult": 0.01, "bid": 0.05, "index_px": 2500}
|
||||||
|
|
||||||
|
def book(_ex, _inst, _n):
|
||||||
|
# 每次只够吃一半深度,强制分两批
|
||||||
|
return {"bids": [{"px": 0.05, "sz": 5}]} if order_px["n"] < 1 else {"bids": [{"px": 0.04, "sz": 5}]}
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
"get_db": lambda: _reuse_conn(conn),
|
||||||
|
"quote_option_contract": quote,
|
||||||
|
"fetch_option_positions": fetch_positions,
|
||||||
|
"fetch_option_book_depth": book,
|
||||||
|
"place_option_limit_order": place_limit,
|
||||||
|
"td_mode": "isolated",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("lib.options.options_close_exec_lib.time.sleep", return_value=None), patch(
|
||||||
|
"lib.exchange.okx_options_lib.invalidate_option_positions_cache"
|
||||||
|
), patch(
|
||||||
|
"lib.options.options_pricing_lib.fetch_option_mark_px", return_value=0.05
|
||||||
|
), patch(
|
||||||
|
"lib.options.options_close_exec_lib._pos_close_refs", return_value=(0.05, 0.0)
|
||||||
|
):
|
||||||
|
r1 = close_option_by_bid1(cfg, ex=object(), inst_id=inst, sheets=10)
|
||||||
|
self.assertTrue(r1.get("ok"), r1)
|
||||||
|
self.assertFalse(r1.get("fully_closed"), r1)
|
||||||
|
open_row = conn.execute(
|
||||||
|
"SELECT close_recv_accum, status, realized_pnl FROM options_trades WHERE inst_id=?",
|
||||||
|
(inst,),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(open_row["status"], "open")
|
||||||
|
self.assertGreater(float(open_row["close_recv_accum"] or 0), 0)
|
||||||
|
self.assertIsNone(open_row["realized_pnl"])
|
||||||
|
|
||||||
|
# 第二次买一深度
|
||||||
|
def book2(_ex, _inst, _n):
|
||||||
|
return {"bids": [{"px": 0.04, "sz": 5}]}
|
||||||
|
|
||||||
|
cfg["fetch_option_book_depth"] = book2
|
||||||
|
r2 = close_option_by_bid1(cfg, ex=object(), inst_id=inst, sheets=5)
|
||||||
|
self.assertTrue(r2.get("ok"), r2)
|
||||||
|
self.assertTrue(r2.get("fully_closed"), r2)
|
||||||
|
|
||||||
|
closed = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT premium_paid, premium_received, realized_pnl, status, close_recv_accum
|
||||||
|
FROM options_trades WHERE inst_id=?
|
||||||
|
""",
|
||||||
|
(inst,),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(closed["status"], "closed")
|
||||||
|
# ct_mult=0.01: 5张×0.05 + 5张×0.04 = 0.0025 + 0.002 = 0.0045
|
||||||
|
self.assertAlmostEqual(float(closed["premium_received"]), 0.0045, places=6)
|
||||||
|
self.assertAlmostEqual(float(closed["realized_pnl"]), 0.0045 - 0.02, places=6)
|
||||||
|
self.assertEqual(float(closed["close_recv_accum"] or 0), 0.0)
|
||||||
|
# 若只记第二批,recv 会是 0.002,pnl=-0.018;合计应为 -0.0155
|
||||||
|
self.assertNotAlmostEqual(float(closed["premium_received"]), 0.002, places=6)
|
||||||
|
|
||||||
|
|
||||||
|
def _reuse_conn(conn: sqlite3.Connection) -> sqlite3.Connection:
|
||||||
|
"""get_db 每次返回同一连接;close 时 no-op,避免 :memory: 被关掉."""
|
||||||
|
|
||||||
|
class _Proxy:
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(conn, name)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _Proxy() # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit
|
||||||
|
|
||||||
|
|
||||||
|
class ProfitExitHitTests(unittest.TestCase):
|
||||||
|
def test_hit_at_0_3_mult(self):
|
||||||
|
# 0.3倍: recycle >= paid * 1.3
|
||||||
|
self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3))
|
||||||
|
self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3))
|
||||||
|
|
||||||
|
def test_estimate_uses_full_sheets_not_depth(self):
|
||||||
|
pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01}
|
||||||
|
preview = {
|
||||||
|
"bid_invalid": False,
|
||||||
|
"levels": [{"px": 0.2, "sheets": 10}], # 深度只够 10 张
|
||||||
|
"total_received": 0.02, # 仅深度内
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_attach(cfg, ex, row, premium_paid=None):
|
||||||
|
row["close_preview"] = preview
|
||||||
|
row["ct_mult"] = 0.01
|
||||||
|
row["pos"] = 100
|
||||||
|
return row
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"lib.options.options_positions_lib.attach_close_preview",
|
||||||
|
side_effect=fake_attach,
|
||||||
|
):
|
||||||
|
recv = _estimate_recycle({}, object(), pos, 1.0)
|
||||||
|
# 应按 100 张 × 0.01 × 0.2 = 0.2,而非深度截断的 0.02
|
||||||
|
self.assertAlmostEqual(float(recv), 0.2, places=6)
|
||||||
|
self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""对冲拆除后的冒烟:交易模式锁定单独期权;hedge_plan 包已移除."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveHedgeSmokeTests(unittest.TestCase):
|
||||||
|
def test_trade_mode_always_options(self):
|
||||||
|
from lib.options.okx_trade_mode_lib import (
|
||||||
|
get_okx_trade_mode,
|
||||||
|
hedge_module_enabled,
|
||||||
|
standalone_options_open_allowed,
|
||||||
|
block_standalone_open_by_mode_msg,
|
||||||
|
normalize_okx_trade_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(normalize_okx_trade_mode("options_options"), "options")
|
||||||
|
self.assertEqual(normalize_okx_trade_mode("perp_options"), "options")
|
||||||
|
self.assertEqual(get_okx_trade_mode(), "options")
|
||||||
|
self.assertFalse(hedge_module_enabled())
|
||||||
|
self.assertTrue(standalone_options_open_allowed())
|
||||||
|
self.assertIsNone(block_standalone_open_by_mode_msg())
|
||||||
|
|
||||||
|
def test_hedge_plan_package_gone(self):
|
||||||
|
with self.assertRaises(ModuleNotFoundError):
|
||||||
|
importlib.import_module("lib.hedge_plan")
|
||||||
|
|
||||||
|
def test_option_expiry_pnl_available(self):
|
||||||
|
from lib.options.options_pricing_lib import option_expiry_pnl
|
||||||
|
|
||||||
|
pnl = option_expiry_pnl(
|
||||||
|
opt_type="P",
|
||||||
|
strike=2700,
|
||||||
|
spot=2600,
|
||||||
|
sheets=10,
|
||||||
|
ct_mult=0.01,
|
||||||
|
premium_paid=1.0,
|
||||||
|
)
|
||||||
|
# intrinsic = 100 * 10 * 0.01 = 10; pnl = 10 - 1 = 9
|
||||||
|
self.assertAlmostEqual(pnl, 9.0, places=6)
|
||||||
|
|
||||||
|
def test_profit_exit_no_hedge_import(self):
|
||||||
|
import inspect
|
||||||
|
from lib.options import options_profit_exit_lib as m
|
||||||
|
|
||||||
|
src = inspect.getsource(m)
|
||||||
|
self.assertNotIn("hedge_plan", src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""模拟盘:用分批卖平成交修复已平仓漏计盈亏."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from lib.options.options_db import init_options_tables
|
||||||
|
from lib.sim.broker_lib import SimBroker
|
||||||
|
from lib.sim.db_lib import init_sim_tables
|
||||||
|
|
||||||
|
|
||||||
|
class RepairSimPartialClosePnlTests(unittest.TestCase):
|
||||||
|
def test_repair_sums_two_sell_orders(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
init_options_tables(conn)
|
||||||
|
init_sim_tables(conn)
|
||||||
|
inst = "ETH-USD-260927-2700-P"
|
||||||
|
# 开仓 228 张,权利金 0.399;旧逻辑只记了第二笔收回 0.2449 → pnl=-0.1541
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_trades(
|
||||||
|
inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
||||||
|
open_quote, premium_paid, premium_received, realized_pnl,
|
||||||
|
status, premium_ccy, created_at, closed_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
inst,
|
||||||
|
"ETH",
|
||||||
|
"P",
|
||||||
|
2700,
|
||||||
|
228,
|
||||||
|
2.28,
|
||||||
|
0.175,
|
||||||
|
0.399,
|
||||||
|
0.2449,
|
||||||
|
-0.1541,
|
||||||
|
"closed",
|
||||||
|
"ETH",
|
||||||
|
"2026-09-24 07:09:19",
|
||||||
|
"2026-09-24 20:59:20",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 两笔卖平各 114 张
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sim_option_orders(
|
||||||
|
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
|
||||||
|
) VALUES (?,?,?,?,?,'filled',?,?)
|
||||||
|
""",
|
||||||
|
("sim-a", inst, "sell", 114, 0.12, 114, "2026-09-24 15:00:00"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sim_option_orders(
|
||||||
|
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
|
||||||
|
) VALUES (?,?,?,?,?,'filled',?,?)
|
||||||
|
""",
|
||||||
|
("sim-b", inst, "sell", 114, 0.1074, 114, "2026-09-24 20:59:10"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# SimBroker 每次 close 自己的连接;测试里复用同一 :memory: 需禁止 close
|
||||||
|
class _Proxy:
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(conn, name)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
broker = SimBroker(lambda: _Proxy()) # type: ignore[arg-type]
|
||||||
|
n = broker.repair_closed_options_pnl_from_sim_orders()
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT premium_received, realized_pnl, close_ord_id FROM options_trades WHERE inst_id=?",
|
||||||
|
(inst,),
|
||||||
|
).fetchone()
|
||||||
|
# ct_mult=2.28/228=0.01; recv=114*0.12*0.01 + 114*0.1074*0.01 = 0.1368+0.122436=0.259236
|
||||||
|
self.assertAlmostEqual(float(row["premium_received"]), 0.259236, places=6)
|
||||||
|
self.assertAlmostEqual(float(row["realized_pnl"]), 0.259236 - 0.399, places=6)
|
||||||
|
self.assertIn("sim-a", str(row["close_ord_id"]))
|
||||||
|
self.assertIn("sim-b", str(row["close_ord_id"]))
|
||||||
|
|
||||||
|
# 历史合成应读到修复后的盈亏
|
||||||
|
hist = broker.option_positions_history_okx_rows(limit=10)
|
||||||
|
self.assertEqual(len(hist), 1)
|
||||||
|
self.assertAlmostEqual(float(hist[0]["realizedPnl"]), 0.259236 - 0.399, places=6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关.
|
基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关.
|
||||||
|
|
||||||
**部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**:
|
**部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**;**新机纯实盘**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
|
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
|
||||||
|
|||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
# 新机纯实盘部署指南(crypto_okx)
|
||||||
|
|
||||||
|
适用场景:**新开一台服务器,直接接 OKX 实盘 API,不做同机模拟→实盘切换**。
|
||||||
|
|
||||||
|
当前推荐用法:
|
||||||
|
|
||||||
|
| 机器 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| 现有模拟机 | 不接 API Key,本地模拟撮合 + 公开行情 |
|
||||||
|
| **本机(新机)** | 只跑实盘,从部署起就是 `live` |
|
||||||
|
|
||||||
|
与主仓 `crypto_monitor` **隔离**:请用独立子账户 / 独立 API Key,避免双实例同 Key 抢仓。
|
||||||
|
|
||||||
|
通用安装步骤见 [部署文档.md](./部署文档.md);页面操作见 [使用说明.md](./使用说明.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 上线前准备
|
||||||
|
|
||||||
|
1. OKX **实盘**账户(非演示盘),建议专用子账户、小资金。
|
||||||
|
2. API Key 权限:交易(期权 + 如需币本位则含现货);按需开提现(一般不必)。
|
||||||
|
3. IP 白名单绑定到新机公网 IP。
|
||||||
|
4. 确认持仓模式 / 保证金模式与下文 `.env` 一致(如双向 + 全仓)。
|
||||||
|
5. 确认本机或经 SOCKS 能访问 `https://www.okx.com`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 一键部署
|
||||||
|
|
||||||
|
新机 root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
菜单选 **1. 一键部署**。默认路径 `/opt/crypto_okx`,端口 `5004`。
|
||||||
|
|
||||||
|
或手动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt
|
||||||
|
git clone https://git.bz121.com/dekun/crypto_okx.git crypto_okx
|
||||||
|
cd /opt/crypto_okx
|
||||||
|
cp -n .env.example .env
|
||||||
|
bash deploy/setup_env.sh --install-system-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 纯实盘 `.env` 必改项
|
||||||
|
|
||||||
|
编辑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano /opt/crypto_okx/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 模式:直接 live(不要 sim)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 启动即实盘(不要写成 sim)
|
||||||
|
SIM_DEFAULT_MODE=live
|
||||||
|
|
||||||
|
# 允许真实下单
|
||||||
|
LIVE_TRADING_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 新机不要依赖页面「切换到实盘」;部署时就把 `SIM_DEFAULT_MODE=live` 写好。
|
||||||
|
- 期权路径主要认 API Key;`LIVE_TRADING_ENABLED` 仍建议保持 `true`,与文档口径一致。
|
||||||
|
|
||||||
|
### 3.2 实盘 API(必填)
|
||||||
|
|
||||||
|
```env
|
||||||
|
OKX_API_KEY=你的实盘Key
|
||||||
|
OKX_API_SECRET=你的实盘Secret
|
||||||
|
OKX_API_PASSPHRASE=你的Passphrase
|
||||||
|
OKX_OPTIONS_ENABLED=true
|
||||||
|
EXCHANGE_DISPLAY_NAME=OKX·实盘
|
||||||
|
```
|
||||||
|
|
||||||
|
不要用演示盘 Key;本仓库未接 `x-simulated-trading`。
|
||||||
|
|
||||||
|
### 3.3 交易模式与预算(按策略改)
|
||||||
|
|
||||||
|
单独期权(常见起步):
|
||||||
|
|
||||||
|
```env
|
||||||
|
OKX_TRADE_MODE=options
|
||||||
|
OKX_OPTIONS_MARGIN_MODE=coin
|
||||||
|
OKX_OPTIONS_COIN_BUDGET_USDT=10
|
||||||
|
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||||
|
```
|
||||||
|
|
||||||
|
若做对冲,再改为 `perp_options` / `options_options`,并打开:
|
||||||
|
|
||||||
|
```env
|
||||||
|
HEDGE_PLAN_LIVE_ORDER=true
|
||||||
|
```
|
||||||
|
|
||||||
|
起步建议先 `HEDGE_PLAN_LIVE_ORDER=false` 或页面 dry_run,跑通单独期权后再开对冲真下单。
|
||||||
|
|
||||||
|
### 3.4 代理(按网络需要)
|
||||||
|
|
||||||
|
若服务器直连 OKX 不稳定:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||||||
|
```
|
||||||
|
|
||||||
|
并先在本机建好 SSH SOCKS(见部署文档)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 启动与确认
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_okx
|
||||||
|
pm2 restart crypto_okx --update-env
|
||||||
|
# 若尚未创建进程,用一键脚本或:
|
||||||
|
# pm2 start ecosystem.config.cjs
|
||||||
|
pm2 status
|
||||||
|
pm2 logs crypto_okx --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
打开:`http://<新机IP>:5004`
|
||||||
|
|
||||||
|
核对:
|
||||||
|
|
||||||
|
1. 能登录。
|
||||||
|
2. 顶栏为 **实盘**(`okx_live`),**没有**「模拟」徽章。
|
||||||
|
3. 资金条能读到交易所余额(不是本地模拟钱包初始值)。
|
||||||
|
4. 系统设置 → 模拟资金:当前模式应为 **live**(勿再切回 sim)。
|
||||||
|
|
||||||
|
可用接口自检:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -u '用户名:密码' http://127.0.0.1:5004/api/sim/status
|
||||||
|
# 期望含 "mode":"live"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 小额冒烟(必做)
|
||||||
|
|
||||||
|
在真钱前用 **最小张数 / 最小预算**:
|
||||||
|
|
||||||
|
1. `/options` 开 1 张(或最小允许张数)→ 看交易所成交与本地记录。
|
||||||
|
2. 持仓出现在页面监控。
|
||||||
|
3. 平仓 → 余额变化符合预期。
|
||||||
|
4. 若币本位:确认买币桥 → 开期权 → 平 → 卖回(或按你策略保留币)。
|
||||||
|
5. `/options/review`:复盘列表有记录;可选勾选自动 K 线(公开行情,有 Key 也可画)。
|
||||||
|
|
||||||
|
任一步异常:**先停手**,查 `pm2 logs crypto_okx`,不要加大仓位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与模拟机、主仓的隔离
|
||||||
|
|
||||||
|
| 不要做 | 原因 |
|
||||||
|
|--------|------|
|
||||||
|
| 模拟机与实盘机共用同一 API Key | 双实例监控/下单互相干扰 |
|
||||||
|
| 与正在跑的 `crypto_monitor` 共用同一 Key | 同上 |
|
||||||
|
| 在实盘机再切回 sim「玩玩」 | 易混仓、混认知;模拟请继续用旧模拟机 |
|
||||||
|
| 把 `.env` 提交进 Git | 泄密 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 日常更新(实盘机)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_okx
|
||||||
|
git pull
|
||||||
|
pm2 restart crypto_okx --update-env
|
||||||
|
git log -1 --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/crypto_okx/deploy/pull_and_restart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`git pull` **不会**覆盖 `.env`。大版本更新前可:`cp .env .env.backup.$(date +%Y%m%d)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 故障快速对照
|
||||||
|
|
||||||
|
| 现象 | 排查 |
|
||||||
|
|------|------|
|
||||||
|
| 顶栏仍显示模拟 | `.env` 是否 `SIM_DEFAULT_MODE=live`;是否重启 `--update-env`;设置页是否被改回 sim |
|
||||||
|
| 读不到余额 / 下单失败 | Key、权限、IP 白名单、SOCKS、OKX 账户是否实盘 |
|
||||||
|
| 能开单但监控不对 | 是否与其它实例同 Key;持仓模式是否与 `OKX_POS_MODE` 一致 |
|
||||||
|
| 复盘无自动 K 线 | 服务器能否访问 OKX 公开行情;看日志是否画图失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 推荐启动清单(可打印)
|
||||||
|
|
||||||
|
- [ ] 新机已部署 `/opt/crypto_okx`
|
||||||
|
- [ ] `SIM_DEFAULT_MODE=live`
|
||||||
|
- [ ] `LIVE_TRADING_ENABLED=true`
|
||||||
|
- [ ] 实盘 `OKX_API_*` 已填,且非演示盘
|
||||||
|
- [ ] Key 与主仓 / 模拟机隔离
|
||||||
|
- [ ] 顶栏确认 live,资金为交易所余额
|
||||||
|
- [ ] 小额开平冒烟通过
|
||||||
|
- [ ] 企业微信 / 监控按需开启
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
实盘有亏损风险。请遵守当地法规与交易所条款;操作与资金安全自负。
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
**功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**.
|
**功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**.
|
||||||
|
|
||||||
|
**新开服务器、直接接 API 跑实盘(不与模拟同机切换)**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一键部署(推荐)
|
## 一键部署(推荐)
|
||||||
|
|||||||
Reference in New Issue
Block a user