Files
crypto_monitor/lib/options/options_review_lib.py
T
dekun 8842f0b869 Restructure options review into pending trades, form, and reviewed detail.
Show five pending trades per page, reveal the upload form only after clicking 复盘, and list saved reviews with image detail above stats.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:50:12 +08:00

913 lines
32 KiB
Python

"""期权复盘业务: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 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)
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 ""
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"),
},
}