"""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 _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/") 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/") @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/", 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/", 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()