Files
crypto_monitor/lib/options/options_review_register.py
T

311 lines
12 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.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(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>")
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:
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:
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()