Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"""期权平仓执行:只锁买一限价卖出;永不市价."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from lib.options.options_close_gate_lib import (
|
||||
clear_close_gate,
|
||||
is_close_gate_passed,
|
||||
mark_close_gate_passed,
|
||||
update_close_gate,
|
||||
)
|
||||
from lib.options.options_pricing_lib import (
|
||||
estimate_close_by_bids,
|
||||
fetch_option_mark_px,
|
||||
is_stub_bid_px,
|
||||
total_premium,
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
|
||||
try:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||
|
||||
init_options_tables(conn)
|
||||
return sum_open_premium_paid(conn, inst_id)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return 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.options.options_pricing_lib import close_ref_prices
|
||||
|
||||
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"))
|
||||
if mark is None:
|
||||
mark = fetch_option_mark_px(ex, inst_id)
|
||||
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
|
||||
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
|
||||
if not opt_type or strike is None:
|
||||
pt, ps = option_fields_from_inst_id(inst_id)
|
||||
opt_type = opt_type or pt
|
||||
if strike is None:
|
||||
strike = ps
|
||||
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)
|
||||
|
||||
|
||||
def _avail_sheets(pos: dict[str, Any]) -> int:
|
||||
avail = _safe_float(pos.get("availPos"))
|
||||
if avail is None or avail <= 0:
|
||||
avail = abs(_safe_float(pos.get("pos")) or 0)
|
||||
return max(0, int(avail or 0))
|
||||
|
||||
|
||||
def _cancel_sell_pending(ex: Any, inst_id: str) -> None:
|
||||
try:
|
||||
pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
|
||||
for o in pending.get("data") or []:
|
||||
if str(o.get("side") or "").lower() != "sell":
|
||||
continue
|
||||
oid = o.get("ordId")
|
||||
if not oid:
|
||||
continue
|
||||
try:
|
||||
ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def close_option_by_bid1(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
inst_id: str,
|
||||
*,
|
||||
sheets: int | None = None,
|
||||
require_recycle_gate: bool = False,
|
||||
signal_note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
本轮只吃买一深度:
|
||||
- 本批张数 = min(请求张数, 持仓, 买一深度)
|
||||
- 限价 = 校验通过时锁定的买一价
|
||||
- 永不市价
|
||||
- 始终校验有效流动性(残档买一禁止)
|
||||
- require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒;
|
||||
一旦通过后对同仓续批只验流动性
|
||||
"""
|
||||
from lib.exchange.okx_options_lib import (
|
||||
_pos_side_from_position,
|
||||
invalidate_option_positions_cache,
|
||||
)
|
||||
|
||||
inst_id = (inst_id or "").strip()
|
||||
if not inst_id:
|
||||
return {"ok": False, "msg": "缺少 inst_id"}
|
||||
|
||||
q = cfg["quote_option_contract"](ex, inst_id)
|
||||
if not q.get("ok"):
|
||||
return {"ok": False, "msg": q.get("msg") or "报价失败"}
|
||||
tick_sz = q.get("tick_sz")
|
||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||
|
||||
raw_positions = cfg["fetch_option_positions"](ex)
|
||||
if raw_positions is None:
|
||||
return {"ok": False, "msg": "获取期权持仓失败"}
|
||||
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
|
||||
if not pos:
|
||||
clear_close_gate(inst_id)
|
||||
return {"ok": False, "msg": "未找到持仓", "already_flat": True}
|
||||
|
||||
avail = _avail_sheets(pos)
|
||||
want = int(sheets) if sheets else avail
|
||||
want = min(want, avail)
|
||||
if want < 1:
|
||||
clear_close_gate(inst_id)
|
||||
return {"ok": False, "msg": "可平张数不足", "already_flat": True}
|
||||
|
||||
td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated")
|
||||
pos_side = _pos_side_from_position(pos) or "net"
|
||||
mark_px, intrinsic_px = _pos_close_refs(ex, pos, q)
|
||||
premium_paid = _open_premium_paid(cfg, inst_id)
|
||||
if premium_paid is None:
|
||||
premium_paid = _safe_float(pos.get("premium_paid"))
|
||||
|
||||
# 已有未成交卖平单:等成交,不撤不重挂
|
||||
try:
|
||||
pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
|
||||
sell_pending = [
|
||||
o
|
||||
for o in (pending.get("data") or [])
|
||||
if str(o.get("side") or "").lower() == "sell" and o.get("ordId")
|
||||
]
|
||||
if sell_pending:
|
||||
time.sleep(0.5)
|
||||
invalidate_option_positions_cache()
|
||||
raw_positions = cfg["fetch_option_positions"](ex)
|
||||
if raw_positions is None:
|
||||
return {"ok": False, "msg": "获取期权持仓失败"}
|
||||
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
|
||||
if not pos or _avail_sheets(pos) < 1:
|
||||
clear_close_gate(inst_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"already_flat": True,
|
||||
"msg": "已有限价卖单成交",
|
||||
"close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
|
||||
"fully_closed": True,
|
||||
"submitted_sheets": want,
|
||||
"remaining_sheets": 0,
|
||||
"mode": "bid1",
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "等待已有买一限价卖单成交",
|
||||
"stopped_reason": "pending_close_order",
|
||||
"close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
book = cfg["fetch_option_book_depth"](ex, inst_id, 1)
|
||||
preview = estimate_close_by_bids(
|
||||
book.get("bids") or [],
|
||||
want,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=premium_paid,
|
||||
mark_px=mark_px,
|
||||
intrinsic_px=intrinsic_px,
|
||||
max_levels=1,
|
||||
)
|
||||
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
|
||||
_cancel_sell_pending(ex, inst_id)
|
||||
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
|
||||
"stopped_reason": "stub_bid",
|
||||
"auto_close_blocked": True,
|
||||
"liquidity_blocked": True,
|
||||
}
|
||||
|
||||
levels = preview.get("levels") or []
|
||||
if not levels:
|
||||
bid_px = _safe_float(q.get("bid"))
|
||||
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
|
||||
if stub or bid_px is None or bid_px <= 0:
|
||||
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": stub_reason or "暂无买一,无法限价平仓",
|
||||
"stopped_reason": "stub_bid" if stub else "no_bid",
|
||||
"auto_close_blocked": True,
|
||||
"liquidity_blocked": True,
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "暂无买一深度,无法平仓",
|
||||
"stopped_reason": "no_bid_depth",
|
||||
"liquidity_blocked": True,
|
||||
}
|
||||
|
||||
level = levels[0]
|
||||
level_sheets = int(level.get("sheets") or 0)
|
||||
level_px = float(level.get("px") or 0)
|
||||
if level_sheets <= 0 or level_px <= 0:
|
||||
return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"}
|
||||
|
||||
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
|
||||
if stub_lv:
|
||||
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
|
||||
"stopped_reason": "stub_bid",
|
||||
"auto_close_blocked": True,
|
||||
"liquidity_blocked": True,
|
||||
}
|
||||
|
||||
# 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性
|
||||
gate = update_close_gate(
|
||||
inst_id,
|
||||
recycle_usdc=_safe_float(preview.get("total_received")),
|
||||
premium_paid=premium_paid,
|
||||
)
|
||||
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)",
|
||||
"stopped_reason": "close_gate",
|
||||
"auto_close_blocked": True,
|
||||
"close_gate": gate,
|
||||
}
|
||||
if gate.get("ready"):
|
||||
mark_close_gate_passed(inst_id)
|
||||
|
||||
locked_bid_px = level_px
|
||||
before_avail = avail
|
||||
order = cfg["place_option_limit_order"](
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
side="sell",
|
||||
sheets=level_sheets,
|
||||
price=locked_bid_px,
|
||||
td_mode=td_mode,
|
||||
tick_sz=tick_sz,
|
||||
reduce_only=True,
|
||||
pos_side=pos_side,
|
||||
)
|
||||
if not order.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": order.get("msg") or "买一限价平仓失败",
|
||||
"stopped_reason": "order_failed",
|
||||
"locked_bid_px": locked_bid_px,
|
||||
"batch_sheets": level_sheets,
|
||||
}
|
||||
|
||||
px = float(order.get("px", locked_bid_px))
|
||||
oid = str((order.get("data") or {}).get("ordId") or "")
|
||||
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4)
|
||||
time.sleep(0.6)
|
||||
invalidate_option_positions_cache()
|
||||
raw2 = cfg["fetch_option_positions"](ex)
|
||||
after_avail = 0
|
||||
if raw2 is not None:
|
||||
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
|
||||
after_avail = _avail_sheets(after_pos) if after_pos else 0
|
||||
reduced = max(0, before_avail - after_avail) if raw2 is not None else 0
|
||||
remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets)
|
||||
fully_closed = remaining_pos < 1
|
||||
|
||||
if fully_closed:
|
||||
clear_close_gate(inst_id)
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
open_rows = conn.execute(
|
||||
"""
|
||||
SELECT id, premium_paid FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchall()
|
||||
total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows)
|
||||
allocated = 0.0
|
||||
for i, row in enumerate(open_rows):
|
||||
paid = float(row["premium_paid"] or 0)
|
||||
if i == len(open_rows) - 1:
|
||||
recv = round(prem_recv - allocated, 4)
|
||||
elif total_paid > 0:
|
||||
recv = round(prem_recv * (paid / total_paid), 4)
|
||||
allocated += recv
|
||||
else:
|
||||
recv = round(prem_recv / len(open_rows), 4)
|
||||
allocated += recv
|
||||
pnl = round(recv - paid, 4)
|
||||
note_sql = ""
|
||||
params: list[Any] = [px, recv, pnl, oid or None]
|
||||
if signal_note and i == len(open_rows) - 1:
|
||||
note_sql = """,
|
||||
signal_note = CASE
|
||||
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ?
|
||||
ELSE signal_note
|
||||
END"""
|
||||
params.append(signal_note)
|
||||
params.append(int(row["id"]))
|
||||
conn.execute(
|
||||
f"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
|
||||
{note_sql}
|
||||
WHERE id = ?
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
elif require_recycle_gate:
|
||||
# 自动平已挂过单:同仓续批只验流动性
|
||||
mark_close_gate_passed(inst_id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "bid1",
|
||||
"orders": [{"order": order, "px": px, "sheets": level_sheets}],
|
||||
"bid": px,
|
||||
"locked_bid_px": locked_bid_px,
|
||||
"submitted_sheets": level_sheets,
|
||||
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
|
||||
"remaining_sheets": remaining_pos,
|
||||
"premium_received": prem_recv,
|
||||
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
|
||||
"close_ord_id": oid or None,
|
||||
"fully_closed": fully_closed,
|
||||
"msg": (
|
||||
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
|
||||
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# 兼容旧名
|
||||
def close_option_by_bid_depth(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
inst_id: str,
|
||||
*,
|
||||
sheets: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return close_option_by_bid1(
|
||||
cfg,
|
||||
ex,
|
||||
inst_id,
|
||||
sheets=sheets,
|
||||
require_recycle_gate=True,
|
||||
signal_note="目标位平仓",
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _env_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓
|
||||
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
|
||||
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
|
||||
|
||||
_lock = threading.Lock()
|
||||
# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float}
|
||||
_gates: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clear_close_gate(inst_id: str | None = None) -> None:
|
||||
with _lock:
|
||||
if inst_id:
|
||||
_gates.pop(str(inst_id).strip(), None)
|
||||
else:
|
||||
_gates.clear()
|
||||
|
||||
|
||||
def mark_close_gate_passed(inst_id: str) -> None:
|
||||
"""标记同仓已通过 2× 门控,续批平仓只验流动性."""
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return
|
||||
with _lock:
|
||||
st = _gates.get(inst) or {}
|
||||
st["passed"] = True
|
||||
st["updated"] = time.time()
|
||||
_gates[inst] = st
|
||||
|
||||
|
||||
def is_close_gate_passed(inst_id: str) -> bool:
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return False
|
||||
with _lock:
|
||||
return bool((_gates.get(inst) or {}).get("passed"))
|
||||
|
||||
|
||||
def update_close_gate(
|
||||
inst_id: str,
|
||||
*,
|
||||
recycle_usdc: float | None,
|
||||
premium_paid: float | None,
|
||||
now: float | None = None,
|
||||
min_mult: float | None = None,
|
||||
hold_seconds: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
根据当前买盘可回收金额刷新门控.
|
||||
条件不满足时重置计时;满足时从首次满足起累计持续时间.
|
||||
"""
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return {
|
||||
"ok": False,
|
||||
"ready": False,
|
||||
"recycle_ok": False,
|
||||
"msg": "缺少合约",
|
||||
}
|
||||
ts = float(now if now is not None else time.time())
|
||||
mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT)
|
||||
hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS)
|
||||
if mult <= 0:
|
||||
mult = 2.0
|
||||
if hold < 0:
|
||||
hold = 0.0
|
||||
|
||||
prem = _safe_float(premium_paid)
|
||||
recv = _safe_float(recycle_usdc)
|
||||
need = round(prem * mult, 4) if prem is not None and prem > 0 else None
|
||||
recycle_ok = bool(
|
||||
prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need
|
||||
)
|
||||
|
||||
with _lock:
|
||||
prev = _gates.get(inst) or {}
|
||||
ok_since = prev.get("ok_since")
|
||||
if recycle_ok:
|
||||
if ok_since is None:
|
||||
ok_since = ts
|
||||
else:
|
||||
ok_since = None
|
||||
held = (ts - float(ok_since)) if ok_since is not None else 0.0
|
||||
ready = bool(recycle_ok and held + 1e-9 >= hold)
|
||||
prev_passed = bool(prev.get("passed"))
|
||||
passed = prev_passed or ready
|
||||
state = {
|
||||
"ok_since": ok_since,
|
||||
"recycle": recv,
|
||||
"premium": prem,
|
||||
"need": need,
|
||||
"updated": ts,
|
||||
"min_mult": mult,
|
||||
"hold_seconds": hold,
|
||||
"passed": passed,
|
||||
}
|
||||
_gates[inst] = state
|
||||
|
||||
remain = max(0.0, hold - held) if recycle_ok and not ready else None
|
||||
if prem is None or prem <= 0:
|
||||
msg = "缺少权利金,无法校验平仓门控"
|
||||
elif recv is None:
|
||||
msg = "暂无有效买盘可回收金额"
|
||||
elif not recycle_ok:
|
||||
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过"
|
||||
elif not ready:
|
||||
msg = (
|
||||
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f}),"
|
||||
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
|
||||
)
|
||||
else:
|
||||
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓"
|
||||
|
||||
auto_blocked = not (ready or passed)
|
||||
return {
|
||||
"ok": True,
|
||||
"ready": ready,
|
||||
"passed": passed,
|
||||
"recycle_ok": recycle_ok,
|
||||
"recycle_usdc": recv,
|
||||
"premium_paid": prem,
|
||||
"need_recycle_usdc": need,
|
||||
"min_mult": mult,
|
||||
"hold_seconds": hold,
|
||||
"held_seconds": round(held, 1) if recycle_ok else 0.0,
|
||||
"remain_seconds": round(remain, 1) if remain is not None else None,
|
||||
"ok_since": ok_since,
|
||||
"msg": msg,
|
||||
"auto_close_blocked": auto_blocked,
|
||||
"close_gate_blocked": auto_blocked,
|
||||
}
|
||||
|
||||
|
||||
def check_close_gate(
|
||||
inst_id: str,
|
||||
*,
|
||||
recycle_usdc: float | None = None,
|
||||
premium_paid: float | None = None,
|
||||
refresh: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""检查是否允许平仓;默认先用最新回收/权利金刷新."""
|
||||
inst = (inst_id or "").strip()
|
||||
if refresh:
|
||||
if recycle_usdc is None or premium_paid is None:
|
||||
with _lock:
|
||||
prev = _gates.get(inst) or {}
|
||||
if recycle_usdc is None:
|
||||
recycle_usdc = prev.get("recycle")
|
||||
if premium_paid is None:
|
||||
premium_paid = prev.get("premium")
|
||||
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid)
|
||||
with _lock:
|
||||
prev = _gates.get(inst)
|
||||
if not prev:
|
||||
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid)
|
||||
return update_close_gate(
|
||||
inst,
|
||||
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
|
||||
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""期权模块 SQLite 表."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||
from lib.options.options_review_db import init_options_review_tables
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inst_id TEXT NOT NULL,
|
||||
underlying TEXT NOT NULL,
|
||||
opt_type TEXT NOT NULL,
|
||||
strike REAL,
|
||||
exp_time TEXT,
|
||||
sheets INTEGER NOT NULL,
|
||||
eth_amount REAL NOT NULL,
|
||||
open_quote REAL,
|
||||
premium_paid REAL,
|
||||
status TEXT DEFAULT 'open',
|
||||
close_quote REAL,
|
||||
premium_received REAL,
|
||||
realized_pnl REAL,
|
||||
profit_alert_sent INTEGER DEFAULT 0,
|
||||
signal_note TEXT,
|
||||
exchange_ord_id TEXT,
|
||||
close_ord_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_convert_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_ccy TEXT,
|
||||
to_ccy TEXT,
|
||||
rfq_sz REAL,
|
||||
received_sz REAL,
|
||||
quote_id TEXT,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_history_hidden (
|
||||
history_key TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_transfer_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ccy TEXT,
|
||||
amount REAL,
|
||||
from_account TEXT,
|
||||
to_account TEXT,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_target_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inst_id TEXT NOT NULL,
|
||||
underlying TEXT,
|
||||
opt_type TEXT,
|
||||
target_index REAL NOT NULL,
|
||||
trade_id INTEGER,
|
||||
sheets INTEGER,
|
||||
status TEXT DEFAULT 'active',
|
||||
trigger_idx REAL,
|
||||
close_ord_id TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
triggered_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
|
||||
ON options_target_monitors(status)
|
||||
"""
|
||||
)
|
||||
init_options_review_tables(conn)
|
||||
|
||||
|
||||
def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | None:
|
||||
"""同合约所有 open 腿权利金合计(加仓后显示/门控用)."""
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT SUM(premium_paid) AS total, COUNT(*) AS n
|
||||
FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open' AND premium_paid IS NOT NULL
|
||||
""",
|
||||
(inst,),
|
||||
).fetchone()
|
||||
if not row or int(row["n"] or 0) < 1:
|
||||
return None
|
||||
return round(float(row["total"] or 0), 4)
|
||||
|
||||
|
||||
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
|
||||
"""同合约所有 open 腿张数合计."""
|
||||
inst = (inst_id or "").strip()
|
||||
if not inst:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT SUM(sheets) AS total, COUNT(*) AS n
|
||||
FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst,),
|
||||
).fetchone()
|
||||
if not row or int(row["n"] or 0) < 1:
|
||||
return None
|
||||
return int(row["total"] or 0)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""期权历史列表(交易所 positions-history + 当前持仓)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||
|
||||
|
||||
def enrich_position_row_display(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
raw_pos: dict[str, Any],
|
||||
*,
|
||||
meta_cache: dict[str, dict[str, Any] | None] | None = None,
|
||||
premium_override: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult
|
||||
|
||||
inst_id = str(raw_pos.get("instId") or "").strip()
|
||||
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
|
||||
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
|
||||
if premium_override is not None:
|
||||
row["premium_paid"] = premium_override
|
||||
row["premium_paid_fmt"] = format_usdc_amount(premium_override)
|
||||
return row
|
||||
|
||||
|
||||
def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""与期权历史页相同的数据源:交易所全平记录 + 当前持仓,排除本地隐藏项."""
|
||||
from lib.exchange.okx_options_lib import (
|
||||
fetch_all_option_positions_history,
|
||||
format_live_option_history_row,
|
||||
format_option_history_row,
|
||||
tick_sz_and_ct_mult,
|
||||
)
|
||||
|
||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
raw_live = cfg["fetch_option_positions"](ex)
|
||||
if raw_live is None:
|
||||
return []
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
hidden_keys = {
|
||||
str(r["history_key"])
|
||||
for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall()
|
||||
}
|
||||
for p in raw_live:
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
open_ms = None
|
||||
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
|
||||
try:
|
||||
if ctime is not None and str(ctime).strip():
|
||||
open_ms = int(float(ctime))
|
||||
except (TypeError, ValueError):
|
||||
open_ms = None
|
||||
items.append(format_live_option_history_row(row, open_ms=open_ms))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
hist_raw = fetch_all_option_positions_history(ex, limit=200)
|
||||
for raw in hist_raw:
|
||||
inst_id = str(raw.get("instId") or "").strip()
|
||||
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
|
||||
items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult))
|
||||
|
||||
open_rows = [x for x in items if x.get("status") == "open"]
|
||||
closed = [x for x in items if x.get("status") != "open"]
|
||||
closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True)
|
||||
open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True)
|
||||
return [
|
||||
x
|
||||
for x in (open_rows + closed)
|
||||
if str(x.get("history_key") or "") not in hidden_keys
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""中控只读聚合:OKX 期权持仓 / 资金 / 本地统计."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lib.options.options_history_lib import load_options_history
|
||||
from lib.options.options_stats_lib import compute_options_stats_from_history
|
||||
|
||||
|
||||
def _compute_options_stats(ex, cfg) -> dict[str, Any]:
|
||||
history = load_options_history(ex, cfg)
|
||||
return compute_options_stats_from_history(history)
|
||||
|
||||
|
||||
def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
if not cfg.get("enabled"):
|
||||
return {"ok": True, "enabled": False}
|
||||
ex = cfg.get("exchange_options")
|
||||
ready_fn = cfg.get("options_api_ready")
|
||||
if not callable(ready_fn):
|
||||
return {"ok": False, "enabled": True, "msg": "期权模块未就绪"}
|
||||
ok, reason = ready_fn(ex)
|
||||
if not ok:
|
||||
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
|
||||
try:
|
||||
from lib.options.options_positions_lib import build_display_option_positions
|
||||
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
|
||||
positions = build_display_option_positions(cfg, ex, raw)
|
||||
target_monitors: list[dict[str, Any]] = []
|
||||
try:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.hedge_plan.hedge_plan_db import active_options_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)
|
||||
tgt_map = targets_by_inst(conn)
|
||||
hedge_target_map = active_options_targets_by_inst(conn)
|
||||
target_monitors.extend(hedge_target_map.values())
|
||||
for p in positions:
|
||||
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
||||
if mon:
|
||||
p["target_index"] = mon.get("target_index")
|
||||
p["target_monitor_id"] = mon.get("id")
|
||||
p["target_monitor"] = mon
|
||||
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:
|
||||
from lib.instance.instance_dashboard_lib import (
|
||||
_format_options_target,
|
||||
_resolve_options_source,
|
||||
)
|
||||
|
||||
inst = str(p.get("inst_id") or "")
|
||||
source_key, source_label = _resolve_options_source(conn, inst)
|
||||
p["source"] = source_key
|
||||
p["source_label"] = source_label
|
||||
p["target_monitor_text"] = _format_options_target(p)
|
||||
except Exception:
|
||||
p.setdefault("source_label", "—")
|
||||
p.setdefault("target_monitor_text", "—")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
target_monitors = []
|
||||
from lib.options.options_positions_lib import net_pnl_from_display_row
|
||||
|
||||
upl_total = 0.0
|
||||
has_upl = False
|
||||
for p in positions:
|
||||
# 与持仓卡「净盈亏」一致(买一回收−权利金);不用交易所标记价 upl
|
||||
net = net_pnl_from_display_row(p)
|
||||
if net is None:
|
||||
continue
|
||||
has_upl = True
|
||||
upl_total += float(net)
|
||||
bal = cfg["fetch_options_balances"](ex)
|
||||
stats = _compute_options_stats(ex, cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"positions": positions,
|
||||
"position_count": len(positions),
|
||||
"target_monitors": target_monitors,
|
||||
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
|
||||
"balances": bal,
|
||||
"funding_usdc": bal.get("funding_usdc"),
|
||||
"funding_usdt": bal.get("funding_usdt"),
|
||||
"trading_usdc": bal.get("trading_usdc"),
|
||||
"trading_usdt": bal.get("trading_usdt"),
|
||||
"stats": stats,
|
||||
"trade_budget": cfg.get("trade_budget"),
|
||||
"account_label": cfg.get("account_label") or "OKX期权",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "enabled": True, "msg": str(e)}
|
||||
@@ -0,0 +1,334 @@
|
||||
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def build_profit_alert_message(
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
premium_paid: float,
|
||||
upl: float,
|
||||
upl_ratio: float | None,
|
||||
bid: float | None,
|
||||
) -> str:
|
||||
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—"
|
||||
bid_txt = f"{bid:.4f}" if bid is not None else "—"
|
||||
return "\n".join(
|
||||
[
|
||||
"【OKX期权·翻倍提醒】",
|
||||
f"账户:{account_label}",
|
||||
f"合约:{inst_id}",
|
||||
f"已付权利金:{premium_paid:.4f} USDC",
|
||||
f"未实现盈亏:{upl:+.4f} USDC({pct})",
|
||||
f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def run_options_profit_alerts(
|
||||
conn: sqlite3.Connection,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
profit_ratio: float,
|
||||
send_wechat: Callable[[str], None],
|
||||
account_label: str,
|
||||
ticker_bid_fn: Callable[[str], float | None],
|
||||
) -> int:
|
||||
"""
|
||||
对比 DB 中 open 记录与交易所持仓;达到阈值发微信.
|
||||
返回发送条数.
|
||||
"""
|
||||
sent = 0
|
||||
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, premium_paid, profit_alert_sent
|
||||
FROM options_trades
|
||||
WHERE status = 'open'
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
# 同合约多腿加仓:按合约汇总权利金,整仓只告警一次
|
||||
by_inst: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
inst_id = str(row["inst_id"] or "")
|
||||
if not inst_id:
|
||||
continue
|
||||
bucket = by_inst.setdefault(
|
||||
inst_id,
|
||||
{"ids": [], "premium": 0.0, "all_sent": True, "has_prem": False},
|
||||
)
|
||||
bucket["ids"].append(int(row["id"]))
|
||||
prem = _safe_float(row["premium_paid"])
|
||||
if prem is not None:
|
||||
bucket["premium"] += float(prem)
|
||||
bucket["has_prem"] = True
|
||||
if not int(row["profit_alert_sent"] or 0):
|
||||
bucket["all_sent"] = False
|
||||
|
||||
for inst_id, bucket in by_inst.items():
|
||||
if bucket["all_sent"] or not bucket["has_prem"] or bucket["premium"] <= 0:
|
||||
continue
|
||||
pos = pos_by_inst.get(inst_id)
|
||||
if not pos:
|
||||
continue
|
||||
prem = float(bucket["premium"])
|
||||
upl = _safe_float(pos.get("upl"))
|
||||
upl_ratio = _safe_float(pos.get("upl_ratio_pct"))
|
||||
if upl_ratio is not None:
|
||||
ratio = upl_ratio / 100.0
|
||||
elif upl is not None:
|
||||
ratio = upl / prem
|
||||
else:
|
||||
continue
|
||||
if ratio < float(profit_ratio):
|
||||
continue
|
||||
bid = ticker_bid_fn(inst_id)
|
||||
msg = build_profit_alert_message(
|
||||
account_label=account_label,
|
||||
inst_id=inst_id,
|
||||
premium_paid=prem,
|
||||
upl=upl or 0.0,
|
||||
upl_ratio=ratio,
|
||||
bid=bid,
|
||||
)
|
||||
try:
|
||||
send_wechat(msg)
|
||||
conn.execute(
|
||||
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
|
||||
tuple(bucket["ids"]),
|
||||
)
|
||||
sent += 1
|
||||
except Exception:
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
def _created_at_ms(created_at: Any) -> int | None:
|
||||
if not created_at:
|
||||
return None
|
||||
raw = str(created_at).strip()
|
||||
if not raw:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%f"):
|
||||
try:
|
||||
dt = datetime.strptime(raw[:26], fmt).replace(tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def sync_open_options_trades(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
live_inst_ids: set[str],
|
||||
fetch_history_fn: Callable[[str], list[dict[str, Any]]],
|
||||
) -> int:
|
||||
"""
|
||||
交易所已无持仓时,将本地 open 记录同步为 closed.
|
||||
优先用 positions-history 回填盈亏;否则到期后按归零处理.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, premium_paid, exp_time, created_at
|
||||
FROM options_trades
|
||||
WHERE status = 'open'
|
||||
"""
|
||||
).fetchall()
|
||||
updated = 0
|
||||
now_ms = int(time.time() * 1000)
|
||||
for row in rows:
|
||||
inst_id = str(row["inst_id"] or "")
|
||||
if not inst_id or inst_id in live_inst_ids:
|
||||
continue
|
||||
paid = _safe_float(row["premium_paid"]) or 0.0
|
||||
open_ms = _created_at_ms(row["created_at"])
|
||||
exp_ms = normalize_option_exp_ms(row["exp_time"], inst_id)
|
||||
close_quote: float | None = None
|
||||
prem_recv: float | None = None
|
||||
realized_pnl: float | None = None
|
||||
close_ord_id: str | None = None
|
||||
closed_at: str | None = None
|
||||
close_reason = "exchange"
|
||||
|
||||
close_info = resolve_option_close_from_history(
|
||||
fetch_history_fn(inst_id),
|
||||
open_ms=open_ms,
|
||||
)
|
||||
if close_info:
|
||||
close_quote = close_info.get("close_quote")
|
||||
realized_pnl = close_info.get("realized_pnl")
|
||||
close_ord_id = close_info.get("pos_id")
|
||||
if realized_pnl is not None:
|
||||
prem_recv = round(paid + float(realized_pnl), 4)
|
||||
close_ms = close_info.get("close_ms")
|
||||
if close_ms:
|
||||
closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
elif exp_ms is not None and now_ms >= int(exp_ms):
|
||||
close_reason = "expired"
|
||||
close_quote = 0.0
|
||||
prem_recv = 0.0
|
||||
realized_pnl = round(-paid, 4)
|
||||
if exp_ms:
|
||||
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed',
|
||||
close_quote = ?,
|
||||
premium_received = ?,
|
||||
realized_pnl = ?,
|
||||
close_ord_id = COALESCE(?, close_ord_id),
|
||||
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP),
|
||||
signal_note = CASE
|
||||
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
|
||||
THEN '到期结算'
|
||||
ELSE signal_note
|
||||
END
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
close_quote,
|
||||
prem_recv,
|
||||
realized_pnl,
|
||||
close_ord_id,
|
||||
closed_at,
|
||||
close_reason,
|
||||
int(row["id"]),
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def reconcile_live_open_trades(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
live_inst_ids: set[str],
|
||||
) -> int:
|
||||
"""交易所有持仓但本地误标 closed 时恢复为 open."""
|
||||
fixed = 0
|
||||
for inst_id in live_inst_ids:
|
||||
if not inst_id:
|
||||
continue
|
||||
open_row = conn.execute(
|
||||
"SELECT id FROM options_trades WHERE inst_id = ? AND status = 'open' LIMIT 1",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if open_row:
|
||||
continue
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, close_ord_id, realized_pnl
|
||||
FROM options_trades
|
||||
WHERE inst_id = ? AND status = 'closed'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
continue
|
||||
if row["close_ord_id"]:
|
||||
continue
|
||||
if row["realized_pnl"] is not None:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET status = 'open',
|
||||
close_quote = NULL,
|
||||
premium_received = NULL,
|
||||
realized_pnl = NULL,
|
||||
closed_at = NULL,
|
||||
signal_note = CASE
|
||||
WHEN signal_note = '到期结算' THEN NULL
|
||||
ELSE signal_note
|
||||
END
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(row["id"]),),
|
||||
)
|
||||
fixed += 1
|
||||
return fixed
|
||||
|
||||
|
||||
def options_monitor_loop(
|
||||
*,
|
||||
enabled: bool,
|
||||
poll_seconds: float,
|
||||
get_db: Callable[[], sqlite3.Connection],
|
||||
fetch_positions: Callable[[], list[dict[str, Any]]],
|
||||
ticker_bid_fn: Callable[[str], float | None],
|
||||
send_wechat: Callable[[str], None],
|
||||
account_label: str,
|
||||
profit_ratio: float,
|
||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
|
||||
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
|
||||
stop_event: Any = None,
|
||||
) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
while True:
|
||||
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
|
||||
break
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
positions = fetch_positions()
|
||||
run_options_profit_alerts(
|
||||
conn,
|
||||
positions,
|
||||
profit_ratio=profit_ratio,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
ticker_bid_fn=ticker_bid_fn,
|
||||
)
|
||||
if target_close_fn is not None:
|
||||
from lib.options.options_target_lib import run_options_target_closes
|
||||
|
||||
run_options_target_closes(
|
||||
conn,
|
||||
positions,
|
||||
close_fn=target_close_fn,
|
||||
send_wechat=send_wechat,
|
||||
account_label=account_label,
|
||||
)
|
||||
if sync_trades_fn is not None:
|
||||
sync_trades_fn(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
# 平仓限价挂单超时撤单(独立于 DB 事务)
|
||||
if stale_pending_fn is not None:
|
||||
try:
|
||||
stale_pending_fn()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(max(5.0, float(poll_seconds)))
|
||||
@@ -0,0 +1,124 @@
|
||||
"""期权限价挂单:展示 enrichment + 超时自动撤单."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def order_age_seconds(order: dict[str, Any], *, now_ms: float | None = None) -> float | None:
|
||||
"""根据交易所 cTime(ms) 估算挂单时长(秒)."""
|
||||
ct = _safe_float(order.get("c_time") or order.get("cTime"))
|
||||
if ct is None or ct <= 0:
|
||||
return None
|
||||
# OKX 一般为毫秒时间戳
|
||||
if ct < 1e12:
|
||||
ct *= 1000.0
|
||||
now = float(now_ms if now_ms is not None else time.time() * 1000.0)
|
||||
age = (now - ct) / 1000.0
|
||||
return age if age >= 0 else 0.0
|
||||
|
||||
|
||||
def is_close_pending_order(order: dict[str, Any]) -> bool:
|
||||
"""平仓向限价挂单:卖出 / reduceOnly."""
|
||||
side = str(order.get("side") or "").lower()
|
||||
if side == "sell":
|
||||
return True
|
||||
return bool(order.get("reduce_only"))
|
||||
|
||||
|
||||
def enrich_pending_orders(
|
||||
orders: list[dict[str, Any]] | None,
|
||||
*,
|
||||
ttl_seconds: float = 600.0,
|
||||
now_ms: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""为 UI 附加挂单时长与自动撤倒计时."""
|
||||
ttl = max(0.0, float(ttl_seconds or 0))
|
||||
now = float(now_ms if now_ms is not None else time.time() * 1000.0)
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in orders or []:
|
||||
o = dict(raw)
|
||||
age = order_age_seconds(o, now_ms=now)
|
||||
is_close = is_close_pending_order(o)
|
||||
o["age_sec"] = round(age, 1) if age is not None else None
|
||||
o["is_close_order"] = is_close
|
||||
o["auto_cancel_enabled"] = bool(is_close and ttl > 0)
|
||||
if age is not None and is_close and ttl > 0:
|
||||
remain = max(0.0, ttl - age)
|
||||
o["ttl_seconds"] = ttl
|
||||
o["expire_in_sec"] = round(remain, 1)
|
||||
o["stale"] = remain <= 0
|
||||
else:
|
||||
o["ttl_seconds"] = ttl if is_close else None
|
||||
o["expire_in_sec"] = None
|
||||
o["stale"] = False
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
|
||||
def cancel_stale_close_pending_orders(
|
||||
*,
|
||||
fetch_pending: Any,
|
||||
cancel_order: Any,
|
||||
ttl_seconds: float = 600.0,
|
||||
now_ms: float | None = None,
|
||||
ex: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
平仓限价挂单超过 ttl 自动撤销.
|
||||
fetch_pending(ex) -> list; cancel_order(ex, inst_id=..., ord_id=...).
|
||||
"""
|
||||
ttl = float(ttl_seconds or 0)
|
||||
if ttl <= 0:
|
||||
return {"ok": True, "cancelled": 0, "checked": 0, "skipped": "ttl_disabled"}
|
||||
try:
|
||||
orders = fetch_pending(ex) if ex is not None else fetch_pending()
|
||||
except TypeError:
|
||||
orders = fetch_pending(ex)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e), "cancelled": 0, "checked": 0}
|
||||
enriched = enrich_pending_orders(orders or [], ttl_seconds=ttl, now_ms=now_ms)
|
||||
cancelled: list[dict[str, Any]] = []
|
||||
errors: list[str] = []
|
||||
checked = 0
|
||||
for o in enriched:
|
||||
if not o.get("is_close_order"):
|
||||
continue
|
||||
checked += 1
|
||||
if not o.get("stale"):
|
||||
continue
|
||||
inst = str(o.get("inst_id") or "").strip()
|
||||
oid = str(o.get("ord_id") or "").strip()
|
||||
if not inst or not oid:
|
||||
continue
|
||||
try:
|
||||
if ex is not None:
|
||||
res = cancel_order(ex, inst_id=inst, ord_id=oid)
|
||||
else:
|
||||
res = cancel_order(inst_id=inst, ord_id=oid)
|
||||
except TypeError:
|
||||
res = cancel_order(ex, inst_id=inst, ord_id=oid)
|
||||
except Exception as e:
|
||||
errors.append(f"{oid}:{e}")
|
||||
continue
|
||||
if res.get("ok"):
|
||||
cancelled.append({"inst_id": inst, "ord_id": oid, "age_sec": o.get("age_sec")})
|
||||
else:
|
||||
errors.append(f"{oid}:{res.get('msg') or 'cancel_failed'}")
|
||||
return {
|
||||
"ok": True,
|
||||
"cancelled": len(cancelled),
|
||||
"checked": checked,
|
||||
"orders": cancelled,
|
||||
"errors": errors,
|
||||
"ttl_seconds": ttl,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""期权持仓展示(实例页 / 中控快照共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
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_close_gate_lib import clear_close_gate, is_close_gate_passed, update_close_gate
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def attach_close_preview(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
sheets: int | None = None,
|
||||
premium_paid: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
inst_id = str(row.get("inst_id") or row.get("instId") or "").strip()
|
||||
if not inst_id:
|
||||
return row
|
||||
ct_mult = float(row.get("ct_mult") or 0.01)
|
||||
target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0))
|
||||
paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid"))
|
||||
book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
|
||||
row["bid_depth"] = book.get("bids") or []
|
||||
row["ask_depth"] = book.get("asks") or []
|
||||
mark_px = _safe_float(row.get("mark_px") or row.get("markPx"))
|
||||
intrinsic = intrinsic_px_per_unit(
|
||||
row.get("opt_type") or row.get("optType"),
|
||||
_safe_float(row.get("strike") or row.get("stk")),
|
||||
_safe_float(row.get("idx_px") or row.get("idxPx")),
|
||||
)
|
||||
# 与实盘一致:只按买一估算本轮可平
|
||||
preview = estimate_close_by_bids(
|
||||
row["bid_depth"],
|
||||
target_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=paid,
|
||||
mark_px=mark_px,
|
||||
intrinsic_px=intrinsic,
|
||||
max_levels=1,
|
||||
)
|
||||
# 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要)
|
||||
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
|
||||
gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid)
|
||||
preview["close_gate"] = gate
|
||||
preview["close_gate_blocked"] = True
|
||||
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
|
||||
preview["manual_close_blocked"] = True
|
||||
preview["liquidity_ok"] = False
|
||||
else:
|
||||
gate = update_close_gate(
|
||||
inst_id,
|
||||
recycle_usdc=_safe_float(preview.get("total_received")),
|
||||
premium_paid=paid,
|
||||
)
|
||||
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
|
||||
preview["close_gate"] = gate
|
||||
preview["close_gate_blocked"] = not passed
|
||||
preview["close_gate_msg"] = gate.get("msg")
|
||||
preview["manual_close_blocked"] = False
|
||||
preview["liquidity_ok"] = True
|
||||
if not passed:
|
||||
preview["auto_close_blocked"] = True
|
||||
row["close_preview"] = preview
|
||||
return row
|
||||
|
||||
|
||||
def forget_close_gate_for_inst(inst_id: str) -> None:
|
||||
clear_close_gate(inst_id)
|
||||
|
||||
|
||||
def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
|
||||
"""与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值."""
|
||||
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
||||
if preview.get("bid_invalid"):
|
||||
return None
|
||||
net = preview.get("estimated_pnl")
|
||||
if net is not None:
|
||||
try:
|
||||
return float(net)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
recv = _safe_float(preview.get("total_received"))
|
||||
paid = _safe_float(row.get("premium_paid"))
|
||||
if recv is not None and paid is not None:
|
||||
return round(recv - paid, 4)
|
||||
return None
|
||||
|
||||
|
||||
def sum_options_net_pnl_usdc(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
raw_positions: list[dict[str, Any]] | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」:
|
||||
各仓买一可回收 − 权利金之和.获取失败返回 None;无持仓返回 0.
|
||||
"""
|
||||
raw = raw_positions
|
||||
if raw is None:
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
if raw is None:
|
||||
return None
|
||||
if not raw:
|
||||
return 0.0
|
||||
positions = build_display_option_positions(cfg, ex, raw)
|
||||
total = 0.0
|
||||
found = False
|
||||
for p in positions:
|
||||
net = net_pnl_from_display_row(p)
|
||||
if net is None:
|
||||
continue
|
||||
found = True
|
||||
total += float(net)
|
||||
return round(total, 4) if found else (0.0 if not positions else None)
|
||||
|
||||
|
||||
def build_display_option_positions(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
raw_positions: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""与实例 /api/options/positions 相同 enrichment + close_preview."""
|
||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
for p in raw_positions:
|
||||
inst = str(p.get("instId") or "").strip()
|
||||
premium_override = sum_open_premium_paid(conn, inst) if inst else None
|
||||
row = enrich_position_row_display(
|
||||
cfg,
|
||||
ex,
|
||||
p,
|
||||
meta_cache=meta_cache,
|
||||
premium_override=premium_override,
|
||||
)
|
||||
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
||||
rows.append(row)
|
||||
finally:
|
||||
conn.close()
|
||||
return rows
|
||||
@@ -0,0 +1,543 @@
|
||||
"""OKX USDⓈ 期权:张数与权利金计算."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
|
||||
if not meta:
|
||||
return 0.01
|
||||
try:
|
||||
return float(meta.get("ctMult") or 0.01)
|
||||
except (TypeError, ValueError):
|
||||
return 0.01
|
||||
|
||||
|
||||
def min_sz_from_meta(meta: dict[str, Any] | None) -> int:
|
||||
if not meta:
|
||||
return 1
|
||||
try:
|
||||
return max(1, int(float(meta.get("minSz") or 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
|
||||
"""报价为每 1 ETH/BTC;每张权利金 = 报价 × ctMult."""
|
||||
return float(quote_per_unit) * float(ct_mult)
|
||||
|
||||
|
||||
def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None:
|
||||
"""盘口展示:价格/张数,如 17.2/150."""
|
||||
if px is None:
|
||||
return None
|
||||
try:
|
||||
price = f"{float(px):.{px_decimals}f}".rstrip("0").rstrip(".")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if sz is None:
|
||||
return price
|
||||
try:
|
||||
s = float(sz)
|
||||
size = str(int(s)) if abs(s - int(s)) < 1e-9 else str(s).rstrip("0").rstrip(".")
|
||||
except (TypeError, ValueError):
|
||||
return price
|
||||
return f"{price}/{size}"
|
||||
|
||||
|
||||
def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.01) -> float:
|
||||
return float(quote_per_unit) * float(eth_amount)
|
||||
|
||||
|
||||
# 买一相对标记价/内在价值低于该比例 → 视为残档,禁止按买盘自动/多档平仓
|
||||
BID_CLOSE_MIN_RATIO = 0.3
|
||||
|
||||
|
||||
def _safe_px(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
x = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return x if x > 0 else None
|
||||
|
||||
|
||||
def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px: float | None) -> float | None:
|
||||
o = (opt_type or "").strip().upper()
|
||||
if strike is None or index_px is None:
|
||||
return None
|
||||
try:
|
||||
k = float(strike)
|
||||
idx = float(index_px)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if o == "C" and idx > k:
|
||||
return idx - k
|
||||
if o == "P" and idx < k:
|
||||
return k - idx
|
||||
return None
|
||||
|
||||
|
||||
def is_stub_bid_px(
|
||||
bid_px: float | None,
|
||||
*,
|
||||
mark_px: float | None = None,
|
||||
intrinsic_px: float | None = None,
|
||||
min_ratio: float = BID_CLOSE_MIN_RATIO,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
判断买一是否为无效残档(如标记 42、买一 0.2).
|
||||
返回 (is_stub, reason).
|
||||
"""
|
||||
bid = _safe_px(bid_px)
|
||||
if bid is None:
|
||||
return True, "无买一"
|
||||
ref = _safe_px(mark_px)
|
||||
ref_name = "标记价"
|
||||
intrinsic = _safe_px(intrinsic_px)
|
||||
if intrinsic is not None and (ref is None or intrinsic > ref):
|
||||
ref = intrinsic
|
||||
ref_name = "内在价值"
|
||||
if ref is None:
|
||||
return False, ""
|
||||
ratio = float(min_ratio) if min_ratio and min_ratio > 0 else BID_CLOSE_MIN_RATIO
|
||||
if bid < ref * ratio:
|
||||
return True, f"买一{bid:g}远低于{ref_name}{ref:g},属无效残档,禁止按买盘自动平仓"
|
||||
return False, ""
|
||||
|
||||
|
||||
def fetch_option_mark_px(ex: Any, inst_id: str) -> float | None:
|
||||
"""优先 mark-price 接口,失败则 None."""
|
||||
inst_id = (inst_id or "").strip()
|
||||
if not inst_id or ex is None:
|
||||
return None
|
||||
try:
|
||||
rows = ex.public_get_public_mark_price({"instType": "OPTION", "instId": inst_id}).get("data") or []
|
||||
if rows:
|
||||
return _safe_px(rows[0].get("markPx"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def close_ref_prices(
|
||||
*,
|
||||
mark_px: float | None = None,
|
||||
opt_type: str | None = None,
|
||||
strike: float | None = None,
|
||||
index_px: float | None = None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""返回 (mark_px, intrinsic_px) 供残档判断."""
|
||||
return _safe_px(mark_px), intrinsic_px_per_unit(opt_type, strike, index_px)
|
||||
|
||||
|
||||
def filter_bids_for_close(
|
||||
bids: list[dict[str, Any]] | None,
|
||||
*,
|
||||
mark_px: float | None = None,
|
||||
intrinsic_px: float | None = None,
|
||||
min_ratio: float = BID_CLOSE_MIN_RATIO,
|
||||
) -> tuple[list[dict[str, Any]], bool, str]:
|
||||
"""过滤不可用于平仓的残档买盘.返回 (usable_bids, had_stub_only, reason)."""
|
||||
raw = list(bids or [])
|
||||
usable: list[dict[str, Any]] = []
|
||||
stub_reason = ""
|
||||
for level in raw:
|
||||
px = _safe_px(level.get("px") if isinstance(level, dict) else None)
|
||||
stub, reason = is_stub_bid_px(px, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_ratio)
|
||||
if stub:
|
||||
if not stub_reason:
|
||||
stub_reason = reason or "买一无效"
|
||||
continue
|
||||
usable.append(level)
|
||||
if raw and not usable:
|
||||
return [], True, stub_reason or "暂无有效买盘"
|
||||
return usable, False, ""
|
||||
|
||||
|
||||
def estimate_close_by_bids(
|
||||
bids: list[dict[str, Any]] | None,
|
||||
sheets: int | float,
|
||||
*,
|
||||
ct_mult: float = 0.01,
|
||||
premium_paid: float | None = None,
|
||||
mark_px: float | None = None,
|
||||
intrinsic_px: float | None = None,
|
||||
min_bid_ratio: float = BID_CLOSE_MIN_RATIO,
|
||||
max_levels: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""按买盘估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与."""
|
||||
target = max(0, int(float(sheets or 0)))
|
||||
remaining = target
|
||||
total_received = 0.0
|
||||
levels: list[dict[str, Any]] = []
|
||||
max_lv = max(1, int(max_levels or 1))
|
||||
empty = {
|
||||
"levels": [],
|
||||
"covered_sheets": 0,
|
||||
"uncovered_sheets": target,
|
||||
"total_received": 0.0,
|
||||
"avg_px": None,
|
||||
"estimated_pnl": None,
|
||||
"estimated_pnl_ratio_pct": None,
|
||||
"bid_invalid": False,
|
||||
"bid_invalid_reason": None,
|
||||
"auto_close_blocked": False,
|
||||
"max_levels": max_lv,
|
||||
}
|
||||
if target <= 0 or ct_mult <= 0:
|
||||
return empty
|
||||
usable, stub_only, stub_reason = filter_bids_for_close(
|
||||
bids, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_bid_ratio
|
||||
)
|
||||
if stub_only:
|
||||
out = dict(empty)
|
||||
out["bid_invalid"] = True
|
||||
out["bid_invalid_reason"] = stub_reason
|
||||
out["auto_close_blocked"] = True
|
||||
out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None
|
||||
return out
|
||||
for i, level in enumerate(usable[:max_lv], start=1):
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
px = float(level.get("px"))
|
||||
sz = int(float(level.get("sz")))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
if px <= 0 or sz <= 0:
|
||||
continue
|
||||
take = min(remaining, sz)
|
||||
eth_amount = eth_amount_from_sheets(take, ct_mult)
|
||||
received = total_premium(px, eth_amount)
|
||||
levels.append(
|
||||
{
|
||||
"level": i,
|
||||
"px": px,
|
||||
"available_sheets": sz,
|
||||
"sheets": take,
|
||||
"eth_amount": eth_amount,
|
||||
"received": round(received, 4),
|
||||
}
|
||||
)
|
||||
total_received += received
|
||||
remaining -= take
|
||||
covered = target - remaining
|
||||
avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None
|
||||
# 净盈亏 = 本轮买盘可回收 − 全部权利金(买一不够时剩余张数计入 uncovered)
|
||||
estimated_pnl = None
|
||||
estimated_pnl_ratio_pct = None
|
||||
if premium_paid is not None and covered > 0:
|
||||
paid = float(premium_paid)
|
||||
estimated_pnl = round(total_received - paid, 4)
|
||||
if paid > 0:
|
||||
estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2)
|
||||
return {
|
||||
"levels": levels,
|
||||
"covered_sheets": covered,
|
||||
"uncovered_sheets": remaining,
|
||||
"total_received": round(total_received, 4),
|
||||
"avg_px": round(avg_px, 4) if avg_px is not None else None,
|
||||
"estimated_pnl": estimated_pnl,
|
||||
"estimated_pnl_ratio_pct": estimated_pnl_ratio_pct,
|
||||
"bid_invalid": False,
|
||||
"bid_invalid_reason": None,
|
||||
"auto_close_blocked": False,
|
||||
"max_levels": max_lv,
|
||||
}
|
||||
|
||||
|
||||
def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int:
|
||||
if eth_amount <= 0 or ct_mult <= 0:
|
||||
return 0
|
||||
return int(math.floor(eth_amount / ct_mult + 1e-12))
|
||||
|
||||
|
||||
def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float:
|
||||
return round(int(sheets) * float(ct_mult), 8)
|
||||
|
||||
|
||||
def calc_order_size(
|
||||
*,
|
||||
quote_per_unit: float,
|
||||
ct_mult: float,
|
||||
min_sz: int,
|
||||
budget_usdc: float | None = None,
|
||||
budget_buffer: float = 0.95,
|
||||
eth_amount: float | None = None,
|
||||
sheets: int | None = None,
|
||||
budget_cap: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
返回 sheets, eth_amount, total_premium.
|
||||
mode: budget_full / eth_amount / sheets.
|
||||
"""
|
||||
if quote_per_unit <= 0:
|
||||
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||
|
||||
if sheets is not None and int(sheets) > 0:
|
||||
sheets = int(sheets)
|
||||
elif eth_amount is not None and eth_amount > 0:
|
||||
sheets = sheets_from_eth_amount(eth_amount, ct_mult)
|
||||
elif budget_usdc is not None and budget_usdc > 0:
|
||||
eff = float(budget_usdc) * float(budget_buffer)
|
||||
per_sheet = premium_per_sheet(quote_per_unit, ct_mult)
|
||||
if per_sheet <= 0:
|
||||
return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||
sheets = int(math.floor(eff / per_sheet))
|
||||
else:
|
||||
return {"ok": False, "msg": "请指定预算,币数量或张数", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||
|
||||
if sheets < min_sz:
|
||||
per = premium_per_sheet(quote_per_unit, ct_mult)
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"预算不足,无法买入 {min_sz} 张(单张约 {per:.4f} USDC)",
|
||||
"sheets": sheets,
|
||||
"eth_amount": eth_amount_from_sheets(sheets, ct_mult),
|
||||
"total_premium": total_premium(quote_per_unit, eth_amount_from_sheets(sheets, ct_mult)),
|
||||
}
|
||||
|
||||
eth = eth_amount_from_sheets(sheets, ct_mult)
|
||||
prem = total_premium(quote_per_unit, eth)
|
||||
if budget_cap is not None and prem > float(budget_cap) + 1e-9:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"权利金 {prem:.4f} 超过单笔上限 {budget_cap} USDC",
|
||||
"sheets": sheets,
|
||||
"eth_amount": eth,
|
||||
"total_premium": prem,
|
||||
}
|
||||
return {"ok": True, "msg": "", "sheets": sheets, "eth_amount": eth, "total_premium": prem}
|
||||
|
||||
|
||||
def is_shallow_itm(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
index_px: float,
|
||||
max_dist_usd: float,
|
||||
) -> bool:
|
||||
o = (opt_type or "").upper()
|
||||
if o == "C":
|
||||
if strike >= index_px:
|
||||
return False
|
||||
return (index_px - strike) <= max_dist_usd
|
||||
if o == "P":
|
||||
if strike <= index_px:
|
||||
return False
|
||||
return (strike - index_px) <= max_dist_usd
|
||||
return False
|
||||
|
||||
|
||||
def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
|
||||
"""返回 itm / otm / atm."""
|
||||
o = (opt_type or "").upper()
|
||||
if strike is None or index_px is None or index_px <= 0:
|
||||
return "unknown"
|
||||
atm_band = max(index_px * 0.002, 2.0)
|
||||
if abs(strike - index_px) <= atm_band:
|
||||
return "atm"
|
||||
if o == "C":
|
||||
return "itm" if strike < index_px else "otm"
|
||||
if o == "P":
|
||||
return "itm" if strike > index_px else "otm"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def option_moneyness_label(moneyness: str) -> str:
|
||||
return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "")
|
||||
|
||||
|
||||
def expiry_breakeven_from_ask(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float | None,
|
||||
ask_px: float | None,
|
||||
mark_px: float | None = None,
|
||||
) -> float | None:
|
||||
"""买入前预估到期平衡:权利金按卖一;无卖一时回退标记价."""
|
||||
prem = ask_px if ask_px is not None and ask_px > 0 else mark_px
|
||||
return expiry_breakeven_px(opt_type=opt_type, strike=strike, avg_px=prem)
|
||||
|
||||
|
||||
def expiry_breakeven_px(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float | None,
|
||||
avg_px: float | None,
|
||||
be_px_api: float | None = None,
|
||||
) -> float | None:
|
||||
"""到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx."""
|
||||
if be_px_api is not None and be_px_api > 0:
|
||||
return round(float(be_px_api), 2)
|
||||
if strike is None or avg_px is None:
|
||||
return None
|
||||
o = (opt_type or "").upper()
|
||||
if o == "C":
|
||||
return round(strike + avg_px, 2)
|
||||
if o == "P":
|
||||
return round(strike - avg_px, 2)
|
||||
return None
|
||||
|
||||
|
||||
def close_breakeven_idx(
|
||||
*,
|
||||
opt_type: str,
|
||||
idx_px: float | None,
|
||||
mark_px: float | None,
|
||||
avg_px: float | None,
|
||||
delta_pa: float | None = None,
|
||||
pos: float = 0,
|
||||
ct_mult: float = 0.01,
|
||||
) -> float | None:
|
||||
"""
|
||||
平掉回本:标的指数达到该价位时,按标记价平仓近似盈亏为 0.
|
||||
优先用 deltaPA 线性外推,否则用时间价值近似(适合短期轻度实值).
|
||||
"""
|
||||
if idx_px is None or mark_px is None or avg_px is None:
|
||||
return None
|
||||
eth_amt = abs(float(pos)) * float(ct_mult)
|
||||
if eth_amt > 1e-12 and delta_pa is not None and abs(float(delta_pa)) > 1e-12:
|
||||
slope = float(delta_pa) / eth_amt
|
||||
return round(float(idx_px) + (float(avg_px) - float(mark_px)) / slope, 2)
|
||||
o = (opt_type or "").upper()
|
||||
if o == "C":
|
||||
return round(float(idx_px) + float(avg_px) - float(mark_px), 2)
|
||||
if o == "P":
|
||||
return round(float(idx_px) + float(mark_px) - float(avg_px), 2)
|
||||
return None
|
||||
|
||||
|
||||
def idx_distance_to_be(idx_px: float | None, be_px: float | None) -> float | None:
|
||||
"""指数距平衡点(正=指数需上涨才到平衡点)."""
|
||||
if idx_px is None or be_px is None:
|
||||
return None
|
||||
return round(float(be_px) - float(idx_px), 2)
|
||||
|
||||
|
||||
def format_options_breakeven_line(
|
||||
*,
|
||||
expiry_be_px: float | None,
|
||||
close_be_px: float | None,
|
||||
idx_px: float | None = None,
|
||||
) -> str:
|
||||
"""持仓摘要行:到期平衡 / 平掉回本."""
|
||||
parts: list[str] = []
|
||||
if expiry_be_px is not None:
|
||||
parts.append(f"到期平衡{expiry_be_px:.0f}")
|
||||
if close_be_px is not None:
|
||||
parts.append(f"平掉回本{close_be_px:.0f}")
|
||||
if idx_px is not None and parts:
|
||||
return " ".join(parts) + f"(指数{idx_px:.0f})"
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def estimate_expiry_value_at_index(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float | None,
|
||||
target_idx: float | None,
|
||||
eth_amount: float | None,
|
||||
) -> float | None:
|
||||
"""到期测算:目标指数价下期权内在价值总额(不含已付权利金)."""
|
||||
if strike is None or target_idx is None or eth_amount is None:
|
||||
return None
|
||||
if eth_amount <= 0:
|
||||
return None
|
||||
o = (opt_type or "").upper()
|
||||
if o == "C":
|
||||
intrinsic = max(0.0, float(target_idx) - float(strike))
|
||||
elif o == "P":
|
||||
intrinsic = max(0.0, float(strike) - float(target_idx))
|
||||
else:
|
||||
return None
|
||||
return round(intrinsic * float(eth_amount), 2)
|
||||
|
||||
|
||||
def estimate_expiry_profit_at_index(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float | None,
|
||||
target_idx: float | None,
|
||||
entry_px: float | None,
|
||||
eth_amount: float | None,
|
||||
total_premium: float | None = None,
|
||||
) -> float | None:
|
||||
"""到期测算:目标指数价下净盈利 = 预计价值 − 权利金."""
|
||||
value = estimate_expiry_value_at_index(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
target_idx=target_idx,
|
||||
eth_amount=eth_amount,
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
prem = total_premium
|
||||
if prem is None and entry_px is not None and eth_amount is not None:
|
||||
prem = float(entry_px) * float(eth_amount)
|
||||
if prem is None:
|
||||
return None
|
||||
return round(float(value) - float(prem), 2)
|
||||
|
||||
|
||||
def equivalent_contract_leverage(
|
||||
*,
|
||||
index_px: float | None,
|
||||
eth_amount: float | None,
|
||||
total_premium: float | None,
|
||||
) -> float | None:
|
||||
"""名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用)."""
|
||||
if index_px is None or eth_amount is None or total_premium is None:
|
||||
return None
|
||||
if eth_amount <= 0 or total_premium <= 0:
|
||||
return None
|
||||
return round(float(index_px) * float(eth_amount) / float(total_premium), 1)
|
||||
|
||||
|
||||
def straddle_ask_per_unit(
|
||||
call_ask: float | None,
|
||||
put_ask: float | None,
|
||||
) -> float | None:
|
||||
"""跨式双买:每 1 标的币的卖一报价之和."""
|
||||
if call_ask is None or put_ask is None:
|
||||
return None
|
||||
if float(call_ask) <= 0 or float(put_ask) <= 0:
|
||||
return None
|
||||
return round(float(call_ask) + float(put_ask), 4)
|
||||
|
||||
|
||||
def straddle_premium_total(
|
||||
call_ask: float | None,
|
||||
put_ask: float | None,
|
||||
eth_amount: float | None,
|
||||
) -> float | None:
|
||||
"""跨式双买权利金总额(USDC)."""
|
||||
per = straddle_ask_per_unit(call_ask, put_ask)
|
||||
if per is None or eth_amount is None or float(eth_amount) <= 0:
|
||||
return None
|
||||
return round(per * float(eth_amount), 2)
|
||||
|
||||
|
||||
def straddle_breakeven_band(
|
||||
strike: float | None,
|
||||
combined_ask_per_unit: float | None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""跨式到期平衡带:下平衡 ~ 上平衡(按双卖一报价和)."""
|
||||
if strike is None or combined_ask_per_unit is None:
|
||||
return None, None
|
||||
k = float(strike)
|
||||
d = float(combined_ask_per_unit)
|
||||
return round(k - d, 2), round(k + d, 2)
|
||||
|
||||
|
||||
def format_straddle_band(
|
||||
strike: float | None,
|
||||
combined_ask_per_unit: float | None,
|
||||
) -> str:
|
||||
lo, hi = straddle_breakeven_band(strike, combined_ask_per_unit)
|
||||
if lo is None or hi is None:
|
||||
return ""
|
||||
return f"{lo:.0f} ~ {hi:.0f}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
"""期权复盘(含对冲) SQLite 表."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
SOURCE_OPTION = "option_spot"
|
||||
SOURCE_PERP_OPTIONS = "perp_options"
|
||||
SOURCE_OPTIONS_OPTIONS = "options_options"
|
||||
SOURCE_TYPES = (SOURCE_OPTION, SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
|
||||
|
||||
|
||||
def init_options_review_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_review_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_type TEXT NOT NULL,
|
||||
history_key TEXT NOT NULL UNIQUE,
|
||||
underlying TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
hold_seconds INTEGER,
|
||||
realized_pnl_total REAL,
|
||||
status_raw TEXT,
|
||||
synced_at TEXT,
|
||||
-- 纯期权
|
||||
pos_id TEXT,
|
||||
inst_id TEXT,
|
||||
opt_type TEXT,
|
||||
strike REAL,
|
||||
exp_time TEXT,
|
||||
sheets INTEGER,
|
||||
open_avg REAL,
|
||||
close_avg REAL,
|
||||
premium_paid REAL,
|
||||
realized_pnl REAL,
|
||||
-- 对冲计划
|
||||
hedge_plan_id INTEGER,
|
||||
plan_close_reason TEXT,
|
||||
realized_pnl_perp REAL,
|
||||
realized_pnl_options REAL,
|
||||
premium_total REAL,
|
||||
direction TEXT,
|
||||
tp REAL,
|
||||
sl REAL,
|
||||
target_price REAL,
|
||||
target_price_up REAL,
|
||||
target_price_down REAL,
|
||||
legs_json TEXT,
|
||||
-- 双计防护:纯期权腿已归属对冲计划
|
||||
linked_hedge_plan_id INTEGER,
|
||||
excluded_as_hedge_leg INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_history_key
|
||||
ON options_review_trades(history_key)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_hedge_plan
|
||||
ON options_review_trades(hedge_plan_id)
|
||||
WHERE hedge_plan_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_review_trades_closed
|
||||
ON options_review_trades(closed_at)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_review_trades_source
|
||||
ON options_review_trades(source_type)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_review_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_id INTEGER NOT NULL UNIQUE,
|
||||
strategy_tag TEXT,
|
||||
direction_view TEXT,
|
||||
entry_logic TEXT,
|
||||
exit_reason TEXT,
|
||||
followed_plan TEXT,
|
||||
mistake_tags TEXT,
|
||||
result_tag TEXT,
|
||||
note TEXT,
|
||||
images_json TEXT,
|
||||
image TEXT,
|
||||
reviewed_at TEXT,
|
||||
updated_at TEXT,
|
||||
FOREIGN KEY(trade_id) REFERENCES options_review_trades(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_review_sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_review_hidden (
|
||||
history_key TEXT PRIMARY KEY,
|
||||
inst_id TEXT,
|
||||
closed_at TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_review_hidden_inst
|
||||
ON options_review_hidden(inst_id, closed_at)
|
||||
"""
|
||||
)
|
||||
_ensure_column(conn, "options_review_trades", "linked_hedge_plan_id", "INTEGER")
|
||||
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
|
||||
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
|
||||
_ensure_column(conn, "options_review_trades", "target_price_down", "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}")
|
||||
@@ -0,0 +1,138 @@
|
||||
"""期权复盘截图:独立命名空间,与合约同款四周期 5m/15m/1h/4h."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
OPTIONS_REVIEW_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
|
||||
OPTIONS_REVIEW_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
|
||||
_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$")
|
||||
_SLOT_FILE_RE = re.compile(
|
||||
r"^options_journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def normalize_options_review_draft_id(raw: Any) -> Optional[str]:
|
||||
s = str(raw or "").strip().lower()
|
||||
if _DRAFT_ID_RE.match(s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _safe_ext(filename: str) -> str:
|
||||
ext = os.path.splitext(str(filename or ""))[1].lower()
|
||||
return ext if ext in OPTIONS_REVIEW_ALLOWED_EXT else ".png"
|
||||
|
||||
|
||||
def options_review_upload_dir(base_upload_folder: str) -> str:
|
||||
"""独立子目录 static/images/options_journal."""
|
||||
base = os.path.abspath(base_upload_folder or "")
|
||||
path = os.path.join(base, "options_journal")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def build_options_review_slot_filename(
|
||||
draft_id: str,
|
||||
tf: str,
|
||||
ext: str,
|
||||
*,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> str:
|
||||
ext = ext if ext.startswith(".") else f".{ext}"
|
||||
ext = _safe_ext(f"x{ext}")
|
||||
fname = secure_filename_fn(f"options_journal_{draft_id}_{tf}{ext}")
|
||||
return fname or ""
|
||||
|
||||
|
||||
def is_valid_options_review_file(filename: str, draft_id: str, tf: str) -> bool:
|
||||
fn = os.path.basename(str(filename or "").strip())
|
||||
if not fn or fn != str(filename or "").strip():
|
||||
return False
|
||||
m = _SLOT_FILE_RE.match(fn)
|
||||
if not m:
|
||||
return False
|
||||
return m.group(1) == draft_id.lower() and m.group(2) == tf
|
||||
|
||||
|
||||
def save_options_review_slot_file(
|
||||
file,
|
||||
draft_id: str,
|
||||
tf: str,
|
||||
upload_folder: str,
|
||||
*,
|
||||
secure_filename_fn: Callable[[str], str],
|
||||
) -> Optional[Dict[str, str]]:
|
||||
if tf not in OPTIONS_REVIEW_UPLOAD_TFS or not draft_id or not upload_folder:
|
||||
return None
|
||||
if not file or not getattr(file, "filename", None):
|
||||
return None
|
||||
ext = _safe_ext(file.filename)
|
||||
fname = build_options_review_slot_filename(
|
||||
draft_id, tf, ext, secure_filename_fn=secure_filename_fn
|
||||
)
|
||||
if not fname:
|
||||
return None
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
path = os.path.join(upload_folder, fname)
|
||||
file.save(path)
|
||||
return {"tf": tf, "file": fname}
|
||||
|
||||
|
||||
def parse_options_review_images_json(raw: Any) -> List[Dict[str, str]]:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
data = raw
|
||||
else:
|
||||
try:
|
||||
data = json.loads(str(raw))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out: List[Dict[str, str]] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tf = str(item.get("tf") or "").strip()
|
||||
file = str(item.get("file") or "").strip()
|
||||
if file:
|
||||
out.append({"tf": tf, "file": file})
|
||||
return out
|
||||
|
||||
|
||||
def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
|
||||
if not items:
|
||||
return None
|
||||
return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def options_review_image_paths(row: Any, upload_folder: str) -> List[str]:
|
||||
upload_folder = os.path.abspath(upload_folder or "")
|
||||
paths: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(name: Optional[str]) -> None:
|
||||
if not name:
|
||||
return
|
||||
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
|
||||
if os.path.isfile(p) and p not in seen:
|
||||
seen.add(p)
|
||||
paths.append(p)
|
||||
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else ()
|
||||
except Exception:
|
||||
keys = ()
|
||||
images = parse_options_review_images_json(
|
||||
row["images_json"] if "images_json" in keys else getattr(row, "images_json", None)
|
||||
)
|
||||
for item in images:
|
||||
_add(item.get("file"))
|
||||
if "image" in keys or hasattr(row, "image"):
|
||||
_add(row["image"] if "image" in keys else getattr(row, "image", None))
|
||||
return paths
|
||||
@@ -0,0 +1,971 @@
|
||||
"""期权复盘业务:OKX 已平期权导入 + 已结束对冲计划导入 + 复盘 CRUD + 统计."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.options.options_review_db import (
|
||||
SOURCE_OPTION,
|
||||
SOURCE_OPTIONS_OPTIONS,
|
||||
SOURCE_PERP_OPTIONS,
|
||||
SOURCE_TYPES,
|
||||
init_options_review_tables,
|
||||
)
|
||||
from lib.options.options_review_images_lib import (
|
||||
images_json_dumps,
|
||||
parse_options_review_images_json,
|
||||
)
|
||||
|
||||
SOURCE_LABELS = {
|
||||
SOURCE_OPTION: "纯期权",
|
||||
SOURCE_PERP_OPTIONS: "永期对冲",
|
||||
SOURCE_OPTIONS_OPTIONS: "期期对冲",
|
||||
}
|
||||
|
||||
HOLD_BUCKETS = (
|
||||
("0-1h", 0, 3600),
|
||||
("1-6h", 3600, 6 * 3600),
|
||||
("6-24h", 6 * 3600, 24 * 3600),
|
||||
("1-3d", 24 * 3600, 3 * 24 * 3600),
|
||||
(">3d", 3 * 24 * 3600, None),
|
||||
)
|
||||
|
||||
|
||||
def _now_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _parse_ts(raw: Any) -> Optional[datetime]:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
s = str(raw).strip().replace(" ", "T", 1)
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _hold_seconds(opened_at: Any, closed_at: Any) -> Optional[int]:
|
||||
start = _parse_ts(opened_at)
|
||||
end = _parse_ts(closed_at)
|
||||
if start is None or end is None:
|
||||
return None
|
||||
sec = int((end - start).total_seconds())
|
||||
return sec if sec >= 0 else None
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM options_review_sync_state WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
return str(row["value"]) if row and row["value"] is not None else None
|
||||
|
||||
|
||||
def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_review_sync_state(key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, value, _now_str()),
|
||||
)
|
||||
|
||||
|
||||
def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool:
|
||||
"""删除已导入的复盘快照(含复盘内容)."""
|
||||
key = str(history_key or "").strip()
|
||||
if not key:
|
||||
return False
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE history_key=?", (key,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return False
|
||||
tid = int(existing["id"])
|
||||
conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (tid,))
|
||||
conn.execute("DELETE FROM options_review_trades WHERE id=?", (tid,))
|
||||
return True
|
||||
|
||||
|
||||
def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str:
|
||||
"""幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入."""
|
||||
history_key = str(row.get("history_key") or "").strip()
|
||||
if not history_key:
|
||||
return "skip"
|
||||
if is_review_hidden(
|
||||
conn,
|
||||
history_key,
|
||||
inst_id=str(row.get("inst_id") or "").strip() or None,
|
||||
closed_at=row.get("closed_at") or row.get("created_at"),
|
||||
):
|
||||
# 若此前已导入,清掉,避免列表残留
|
||||
return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden"
|
||||
opened_at = row.get("created_at") or row.get("opened_at")
|
||||
closed_at = row.get("closed_at")
|
||||
pnl = _safe_float(row.get("realized_pnl"))
|
||||
hold = _hold_seconds(opened_at, closed_at)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE history_key=?", (history_key,)
|
||||
).fetchone()
|
||||
fields = {
|
||||
"source_type": SOURCE_OPTION,
|
||||
"history_key": history_key,
|
||||
"underlying": str(row.get("underlying") or "").strip() or None,
|
||||
"opened_at": opened_at,
|
||||
"closed_at": closed_at,
|
||||
"hold_seconds": hold,
|
||||
"realized_pnl_total": pnl,
|
||||
"status_raw": str(row.get("status_label") or row.get("status") or "closed"),
|
||||
"synced_at": _now_str(),
|
||||
"pos_id": str(row.get("pos_id") or "").strip() or None,
|
||||
"inst_id": str(row.get("inst_id") or "").strip() or None,
|
||||
"opt_type": str(row.get("opt_type") or "").strip() or None,
|
||||
"strike": _safe_float(row.get("strike")),
|
||||
"exp_time": str(row.get("exp_time") or "").strip() or None,
|
||||
"sheets": int(row.get("sheets") or 0) or None,
|
||||
"open_avg": _safe_float(row.get("open_avg_px") if row.get("open_avg_px") is not None else row.get("open_avg")),
|
||||
"close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")),
|
||||
"premium_paid": _safe_float(row.get("premium_paid")),
|
||||
"realized_pnl": pnl,
|
||||
}
|
||||
cols = list(fields.keys())
|
||||
if existing:
|
||||
sets = ", ".join(f"{c}=?" for c in cols if c != "history_key")
|
||||
vals = [fields[c] for c in cols if c != "history_key"]
|
||||
conn.execute(
|
||||
f"UPDATE options_review_trades SET {sets} WHERE history_key=?",
|
||||
[*vals, history_key],
|
||||
)
|
||||
return "updated"
|
||||
placeholders = ",".join(["?"] * len(cols))
|
||||
conn.execute(
|
||||
f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})",
|
||||
[fields[c] for c in cols],
|
||||
)
|
||||
return "inserted"
|
||||
|
||||
|
||||
def _close_fingerprint(inst_id: Any, closed_at: Any) -> str | None:
|
||||
inst = str(inst_id or "").strip()
|
||||
if not inst:
|
||||
return None
|
||||
closed = str(closed_at or "").strip()
|
||||
if not closed:
|
||||
return f"inst:{inst}"
|
||||
# 精确到分钟,避免秒差导致漏匹配
|
||||
return f"inst_close:{inst}:{closed[:16]}"
|
||||
|
||||
|
||||
def is_review_hidden(
|
||||
conn: sqlite3.Connection,
|
||||
history_key: str,
|
||||
*,
|
||||
inst_id: str | None = None,
|
||||
closed_at: Any = None,
|
||||
) -> bool:
|
||||
init_options_review_tables(conn)
|
||||
key = str(history_key or "").strip()
|
||||
if key and conn.execute(
|
||||
"SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (key,)
|
||||
).fetchone():
|
||||
return True
|
||||
fp = _close_fingerprint(inst_id, closed_at)
|
||||
if fp and conn.execute(
|
||||
"SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (fp,)
|
||||
).fetchone():
|
||||
return True
|
||||
# 期权历史页删除:options_history_hidden,按合约指纹或原 key
|
||||
try:
|
||||
if key and conn.execute(
|
||||
"SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (key,)
|
||||
).fetchone():
|
||||
return True
|
||||
if fp and conn.execute(
|
||||
"SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (fp,)
|
||||
).fetchone():
|
||||
return True
|
||||
# 仅隐藏了 ex:posId 时,用合约+平仓时间在历史隐藏表无直接命中;
|
||||
# 若指纹已写入 options_review_hidden(新删除路径)上面已覆盖.
|
||||
# 兼容:inst 级隐藏
|
||||
if inst_id:
|
||||
inst_fp = f"inst:{str(inst_id).strip()}"
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1",
|
||||
(inst_fp,),
|
||||
).fetchone():
|
||||
return True
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1",
|
||||
(inst_fp,),
|
||||
).fetchone():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def hide_review_keys(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
history_key: str,
|
||||
inst_id: str | None = None,
|
||||
closed_at: Any = None,
|
||||
) -> None:
|
||||
init_options_review_tables(conn)
|
||||
keys = [str(history_key or "").strip()]
|
||||
fp = _close_fingerprint(inst_id, closed_at)
|
||||
if fp:
|
||||
keys.append(fp)
|
||||
for k in keys:
|
||||
if not k:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(k, (inst_id or None), str(closed_at or "")[:19] or None),
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO options_history_hidden(history_key) VALUES (?)",
|
||||
(k,),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]:
|
||||
"""从复盘列表删除并持久隐藏,刷新本地源也不会再回来."""
|
||||
init_options_review_tables(conn)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"ok": False, "msg": "记录不存在"}
|
||||
d = _row_to_dict(row)
|
||||
hide_review_keys(
|
||||
conn,
|
||||
history_key=str(d.get("history_key") or ""),
|
||||
inst_id=str(d.get("inst_id") or "").strip() or None,
|
||||
closed_at=d.get("closed_at") or d.get("opened_at"),
|
||||
)
|
||||
entry = conn.execute(
|
||||
"SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),)
|
||||
).fetchone()
|
||||
conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),))
|
||||
conn.execute("DELETE FROM options_review_trades WHERE id=?", (int(trade_id),))
|
||||
return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")}
|
||||
|
||||
|
||||
|
||||
def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所)."""
|
||||
init_options_review_tables(conn)
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
|
||||
open_quote, close_quote, premium_paid, realized_pnl,
|
||||
created_at, closed_at, signal_note, status
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
"""
|
||||
).fetchall()
|
||||
inserted = updated = skipped = 0
|
||||
for r in rows:
|
||||
trade_id = int(r["id"])
|
||||
history_key = f"local_opt:{trade_id}"
|
||||
pnl = _safe_float(r["realized_pnl"])
|
||||
opened_at = r["created_at"]
|
||||
closed_at = r["closed_at"]
|
||||
action = upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": history_key,
|
||||
"pos_id": f"local:{trade_id}",
|
||||
"inst_id": r["inst_id"],
|
||||
"underlying": r["underlying"],
|
||||
"opt_type": r["opt_type"],
|
||||
"strike": r["strike"],
|
||||
"exp_time": r["exp_time"],
|
||||
"sheets": r["sheets"],
|
||||
"open_avg_px": r["open_quote"],
|
||||
"close_avg_px": r["close_quote"],
|
||||
"premium_paid": r["premium_paid"],
|
||||
"realized_pnl": pnl,
|
||||
"created_at": opened_at,
|
||||
"closed_at": closed_at,
|
||||
"status_label": "已平",
|
||||
},
|
||||
)
|
||||
if action == "inserted":
|
||||
inserted += 1
|
||||
elif action == "updated":
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
set_sync_state(conn, "options_last_sync_at", _now_str())
|
||||
set_sync_state(conn, "options_last_count", str(len(rows)))
|
||||
set_sync_state(conn, "options_sync_source", "local")
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "local",
|
||||
"fetched": len(rows),
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
def sync_options_from_exchange(
|
||||
conn: sqlite3.Connection,
|
||||
ex: Any,
|
||||
*,
|
||||
limit: int = 500,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
format_fn: Optional[Callable[..., dict[str, Any]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""从 OKX positions-history 导入已全平期权仓位(可选,默认不用)."""
|
||||
init_options_review_tables(conn)
|
||||
from lib.exchange.okx_options_lib import (
|
||||
fetch_all_option_positions_history,
|
||||
format_option_history_row,
|
||||
tick_sz_and_ct_mult,
|
||||
)
|
||||
|
||||
fetch = fetch_fn or fetch_all_option_positions_history
|
||||
fmt = format_fn or format_option_history_row
|
||||
raw_rows = fetch(ex, limit=limit)
|
||||
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||
inserted = updated = skipped = 0
|
||||
for raw in raw_rows:
|
||||
inst_id = str(raw.get("instId") or "").strip()
|
||||
tick_sz, ct_mult = None, 0.01
|
||||
try:
|
||||
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
|
||||
except Exception:
|
||||
pass
|
||||
formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult)
|
||||
action = upsert_option_history_row(conn, formatted)
|
||||
if action == "inserted":
|
||||
inserted += 1
|
||||
elif action == "updated":
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
set_sync_state(conn, "options_last_sync_at", _now_str())
|
||||
set_sync_state(conn, "options_last_count", str(len(raw_rows)))
|
||||
set_sync_state(conn, "options_sync_source", "exchange")
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "exchange",
|
||||
"fetched": len(raw_rows),
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
def _legs_json_from_plan(legs: list[dict[str, Any]]) -> str:
|
||||
slim = []
|
||||
for leg in legs:
|
||||
slim.append(
|
||||
{
|
||||
"id": leg.get("id"),
|
||||
"leg_role": leg.get("leg_role"),
|
||||
"symbol": leg.get("symbol"),
|
||||
"inst_id": leg.get("inst_id"),
|
||||
"opt_type": leg.get("opt_type"),
|
||||
"strike": leg.get("strike"),
|
||||
"side": leg.get("side"),
|
||||
"size": leg.get("size"),
|
||||
"avg_open": leg.get("avg_open"),
|
||||
"premium": leg.get("premium"),
|
||||
"status": leg.get("status"),
|
||||
"realized_pnl": leg.get("realized_pnl"),
|
||||
"close_reason": leg.get("close_reason"),
|
||||
"opened_at": leg.get("opened_at"),
|
||||
"closed_at": leg.get("closed_at"),
|
||||
}
|
||||
)
|
||||
return json.dumps(slim, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def upsert_hedge_plan_row(
|
||||
conn: sqlite3.Connection,
|
||||
plan: dict[str, Any],
|
||||
legs: list[dict[str, Any]],
|
||||
) -> str:
|
||||
plan_id = int(plan["id"])
|
||||
history_key = f"hedge:{plan_id}"
|
||||
plan_type = str(plan.get("plan_type") or "").strip()
|
||||
if plan_type not in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS):
|
||||
return "skip"
|
||||
opened_at = plan.get("opened_at") or plan.get("created_at")
|
||||
closed_at = plan.get("closed_at")
|
||||
if is_review_hidden(
|
||||
conn,
|
||||
history_key,
|
||||
inst_id=None,
|
||||
closed_at=closed_at,
|
||||
):
|
||||
return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden"
|
||||
total = _safe_float(plan.get("realized_pnl_total"))
|
||||
hold = _hold_seconds(opened_at, closed_at)
|
||||
fields = {
|
||||
"source_type": plan_type,
|
||||
"history_key": history_key,
|
||||
"underlying": str(plan.get("underlying") or "").strip() or None,
|
||||
"opened_at": opened_at,
|
||||
"closed_at": closed_at,
|
||||
"hold_seconds": hold,
|
||||
"realized_pnl_total": total,
|
||||
"status_raw": str(plan.get("status") or "closed"),
|
||||
"synced_at": _now_str(),
|
||||
"hedge_plan_id": plan_id,
|
||||
"plan_close_reason": str(plan.get("close_reason") or "").strip() or None,
|
||||
"realized_pnl_perp": _safe_float(plan.get("realized_pnl_perp")),
|
||||
"realized_pnl_options": _safe_float(plan.get("realized_pnl_options")),
|
||||
"premium_total": _safe_float(plan.get("premium_total")),
|
||||
"direction": str(plan.get("direction") or "").strip() or None,
|
||||
"tp": _safe_float(plan.get("tp")),
|
||||
"sl": _safe_float(plan.get("sl")),
|
||||
"target_price": _safe_float(plan.get("target_price")),
|
||||
"target_price_up": _safe_float(plan.get("target_price_up")),
|
||||
"target_price_down": _safe_float(plan.get("target_price_down")),
|
||||
"legs_json": _legs_json_from_plan(legs),
|
||||
}
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE history_key=?", (history_key,)
|
||||
).fetchone()
|
||||
cols = list(fields.keys())
|
||||
if existing:
|
||||
sets = ", ".join(f"{c}=?" for c in cols if c != "history_key")
|
||||
vals = [fields[c] for c in cols if c != "history_key"]
|
||||
conn.execute(
|
||||
f"UPDATE options_review_trades SET {sets} WHERE history_key=?",
|
||||
[*vals, history_key],
|
||||
)
|
||||
trade_id = int(existing["id"])
|
||||
action = "updated"
|
||||
else:
|
||||
placeholders = ",".join(["?"] * len(cols))
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})",
|
||||
[fields[c] for c in cols],
|
||||
)
|
||||
trade_id = int(cur.lastrowid)
|
||||
action = "inserted"
|
||||
_mark_option_legs_excluded(conn, plan_id, legs)
|
||||
del trade_id
|
||||
return action
|
||||
|
||||
|
||||
def _mark_option_legs_excluded(
|
||||
conn: sqlite3.Connection,
|
||||
plan_id: int,
|
||||
legs: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""纯期权记录若 inst_id 出现在对冲腿中,标记排除以免双计."""
|
||||
inst_ids = {
|
||||
str(leg.get("inst_id") or "").strip()
|
||||
for leg in legs
|
||||
if str(leg.get("leg_role") or "").startswith("option") and str(leg.get("inst_id") or "").strip()
|
||||
}
|
||||
if not inst_ids:
|
||||
return 0
|
||||
n = 0
|
||||
for inst_id in inst_ids:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE options_review_trades
|
||||
SET excluded_as_hedge_leg = 1, linked_hedge_plan_id = ?
|
||||
WHERE source_type = ? AND inst_id = ? AND excluded_as_hedge_leg = 0
|
||||
""",
|
||||
(plan_id, SOURCE_OPTION, inst_id),
|
||||
)
|
||||
n += int(cur.rowcount or 0)
|
||||
return n
|
||||
|
||||
|
||||
def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""从本地 hedge_plans 导入已结束计划(计划级)."""
|
||||
init_options_review_tables(conn)
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans
|
||||
|
||||
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(
|
||||
conn: sqlite3.Connection,
|
||||
ex: Any | None = None,
|
||||
*,
|
||||
options_limit: int = 500,
|
||||
from_exchange: bool = False,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
format_fn: Optional[Callable[..., dict[str, Any]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""默认只读本地 options_trades + 已结束对冲计划;不访问交易所."""
|
||||
init_options_review_tables(conn)
|
||||
out: dict[str, Any] = {"ok": True, "options": None, "hedge": None}
|
||||
if from_exchange and ex is not None:
|
||||
out["options"] = sync_options_from_exchange(
|
||||
conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn
|
||||
)
|
||||
else:
|
||||
out["options"] = sync_options_from_local_trades(conn)
|
||||
out["hedge"] = sync_hedge_plans_closed(conn)
|
||||
return out
|
||||
|
||||
|
||||
def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""列表/统计前轻量刷新本地源."""
|
||||
return sync_all_review_sources(conn, from_exchange=False)
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
return dict(row) if row is not None else {}
|
||||
|
||||
|
||||
def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
out = dict(row)
|
||||
out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type"))
|
||||
out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
|
||||
legs = []
|
||||
if out.get("legs_json"):
|
||||
try:
|
||||
legs = json.loads(str(out["legs_json"]))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
legs = []
|
||||
out["legs"] = legs if isinstance(legs, list) else []
|
||||
out["reviewed"] = bool(entry)
|
||||
if entry:
|
||||
out["entry"] = dict(entry)
|
||||
out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json"))
|
||||
out["strategy_tag"] = entry.get("strategy_tag")
|
||||
out["result_tag"] = entry.get("result_tag")
|
||||
out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at")
|
||||
else:
|
||||
out["entry"] = None
|
||||
out["strategy_tag"] = None
|
||||
out["result_tag"] = None
|
||||
out["reviewed_at"] = None
|
||||
return out
|
||||
|
||||
|
||||
def _review_trades_filters(
|
||||
*,
|
||||
source_type: str | None = None,
|
||||
underlying: str | None = None,
|
||||
opt_type: str | None = None,
|
||||
strategy_tag: str | None = None,
|
||||
reviewed: str | None = None,
|
||||
include_hedge_legs: bool = False,
|
||||
closed_from: str | None = None,
|
||||
closed_to: str | None = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
wheres: list[str] = []
|
||||
args: list[Any] = []
|
||||
if source_type and source_type in SOURCE_TYPES:
|
||||
wheres.append("t.source_type=?")
|
||||
args.append(source_type)
|
||||
if underlying:
|
||||
wheres.append("UPPER(COALESCE(t.underlying,''))=?")
|
||||
args.append(underlying.strip().upper())
|
||||
if opt_type:
|
||||
ot = opt_type.strip().upper()
|
||||
if ot in ("C", "P", "CALL", "PUT"):
|
||||
if ot.startswith("C"):
|
||||
ot = "C"
|
||||
elif ot.startswith("P"):
|
||||
ot = "P"
|
||||
wheres.append(
|
||||
"""(
|
||||
UPPER(COALESCE(t.opt_type,''))=?
|
||||
OR (
|
||||
t.legs_json IS NOT NULL
|
||||
AND t.legs_json LIKE '%' || '"opt_type":"' || ? || '%'
|
||||
)
|
||||
)"""
|
||||
)
|
||||
args.extend([ot, ot])
|
||||
if not include_hedge_legs:
|
||||
wheres.append("COALESCE(t.excluded_as_hedge_leg,0)=0")
|
||||
if closed_from:
|
||||
wheres.append("COALESCE(t.closed_at,'')>=?")
|
||||
args.append(closed_from)
|
||||
if closed_to:
|
||||
wheres.append("COALESCE(t.closed_at,'')<=?")
|
||||
args.append(closed_to)
|
||||
if strategy_tag:
|
||||
wheres.append("e.strategy_tag=?")
|
||||
args.append(strategy_tag)
|
||||
if reviewed == "1" or reviewed == "yes":
|
||||
wheres.append("e.id IS NOT NULL")
|
||||
elif reviewed == "0" or reviewed == "no":
|
||||
wheres.append("e.id IS NULL")
|
||||
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
return where, args
|
||||
|
||||
|
||||
def count_review_trades(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
source_type: str | None = None,
|
||||
underlying: str | None = None,
|
||||
opt_type: str | None = None,
|
||||
strategy_tag: str | None = None,
|
||||
reviewed: str | None = None,
|
||||
include_hedge_legs: bool = False,
|
||||
closed_from: str | None = None,
|
||||
closed_to: str | None = None,
|
||||
) -> int:
|
||||
init_options_review_tables(conn)
|
||||
where, args = _review_trades_filters(
|
||||
source_type=source_type,
|
||||
underlying=underlying,
|
||||
opt_type=opt_type,
|
||||
strategy_tag=strategy_tag,
|
||||
reviewed=reviewed,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
closed_to=closed_to,
|
||||
)
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS c
|
||||
FROM options_review_trades t
|
||||
LEFT JOIN options_review_entries e ON e.trade_id = t.id
|
||||
{where}
|
||||
""",
|
||||
args,
|
||||
).fetchone()
|
||||
return int(row["c"] if row else 0)
|
||||
|
||||
|
||||
def list_review_trades(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
source_type: str | None = None,
|
||||
underlying: str | None = None,
|
||||
opt_type: str | None = None,
|
||||
strategy_tag: str | None = None,
|
||||
reviewed: str | None = None,
|
||||
include_hedge_legs: bool = False,
|
||||
closed_from: str | None = None,
|
||||
closed_to: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
init_options_review_tables(conn)
|
||||
where, args = _review_trades_filters(
|
||||
source_type=source_type,
|
||||
underlying=underlying,
|
||||
opt_type=opt_type,
|
||||
strategy_tag=strategy_tag,
|
||||
reviewed=reviewed,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
closed_to=closed_to,
|
||||
)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag,
|
||||
e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan,
|
||||
e.mistake_tags, e.result_tag, e.note, e.images_json, e.image,
|
||||
e.reviewed_at, e.updated_at
|
||||
FROM options_review_trades t
|
||||
LEFT JOIN options_review_entries e ON e.trade_id = t.id
|
||||
{where}
|
||||
ORDER BY COALESCE(t.closed_at, t.opened_at, '') DESC, t.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[*args, int(limit), int(offset)],
|
||||
).fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
d = _row_to_dict(r)
|
||||
entry = None
|
||||
if d.get("entry_id"):
|
||||
entry = {
|
||||
"id": d.pop("entry_id", None),
|
||||
"strategy_tag": d.pop("e_strategy_tag", None),
|
||||
"direction_view": d.pop("direction_view", None),
|
||||
"entry_logic": d.pop("entry_logic", None),
|
||||
"exit_reason": d.pop("exit_reason", None),
|
||||
"followed_plan": d.pop("followed_plan", None),
|
||||
"mistake_tags": d.pop("mistake_tags", None),
|
||||
"result_tag": d.pop("result_tag", None),
|
||||
"note": d.pop("note", None),
|
||||
"images_json": d.pop("images_json", None),
|
||||
"image": d.pop("image", None),
|
||||
"reviewed_at": d.pop("reviewed_at", None),
|
||||
"updated_at": d.pop("updated_at", None),
|
||||
}
|
||||
else:
|
||||
for k in (
|
||||
"entry_id",
|
||||
"e_strategy_tag",
|
||||
"direction_view",
|
||||
"entry_logic",
|
||||
"exit_reason",
|
||||
"followed_plan",
|
||||
"mistake_tags",
|
||||
"result_tag",
|
||||
"note",
|
||||
"images_json",
|
||||
"image",
|
||||
"reviewed_at",
|
||||
"updated_at",
|
||||
):
|
||||
d.pop(k, None)
|
||||
out.append(enrich_trade_row(d, entry))
|
||||
return out
|
||||
|
||||
|
||||
def get_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None:
|
||||
init_options_review_tables(conn)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
entry_row = conn.execute(
|
||||
"SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),)
|
||||
).fetchone()
|
||||
entry = _row_to_dict(entry_row) if entry_row else None
|
||||
return enrich_trade_row(_row_to_dict(row), entry)
|
||||
|
||||
|
||||
def save_review_entry(
|
||||
conn: sqlite3.Connection,
|
||||
trade_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""保存/更新人工复盘;不影响 trades 快照字段."""
|
||||
init_options_review_tables(conn)
|
||||
trade = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE id=?", (int(trade_id),)
|
||||
).fetchone()
|
||||
if not trade:
|
||||
return {"ok": False, "msg": "交易不存在"}
|
||||
images = payload.get("images")
|
||||
if images is None and payload.get("images_json") is not None:
|
||||
images = parse_options_review_images_json(payload.get("images_json"))
|
||||
if not isinstance(images, list):
|
||||
images = []
|
||||
images_json = images_json_dumps(images)
|
||||
primary = None
|
||||
if images:
|
||||
primary = str(images[0].get("file") or "").strip() or None
|
||||
fields = {
|
||||
"strategy_tag": str(payload.get("strategy_tag") or "").strip() or None,
|
||||
"direction_view": str(payload.get("direction_view") or "").strip() or None,
|
||||
"entry_logic": str(payload.get("entry_logic") or "").strip() or None,
|
||||
"exit_reason": str(payload.get("exit_reason") or "").strip() or None,
|
||||
"followed_plan": str(payload.get("followed_plan") or "").strip() or None,
|
||||
"mistake_tags": str(payload.get("mistake_tags") or "").strip() or None,
|
||||
"result_tag": str(payload.get("result_tag") or "").strip() or None,
|
||||
"note": str(payload.get("note") or "").strip() or None,
|
||||
"images_json": images_json,
|
||||
"image": primary or (str(payload.get("image") or "").strip() or None),
|
||||
"updated_at": _now_str(),
|
||||
}
|
||||
existing = conn.execute(
|
||||
"SELECT id, reviewed_at FROM options_review_entries WHERE trade_id=?",
|
||||
(int(trade_id),),
|
||||
).fetchone()
|
||||
if existing:
|
||||
sets = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(
|
||||
f"UPDATE options_review_entries SET {sets} WHERE trade_id=?",
|
||||
[*fields.values(), int(trade_id)],
|
||||
)
|
||||
else:
|
||||
fields["trade_id"] = int(trade_id)
|
||||
fields["reviewed_at"] = _now_str()
|
||||
cols = list(fields.keys())
|
||||
conn.execute(
|
||||
f"INSERT INTO options_review_entries ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
|
||||
[fields[c] for c in cols],
|
||||
)
|
||||
return {"ok": True, "trade": get_review_trade(conn, int(trade_id))}
|
||||
|
||||
|
||||
def delete_review_entry(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]:
|
||||
init_options_review_tables(conn)
|
||||
entry = conn.execute(
|
||||
"SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),)
|
||||
).fetchone()
|
||||
if not entry:
|
||||
return {"ok": False, "msg": "无复盘记录"}
|
||||
conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),))
|
||||
return {"ok": True, "entry": _row_to_dict(entry)}
|
||||
|
||||
|
||||
def _hold_bucket(sec: Optional[int]) -> str:
|
||||
if sec is None:
|
||||
return "未知"
|
||||
for label, lo, hi in HOLD_BUCKETS:
|
||||
if sec >= lo and (hi is None or sec < hi):
|
||||
return label
|
||||
return "未知"
|
||||
|
||||
|
||||
def _group_stats(rows: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
key = str(key_fn(row) or "未填")
|
||||
b = buckets.setdefault(
|
||||
key,
|
||||
{"key": key, "count": 0, "wins": 0, "losses": 0, "pnl_sum": 0.0, "hold_sum": 0.0, "hold_n": 0},
|
||||
)
|
||||
pnl = _safe_float(row.get("realized_pnl_total"))
|
||||
if pnl is None:
|
||||
continue
|
||||
b["count"] += 1
|
||||
b["pnl_sum"] = round(b["pnl_sum"] + pnl, 4)
|
||||
if pnl > 0:
|
||||
b["wins"] += 1
|
||||
elif pnl < 0:
|
||||
b["losses"] += 1
|
||||
hs = row.get("hold_seconds")
|
||||
if hs is not None:
|
||||
try:
|
||||
b["hold_sum"] += float(hs)
|
||||
b["hold_n"] += 1
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out = []
|
||||
for b in buckets.values():
|
||||
c = b["count"]
|
||||
out.append(
|
||||
{
|
||||
"key": b["key"],
|
||||
"count": c,
|
||||
"wins": b["wins"],
|
||||
"losses": b["losses"],
|
||||
"win_rate": round(b["wins"] / c * 100, 2) if c else 0,
|
||||
"pnl_sum": round(b["pnl_sum"], 4),
|
||||
"avg_pnl": round(b["pnl_sum"] / c, 4) if c else None,
|
||||
"avg_hold_sec": round(b["hold_sum"] / b["hold_n"], 1) if b["hold_n"] else None,
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda x: abs(float(x.get("pnl_sum") or 0)), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def compute_review_stats(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
source_type: str | None = None,
|
||||
underlying: str | None = None,
|
||||
include_hedge_legs: bool = False,
|
||||
closed_from: str | None = None,
|
||||
closed_to: str | None = None,
|
||||
require_strategy: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
rows = list_review_trades(
|
||||
conn,
|
||||
source_type=source_type,
|
||||
underlying=underlying,
|
||||
include_hedge_legs=include_hedge_legs,
|
||||
closed_from=closed_from,
|
||||
closed_to=closed_to,
|
||||
limit=5000,
|
||||
offset=0,
|
||||
)
|
||||
if require_strategy:
|
||||
rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()]
|
||||
|
||||
wins = losses = reviewed = 0
|
||||
pnl_sum = 0.0
|
||||
hold_vals: list[float] = []
|
||||
for r in rows:
|
||||
if r.get("reviewed"):
|
||||
reviewed += 1
|
||||
pnl = _safe_float(r.get("realized_pnl_total"))
|
||||
if pnl is None:
|
||||
continue
|
||||
pnl_sum += pnl
|
||||
if pnl > 0:
|
||||
wins += 1
|
||||
elif pnl < 0:
|
||||
losses += 1
|
||||
if r.get("hold_seconds") is not None:
|
||||
hold_vals.append(float(r["hold_seconds"]))
|
||||
|
||||
total = wins + losses
|
||||
kpi = {
|
||||
"total": len(rows),
|
||||
"pnl_count": total,
|
||||
"reviewed": reviewed,
|
||||
"review_rate": round(reviewed / len(rows) * 100, 2) if rows else 0,
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"win_rate": round(wins / total * 100, 2) if total else 0,
|
||||
"pnl_sum": round(pnl_sum, 4),
|
||||
"avg_pnl": round(pnl_sum / total, 4) if total else None,
|
||||
"avg_hold_sec": round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else None,
|
||||
}
|
||||
|
||||
strategy_rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()]
|
||||
return {
|
||||
"ok": True,
|
||||
"kpi": kpi,
|
||||
"by_source_type": _group_stats(rows, lambda r: SOURCE_LABELS.get(str(r.get("source_type") or ""), r.get("source_type"))),
|
||||
"by_underlying": _group_stats(rows, lambda r: r.get("underlying") or "未填"),
|
||||
"by_opt_type": _group_stats(
|
||||
[r for r in rows if r.get("source_type") == SOURCE_OPTION],
|
||||
lambda r: r.get("opt_type") or "未填",
|
||||
),
|
||||
"by_strategy": _group_stats(strategy_rows, lambda r: r.get("strategy_tag")),
|
||||
"by_close_reason": _group_stats(
|
||||
[r for r in rows if r.get("is_hedge")],
|
||||
lambda r: r.get("plan_close_reason") or "未填",
|
||||
),
|
||||
"by_hold_bucket": _group_stats(rows, lambda r: _hold_bucket(r.get("hold_seconds"))),
|
||||
"sync": {
|
||||
"options_last_sync_at": get_sync_state(conn, "options_last_sync_at"),
|
||||
"hedge_last_sync_at": get_sync_state(conn, "hedge_last_sync_at"),
|
||||
"hedge_last_plan_id": get_sync_state(conn, "hedge_last_plan_id"),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables
|
||||
from lib.options.options_review_images_lib import (
|
||||
OPTIONS_REVIEW_UPLOAD_TFS,
|
||||
normalize_options_review_draft_id,
|
||||
options_review_image_paths,
|
||||
options_review_upload_dir,
|
||||
save_options_review_slot_file,
|
||||
)
|
||||
from lib.options.options_review_lib import (
|
||||
SOURCE_LABELS,
|
||||
compute_review_stats,
|
||||
count_review_trades,
|
||||
delete_review_entry,
|
||||
ensure_local_review_synced,
|
||||
get_review_trade,
|
||||
hide_review_trade,
|
||||
list_review_trades,
|
||||
save_review_entry,
|
||||
sync_all_review_sources,
|
||||
)
|
||||
|
||||
|
||||
def attach_options_review_templates(app: Flask, repo_root: str) -> None:
|
||||
tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
|
||||
if not os.path.isdir(tpl_dir):
|
||||
return
|
||||
existing = app.jinja_loader
|
||||
loaders = [FileSystemLoader(tpl_dir)]
|
||||
if existing is not None:
|
||||
if isinstance(existing, ChoiceLoader):
|
||||
loaders = list(existing.loaders) + loaders
|
||||
else:
|
||||
loaders.insert(0, existing)
|
||||
app.jinja_loader = ChoiceLoader(loaders)
|
||||
|
||||
|
||||
def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None:
|
||||
attach_options_review_templates(app, repo_root)
|
||||
cfg = {
|
||||
"get_db": app_module.get_db,
|
||||
"login_required": app_module.login_required,
|
||||
"exchange_options": getattr(app_module, "exchange_options", None),
|
||||
"render_main_page": app_module.render_main_page,
|
||||
"upload_folder": getattr(app_module, "UPLOAD_FOLDER", None)
|
||||
or os.path.join(os.path.dirname(getattr(app_module, "BASE_DIR", repo_root)), "static", "images"),
|
||||
"options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)),
|
||||
"app_module": app_module,
|
||||
}
|
||||
app.extensions["options_review_cfg"] = cfg
|
||||
register_options_review_routes(app, cfg, repo_root)
|
||||
|
||||
|
||||
def _require_ex(cfg: dict[str, Any]):
|
||||
from lib.exchange.okx_options_lib import options_api_ready
|
||||
|
||||
if not cfg.get("options_enabled"):
|
||||
return None, "期权模块未启用"
|
||||
ex = cfg.get("exchange_options")
|
||||
ok, reason = options_api_ready(ex)
|
||||
if not ok:
|
||||
return None, reason or "期权 API 未配置"
|
||||
return ex, ""
|
||||
|
||||
|
||||
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
|
||||
lr = cfg["login_required"]
|
||||
|
||||
@app.route("/options/review")
|
||||
@lr
|
||||
def options_review_page():
|
||||
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
|
||||
|
||||
redir = redirect_to_embed_shell_if_enabled("options_review")
|
||||
if redir is not None:
|
||||
return redir
|
||||
return cfg["render_main_page"]("options_review")
|
||||
|
||||
@app.route("/static/options_review.js")
|
||||
@lr
|
||||
def static_options_review_js():
|
||||
path = os.path.join(repo_root, "lib", "common", "static", "options_review.js")
|
||||
if not os.path.isfile(path):
|
||||
return ("not found", 404)
|
||||
return send_file(path, mimetype="application/javascript; charset=utf-8")
|
||||
|
||||
@app.route("/static/images/options_journal/<path:filename>")
|
||||
@lr
|
||||
def static_options_review_image(filename: str):
|
||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||
safe = os.path.basename(filename or "")
|
||||
path = os.path.join(folder, safe)
|
||||
if not os.path.isfile(path):
|
||||
return ("not found", 404)
|
||||
return send_file(path)
|
||||
|
||||
@app.route("/api/options/review/sync", methods=["POST"])
|
||||
@lr
|
||||
def api_options_review_sync():
|
||||
"""刷新本地 options_trades + 已结束对冲计划(不访问交易所)."""
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_review_tables(conn)
|
||||
result = sync_all_review_sources(conn, from_exchange=False)
|
||||
conn.commit()
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/review/trades")
|
||||
@lr
|
||||
def api_options_review_trades():
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
# 翻页可跳过同步,仅刷新当前卡片列表
|
||||
do_sync = (request.args.get("sync") or "1").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
)
|
||||
if do_sync:
|
||||
ensure_local_review_synced(conn)
|
||||
conn.commit()
|
||||
filt = dict(
|
||||
source_type=(request.args.get("source_type") or "").strip() or None,
|
||||
underlying=(request.args.get("underlying") 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,
|
||||
reviewed=(request.args.get("reviewed") or "").strip() or None,
|
||||
include_hedge_legs=(request.args.get("include_hedge_legs") or "")
|
||||
.strip()
|
||||
.lower()
|
||||
in ("1", "true", "yes"),
|
||||
closed_from=(request.args.get("closed_from") 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)))
|
||||
offset = max(0, int(request.args.get("offset") or 0))
|
||||
total = count_review_trades(conn, **filt)
|
||||
items = list_review_trades(conn, **filt, limit=limit, offset=offset)
|
||||
pages = max(1, (total + limit - 1) // limit) if total else 1
|
||||
page = (offset // limit) + 1 if limit else 1
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"trades": items,
|
||||
"source_labels": SOURCE_LABELS,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/review/trades/<int:trade_id>")
|
||||
@lr
|
||||
def api_options_review_trade_detail(trade_id: int):
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
item = get_review_trade(conn, trade_id)
|
||||
if not item:
|
||||
return jsonify({"ok": False, "msg": "未找到"}), 404
|
||||
return jsonify({"ok": True, "trade": item})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/review/entry", methods=["POST"])
|
||||
@lr
|
||||
def api_options_review_entry_save():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
trade_id = int(data.get("trade_id"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
out = save_review_entry(conn, trade_id, data)
|
||||
if out.get("ok"):
|
||||
conn.commit()
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/review/trades/<int:trade_id>", methods=["DELETE"])
|
||||
@lr
|
||||
def api_options_review_trade_hide(trade_id: int):
|
||||
"""从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
out = hide_review_trade(conn, trade_id)
|
||||
if out.get("ok"):
|
||||
entry = out.get("entry") or {}
|
||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||
for path in options_review_image_paths(entry, folder):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
conn.commit()
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/review/entry/<int:trade_id>", methods=["DELETE"])
|
||||
@lr
|
||||
def api_options_review_entry_delete(trade_id: int):
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
out = delete_review_entry(conn, trade_id)
|
||||
if out.get("ok"):
|
||||
entry = out.get("entry") or {}
|
||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||
for path in options_review_image_paths(entry, folder):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
conn.commit()
|
||||
return jsonify(out), (200 if out.get("ok") else 400)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/options/review/upload_slot", methods=["POST"])
|
||||
@lr
|
||||
def api_options_review_upload_slot():
|
||||
draft_id = normalize_options_review_draft_id(
|
||||
request.form.get("draft_id") if request.form else None
|
||||
)
|
||||
tf = str((request.form.get("tf") if request.form else None) or "").strip()
|
||||
if not draft_id:
|
||||
return jsonify({"ok": False, "error": "invalid draft_id"}), 400
|
||||
if tf not in OPTIONS_REVIEW_UPLOAD_TFS:
|
||||
return jsonify({"ok": False, "error": "invalid tf"}), 400
|
||||
f = request.files.get("file") if request.files else None
|
||||
if not f or not getattr(f, "filename", None):
|
||||
return jsonify({"ok": False, "error": "no file"}), 400
|
||||
folder = options_review_upload_dir(cfg["upload_folder"])
|
||||
item = save_options_review_slot_file(
|
||||
f, draft_id, tf, folder, secure_filename_fn=secure_filename
|
||||
)
|
||||
if not item:
|
||||
return jsonify({"ok": False, "error": "save failed"}), 500
|
||||
return jsonify({"ok": True, "tf": tf, "file": item["file"]})
|
||||
|
||||
@app.route("/api/options/review/stats")
|
||||
@lr
|
||||
def api_options_review_stats():
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
ensure_local_review_synced(conn)
|
||||
conn.commit()
|
||||
stats = compute_review_stats(
|
||||
conn,
|
||||
source_type=(request.args.get("source_type") 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()
|
||||
in ("1", "true", "yes"),
|
||||
closed_from=(request.args.get("closed_from") 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()
|
||||
in ("1", "true", "yes"),
|
||||
)
|
||||
stats["source_types"] = list(SOURCE_TYPES)
|
||||
stats["source_labels"] = SOURCE_LABELS
|
||||
return jsonify(stats)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,173 @@
|
||||
"""期权本地交易统计(胜率 / 盈亏 / 持仓时长)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
|
||||
def _parse_ts(raw: Any) -> datetime | None:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
s = str(raw).strip().replace(" ", "T", 1)
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _hold_seconds(created_at: Any, closed_at: Any) -> float | None:
|
||||
start = _parse_ts(created_at)
|
||||
end = _parse_ts(closed_at)
|
||||
if start is None or end is None:
|
||||
return None
|
||||
sec = (end - start).total_seconds()
|
||||
return sec if sec >= 0 else None
|
||||
|
||||
|
||||
def _avg_seconds(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 1)
|
||||
|
||||
|
||||
def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""基于期权历史列表(交易所)计算统计."""
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
win_holds: list[float] = []
|
||||
loss_holds: list[float] = []
|
||||
all_holds: list[float] = []
|
||||
open_holds: list[float] = []
|
||||
now = datetime.now()
|
||||
|
||||
for row in history:
|
||||
if row.get("status") == "open":
|
||||
start = _parse_ts(row.get("created_at"))
|
||||
if start is not None:
|
||||
sec = (now - start).total_seconds()
|
||||
if sec >= 0:
|
||||
open_holds.append(sec)
|
||||
continue
|
||||
pnl_raw = row.get("realized_pnl")
|
||||
if pnl_raw is None:
|
||||
continue
|
||||
try:
|
||||
pnl = float(pnl_raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
hold = _hold_seconds(row.get("created_at"), row.get("closed_at"))
|
||||
if hold is not None:
|
||||
all_holds.append(hold)
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
if hold is not None:
|
||||
win_holds.append(hold)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
if hold is not None:
|
||||
loss_holds.append(hold)
|
||||
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
|
||||
total_profit = round(sum(wins), 4) if wins else 0.0
|
||||
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
|
||||
net_realized = round(sum(wins) + sum(losses), 4)
|
||||
return {
|
||||
"total_closed": total_closed,
|
||||
"win_count": len(wins),
|
||||
"loss_count": len(losses),
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"avg_win": round(avg_win, 4) if avg_win is not None else None,
|
||||
"avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None,
|
||||
"total_profit": total_profit,
|
||||
"total_loss": total_loss,
|
||||
"net_realized_pnl": net_realized,
|
||||
"avg_hold_sec": _avg_seconds(all_holds),
|
||||
"avg_win_hold_sec": _avg_seconds(win_holds),
|
||||
"avg_loss_hold_sec": _avg_seconds(loss_holds),
|
||||
"open_count": len(open_holds),
|
||||
"avg_open_hold_sec": _avg_seconds(open_holds),
|
||||
}
|
||||
|
||||
|
||||
def compute_options_stats(get_db) -> dict[str, Any]:
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
closed_rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl, created_at, closed_at
|
||||
FROM options_trades
|
||||
WHERE status = 'closed' AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
open_rows = conn.execute(
|
||||
"""
|
||||
SELECT created_at FROM options_trades WHERE status = 'open'
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
win_holds: list[float] = []
|
||||
loss_holds: list[float] = []
|
||||
all_holds: list[float] = []
|
||||
now = datetime.now()
|
||||
|
||||
for row in closed_rows:
|
||||
pnl = float(row["realized_pnl"])
|
||||
hold = _hold_seconds(row["created_at"], row["closed_at"])
|
||||
if hold is not None:
|
||||
all_holds.append(hold)
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
if hold is not None:
|
||||
win_holds.append(hold)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
if hold is not None:
|
||||
loss_holds.append(hold)
|
||||
|
||||
open_holds: list[float] = []
|
||||
for row in open_rows:
|
||||
start = _parse_ts(row["created_at"])
|
||||
if start is None:
|
||||
continue
|
||||
sec = (now - start).total_seconds()
|
||||
if sec >= 0:
|
||||
open_holds.append(sec)
|
||||
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
|
||||
total_profit = round(sum(wins), 4) if wins else 0.0
|
||||
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
|
||||
net_realized = round(sum(wins) + sum(losses), 4)
|
||||
return {
|
||||
"total_closed": total_closed,
|
||||
"win_count": len(wins),
|
||||
"loss_count": len(losses),
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"avg_win": round(avg_win, 4) if avg_win is not None else None,
|
||||
"avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None,
|
||||
"total_profit": total_profit,
|
||||
"total_loss": total_loss,
|
||||
"net_realized_pnl": net_realized,
|
||||
"avg_hold_sec": _avg_seconds(all_holds),
|
||||
"avg_win_hold_sec": _avg_seconds(win_holds),
|
||||
"avg_loss_hold_sec": _avg_seconds(loss_holds),
|
||||
"open_count": len(open_holds),
|
||||
"avg_open_hold_sec": _avg_seconds(open_holds),
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 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
|
||||
|
||||
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"))
|
||||
if mark is None:
|
||||
mark = fetch_option_mark_px(ex, inst_id)
|
||||
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
|
||||
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
|
||||
if not opt_type or strike is None:
|
||||
pt, ps = option_fields_from_inst_id(inst_id)
|
||||
opt_type = opt_type or pt
|
||||
if strike is None:
|
||||
strike = ps
|
||||
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)
|
||||
|
||||
|
||||
def ensure_target_tables(conn: sqlite3.Connection) -> None:
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS options_target_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inst_id TEXT NOT NULL,
|
||||
underlying TEXT,
|
||||
opt_type TEXT,
|
||||
target_index REAL NOT NULL,
|
||||
trade_id INTEGER,
|
||||
sheets INTEGER,
|
||||
status TEXT DEFAULT 'active',
|
||||
trigger_idx REAL,
|
||||
close_ord_id TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
triggered_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
|
||||
ON options_target_monitors(status)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
||||
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
|
||||
ot = (opt_type or "").strip().upper()
|
||||
if ot == "P":
|
||||
return index_px <= target_index
|
||||
return index_px >= target_index
|
||||
|
||||
|
||||
def upsert_target_monitor(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
inst_id: str,
|
||||
target_index: float,
|
||||
underlying: str | None = None,
|
||||
opt_type: str | None = None,
|
||||
trade_id: int | None = None,
|
||||
sheets: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ensure_target_tables(conn)
|
||||
inst_id = (inst_id or "").strip()
|
||||
if not inst_id:
|
||||
return {"ok": False, "msg": "缺少 inst_id"}
|
||||
if target_index is None or float(target_index) <= 0:
|
||||
return {"ok": False, "msg": "目标位无效"}
|
||||
target_index = float(target_index)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id FROM options_target_monitors
|
||||
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
|
||||
LIMIT 1
|
||||
""",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET target_index = ?,
|
||||
underlying = COALESCE(?, underlying),
|
||||
opt_type = COALESCE(?, opt_type),
|
||||
trade_id = COALESCE(?, trade_id),
|
||||
sheets = COALESCE(?, sheets),
|
||||
status = 'active',
|
||||
trigger_idx = NULL,
|
||||
close_ord_id = NULL,
|
||||
message = NULL,
|
||||
triggered_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
||||
)
|
||||
mon_id = int(row["id"])
|
||||
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET status = 'cancelled', message = '被新目标位覆盖'
|
||||
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
||||
""",
|
||||
(inst_id, mon_id),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO options_target_monitors
|
||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||
""",
|
||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
|
||||
)
|
||||
mon_id = int(cur.lastrowid)
|
||||
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:
|
||||
ensure_target_tables(conn)
|
||||
if monitor_id is not None:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET status = 'cancelled', message = '手动取消'
|
||||
WHERE id = ? AND status IN ('active', 'closing')
|
||||
""",
|
||||
(int(monitor_id),),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
if inst_id:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET status = 'cancelled', message = '手动取消'
|
||||
WHERE inst_id = ? AND status IN ('active', 'closing')
|
||||
""",
|
||||
(inst_id.strip(),),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
return 0
|
||||
|
||||
|
||||
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(r["id"]),
|
||||
"inst_id": r["inst_id"],
|
||||
"underlying": r["underlying"],
|
||||
"opt_type": r["opt_type"],
|
||||
"target_index": _safe_float(r["target_index"]),
|
||||
"trade_id": r["trade_id"],
|
||||
"sheets": r["sheets"],
|
||||
"status": r["status"],
|
||||
"message": r["message"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
ensure_target_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
||||
status, message, created_at
|
||||
FROM options_target_monitors
|
||||
WHERE status = 'active'
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [_row_to_target(r) for r in rows]
|
||||
|
||||
|
||||
def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
||||
ensure_target_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
||||
status, message, created_at
|
||||
FROM options_target_monitors
|
||||
WHERE status = 'closing'
|
||||
ORDER BY id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [_row_to_target(r) for r in rows]
|
||||
|
||||
|
||||
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||||
"""UI/持仓挂载:active 与 closing 都算进行中."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for t in list_closing_targets(conn) + list_active_targets(conn):
|
||||
inst = str(t.get("inst_id") or "")
|
||||
if inst and inst not in out:
|
||||
out[inst] = t
|
||||
return out
|
||||
|
||||
|
||||
def mark_monitor(
|
||||
conn: sqlite3.Connection,
|
||||
monitor_id: int,
|
||||
*,
|
||||
status: str,
|
||||
trigger_idx: float | None = None,
|
||||
close_ord_id: str | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_target_monitors
|
||||
SET status = ?,
|
||||
trigger_idx = COALESCE(?, trigger_idx),
|
||||
close_ord_id = COALESCE(?, close_ord_id),
|
||||
message = COALESCE(?, message),
|
||||
triggered_at = CASE
|
||||
WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
|
||||
ELSE triggered_at
|
||||
END
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
|
||||
)
|
||||
|
||||
|
||||
def cancel_orphans_without_position(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
live_inst_ids: set[str],
|
||||
) -> int:
|
||||
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
|
||||
ensure_target_tables(conn)
|
||||
rows = list_active_targets(conn) + list_closing_targets(conn)
|
||||
n = 0
|
||||
for t in rows:
|
||||
inst = str(t.get("inst_id") or "")
|
||||
if inst and inst not in live_inst_ids:
|
||||
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _commit_monitor(conn: sqlite3.Connection) -> None:
|
||||
"""状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def close_option_by_bid_depth(
|
||||
cfg: dict[str, Any],
|
||||
ex: Any,
|
||||
inst_id: str,
|
||||
*,
|
||||
sheets: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
|
||||
from lib.options.options_close_exec_lib import close_option_by_bid1
|
||||
|
||||
return close_option_by_bid1(
|
||||
cfg,
|
||||
ex,
|
||||
inst_id,
|
||||
sheets=sheets,
|
||||
require_recycle_gate=True,
|
||||
signal_note="目标位平仓",
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _notify_target_close(
|
||||
send_wechat: Callable[[str], None] | None,
|
||||
*,
|
||||
account_label: str,
|
||||
inst_id: str,
|
||||
target: float,
|
||||
idx: float,
|
||||
result: dict[str, Any],
|
||||
) -> None:
|
||||
if not send_wechat:
|
||||
return
|
||||
try:
|
||||
send_wechat(
|
||||
"\n".join(
|
||||
[
|
||||
"【OKX期权·目标位平仓】",
|
||||
f"账户:{account_label}",
|
||||
f"合约:{inst_id}",
|
||||
f"目标指数:{target:g}",
|
||||
f"触发指数:{idx:g}",
|
||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
||||
]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _result_fully_done(result: dict[str, Any]) -> bool:
|
||||
if result.get("already_flat"):
|
||||
return True
|
||||
if result.get("fully_closed"):
|
||||
return True
|
||||
remaining = result.get("remaining_sheets")
|
||||
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_options_target_closes(
|
||||
conn: sqlite3.Connection,
|
||||
positions: list[dict[str, Any]],
|
||||
*,
|
||||
close_fn: Callable[[str], dict[str, Any]],
|
||||
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
||||
send_wechat: Callable[[str], None] | None = None,
|
||||
account_label: str = "OKX期权",
|
||||
) -> int:
|
||||
"""
|
||||
扫描 active 目标委托;指数到位后限价平仓.
|
||||
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
||||
未完全成交进入 closing,仅重试平仓不再推送.
|
||||
返回本次新触发(并推送)的条数.
|
||||
"""
|
||||
ensure_target_tables(conn)
|
||||
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}
|
||||
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
|
||||
_commit_monitor(conn)
|
||||
|
||||
# 先处理已挂单等待成交的,绝不再发微信
|
||||
for mon in list_closing_targets(conn):
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
if not inst_id:
|
||||
continue
|
||||
if inst_id not in pos_by_inst:
|
||||
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
result = close_fn(inst_id)
|
||||
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
|
||||
if result.get("already_flat") or _result_fully_done(result):
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="triggered",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message="目标位限价平仓完成",
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="closing",
|
||||
trigger_idx=idx,
|
||||
close_ord_id=result.get("close_ord_id"),
|
||||
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
|
||||
)
|
||||
_commit_monitor(conn)
|
||||
|
||||
triggered = 0
|
||||
for mon in list_active_targets(conn):
|
||||
inst_id = str(mon.get("inst_id") or "")
|
||||
target = _safe_float(mon.get("target_index"))
|
||||
if not inst_id or target is None:
|
||||
continue
|
||||
pos = pos_by_inst.get(inst_id)
|
||||
if not pos:
|
||||
continue
|
||||
if index_fn is not None:
|
||||
idx = index_fn(pos)
|
||||
else:
|
||||
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
||||
if idx is None:
|
||||
continue
|
||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
||||
continue
|
||||
|
||||
result = close_fn(inst_id)
|
||||
if result.get("already_flat"):
|
||||
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||
_commit_monitor(conn)
|
||||
continue
|
||||
if not result.get("ok"):
|
||||
mark_monitor(
|
||||
conn,
|
||||
int(mon["id"]),
|
||||
status="active",
|
||||
trigger_idx=idx,
|
||||
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
|
||||
)
|
||||
_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(
|
||||
send_wechat,
|
||||
account_label=account_label,
|
||||
inst_id=inst_id,
|
||||
target=target,
|
||||
idx=idx,
|
||||
result=result,
|
||||
)
|
||||
return triggered
|
||||
@@ -0,0 +1,277 @@
|
||||
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
|
||||
data-default-underly="{{ options_default_underly | default('ETH') }}">
|
||||
{% if not options_enabled %}
|
||||
<div class="flash" style="margin-bottom:12px">期权 API 未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及主账户 <code>OKX_OPTIONS_API_*</code>,然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="options-dual-grid">
|
||||
<div class="card options-order-card">
|
||||
<h2>期权下单 <a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">开平仓与监控说明</a></h2>
|
||||
<p class="muted options-hint">报价单位为每 1 ETH/BTC;1 张 = 0.01.<strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 <strong>~</strong>(仅参考).<strong>开仓只认真实卖一价且卖一深度>0</strong>;无深度时面板显示参考标记价并禁用买入.链展示近 <span id="opt-chain-dte">14</span> 日到期.<strong>T 型</strong>默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.</p>
|
||||
<div class="form-row options-chain-toolbar">
|
||||
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||
<select id="opt-exp-select"><option value="">选择到期日</option></select>
|
||||
<span class="opt-chain-view-group">
|
||||
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
|
||||
<button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button>
|
||||
</span>
|
||||
<span id="opt-type-btn-group" class="opt-type-btn-group">
|
||||
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
|
||||
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
|
||||
</span>
|
||||
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
|
||||
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
|
||||
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label" hidden>
|
||||
<input type="checkbox" id="opt-strike-expand-all"> 展开全部
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
|
||||
</div>
|
||||
<div id="opt-index-line" class="muted"></div>
|
||||
<div class="options-strike-table-wrap" id="opt-strike-table-wrap">
|
||||
<table class="options-strike-table" id="opt-strike-table">
|
||||
<thead>
|
||||
<tr id="opt-strike-head-list">
|
||||
<th>行权价</th>
|
||||
<th>类型</th>
|
||||
<th>合约</th>
|
||||
<th>卖一/张</th>
|
||||
<th>买一/张</th>
|
||||
<th>到期平衡</th>
|
||||
<th>距平衡</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
<tr id="opt-strike-head-t" class="hidden" hidden>
|
||||
<th colspan="3" class="opt-t-head-call">Call</th>
|
||||
<th colspan="3" class="opt-t-head-mid">跨式</th>
|
||||
<th colspan="3" class="opt-t-head-put">Put</th>
|
||||
</tr>
|
||||
<tr id="opt-strike-head-t-cols" class="hidden" hidden>
|
||||
<th>卖一/张</th>
|
||||
<th>类型</th>
|
||||
<th>操作</th>
|
||||
<th>行权价</th>
|
||||
<th title="Call卖一+Put卖一(每1币)">双买/币</th>
|
||||
<th title="到期测算平衡带">平衡带</th>
|
||||
<th>类型</th>
|
||||
<th>卖一/张</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-strike-tbody">
|
||||
<tr><td colspan="8" class="muted">请选择到期日</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="opt-order-panel-host" class="opt-order-panel-host" hidden aria-hidden="true">
|
||||
<div id="opt-order-panel" class="opt-order-panel-inner" style="display:none">
|
||||
<div class="opt-order-layout">
|
||||
<div class="opt-order-main">
|
||||
<h3 class="opt-order-title">下单</h3>
|
||||
<div id="opt-order-inst" class="options-order-inst"></div>
|
||||
<div class="options-order-grid">
|
||||
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v">—</span></div>
|
||||
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v">—</span></div>
|
||||
<div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted">—</span></div>
|
||||
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
||||
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
||||
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
||||
<div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用">—</span></div>
|
||||
<div><span class="k">到期平衡</span><span id="opt-order-expiry-be" class="v">—</span></div>
|
||||
<div><span class="k">距平衡</span><span id="opt-order-dist-be" class="v">—</span></div>
|
||||
</div>
|
||||
<div class="options-estimate-row">
|
||||
<label class="opt-est-label" for="opt-target-idx">目标位(指数)</label>
|
||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓">
|
||||
<span class="k">预计价值</span>
|
||||
<span id="opt-est-value" class="v">—</span>
|
||||
<span class="k">盈利</span>
|
||||
<span id="opt-est-profit" class="v">—</span>
|
||||
<span class="k">目标杠杆</span>
|
||||
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金">—</span>
|
||||
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
|
||||
</div>
|
||||
<div class="form-row options-order-mode-row">
|
||||
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
|
||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数">
|
||||
<label><input type="radio" name="opt-size-mode" value="budget_full"> 按可用余额打满</label>
|
||||
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
|
||||
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
|
||||
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
|
||||
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
|
||||
</div>
|
||||
<div id="opt-order-msg" class="muted"></div>
|
||||
</div>
|
||||
<aside class="opt-order-pending" aria-label="未成交委托">
|
||||
<div class="opt-order-pending-head">
|
||||
<h4 class="opt-order-pending-title">委托</h4>
|
||||
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
|
||||
</div>
|
||||
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint">平仓限价超 10 分未成交将自动撤销</p>
|
||||
<div id="opt-pending-list" class="opt-pending-list">
|
||||
<div class="muted opt-pending-empty">暂无未成交委托</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card options-pos-card-wrap">
|
||||
<div class="options-pos-head">
|
||||
<h2>持仓</h2>
|
||||
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
|
||||
</div>
|
||||
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
|
||||
<button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button>
|
||||
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
|
||||
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="history" role="tab" aria-selected="false" id="opt-pos-tab-history">期权历史</button>
|
||||
</div>
|
||||
<div class="options-pos-tab-body">
|
||||
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
|
||||
<div id="opt-target-monitors" class="opt-target-monitors" hidden>
|
||||
<div class="opt-target-monitors-head">目标监控</div>
|
||||
<div id="opt-target-monitors-list"></div>
|
||||
</div>
|
||||
<div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
|
||||
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
|
||||
<div id="opt-pos-cards"></div>
|
||||
</div>
|
||||
<details class="opt-close-rule">
|
||||
<summary>买一平仓规则说明</summary>
|
||||
<div class="opt-close-rule-body">
|
||||
<p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
|
||||
<ul>
|
||||
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
|
||||
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
|
||||
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
|
||||
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
|
||||
</ul>
|
||||
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
|
||||
<div class="options-stats-panel">
|
||||
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
|
||||
<div class="options-stat-item opt-stats-net-item">
|
||||
<span class="k">合计盈亏</span>
|
||||
<span class="v" id="opt-stats-total-pnl">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平净盈亏</span>
|
||||
<span class="v" id="opt-stats-net-realized">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓浮盈</span>
|
||||
<span class="v" id="opt-stats-open-float">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stats-charts">
|
||||
<div class="opt-stats-chart opt-stats-chart--ring">
|
||||
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
|
||||
<span class="opt-stats-ring-label" id="opt-stats-ring-label">—</span>
|
||||
</div>
|
||||
<span class="opt-stats-chart-caption">胜率</span>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--pnl">
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">平均盈利</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">平均亏损</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
|
||||
</div>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-stats-chart opt-stats-chart--hold">
|
||||
<div class="opt-stats-chart-title">持仓时长对比</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">盈单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-win-hold-label">—</span>
|
||||
</div>
|
||||
<div class="opt-stats-bar-row">
|
||||
<span class="k">亏单</span>
|
||||
<div class="opt-stats-bar-track">
|
||||
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
|
||||
</div>
|
||||
<span class="v" id="opt-stats-loss-hold-label">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-stats-grid">
|
||||
<div class="options-stat-item">
|
||||
<span class="k">胜率</span>
|
||||
<span class="v" id="opt-stats-winrate">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈亏比</span>
|
||||
<span class="v" id="opt-stats-plr">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">已平笔数</span>
|
||||
<span class="v" id="opt-stats-closed">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">平均盈利</span>
|
||||
<span class="v pos-pnl-profit" id="opt-stats-profit">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">平均亏损</span>
|
||||
<span class="v pos-pnl-loss" id="opt-stats-loss">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">均持仓</span>
|
||||
<span class="v" id="opt-stats-avg-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">盈单持仓</span>
|
||||
<span class="v" id="opt-stats-win-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">亏单持仓</span>
|
||||
<span class="v" id="opt-stats-loss-hold">—</span>
|
||||
</div>
|
||||
<div class="options-stat-item">
|
||||
<span class="k">持仓中</span>
|
||||
<span class="v" id="opt-stats-open-hold">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-pos-pane" data-opt-pos-pane="history" role="tabpanel" aria-labelledby="opt-pos-tab-history" hidden>
|
||||
<div class="options-history-table-wrap">
|
||||
<table class="options-strike-table opt-history-table" id="opt-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合约</th>
|
||||
<th>张数</th>
|
||||
<th>权利金</th>
|
||||
<th>状态</th>
|
||||
<th>盈亏</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opt-history-tbody">
|
||||
<tr><td colspan="7" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/options_panel.js?v=39"></script>
|
||||
@@ -0,0 +1,227 @@
|
||||
{# OKX 期权复盘:交易记录(5行) → 点复盘出表单 → 复盘记录 → 统计 #}
|
||||
<div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1">
|
||||
{% if not options_enabled %}
|
||||
<div class="flash" style="margin-bottom:12px;font-size:.82rem">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.options-review-wrap{font-size:.82rem}
|
||||
.options-review-wrap h2{font-size:1rem;margin:0 0 8px}
|
||||
.options-review-wrap h3{font-size:.9rem;margin:0 0 8px}
|
||||
.or-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.or-tab{border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;padding:5px 10px;border-radius:6px;cursor:pointer;font-size:.78rem}
|
||||
.or-tab.active{background:rgba(59,130,246,.25);border-color:rgba(59,130,246,.55)}
|
||||
.or-badge{display:inline-block;padding:1px 6px;border-radius:999px;background:rgba(127,127,127,.2);font-size:.7rem}
|
||||
.or-stat-card{border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:8px;font-size:.78rem}
|
||||
.or-trades-table{font-size:.78rem}
|
||||
.or-trades-table tr.or-row-active{outline:1px solid rgba(59,130,246,.55);background:rgba(59,130,246,.08)}
|
||||
.or-journal-card{font-size:.78rem}
|
||||
.or-journal-card h2{font-size:.92rem}
|
||||
.or-journal-card input,
|
||||
.or-journal-card select,
|
||||
.or-journal-card textarea,
|
||||
.or-journal-card button{font-size:.76rem}
|
||||
.or-journal-card .or-form-grid,
|
||||
.or-journal-card .or-form-grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:6px}
|
||||
.or-journal-card .or-mood-grid{display:flex;flex-wrap:wrap;gap:6px 12px;margin:8px 0;font-size:.74rem}
|
||||
.or-journal-card .muted,
|
||||
.or-journal-card .sub{font-size:.7rem}
|
||||
.or-journal-card.hidden{display:none!important}
|
||||
.or-reviewed-table tbody tr{cursor:pointer}
|
||||
.or-detail-panel{margin-top:10px;padding-top:10px;border-top:1px solid rgba(127,127,127,.25)}
|
||||
.or-detail-panel.hidden{display:none!important}
|
||||
.or-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px 12px;font-size:.76rem;margin-bottom:8px}
|
||||
.or-detail-images{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:8px 0}
|
||||
.or-detail-img-cell{border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:6px;text-align:center}
|
||||
.or-detail-img-label{display:block;font-size:.7rem;margin-bottom:4px;opacity:.8}
|
||||
.or-detail-img-thumb{max-width:100%;max-height:160px;border-radius:4px;cursor:pointer}
|
||||
.or-pager{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:.74rem}
|
||||
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
|
||||
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem}
|
||||
</style>
|
||||
|
||||
{# 1. 交易记录(含 Tab/筛选,固定约5行) #}
|
||||
<div class="card" style="margin-bottom:10px">
|
||||
<div class="form-row" style="flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<h2 style="margin:0;margin-right:auto">期权复盘</h2>
|
||||
<span class="muted" id="or-sync-status" style="font-size:.72rem"></span>
|
||||
<button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
|
||||
</div>
|
||||
<div class="or-tabs" role="tablist" aria-label="复盘分类">
|
||||
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
|
||||
<button type="button" class="or-tab" data-source="options_options" role="tab">期期对冲记录</button>
|
||||
<button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button>
|
||||
</div>
|
||||
<p class="muted" style="margin:0 0 8px;font-size:.72rem">待复盘交易(每页5条).点「复盘」填写表单;保存后进入下方复盘记录.</p>
|
||||
<div class="form-row" style="flex-wrap:wrap;gap:6px;margin-bottom:8px">
|
||||
<select id="or-filter-uly" style="font-size:.76rem">
|
||||
<option value="">标的:全部</option>
|
||||
<option value="ETH">ETH</option>
|
||||
<option value="BTC">BTC</option>
|
||||
</select>
|
||||
<select id="or-filter-opt" style="font-size:.76rem">
|
||||
<option value="">Call/Put:全部</option>
|
||||
<option value="C">Call</option>
|
||||
<option value="P">Put</option>
|
||||
</select>
|
||||
<input type="text" id="or-filter-strategy" placeholder="策略标签" style="max-width:110px;font-size:.76rem">
|
||||
<input type="datetime-local" id="or-filter-from" title="平仓起" style="font-size:.76rem">
|
||||
<input type="datetime-local" id="or-filter-to" title="平仓止" style="font-size:.76rem">
|
||||
<label class="muted" style="display:flex;align-items:center;gap:4px;font-size:.72rem">
|
||||
<input type="checkbox" id="or-include-hedge-legs"> 含已归属对冲的期权腿
|
||||
</label>
|
||||
</div>
|
||||
<h3 id="or-list-title" style="margin-top:0">期权交易记录</h3>
|
||||
<div class="options-strike-table-wrap or-trades-table-wrap" id="or-trades-wrap">
|
||||
<table class="options-strike-table or-trades-table" id="or-trades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>标的/合约</th>
|
||||
<th>盈亏</th>
|
||||
<th>开/平</th>
|
||||
<th>持有</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="or-trades-tbody">
|
||||
<tr><td colspan="6" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="or-pager" id="or-trades-pager">
|
||||
<button type="button" class="btn-secondary" id="or-trades-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
|
||||
<span class="muted" id="or-trades-page-label">第 1 / 1 页</span>
|
||||
<button type="button" class="btn-secondary" id="or-trades-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 2. 复盘上传(默认隐藏,点交易「复盘」后显示) #}
|
||||
<div class="card journal-card or-journal-card hidden" id="or-journal-card" style="margin-bottom:10px">
|
||||
<h2>复盘记录上传(含截图)</h2>
|
||||
<p class="muted" id="or-journal-summary" style="margin-top:0">截图槽位与合约复盘相同(5m / 15m / 1h / 4h).</p>
|
||||
<div class="or-journal-body">
|
||||
<form id="or-journal-form" onsubmit="return false;">
|
||||
<input type="hidden" id="or-trade-id" value="">
|
||||
<input type="hidden" id="or-draft-id" value="">
|
||||
<div class="or-form-grid">
|
||||
<input type="datetime-local" id="or-f-open" title="开仓时间">
|
||||
<input type="datetime-local" id="or-f-close" title="平仓时间">
|
||||
<input type="text" id="or-f-coin" placeholder="标的(如 ETH)">
|
||||
<input type="text" id="or-f-inst" placeholder="合约/计划">
|
||||
<input type="text" id="or-f-pnl" placeholder="盈亏(U)">
|
||||
<input type="text" id="or-f-hold" placeholder="持有时长" readonly>
|
||||
</div>
|
||||
<div class="or-form-grid2">
|
||||
<select id="or-f-strategy" title="策略标签" required>
|
||||
<option value="">策略标签</option>
|
||||
</select>
|
||||
<select id="or-f-direction" title="方向判断">
|
||||
<option value="">方向判断</option>
|
||||
</select>
|
||||
<select id="or-f-exit" title="离场原因">
|
||||
<option value="">离场原因</option>
|
||||
<option value="止盈">止盈</option>
|
||||
<option value="止损">止损</option>
|
||||
<option value="到期">到期</option>
|
||||
<option value="目标价">目标价</option>
|
||||
<option value="手动平仓">手动平仓</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
<select id="or-f-followed" title="是否按计划">
|
||||
<option value="">是否按计划</option>
|
||||
<option value="是">是</option>
|
||||
<option value="否">否</option>
|
||||
<option value="部分">部分</option>
|
||||
</select>
|
||||
<select id="or-f-result" title="结果标签">
|
||||
<option value="">结果标签</option>
|
||||
<option value="盈利">盈利</option>
|
||||
<option value="亏损">亏损</option>
|
||||
<option value="持平">持平</option>
|
||||
</select>
|
||||
</div>
|
||||
<select id="or-f-entry" title="入场逻辑" style="width:100%;margin-bottom:6px;box-sizing:border-box">
|
||||
<option value="">入场逻辑</option>
|
||||
</select>
|
||||
|
||||
<input type="hidden" id="journal-draft-id" value="">
|
||||
<div class="journal-upload-slots" id="or-upload-slots">
|
||||
{% for tf in ['5m', '15m', '1h', '4h'] %}
|
||||
<div class="journal-upload-row" data-tf="{{ tf }}">
|
||||
<span class="journal-upload-slot-label">{{ tf }}</span>
|
||||
<input type="file" accept="image/*" class="journal-upload-slot-input or-upload-input" data-tf="{{ tf }}">
|
||||
<input type="hidden" class="journal-upload-hidden-file or-upload-hidden" data-tf="{{ tf }}" value="">
|
||||
<span class="journal-upload-status or-upload-status" data-tf="{{ tf }}" aria-live="polite"></span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p>
|
||||
|
||||
<div class="or-mood-grid mood-grid">
|
||||
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
|
||||
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
|
||||
<label><input type="checkbox" class="or-mood" value="盈利飘了">盈利飘了</label>
|
||||
<label><input type="checkbox" class="or-mood" value="拿不住单">拿不住单</label>
|
||||
<label><input type="checkbox" class="or-mood" value="扛单">扛单</label>
|
||||
<label><input type="checkbox" class="or-mood" value="重仓违规">重仓违规</label>
|
||||
</div>
|
||||
<textarea id="or-f-note" rows="2" placeholder="备注" style="width:100%;box-sizing:border-box"></textarea>
|
||||
<div class="form-row" style="margin-top:8px;gap:6px">
|
||||
<button type="button" class="btn" id="or-save-btn">保存复盘记录</button>
|
||||
<button type="button" class="btn-secondary" id="or-clear-btn">取消</button>
|
||||
<button type="button" class="btn-secondary" id="or-del-btn">删除复盘内容</button>
|
||||
<span class="muted" id="or-save-status"></span>
|
||||
</div>
|
||||
<div id="or-legs-host" style="margin-top:10px;font-size:.74rem"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 3. 已复盘记录 + 详情 #}
|
||||
<div class="card" style="margin-bottom:10px">
|
||||
<h3>复盘记录</h3>
|
||||
<p class="muted" style="margin:0 0 8px;font-size:.72rem">已保存的复盘(每页5条).点一行查看详情.</p>
|
||||
<div class="options-strike-table-wrap or-reviewed-table-wrap" id="or-reviewed-wrap">
|
||||
<table class="options-strike-table or-reviewed-table" id="or-reviewed-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>标的/合约</th>
|
||||
<th>盈亏</th>
|
||||
<th>策略</th>
|
||||
<th>结果</th>
|
||||
<th>复盘时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="or-reviewed-tbody">
|
||||
<tr><td colspan="6" class="muted">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="or-pager" id="or-reviewed-pager">
|
||||
<button type="button" class="btn-secondary" id="or-reviewed-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
|
||||
<span class="muted" id="or-reviewed-page-label">第 1 / 1 页</span>
|
||||
<button type="button" class="btn-secondary" id="or-reviewed-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
|
||||
</div>
|
||||
<div class="or-detail-panel hidden" id="or-detail-panel">
|
||||
<div class="form-row" style="align-items:center;gap:8px;margin-bottom:6px">
|
||||
<h3 style="margin:0;margin-right:auto" id="or-detail-title">复盘详情</h3>
|
||||
<button type="button" class="btn-secondary" id="or-detail-edit-btn" style="font-size:.72rem;padding:2px 8px">编辑</button>
|
||||
<button type="button" class="btn-secondary" id="or-detail-close-btn" style="font-size:.72rem;padding:2px 8px">收起</button>
|
||||
</div>
|
||||
<div class="or-detail-grid" id="or-detail-meta"></div>
|
||||
<div id="or-detail-text" style="font-size:.76rem;line-height:1.5;margin-bottom:8px"></div>
|
||||
<div class="or-detail-images" id="or-detail-images"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 4. 统计 #}
|
||||
<div class="card" style="margin-bottom:10px">
|
||||
<h3>统计</h3>
|
||||
<div id="or-kpi" class="form-row" style="flex-wrap:wrap;gap:10px"></div>
|
||||
<div id="or-stats-groups" style="margin-top:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/options_review.js?v=10"></script>
|
||||
@@ -0,0 +1,4 @@
|
||||
{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #}
|
||||
<div id="options-settings-root" hidden
|
||||
data-sub-account="{{ instance_settings.options_sub_account | default('', true) }}"></div>
|
||||
<script src="/static/options_settings.js?v=8"></script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="options-settings-section">
|
||||
<p class="options-settings-hint">主账户资金账户:USDT ↔ USDC 现货市价单.</p>
|
||||
<div class="form-row settings-transfer-form options-settings-row">
|
||||
<select id="opt-set-swap-dir" aria-label="兑换方向">
|
||||
<option value="usdt_to_usdc" selected>USDT → USDC</option>
|
||||
<option value="usdc_to_usdt">USDC → USDT</option>
|
||||
</select>
|
||||
<input type="number" id="opt-set-swap-amount" min="0.01" step="0.01" placeholder="数量">
|
||||
<button type="button" class="btn-secondary btn-sm" id="opt-set-swap-all-btn">全部兑换</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="opt-set-swap-btn">市价兑换</button>
|
||||
</div>
|
||||
<div id="opt-set-swap-msg" class="options-settings-msg muted"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="options-settings-section">
|
||||
<div class="options-settings-subtitle">主账户内</div>
|
||||
<div class="form-row settings-transfer-form options-settings-row">
|
||||
<select id="opt-set-int-ccy" aria-label="币种">
|
||||
<option value="USDC" selected>USDC</option>
|
||||
<option value="USDT">USDT</option>
|
||||
</select>
|
||||
<select id="opt-set-int-from" aria-label="划出账户">
|
||||
<option value="funding" selected>from: 资金</option>
|
||||
<option value="trading">from: 交易</option>
|
||||
</select>
|
||||
<select id="opt-set-int-to" aria-label="划入账户">
|
||||
<option value="trading" selected>to: 交易</option>
|
||||
<option value="funding">to: 资金</option>
|
||||
</select>
|
||||
<input type="number" id="opt-set-int-amount" min="0.01" step="0.01" placeholder="数量">
|
||||
<button type="button" class="btn-secondary btn-sm" id="opt-set-int-all-btn">全部划转</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="opt-set-int-btn">划转</button>
|
||||
</div>
|
||||
<div id="opt-set-int-msg" class="options-settings-msg muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="options-settings-section">
|
||||
<div class="options-settings-subtitle">
|
||||
主子账户
|
||||
<span class="muted">({{ instance_settings.options_sub_account or '未配置' }})</span>
|
||||
</div>
|
||||
<div class="form-row settings-transfer-form options-settings-row">
|
||||
<select id="opt-set-cross-dir" aria-label="主子方向">
|
||||
<option value="main_to_sub" selected>主 → 子</option>
|
||||
<option value="sub_to_main">子 → 主</option>
|
||||
</select>
|
||||
<select id="opt-set-cross-ccy" aria-label="币种">
|
||||
<option value="USDT" selected>USDT</option>
|
||||
<option value="USDC">USDC</option>
|
||||
</select>
|
||||
<select id="opt-set-cross-from" aria-label="划出账户">
|
||||
<option value="funding" selected>from: 资金</option>
|
||||
<option value="trading">from: 交易</option>
|
||||
</select>
|
||||
<select id="opt-set-cross-to" aria-label="划入账户">
|
||||
<option value="trading" selected>to: 交易</option>
|
||||
<option value="funding">to: 资金</option>
|
||||
</select>
|
||||
<input type="number" id="opt-set-cross-amount" min="0.01" step="0.01" placeholder="数量">
|
||||
<button type="button" class="btn-secondary btn-sm" id="opt-set-cross-all-btn">全部划转</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="opt-set-cross-btn">划转</button>
|
||||
</div>
|
||||
<div id="opt-set-cross-msg" class="options-settings-msg muted"></div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user