Improve OKX options sizing, expiry sync, and multi-position accordion UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
"""期权持仓监控:浮盈翻倍微信提醒."""
|
||||
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
|
||||
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:
|
||||
@@ -101,6 +104,109 @@ def run_options_profit_alerts(
|
||||
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"
|
||||
)
|
||||
|
||||
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 options_monitor_loop(
|
||||
*,
|
||||
enabled: bool,
|
||||
@@ -111,6 +217,7 @@ def options_monitor_loop(
|
||||
send_wechat: Callable[[str], None],
|
||||
account_label: str,
|
||||
profit_ratio: float,
|
||||
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||
stop_event: Any = None,
|
||||
) -> None:
|
||||
if not enabled:
|
||||
@@ -130,6 +237,8 @@ def options_monitor_loop(
|
||||
account_label=account_label,
|
||||
ticker_bid_fn=ticker_bid_fn,
|
||||
)
|
||||
if sync_trades_fn is not None:
|
||||
sync_trades_fn(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -132,6 +132,38 @@ def _require_options_ex(cfg: dict[str, Any]):
|
||||
return ex, ""
|
||||
|
||||
|
||||
def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
||||
"""交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量)."""
|
||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||
|
||||
raw = fetch_options_trading_usdc(ex)
|
||||
if raw is None or float(raw) <= 0:
|
||||
return None, "交易账户 USDC 可用余额不足"
|
||||
return float(raw), ""
|
||||
|
||||
|
||||
def _sync_options_trades(cfg: dict[str, Any]) -> None:
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return
|
||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
|
||||
|
||||
def _hist(inst_id: str):
|
||||
return fetch_option_position_history(ex, inst_id)
|
||||
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
lr = cfg["login_required"]
|
||||
|
||||
@@ -177,6 +209,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
min_sz = q.get("min_sz") or 1
|
||||
mode = (request.args.get("mode") or "budget_full").strip()
|
||||
budget = cfg["trade_budget"]
|
||||
budget_cap = cfg["trade_budget"]
|
||||
available_usdc = None
|
||||
if mode == "budget_full":
|
||||
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||
if budget is None:
|
||||
return jsonify({"ok": False, "msg": budget_err})
|
||||
budget_cap = budget
|
||||
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||
|
||||
available_usdc = fetch_options_trading_usdc(ex)
|
||||
eth_amount = None
|
||||
sheet_count = None
|
||||
try:
|
||||
@@ -199,7 +241,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
budget_buffer=cfg["budget_buffer"],
|
||||
eth_amount=eth_amount if mode == "eth_amount" else None,
|
||||
sheets=sheet_count if mode == "sheets" else None,
|
||||
budget_cap=cfg["trade_budget"],
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
@@ -207,6 +249,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"quote_per_unit": ask,
|
||||
"premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
|
||||
"sizing": sizing,
|
||||
"available_usdc": available_usdc,
|
||||
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -242,15 +286,22 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
sheet_count = int(data.get("sheets"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "张数无效"})
|
||||
budget = cfg["trade_budget"]
|
||||
budget_cap = cfg["trade_budget"]
|
||||
if mode == "budget_full":
|
||||
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||
if budget is None:
|
||||
return jsonify({"ok": False, "msg": budget_err})
|
||||
budget_cap = budget
|
||||
sizing = calc_order_size(
|
||||
quote_per_unit=float(ask),
|
||||
ct_mult=ct_mult,
|
||||
min_sz=min_sz,
|
||||
budget_usdc=cfg["trade_budget"] if mode == "budget_full" else None,
|
||||
budget_usdc=budget if mode == "budget_full" else None,
|
||||
budget_buffer=cfg["budget_buffer"],
|
||||
eth_amount=eth_amount,
|
||||
sheets=sheet_count,
|
||||
budget_cap=cfg["trade_budget"],
|
||||
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||
)
|
||||
if not sizing.get("ok"):
|
||||
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||
@@ -304,6 +355,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
_sync_options_trades(cfg)
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
rows = [cfg["format_position_row"](p) for p in raw]
|
||||
conn = cfg["get_db"]()
|
||||
@@ -554,6 +606,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
ex, err = _require_options_ex(cfg)
|
||||
if ex is None:
|
||||
return jsonify({"ok": False, "msg": err})
|
||||
_sync_options_trades(cfg)
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
@@ -660,6 +713,21 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
return [cfg["format_position_row"](p) for p in raw]
|
||||
|
||||
def _sync(conn):
|
||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||
|
||||
ex = cfg.get("exchange_options")
|
||||
if ex is None:
|
||||
return 0
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
|
||||
return sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=live_ids,
|
||||
fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
|
||||
)
|
||||
|
||||
t = threading.Thread(
|
||||
target=options_monitor_loop,
|
||||
kwargs={
|
||||
@@ -671,6 +739,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
"send_wechat": cfg["send_wechat"],
|
||||
"account_label": cfg["account_label"],
|
||||
"profit_ratio": cfg["profit_ratio"],
|
||||
"sync_trades_fn": _sync,
|
||||
},
|
||||
daemon=True,
|
||||
name="options-monitor",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<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="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="备注(关键位说明)">
|
||||
|
||||
Reference in New Issue
Block a user