"""期权复盘业务:OKX 已平期权导入 + 已结束对冲计划导入 + 复盘 CRUD + 统计.""" from __future__ import annotations import json import sqlite3 from datetime import datetime from typing import Any, Callable, Optional from lib.options.options_review_db import ( SOURCE_OPTION, SOURCE_OPTIONS_OPTIONS, SOURCE_PERP_OPTIONS, SOURCE_TYPES, init_options_review_tables, ) from lib.options.options_review_images_lib import ( images_json_dumps, parse_options_review_images_json, ) SOURCE_LABELS = { SOURCE_OPTION: "纯期权", SOURCE_PERP_OPTIONS: "永期对冲", SOURCE_OPTIONS_OPTIONS: "期期对冲", } HOLD_BUCKETS = ( ("0-1h", 0, 3600), ("1-6h", 3600, 6 * 3600), ("6-24h", 6 * 3600, 24 * 3600), ("1-3d", 24 * 3600, 3 * 24 * 3600), (">3d", 3 * 24 * 3600, None), ) def _now_str() -> str: return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _parse_ts(raw: Any) -> Optional[datetime]: if raw is None or raw == "": return None s = str(raw).strip().replace(" ", "T", 1) try: return datetime.fromisoformat(s) except (TypeError, ValueError): return None def _hold_seconds(opened_at: Any, closed_at: Any) -> Optional[int]: start = _parse_ts(opened_at) end = _parse_ts(closed_at) if start is None or end is None: return None sec = int((end - start).total_seconds()) return sec if sec >= 0 else None def _safe_float(v: Any) -> Optional[float]: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]: row = conn.execute( "SELECT value FROM options_review_sync_state WHERE key=?", (key,) ).fetchone() return str(row["value"]) if row and row["value"] is not None else None def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None: conn.execute( """ INSERT INTO options_review_sync_state(key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at """, (key, value, _now_str()), ) def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool: """删除已导入的复盘快照(含复盘内容).""" key = str(history_key or "").strip() if not key: return False existing = conn.execute( "SELECT id FROM options_review_trades WHERE history_key=?", (key,) ).fetchone() if not existing: return False tid = int(existing["id"]) conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (tid,)) conn.execute("DELETE FROM options_review_trades WHERE id=?", (tid,)) return True def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str: """幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.""" history_key = str(row.get("history_key") or "").strip() if not history_key: return "skip" if is_review_hidden( conn, history_key, inst_id=str(row.get("inst_id") or "").strip() or None, closed_at=row.get("closed_at") or row.get("created_at"), ): # 若此前已导入,清掉,避免列表残留 return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" opened_at = row.get("created_at") or row.get("opened_at") closed_at = row.get("closed_at") pnl = _safe_float(row.get("realized_pnl")) hold = _hold_seconds(opened_at, closed_at) existing = conn.execute( "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,) ).fetchone() fields = { "source_type": SOURCE_OPTION, "history_key": history_key, "underlying": str(row.get("underlying") or "").strip() or None, "opened_at": opened_at, "closed_at": closed_at, "hold_seconds": hold, "realized_pnl_total": pnl, "status_raw": str(row.get("status_label") or row.get("status") or "closed"), "synced_at": _now_str(), "pos_id": str(row.get("pos_id") or "").strip() or None, "inst_id": str(row.get("inst_id") or "").strip() or None, "opt_type": str(row.get("opt_type") or "").strip() or None, "strike": _safe_float(row.get("strike")), "exp_time": str(row.get("exp_time") or "").strip() or None, "sheets": int(row.get("sheets") or 0) or None, "open_avg": _safe_float(row.get("open_avg_px") if row.get("open_avg_px") is not None else row.get("open_avg")), "close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")), "premium_paid": _safe_float(row.get("premium_paid")), "realized_pnl": pnl, } cols = list(fields.keys()) if existing: sets = ", ".join(f"{c}=?" for c in cols if c != "history_key") vals = [fields[c] for c in cols if c != "history_key"] conn.execute( f"UPDATE options_review_trades SET {sets} WHERE history_key=?", [*vals, history_key], ) return "updated" placeholders = ",".join(["?"] * len(cols)) conn.execute( f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})", [fields[c] for c in cols], ) return "inserted" def _close_fingerprint(inst_id: Any, closed_at: Any) -> str | None: inst = str(inst_id or "").strip() if not inst: return None closed = str(closed_at or "").strip() if not closed: return f"inst:{inst}" # 精确到分钟,避免秒差导致漏匹配 return f"inst_close:{inst}:{closed[:16]}" def is_review_hidden( conn: sqlite3.Connection, history_key: str, *, inst_id: str | None = None, closed_at: Any = None, ) -> bool: init_options_review_tables(conn) key = str(history_key or "").strip() if key and conn.execute( "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (key,) ).fetchone(): return True fp = _close_fingerprint(inst_id, closed_at) if fp and conn.execute( "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (fp,) ).fetchone(): return True # 期权历史页删除:options_history_hidden,按合约指纹或原 key try: if key and conn.execute( "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (key,) ).fetchone(): return True if fp and conn.execute( "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (fp,) ).fetchone(): return True # 仅隐藏了 ex:posId 时,用合约+平仓时间在历史隐藏表无直接命中; # 若指纹已写入 options_review_hidden(新删除路径)上面已覆盖. # 兼容:inst 级隐藏 if inst_id: inst_fp = f"inst:{str(inst_id).strip()}" if conn.execute( "SELECT 1 FROM options_review_hidden WHERE history_key=? LIMIT 1", (inst_fp,), ).fetchone(): return True if conn.execute( "SELECT 1 FROM options_history_hidden WHERE history_key=? LIMIT 1", (inst_fp,), ).fetchone(): return True except Exception: pass return False def hide_review_keys( conn: sqlite3.Connection, *, history_key: str, inst_id: str | None = None, closed_at: Any = None, ) -> None: init_options_review_tables(conn) keys = [str(history_key or "").strip()] fp = _close_fingerprint(inst_id, closed_at) if fp: keys.append(fp) for k in keys: if not k: continue conn.execute( """ INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?, ?, ?) """, (k, (inst_id or None), str(closed_at or "")[:19] or None), ) try: conn.execute( "INSERT OR IGNORE INTO options_history_hidden(history_key) VALUES (?)", (k,), ) except Exception: pass def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: """从复盘列表删除并持久隐藏,刷新本地源也不会再回来.""" init_options_review_tables(conn) row = conn.execute( "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) ).fetchone() if not row: return {"ok": False, "msg": "记录不存在"} d = _row_to_dict(row) hide_review_keys( conn, history_key=str(d.get("history_key") or ""), inst_id=str(d.get("inst_id") or "").strip() or None, closed_at=d.get("closed_at") or d.get("opened_at"), ) entry = conn.execute( "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) ).fetchone() conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) conn.execute("DELETE FROM options_review_trades WHERE id=?", (int(trade_id),)) return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")} def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" init_options_review_tables(conn) from lib.options.options_db import init_options_tables init_options_tables(conn) rows = conn.execute( """ SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, open_quote, close_quote, premium_paid, realized_pnl, created_at, closed_at, signal_note, status FROM options_trades WHERE status = 'closed' ORDER BY id DESC LIMIT 500 """ ).fetchall() inserted = updated = skipped = 0 for r in rows: trade_id = int(r["id"]) history_key = f"local_opt:{trade_id}" pnl = _safe_float(r["realized_pnl"]) opened_at = r["created_at"] closed_at = r["closed_at"] action = upsert_option_history_row( conn, { "history_key": history_key, "pos_id": f"local:{trade_id}", "inst_id": r["inst_id"], "underlying": r["underlying"], "opt_type": r["opt_type"], "strike": r["strike"], "exp_time": r["exp_time"], "sheets": r["sheets"], "open_avg_px": r["open_quote"], "close_avg_px": r["close_quote"], "premium_paid": r["premium_paid"], "realized_pnl": pnl, "created_at": opened_at, "closed_at": closed_at, "status_label": "已平", }, ) if action == "inserted": inserted += 1 elif action == "updated": updated += 1 else: skipped += 1 set_sync_state(conn, "options_last_sync_at", _now_str()) set_sync_state(conn, "options_last_count", str(len(rows))) set_sync_state(conn, "options_sync_source", "local") return { "ok": True, "source": "local", "fetched": len(rows), "inserted": inserted, "updated": updated, "skipped": skipped, } def sync_options_from_exchange( conn: sqlite3.Connection, ex: Any, *, limit: int = 500, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, format_fn: Optional[Callable[..., dict[str, Any]]] = None, ) -> dict[str, Any]: """从 OKX positions-history 导入已全平期权仓位(可选,默认不用).""" init_options_review_tables(conn) from lib.exchange.okx_options_lib import ( fetch_all_option_positions_history, format_option_history_row, tick_sz_and_ct_mult, ) fetch = fetch_fn or fetch_all_option_positions_history fmt = format_fn or format_option_history_row raw_rows = fetch(ex, limit=limit) meta_cache: dict[str, dict[str, Any] | None] = {} inserted = updated = skipped = 0 for raw in raw_rows: inst_id = str(raw.get("instId") or "").strip() tick_sz, ct_mult = None, 0.01 try: tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) except Exception: pass formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult) action = upsert_option_history_row(conn, formatted) if action == "inserted": inserted += 1 elif action == "updated": updated += 1 else: skipped += 1 set_sync_state(conn, "options_last_sync_at", _now_str()) set_sync_state(conn, "options_last_count", str(len(raw_rows))) set_sync_state(conn, "options_sync_source", "exchange") return { "ok": True, "source": "exchange", "fetched": len(raw_rows), "inserted": inserted, "updated": updated, "skipped": skipped, } def _legs_json_from_plan(legs: list[dict[str, Any]]) -> str: slim = [] for leg in legs: slim.append( { "id": leg.get("id"), "leg_role": leg.get("leg_role"), "symbol": leg.get("symbol"), "inst_id": leg.get("inst_id"), "opt_type": leg.get("opt_type"), "strike": leg.get("strike"), "side": leg.get("side"), "size": leg.get("size"), "avg_open": leg.get("avg_open"), "premium": leg.get("premium"), "status": leg.get("status"), "realized_pnl": leg.get("realized_pnl"), "close_reason": leg.get("close_reason"), "opened_at": leg.get("opened_at"), "closed_at": leg.get("closed_at"), } ) return json.dumps(slim, ensure_ascii=False, separators=(",", ":")) def upsert_hedge_plan_row( conn: sqlite3.Connection, plan: dict[str, Any], legs: list[dict[str, Any]], ) -> str: plan_id = int(plan["id"]) history_key = f"hedge:{plan_id}" plan_type = str(plan.get("plan_type") or "").strip() if plan_type not in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS): return "skip" opened_at = plan.get("opened_at") or plan.get("created_at") closed_at = plan.get("closed_at") if is_review_hidden( conn, history_key, inst_id=None, closed_at=closed_at, ): return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden" total = _safe_float(plan.get("realized_pnl_total")) hold = _hold_seconds(opened_at, closed_at) fields = { "source_type": plan_type, "history_key": history_key, "underlying": str(plan.get("underlying") or "").strip() or None, "opened_at": opened_at, "closed_at": closed_at, "hold_seconds": hold, "realized_pnl_total": total, "status_raw": str(plan.get("status") or "closed"), "synced_at": _now_str(), "hedge_plan_id": plan_id, "plan_close_reason": str(plan.get("close_reason") or "").strip() or None, "realized_pnl_perp": _safe_float(plan.get("realized_pnl_perp")), "realized_pnl_options": _safe_float(plan.get("realized_pnl_options")), "premium_total": _safe_float(plan.get("premium_total")), "direction": str(plan.get("direction") or "").strip() or None, "tp": _safe_float(plan.get("tp")), "sl": _safe_float(plan.get("sl")), "target_price": _safe_float(plan.get("target_price")), "target_price_up": _safe_float(plan.get("target_price_up")), "target_price_down": _safe_float(plan.get("target_price_down")), "legs_json": _legs_json_from_plan(legs), } existing = conn.execute( "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,) ).fetchone() cols = list(fields.keys()) if existing: sets = ", ".join(f"{c}=?" for c in cols if c != "history_key") vals = [fields[c] for c in cols if c != "history_key"] conn.execute( f"UPDATE options_review_trades SET {sets} WHERE history_key=?", [*vals, history_key], ) trade_id = int(existing["id"]) action = "updated" else: placeholders = ",".join(["?"] * len(cols)) cur = conn.execute( f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})", [fields[c] for c in cols], ) trade_id = int(cur.lastrowid) action = "inserted" _mark_option_legs_excluded(conn, plan_id, legs) del trade_id return action def _mark_option_legs_excluded( conn: sqlite3.Connection, plan_id: int, legs: list[dict[str, Any]], ) -> int: """纯期权记录若 inst_id 出现在对冲腿中,标记排除以免双计.""" inst_ids = { str(leg.get("inst_id") or "").strip() for leg in legs if str(leg.get("leg_role") or "").startswith("option") and str(leg.get("inst_id") or "").strip() } if not inst_ids: return 0 n = 0 for inst_id in inst_ids: cur = conn.execute( """ UPDATE options_review_trades SET excluded_as_hedge_leg = 1, linked_hedge_plan_id = ? WHERE source_type = ? AND inst_id = ? AND excluded_as_hedge_leg = 0 """, (plan_id, SOURCE_OPTION, inst_id), ) n += int(cur.rowcount or 0) return n 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 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( conn: sqlite3.Connection, ex: Any | None = None, *, options_limit: int = 500, from_exchange: bool = False, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, format_fn: Optional[Callable[..., dict[str, Any]]] = None, ) -> dict[str, Any]: """默认只读本地 options_trades + 已结束对冲计划;不访问交易所.""" init_options_review_tables(conn) out: dict[str, Any] = {"ok": True, "options": None, "hedge": None} if from_exchange and ex is not None: out["options"] = sync_options_from_exchange( conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn ) else: out["options"] = sync_options_from_local_trades(conn) out["hedge"] = sync_hedge_plans_closed(conn) return out def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]: """列表/统计前轻量刷新本地源.""" return sync_all_review_sources(conn, from_exchange=False) def _row_to_dict(row: Any) -> dict[str, Any]: return dict(row) if row is not None else {} def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]: out = dict(row) out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type")) out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS) legs = [] if out.get("legs_json"): try: legs = json.loads(str(out["legs_json"])) except (TypeError, ValueError, json.JSONDecodeError): legs = [] out["legs"] = legs if isinstance(legs, list) else [] out["reviewed"] = bool(entry) if entry: out["entry"] = dict(entry) out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json")) out["strategy_tag"] = entry.get("strategy_tag") out["result_tag"] = entry.get("result_tag") out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at") else: out["entry"] = None out["strategy_tag"] = None out["result_tag"] = None out["reviewed_at"] = None return out def _review_trades_filters( *, source_type: str | None = None, underlying: str | None = None, opt_type: str | None = None, strategy_tag: str | None = None, reviewed: str | None = None, include_hedge_legs: bool = False, closed_from: str | None = None, closed_to: str | None = None, ) -> tuple[str, list[Any]]: wheres: list[str] = [] args: list[Any] = [] if source_type and source_type in SOURCE_TYPES: wheres.append("t.source_type=?") args.append(source_type) if underlying: wheres.append("UPPER(COALESCE(t.underlying,''))=?") args.append(underlying.strip().upper()) if opt_type: ot = opt_type.strip().upper() if ot in ("C", "P", "CALL", "PUT"): if ot.startswith("C"): ot = "C" elif ot.startswith("P"): ot = "P" wheres.append( """( UPPER(COALESCE(t.opt_type,''))=? OR ( t.legs_json IS NOT NULL AND t.legs_json LIKE '%' || '"opt_type":"' || ? || '%' ) )""" ) args.extend([ot, ot]) if not include_hedge_legs: wheres.append("COALESCE(t.excluded_as_hedge_leg,0)=0") if closed_from: wheres.append("COALESCE(t.closed_at,'')>=?") args.append(closed_from) if closed_to: wheres.append("COALESCE(t.closed_at,'')<=?") args.append(closed_to) if strategy_tag: wheres.append("e.strategy_tag=?") args.append(strategy_tag) if reviewed == "1" or reviewed == "yes": wheres.append("e.id IS NOT NULL") elif reviewed == "0" or reviewed == "no": wheres.append("e.id IS NULL") where = (" WHERE " + " AND ".join(wheres)) if wheres else "" return where, args def count_review_trades( conn: sqlite3.Connection, *, source_type: str | None = None, underlying: str | None = None, opt_type: str | None = None, strategy_tag: str | None = None, reviewed: str | None = None, include_hedge_legs: bool = False, closed_from: str | None = None, closed_to: str | None = None, ) -> int: init_options_review_tables(conn) where, args = _review_trades_filters( source_type=source_type, underlying=underlying, opt_type=opt_type, strategy_tag=strategy_tag, reviewed=reviewed, include_hedge_legs=include_hedge_legs, closed_from=closed_from, closed_to=closed_to, ) row = conn.execute( f""" SELECT COUNT(*) AS c FROM options_review_trades t LEFT JOIN options_review_entries e ON e.trade_id = t.id {where} """, args, ).fetchone() return int(row["c"] if row else 0) def list_review_trades( conn: sqlite3.Connection, *, source_type: str | None = None, underlying: str | None = None, opt_type: str | None = None, strategy_tag: str | None = None, reviewed: str | None = None, include_hedge_legs: bool = False, closed_from: str | None = None, closed_to: str | None = None, limit: int = 200, offset: int = 0, ) -> list[dict[str, Any]]: init_options_review_tables(conn) where, args = _review_trades_filters( source_type=source_type, underlying=underlying, opt_type=opt_type, strategy_tag=strategy_tag, reviewed=reviewed, include_hedge_legs=include_hedge_legs, closed_from=closed_from, closed_to=closed_to, ) rows = conn.execute( f""" SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag, e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan, e.mistake_tags, e.result_tag, e.note, e.images_json, e.image, e.reviewed_at, e.updated_at FROM options_review_trades t LEFT JOIN options_review_entries e ON e.trade_id = t.id {where} ORDER BY COALESCE(t.closed_at, t.opened_at, '') DESC, t.id DESC LIMIT ? OFFSET ? """, [*args, int(limit), int(offset)], ).fetchall() out: list[dict[str, Any]] = [] for r in rows: d = _row_to_dict(r) entry = None if d.get("entry_id"): entry = { "id": d.pop("entry_id", None), "strategy_tag": d.pop("e_strategy_tag", None), "direction_view": d.pop("direction_view", None), "entry_logic": d.pop("entry_logic", None), "exit_reason": d.pop("exit_reason", None), "followed_plan": d.pop("followed_plan", None), "mistake_tags": d.pop("mistake_tags", None), "result_tag": d.pop("result_tag", None), "note": d.pop("note", None), "images_json": d.pop("images_json", None), "image": d.pop("image", None), "reviewed_at": d.pop("reviewed_at", None), "updated_at": d.pop("updated_at", None), } else: for k in ( "entry_id", "e_strategy_tag", "direction_view", "entry_logic", "exit_reason", "followed_plan", "mistake_tags", "result_tag", "note", "images_json", "image", "reviewed_at", "updated_at", ): d.pop(k, None) out.append(enrich_trade_row(d, entry)) return out def get_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None: init_options_review_tables(conn) row = conn.execute( "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),) ).fetchone() if not row: return None entry_row = conn.execute( "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) ).fetchone() entry = _row_to_dict(entry_row) if entry_row else None return enrich_trade_row(_row_to_dict(row), entry) def save_review_entry( conn: sqlite3.Connection, trade_id: int, payload: dict[str, Any], ) -> dict[str, Any]: """保存/更新人工复盘;不影响 trades 快照字段.""" init_options_review_tables(conn) trade = conn.execute( "SELECT id FROM options_review_trades WHERE id=?", (int(trade_id),) ).fetchone() if not trade: return {"ok": False, "msg": "交易不存在"} images = payload.get("images") if images is None and payload.get("images_json") is not None: images = parse_options_review_images_json(payload.get("images_json")) if not isinstance(images, list): images = [] images_json = images_json_dumps(images) primary = None if images: primary = str(images[0].get("file") or "").strip() or None fields = { "strategy_tag": str(payload.get("strategy_tag") or "").strip() or None, "direction_view": str(payload.get("direction_view") or "").strip() or None, "entry_logic": str(payload.get("entry_logic") or "").strip() or None, "exit_reason": str(payload.get("exit_reason") or "").strip() or None, "followed_plan": str(payload.get("followed_plan") or "").strip() or None, "mistake_tags": str(payload.get("mistake_tags") or "").strip() or None, "result_tag": str(payload.get("result_tag") or "").strip() or None, "note": str(payload.get("note") or "").strip() or None, "images_json": images_json, "image": primary or (str(payload.get("image") or "").strip() or None), "updated_at": _now_str(), } existing = conn.execute( "SELECT id, reviewed_at FROM options_review_entries WHERE trade_id=?", (int(trade_id),), ).fetchone() if existing: sets = ", ".join(f"{k}=?" for k in fields) conn.execute( f"UPDATE options_review_entries SET {sets} WHERE trade_id=?", [*fields.values(), int(trade_id)], ) else: fields["trade_id"] = int(trade_id) fields["reviewed_at"] = _now_str() cols = list(fields.keys()) conn.execute( f"INSERT INTO options_review_entries ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})", [fields[c] for c in cols], ) return {"ok": True, "trade": get_review_trade(conn, int(trade_id))} def delete_review_entry(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]: init_options_review_tables(conn) entry = conn.execute( "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),) ).fetchone() if not entry: return {"ok": False, "msg": "无复盘记录"} conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),)) return {"ok": True, "entry": _row_to_dict(entry)} def _hold_bucket(sec: Optional[int]) -> str: if sec is None: return "未知" for label, lo, hi in HOLD_BUCKETS: if sec >= lo and (hi is None or sec < hi): return label return "未知" def _group_stats(rows: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]: buckets: dict[str, dict[str, Any]] = {} for row in rows: key = str(key_fn(row) or "未填") b = buckets.setdefault( key, {"key": key, "count": 0, "wins": 0, "losses": 0, "pnl_sum": 0.0, "hold_sum": 0.0, "hold_n": 0}, ) pnl = _safe_float(row.get("realized_pnl_total")) if pnl is None: continue b["count"] += 1 b["pnl_sum"] = round(b["pnl_sum"] + pnl, 4) if pnl > 0: b["wins"] += 1 elif pnl < 0: b["losses"] += 1 hs = row.get("hold_seconds") if hs is not None: try: b["hold_sum"] += float(hs) b["hold_n"] += 1 except (TypeError, ValueError): pass out = [] for b in buckets.values(): c = b["count"] out.append( { "key": b["key"], "count": c, "wins": b["wins"], "losses": b["losses"], "win_rate": round(b["wins"] / c * 100, 2) if c else 0, "pnl_sum": round(b["pnl_sum"], 4), "avg_pnl": round(b["pnl_sum"] / c, 4) if c else None, "avg_hold_sec": round(b["hold_sum"] / b["hold_n"], 1) if b["hold_n"] else None, } ) out.sort(key=lambda x: abs(float(x.get("pnl_sum") or 0)), reverse=True) return out def compute_review_stats( conn: sqlite3.Connection, *, source_type: str | None = None, underlying: str | None = None, include_hedge_legs: bool = False, closed_from: str | None = None, closed_to: str | None = None, require_strategy: bool = False, ) -> dict[str, Any]: rows = list_review_trades( conn, source_type=source_type, underlying=underlying, include_hedge_legs=include_hedge_legs, closed_from=closed_from, closed_to=closed_to, limit=5000, offset=0, ) if require_strategy: rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()] wins = losses = reviewed = 0 pnl_sum = 0.0 hold_vals: list[float] = [] for r in rows: if r.get("reviewed"): reviewed += 1 pnl = _safe_float(r.get("realized_pnl_total")) if pnl is None: continue pnl_sum += pnl if pnl > 0: wins += 1 elif pnl < 0: losses += 1 if r.get("hold_seconds") is not None: hold_vals.append(float(r["hold_seconds"])) total = wins + losses kpi = { "total": len(rows), "pnl_count": total, "reviewed": reviewed, "review_rate": round(reviewed / len(rows) * 100, 2) if rows else 0, "wins": wins, "losses": losses, "win_rate": round(wins / total * 100, 2) if total else 0, "pnl_sum": round(pnl_sum, 4), "avg_pnl": round(pnl_sum / total, 4) if total else None, "avg_hold_sec": round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else None, } strategy_rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()] return { "ok": True, "kpi": kpi, "by_source_type": _group_stats(rows, lambda r: SOURCE_LABELS.get(str(r.get("source_type") or ""), r.get("source_type"))), "by_underlying": _group_stats(rows, lambda r: r.get("underlying") or "未填"), "by_opt_type": _group_stats( [r for r in rows if r.get("source_type") == SOURCE_OPTION], lambda r: r.get("opt_type") or "未填", ), "by_strategy": _group_stats(strategy_rows, lambda r: r.get("strategy_tag")), "by_close_reason": _group_stats( [r for r in rows if r.get("is_hedge")], lambda r: r.get("plan_close_reason") or "未填", ), "by_hold_bucket": _group_stats(rows, lambda r: _hold_bucket(r.get("hold_seconds"))), "sync": { "options_last_sync_at": get_sync_state(conn, "options_last_sync_at"), "hedge_last_sync_at": get_sync_state(conn, "hedge_last_sync_at"), "hedge_last_plan_id": get_sync_state(conn, "hedge_last_plan_id"), }, }