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:
dekun
2026-07-17 16:18:13 +08:00
commit 53863559f4
608 changed files with 162764 additions and 0 deletions
View File
+380
View File
@@ -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="目标位平仓",
)
+184
View File
@@ -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"),
)
+134
View File
@@ -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)
+86
View File
@@ -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
]
+104
View File
@@ -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)}
+334
View File
@@ -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)))
+124
View File
@@ -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,
}
+155
View File
@@ -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
+543
View File
@@ -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
+143
View File
@@ -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}")
+138
View File
@@ -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
+971
View File
@@ -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"),
},
}
+279
View File
@@ -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()
+173
View File
@@ -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),
}
+443
View File
@@ -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
+277
View File
@@ -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>开仓只认真实卖一价且卖一深度&gt;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>