d7b3f7a8e7
Co-authored-by: Cursor <cursoragent@cursor.com>
502 lines
20 KiB
Python
502 lines
20 KiB
Python
"""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()
|
|
if isinstance(result, dict) and result.get("ok") is not False:
|
|
result["ok"] = True
|
|
return jsonify(result if isinstance(result, dict) else {"ok": True, "result": result})
|
|
except Exception as e:
|
|
print(f"[options_review_sync] {e}", flush=True)
|
|
try:
|
|
conn.rollback()
|
|
except Exception:
|
|
pass
|
|
return jsonify({"ok": False, "msg": f"同步失败: {e}"}), 500
|
|
finally:
|
|
conn.close()
|
|
|
|
@app.route("/api/options/review/trades")
|
|
@lr
|
|
def api_options_review_trades():
|
|
conn = cfg["get_db"]()
|
|
try:
|
|
# 默认不同步:列表先读缓存;完整同步走 POST /api/options/review/sync
|
|
do_sync = (request.args.get("sync") or "0").strip().lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
if do_sync:
|
|
try:
|
|
ex, _err = _require_ex(cfg)
|
|
# 列表路径不做历史回填,避免模拟盘握库二次开连接卡死
|
|
ensure_local_review_synced(
|
|
conn,
|
|
ex=ex if ex is not None else None,
|
|
backfill_exchange_pnl=False,
|
|
)
|
|
conn.commit()
|
|
except Exception as sync_err:
|
|
# 同步撞锁时仍返回已有列表,避免整页 500
|
|
print(f"[options_review_trades] sync skipped: {sync_err}", flush=True)
|
|
try:
|
|
conn.rollback()
|
|
except Exception:
|
|
pass
|
|
init_options_review_tables(conn)
|
|
else:
|
|
init_options_review_tables(conn)
|
|
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,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
print(f"[options_review_trades] {e}", flush=True)
|
|
return jsonify({"ok": False, "msg": f"加载交易记录失败: {e}"}), 500
|
|
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:
|
|
# 默认不同步:与 trades?sync=1 并行时避免双写撞锁;需要时再 sync=1
|
|
do_sync = (request.args.get("sync") or "0").strip().lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
if do_sync:
|
|
try:
|
|
ex, _err = _require_ex(cfg)
|
|
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
|
|
conn.commit()
|
|
except Exception as sync_err:
|
|
print(f"[options_review_stats] sync skipped: {sync_err}", flush=True)
|
|
try:
|
|
conn.rollback()
|
|
except Exception:
|
|
pass
|
|
init_options_review_tables(conn)
|
|
else:
|
|
init_options_review_tables(conn)
|
|
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
|
|
stats["ok"] = True
|
|
return jsonify(stats)
|
|
except Exception as e:
|
|
print(f"[options_review_stats] {e}", flush=True)
|
|
return jsonify({"ok": False, "msg": f"加载统计失败: {e}"}), 500
|
|
finally:
|
|
conn.close()
|