移除全部对冲计划模块:crypto_okx 改为币本位单独期权执行机。

交易模式锁定 options;okx_trade_mode / 到期盈亏迁入 options;保留期权/桥/关键位/企微与中控预留快照口径。恢复点:backup/before-remove-hedge 与 tag backup-before-remove-hedge-20260930。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-09-30 23:20:21 +08:00
parent 8a62cfbb48
commit 0acba5e351
32 changed files with 11330 additions and 21601 deletions
+74
View File
@@ -0,0 +1,74 @@
"""OKX 交易模式(币本位期权专用机:仅单独期权).
历史 .env 若仍为 perp_options / options_options,读取时归一为 options,
对冲模块已移除,不可再启用.
"""
from __future__ import annotations
import os
from typing import Optional
MODE_OPTIONS = "options"
# 兼容旧配置名,不再作为可选模式
MODE_PERP = "perp_options"
MODE_OO = "options_options"
VALID_MODES = frozenset({MODE_OPTIONS})
UI_MODES = frozenset({MODE_OPTIONS})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_OPTIONS,
"perp": MODE_OPTIONS,
"perp_options": MODE_OPTIONS,
"永期": MODE_OPTIONS,
"永期对冲": MODE_OPTIONS,
"oo": MODE_OPTIONS,
"options_options": MODE_OPTIONS,
"期期": MODE_OPTIONS,
"期期对冲": MODE_OPTIONS,
}
def normalize_okx_trade_mode(raw: Optional[str] = None) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
# 未知或空 → 单独期权
return MODE_OPTIONS
def legacy_infer_okx_trade_mode() -> str:
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
return normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
def hedge_module_enabled() -> bool:
return False
def show_perp_options() -> bool:
return False
def show_options_options() -> bool:
return False
def standalone_options_open_allowed() -> bool:
return True
def mode_label(mode: Optional[str] = None) -> str:
return "单独期权"
def block_standalone_open_by_mode_msg() -> Optional[str]:
return None
+77 -85
View File
@@ -1,85 +1,77 @@
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
from __future__ import annotations
from typing import Any
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
"""
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
不走 options dashboard snapshot(避免余额/历史).
"""
if not cfg.get("enabled"):
return []
ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready")
if not callable(ready_fn):
return []
ok, _reason = ready_fn(ex)
if not ok:
return []
fetch_fn = cfg.get("fetch_option_positions")
if not callable(fetch_fn):
return []
raw = fetch_fn(ex)
if raw is None:
return []
if not raw:
return []
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_positions_lib import attach_close_preview
meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = []
get_db = cfg.get("get_db")
if not callable(get_db):
for p in raw:
if not isinstance(p, dict):
continue
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
attach_close_preview(cfg, ex, row)
rows.append(row)
return rows
conn = get_db()
try:
init_options_tables(conn)
try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
except Exception:
tgt_map = {}
hedge_target_map = {}
for p in raw:
if not isinstance(p, dict):
continue
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=premium_override)
mon = tgt_map.get(str(row.get("inst_id") or ""))
if mon:
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
if hedge_target:
row["hedge_plan_target"] = hedge_target
if not mon:
row["target_index"] = hedge_target.get("target_index")
rows.append(row)
finally:
conn.close()
return rows
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
from __future__ import annotations
from typing import Any
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
"""
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
不走 options dashboard snapshot(避免余额/历史).
"""
if not cfg.get("enabled"):
return []
ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready")
if not callable(ready_fn):
return []
ok, _reason = ready_fn(ex)
if not ok:
return []
fetch_fn = cfg.get("fetch_option_positions")
if not callable(fetch_fn):
return []
raw = fetch_fn(ex)
if raw is None:
return []
if not raw:
return []
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_positions_lib import attach_close_preview
meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = []
get_db = cfg.get("get_db")
if not callable(get_db):
for p in raw:
if not isinstance(p, dict):
continue
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
attach_close_preview(cfg, ex, row)
rows.append(row)
return rows
conn = get_db()
try:
init_options_tables(conn)
try:
from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn)
except Exception:
tgt_map = {}
for p in raw:
if not isinstance(p, dict):
continue
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=premium_override)
mon = tgt_map.get(str(row.get("inst_id") or ""))
if mon:
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
rows.append(row)
finally:
conn.close()
return rows
-9
View File
@@ -28,15 +28,12 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> 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_profit_exit_lib import profit_exit_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)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
@@ -59,12 +56,6 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target:
p["hedge_plan_target"] = hedge_target
if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index")
try:
from lib.instance.instance_dashboard_lib import (
_format_options_target,
+1 -1
View File
@@ -74,7 +74,7 @@ def option_position_limit_block_msg(
- 拉持仓失败:拒绝开仓(避免绕过上限)
"""
try:
from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed
from lib.options.okx_trade_mode_lib import standalone_options_open_allowed
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
if max_active is None and not standalone_options_open_allowed():
+22
View File
@@ -28,6 +28,28 @@ def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
return float(quote_per_unit) * float(ct_mult)
def option_expiry_pnl(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
"""到期按实值估算盈亏(权利金币种口径);模拟盘到期结算等复用."""
o = (opt_type or "").strip().upper()
intrinsic_per_coin = 0.0
if o in ("C", "CALL"):
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
elif o in ("P", "PUT"):
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
else:
return -float(premium_paid)
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
return value - float(premium_paid)
def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None:
"""盘口展示:价格/张数,如 17.2/150."""
if px is None:
-22
View File
@@ -310,34 +310,12 @@ def run_options_profit_exits(
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
# 对冲表异常时不得整段跳过翻倍出场
hedge_managed = set()
rules = profit_exit_by_inst(conn)
triggered = 0
for inst_id, info in list(rules.items()):
if not inst_id:
continue
if inst_id in hedge_managed:
_mark_state(conn, inst_id, "idle")
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
_commit(conn)
continue
pos = pos_by_inst.get(inst_id)
if not pos:
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
File diff suppressed because it is too large Load Diff
+3 -28
View File
@@ -586,32 +586,11 @@ def _mark_option_legs_excluded(
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
return {"ok": True, "fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "removed": True}
init_hedge_plan_tables(conn)
plans = list_plans(conn, status="closed", limit=500)
inserted = updated = skipped = 0
for plan in plans:
legs = get_plan_legs(conn, int(plan["id"]))
action = upsert_hedge_plan_row(conn, plan, legs)
if action == "inserted":
inserted += 1
elif action == "updated":
updated += 1
else:
skipped += 1
last_id = max((int(p["id"]) for p in plans), default=0)
set_sync_state(conn, "hedge_last_sync_at", _now_str())
set_sync_state(conn, "hedge_last_plan_id", str(last_id))
return {
"ok": True,
"fetched": len(plans),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}
def sync_all_review_sources(
@@ -653,16 +632,12 @@ def ensure_local_review_synced(
if backfill_exchange_pnl and ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
from lib.options.options_monitor_lib import (
backfill_closed_options_realized_pnl_from_history,
)
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist)
backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
+452 -452
View File
@@ -1,452 +1,452 @@
"""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,
)
def _review_source_for_mode(requested: str | None) -> str | None:
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
mode = get_okx_trade_mode()
except Exception:
mode = "options"
allowed = {
"options": "option_spot",
"perp_options": "perp_options",
"options_options": "options_options",
}.get(mode, "option_spot")
req = (requested or "").strip()
if not req:
return allowed
if req == allowed:
return allowed
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
return allowed
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(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 _truthy(raw: Any) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
def _prepare_review_entry_images(
conn: Any,
trade_id: int,
data: dict[str, Any],
) -> dict[str, Any]:
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
from lib.options.options_review_images_lib import parse_options_review_images_json
images = data.get("images") if isinstance(data.get("images"), list) else []
cleaned: list[dict[str, str]] = []
for item in images:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
continue
cleaned.append({"tf": tf, "file": file})
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
if not want_chart:
row = conn.execute(
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
(int(trade_id),),
).fetchone()
if row:
for item in parse_options_review_images_json(row["images_json"]):
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
cleaned.insert(0, dict(item))
break
out = dict(data)
out["images"] = cleaned
return out
def _maybe_schedule_options_review_chart(
cfg: dict[str, Any],
trade: dict[str, Any],
data: dict[str, Any],
) -> bool:
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
if str(trade.get("source_type") or "").strip() != "option_spot":
return False
images = data.get("images") if isinstance(data.get("images"), list) else []
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
if has_manual or not _truthy(data.get("journal_exchange_chart")):
return False
app_module = cfg.get("app_module")
if app_module is None:
return False
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
return False
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
if not callable(generate_fn):
return False
from lib.instance.journal_chart_lib import (
parse_journal_chart_anchor,
parse_journal_chart_limit,
parse_journal_chart_timeframes,
)
from lib.options.options_review_chart_async_lib import (
schedule_options_review_exchange_chart,
underlying_to_okx_perp_symbol,
)
from lib.options.options_review_images_lib import options_review_upload_dir
from lib.options.options_review_lib import _now_str
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
journal_tfs = parse_journal_chart_timeframes(
data.get("journal_chart_tf1"),
data.get("journal_chart_tf2"),
fallback_tfs or None,
)
journal_limit = parse_journal_chart_limit(
data.get("journal_chart_limit"),
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
)
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
exchange_symbol = underlying_to_okx_perp_symbol(
underlying,
normalize_symbol_input_fn=app_module.normalize_symbol_input,
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
)
if not exchange_symbol:
return False
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
if not callable(to_ms):
return False
entry_ts = to_ms(trade.get("opened_at"))
exit_ts = to_ms(trade.get("closed_at"))
if entry_ts is None and exit_ts is None:
return False
now_ms = None
app_now = getattr(app_module, "app_now", None)
if callable(app_now):
try:
now_ms = int(app_now().timestamp() * 1000)
except Exception:
now_ms = None
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
upload_folder = options_review_upload_dir(cfg["upload_folder"])
schedule_options_review_exchange_chart(
trade_id=int(trade["id"]),
exchange_symbol=exchange_symbol,
title_prefix=f"{underlying or 'OPT'} {inst} review",
journal_tfs=journal_tfs,
journal_limit=journal_limit,
marker_payload={
"entry_ts_ms": entry_ts,
"exit_ts_ms": exit_ts,
# 只按时间钉进/出场,不传价格与止损
"chart_anchor": chart_anchor,
"now_ts_ms": now_ms,
},
upload_folder=upload_folder,
generate_chart_fn=generate_fn,
get_db_fn=cfg["get_db"],
now_str_fn=_now_str,
)
return True
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>")
def static_options_review_image(filename: str):
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
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):
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
root = os.path.abspath(cfg["upload_folder"] or "")
alt = os.path.join(root, safe)
if os.path.isfile(alt):
path = alt
else:
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)
ex, _err = _require_ex(cfg)
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
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:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
filt = dict(
source_type=_review_source_for_mode(request.args.get("source_type")),
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,
q=(request.args.get("q") 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:
data = _prepare_review_entry_images(conn, trade_id, data)
out = save_review_entry(conn, trade_id, data)
if out.get("ok"):
conn.commit()
chart_queued = _maybe_schedule_options_review_chart(
cfg, out.get("trade") or {}, data
)
if chart_queued:
out = dict(out)
out["chart_queued"] = True
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:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
stats = compute_review_stats(
conn,
source_type=_review_source_for_mode(request.args.get("source_type")),
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()
"""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,
)
def _review_source_for_mode(requested: str | None) -> str | None:
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
try:
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
mode = get_okx_trade_mode()
except Exception:
mode = "options"
allowed = {
"options": "option_spot",
"perp_options": "perp_options",
"options_options": "options_options",
}.get(mode, "option_spot")
req = (requested or "").strip()
if not req:
return allowed
if req == allowed:
return allowed
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
return allowed
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(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 _truthy(raw: Any) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
def _prepare_review_entry_images(
conn: Any,
trade_id: int,
data: dict[str, Any],
) -> dict[str, Any]:
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
from lib.options.options_review_images_lib import parse_options_review_images_json
images = data.get("images") if isinstance(data.get("images"), list) else []
cleaned: list[dict[str, str]] = []
for item in images:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
continue
cleaned.append({"tf": tf, "file": file})
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
if not want_chart:
row = conn.execute(
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
(int(trade_id),),
).fetchone()
if row:
for item in parse_options_review_images_json(row["images_json"]):
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
cleaned.insert(0, dict(item))
break
out = dict(data)
out["images"] = cleaned
return out
def _maybe_schedule_options_review_chart(
cfg: dict[str, Any],
trade: dict[str, Any],
data: dict[str, Any],
) -> bool:
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
if str(trade.get("source_type") or "").strip() != "option_spot":
return False
images = data.get("images") if isinstance(data.get("images"), list) else []
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
if has_manual or not _truthy(data.get("journal_exchange_chart")):
return False
app_module = cfg.get("app_module")
if app_module is None:
return False
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
return False
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
if not callable(generate_fn):
return False
from lib.instance.journal_chart_lib import (
parse_journal_chart_anchor,
parse_journal_chart_limit,
parse_journal_chart_timeframes,
)
from lib.options.options_review_chart_async_lib import (
schedule_options_review_exchange_chart,
underlying_to_okx_perp_symbol,
)
from lib.options.options_review_images_lib import options_review_upload_dir
from lib.options.options_review_lib import _now_str
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
journal_tfs = parse_journal_chart_timeframes(
data.get("journal_chart_tf1"),
data.get("journal_chart_tf2"),
fallback_tfs or None,
)
journal_limit = parse_journal_chart_limit(
data.get("journal_chart_limit"),
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
)
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
exchange_symbol = underlying_to_okx_perp_symbol(
underlying,
normalize_symbol_input_fn=app_module.normalize_symbol_input,
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
)
if not exchange_symbol:
return False
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
if not callable(to_ms):
return False
entry_ts = to_ms(trade.get("opened_at"))
exit_ts = to_ms(trade.get("closed_at"))
if entry_ts is None and exit_ts is None:
return False
now_ms = None
app_now = getattr(app_module, "app_now", None)
if callable(app_now):
try:
now_ms = int(app_now().timestamp() * 1000)
except Exception:
now_ms = None
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
upload_folder = options_review_upload_dir(cfg["upload_folder"])
schedule_options_review_exchange_chart(
trade_id=int(trade["id"]),
exchange_symbol=exchange_symbol,
title_prefix=f"{underlying or 'OPT'} {inst} review",
journal_tfs=journal_tfs,
journal_limit=journal_limit,
marker_payload={
"entry_ts_ms": entry_ts,
"exit_ts_ms": exit_ts,
# 只按时间钉进/出场,不传价格与止损
"chart_anchor": chart_anchor,
"now_ts_ms": now_ms,
},
upload_folder=upload_folder,
generate_chart_fn=generate_fn,
get_db_fn=cfg["get_db"],
now_str_fn=_now_str,
)
return True
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>")
def static_options_review_image(filename: str):
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
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):
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
root = os.path.abspath(cfg["upload_folder"] or "")
alt = os.path.join(root, safe)
if os.path.isfile(alt):
path = alt
else:
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)
ex, _err = _require_ex(cfg)
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
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:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
filt = dict(
source_type=_review_source_for_mode(request.args.get("source_type")),
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,
q=(request.args.get("q") 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:
data = _prepare_review_entry_images(conn, trade_id, data)
out = save_review_entry(conn, trade_id, data)
if out.get("ok"):
conn.commit()
chart_queued = _maybe_schedule_options_review_chart(
cfg, out.get("trade") or {}, data
)
if chart_queued:
out = dict(out)
out["chart_queued"] = True
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:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
stats = compute_review_stats(
conn,
source_type=_review_source_for_mode(request.args.get("source_type")),
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()
+474 -501
View File
@@ -1,501 +1,474 @@
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
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(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
target: float,
idx: float,
result: dict[str, Any],
conn: Any = None,
) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
from lib.options.options_notify_lib import resolve_premium_ccy
ccy = resolve_premium_ccy(inst_id=inst_id)
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason="目标位平仓",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target,
trigger_idx=idx,
premium_ccy=ccy,
)
return
except Exception:
pass
if not send_wechat:
return
try:
recv = result.get("premium_received")
send_wechat(
"\n".join(
[
"【OKX期权·目标位平仓】",
f"账户:{account_label}",
f"合约:{inst_id}",
f"目标指数:{target:g}",
f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or '—'}",
f"预估收回:{recv if recv is not None else '—'} {ccy}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
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期权",
cfg: dict[str, Any] | None = None,
) -> 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}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
# fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿
return 0
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 in hedge_managed:
mark_monitor(
conn,
int(mon["id"]),
status="expired",
message="已移交对冲计划托管,跳过单独目标平仓",
)
_commit_monitor(conn)
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
if inst_id in hedge_managed:
mark_monitor(
conn,
int(mon["id"]),
status="expired",
message="已移交对冲计划托管,跳过单独目标平仓",
)
_commit_monitor(conn)
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(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
return triggered
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
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(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
target: float,
idx: float,
result: dict[str, Any],
conn: Any = None,
) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
from lib.options.options_notify_lib import resolve_premium_ccy
ccy = resolve_premium_ccy(inst_id=inst_id)
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason="目标位平仓",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target,
trigger_idx=idx,
premium_ccy=ccy,
)
return
except Exception:
pass
if not send_wechat:
return
try:
recv = result.get("premium_received")
send_wechat(
"\n".join(
[
"【OKX期权·目标位平仓】",
f"账户:{account_label}",
f"合约:{inst_id}",
f"目标指数:{target:g}",
f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or '—'}",
f"预估收回:{recv if recv is not None else '—'} {ccy}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
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期权",
cfg: dict[str, Any] | None = None,
) -> 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(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
return triggered