diff --git a/docs/hub-symbol-archive-kline.md b/docs/hub-symbol-archive-kline.md index c609050..87b14cc 100644 --- a/docs/hub-symbol-archive-kline.md +++ b/docs/hub-symbol-archive-kline.md @@ -4,6 +4,15 @@ 「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**.左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易. +顶栏有 **永续 / 期权** 品种切换: + +| 品种 | 数据 | 说明 | +|------|------|------| +| **永续** | 三所 `trade_records` → `archive_trade_cache` | 含犯病标签、K 线 | +| **期权** | OKX `options_review_trades` → `archive_options_trade_cache` | 独立 Tab;同步进中控库后离线可看;默认排除对冲腿 | + +同步:「同步」按钮与后台 4h 任务会同时拉永续与期权(仅 `capabilities` 含 `options` 的账户). + 与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留. ## 页面布局 diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 9c8d0b7..000e2f0 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -672,6 +672,72 @@ def register_hub_routes(app): } ) + @app.route("/api/hub/options/review/archive") + @_hub_auth_required + def api_hub_options_review_archive(): + """中控期权档案:近 N 天已平仓复盘记录(默认排除对冲腿).""" + from datetime import datetime, timedelta + from zoneinfo import ZoneInfo + + from flask import current_app + + from lib.options.options_review_lib import ( + compute_review_stats, + ensure_local_review_synced, + list_review_trades, + ) + + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + try: + days = int(request.args.get("days") or "365") + except ValueError: + days = 365 + days = max(1, min(days, 3650)) + try: + limit = int(request.args.get("limit") or "2000") + except ValueError: + limit = 2000 + limit = max(1, min(limit, 5000)) + include_hedge_legs = str(request.args.get("include_hedge_legs") or "").strip() in ( + "1", + "true", + "yes", + ) + tz = ZoneInfo("Asia/Shanghai") + closed_from = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d") + cfg = (current_app.extensions or {}).get("options_cfg") or {} + ex = cfg.get("exchange_options") + conn = get_db() + try: + ensure_local_review_synced(conn, ex=ex, backfill_exchange_pnl=bool(ex)) + trades = list_review_trades( + conn, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + limit=limit, + offset=0, + ) + stats = compute_review_stats( + conn, + include_hedge_legs=include_hedge_legs, + closed_from=closed_from, + ) + finally: + conn.close() + return jsonify( + { + "ok": True, + "days": days, + "limit": limit, + "product": "options", + "trades": trades, + "stats": stats, + } + ) + @app.route("/api/hub/trades/today") @_hub_auth_required def api_hub_trades_today(): diff --git a/lib/hub/hub_options_archive_lib.py b/lib/hub/hub_options_archive_lib.py new file mode 100644 index 0000000..a44e1ac --- /dev/null +++ b/lib/hub/hub_options_archive_lib.py @@ -0,0 +1,599 @@ +"""中控期权档案:同步 OKX options_review_trades 到 hub_symbol_archive.db.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +from lib.hub.hub_symbol_archive_lib import ( + TRADING_DAY_RESET_HOUR, + _connect, + default_db_path, + init_db as init_perp_archive_db, + ms_to_trading_day, + parse_wall_clock_ms, + resolve_period_bounds, + trading_day_bounds_ms, +) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def init_options_archive_db(db_path: Path | None = None) -> None: + """确保期权缓存表存在(与永续共用同一 SQLite).""" + init_perp_archive_db(db_path) + conn = _connect(db_path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS archive_options_trade_cache ( + exchange_key TEXT NOT NULL, + history_key TEXT NOT NULL, + source_type TEXT, + underlying TEXT, + opened_at TEXT, + closed_at TEXT, + opened_at_ms INTEGER, + closed_at_ms INTEGER, + hold_seconds INTEGER, + realized_pnl_total REAL, + status_raw TEXT, + pos_id TEXT, + inst_id TEXT, + opt_type TEXT, + strike REAL, + exp_time TEXT, + sheets INTEGER, + open_avg REAL, + close_avg REAL, + premium_paid REAL, + realized_pnl REAL, + hedge_plan_id INTEGER, + plan_close_reason TEXT, + realized_pnl_perp REAL, + realized_pnl_options REAL, + premium_total REAL, + direction TEXT, + tp REAL, + sl REAL, + target_price REAL, + target_price_up REAL, + target_price_down REAL, + legs_json TEXT, + linked_hedge_plan_id INTEGER, + excluded_as_hedge_leg INTEGER DEFAULT 0, + strategy_tag TEXT, + result_tag TEXT, + reviewed INTEGER DEFAULT 0, + source_label TEXT, + payload_json TEXT NOT NULL, + synced_at INTEGER NOT NULL, + PRIMARY KEY (exchange_key, history_key) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_archive_options_closed + ON archive_options_trade_cache (exchange_key, closed_at_ms) + """ + ) + finally: + conn.close() + + +def purge_stale_options_trades_cache( + exchange_key: str, + active_history_keys: list[str], + *, + db_path: Path | None = None, +) -> int: + init_options_archive_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return 0 + active = {str(k).strip() for k in (active_history_keys or []) if str(k).strip()} + conn = _connect(db_path) + try: + rows = conn.execute( + "SELECT history_key FROM archive_options_trade_cache WHERE exchange_key=?", + (ex_k,), + ).fetchall() + stale = [r["history_key"] for r in rows if r["history_key"] not in active] + removed = 0 + for hk in stale: + cur = conn.execute( + "DELETE FROM archive_options_trade_cache WHERE exchange_key=? AND history_key=?", + (ex_k, hk), + ) + removed += int(cur.rowcount or 0) + return removed + finally: + conn.close() + + +def _optional_float(raw: Any) -> float | None: + if raw in (None, ""): + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _optional_int(raw: Any) -> int | None: + if raw in (None, ""): + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def upsert_options_trades_cache( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, + prune_missing: bool = True, +) -> dict[str, int]: + init_options_archive_db(db_path) + ex_k = (exchange_key or "").strip().lower() + if not ex_k: + return {"upserted": 0, "removed": 0} + now = _now_ms() + n = 0 + active_keys: list[str] = [] + conn = _connect(db_path) + try: + for t in trades or []: + if not isinstance(t, dict): + continue + hk = str(t.get("history_key") or "").strip() + if not hk: + continue + if int(t.get("excluded_as_hedge_leg") or 0): + continue + active_keys.append(hk) + opened_at = t.get("opened_at") + closed_at = t.get("closed_at") + opened_ms = t.get("opened_at_ms") or parse_wall_clock_ms(opened_at) + closed_ms = t.get("closed_at_ms") or parse_wall_clock_ms(closed_at) + entry = t.get("entry") if isinstance(t.get("entry"), dict) else {} + strategy_tag = t.get("strategy_tag") or (entry or {}).get("strategy_tag") + result_tag = t.get("result_tag") or (entry or {}).get("result_tag") + reviewed = 1 if t.get("reviewed") or entry else 0 + row = dict(t) + row["exchange_key"] = ex_k + payload = json.dumps(row, ensure_ascii=False, default=str) + conn.execute( + """ + INSERT INTO archive_options_trade_cache ( + exchange_key, history_key, source_type, underlying, + opened_at, closed_at, opened_at_ms, closed_at_ms, hold_seconds, + realized_pnl_total, status_raw, + pos_id, inst_id, opt_type, strike, exp_time, sheets, + open_avg, close_avg, premium_paid, realized_pnl, + hedge_plan_id, plan_close_reason, realized_pnl_perp, realized_pnl_options, + premium_total, direction, tp, sl, target_price, target_price_up, target_price_down, + legs_json, linked_hedge_plan_id, excluded_as_hedge_leg, + strategy_tag, result_tag, reviewed, source_label, + payload_json, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(exchange_key, history_key) DO UPDATE SET + source_type=excluded.source_type, + underlying=excluded.underlying, + opened_at=excluded.opened_at, + closed_at=excluded.closed_at, + opened_at_ms=excluded.opened_at_ms, + closed_at_ms=excluded.closed_at_ms, + hold_seconds=excluded.hold_seconds, + realized_pnl_total=excluded.realized_pnl_total, + status_raw=excluded.status_raw, + pos_id=excluded.pos_id, + inst_id=excluded.inst_id, + opt_type=excluded.opt_type, + strike=excluded.strike, + exp_time=excluded.exp_time, + sheets=excluded.sheets, + open_avg=excluded.open_avg, + close_avg=excluded.close_avg, + premium_paid=excluded.premium_paid, + realized_pnl=excluded.realized_pnl, + hedge_plan_id=excluded.hedge_plan_id, + plan_close_reason=excluded.plan_close_reason, + realized_pnl_perp=excluded.realized_pnl_perp, + realized_pnl_options=excluded.realized_pnl_options, + premium_total=excluded.premium_total, + direction=excluded.direction, + tp=excluded.tp, + sl=excluded.sl, + target_price=excluded.target_price, + target_price_up=excluded.target_price_up, + target_price_down=excluded.target_price_down, + legs_json=excluded.legs_json, + linked_hedge_plan_id=excluded.linked_hedge_plan_id, + excluded_as_hedge_leg=excluded.excluded_as_hedge_leg, + strategy_tag=excluded.strategy_tag, + result_tag=excluded.result_tag, + reviewed=excluded.reviewed, + source_label=excluded.source_label, + payload_json=excluded.payload_json, + synced_at=excluded.synced_at + """, + ( + ex_k, + hk, + t.get("source_type"), + t.get("underlying"), + opened_at, + closed_at, + int(opened_ms) if opened_ms else None, + int(closed_ms) if closed_ms else None, + _optional_int(t.get("hold_seconds")), + float(t.get("realized_pnl_total") or t.get("realized_pnl") or 0), + t.get("status_raw"), + t.get("pos_id"), + t.get("inst_id"), + t.get("opt_type"), + _optional_float(t.get("strike")), + t.get("exp_time"), + _optional_int(t.get("sheets")), + _optional_float(t.get("open_avg")), + _optional_float(t.get("close_avg")), + _optional_float(t.get("premium_paid")), + _optional_float(t.get("realized_pnl")), + _optional_int(t.get("hedge_plan_id")), + t.get("plan_close_reason"), + _optional_float(t.get("realized_pnl_perp")), + _optional_float(t.get("realized_pnl_options")), + _optional_float(t.get("premium_total")), + t.get("direction"), + _optional_float(t.get("tp")), + _optional_float(t.get("sl")), + _optional_float(t.get("target_price")), + _optional_float(t.get("target_price_up")), + _optional_float(t.get("target_price_down")), + t.get("legs_json") + if isinstance(t.get("legs_json"), str) + else (json.dumps(t.get("legs"), ensure_ascii=False) if t.get("legs") else None), + _optional_int(t.get("linked_hedge_plan_id")), + int(t.get("excluded_as_hedge_leg") or 0), + strategy_tag, + result_tag, + reviewed, + t.get("source_label"), + payload, + now, + ), + ) + n += 1 + finally: + conn.close() + removed = 0 + if prune_missing: + removed = purge_stale_options_trades_cache(ex_k, active_keys, db_path=db_path) + return {"upserted": n, "removed": removed} + + +def _options_row_to_dict(row: Any) -> dict[str, Any]: + out: dict[str, Any] = dict(row) + payload = {} + raw = out.get("payload_json") + if raw: + try: + payload = json.loads(raw) if isinstance(raw, str) else {} + except (TypeError, ValueError, json.JSONDecodeError): + payload = {} + if isinstance(payload, dict): + for k, v in payload.items(): + if k not in out or out.get(k) in (None, ""): + out[k] = v + pnl = float(out.get("realized_pnl_total") or out.get("realized_pnl") or 0) + out["realized_pnl_total"] = pnl + out["pnl_amount"] = pnl # 复用永续统计/日历字段名 + hold_sec = out.get("hold_seconds") + if hold_sec is not None: + try: + out["hold_minutes"] = round(float(hold_sec) / 60.0, 2) + except (TypeError, ValueError): + pass + if not out.get("opened_at_ms") and out.get("opened_at"): + ms = parse_wall_clock_ms(out.get("opened_at")) + if ms: + out["opened_at_ms"] = int(ms) + if not out.get("closed_at_ms") and out.get("closed_at"): + ms = parse_wall_clock_ms(out.get("closed_at")) + if ms: + out["closed_at_ms"] = int(ms) + out["trade_id"] = out.get("history_key") + out["id"] = out.get("history_key") + out["symbol"] = out.get("inst_id") or out.get("underlying") or "" + return out + + +def _empty_options_stats() -> dict[str, Any]: + return { + "open_count": 0, + "sick_count": 0, + "sick_pct": 0.0, + "pnl_total": 0.0, + "pnl_ex_sick": 0.0, + "win_count": 0, + "loss_count": 0, + "avg_win": 0.0, + "avg_loss": 0.0, + "max_win": 0.0, + "max_loss": 0.0, + "win_rate": 0.0, + "profit_loss_ratio": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "premium_total": 0.0, + "by_exchange": {}, + "by_source_type": {}, + } + + +def _compute_options_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]: + st = _empty_options_stats() + wins: list[float] = [] + losses: list[float] = [] + by_ex: dict[str, dict[str, Any]] = {} + by_src: dict[str, dict[str, Any]] = {} + + def bucket() -> dict[str, Any]: + return { + "open_count": 0, + "pnl_total": 0.0, + "win_count": 0, + "loss_count": 0, + "premium_total": 0.0, + } + + for td in trade_rows: + pnl = float(td.get("pnl_amount") or td.get("realized_pnl_total") or 0) + ex = str(td.get("exchange_key") or "okx") + src = str(td.get("source_type") or td.get("source_label") or "?") + prem = float(td.get("premium_total") or td.get("premium_paid") or 0) + st["open_count"] += 1 + st["pnl_total"] += pnl + st["premium_total"] += prem + if pnl > 0.0001: + st["win_count"] += 1 + wins.append(pnl) + elif pnl < -0.0001: + st["loss_count"] += 1 + losses.append(pnl) + if ex not in by_ex: + by_ex[ex] = bucket() + by_ex[ex]["open_count"] += 1 + by_ex[ex]["pnl_total"] += pnl + by_ex[ex]["premium_total"] += prem + if pnl > 0.0001: + by_ex[ex]["win_count"] += 1 + elif pnl < -0.0001: + by_ex[ex]["loss_count"] += 1 + if src not in by_src: + by_src[src] = bucket() + by_src[src]["open_count"] += 1 + by_src[src]["pnl_total"] += pnl + + total = int(st["open_count"] or 0) + st["pnl_ex_sick"] = round(float(st["pnl_total"]), 4) + st["pnl_total"] = round(float(st["pnl_total"]), 4) + st["premium_total"] = round(float(st["premium_total"]), 4) + st["avg_win"] = round(sum(wins) / len(wins), 4) if wins else 0.0 + st["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else 0.0 + st["max_win"] = round(max(wins), 4) if wins else 0.0 + st["max_loss"] = round(min(losses), 4) if losses else 0.0 + st["win_rate"] = round(st["win_count"] / total * 100, 1) if total else 0.0 + if wins and losses and abs(st["avg_loss"]) > 1e-9: + st["profit_loss_ratio"] = round(abs(st["avg_win"] / st["avg_loss"]), 2) + for ex, b in by_ex.items(): + b["pnl_total"] = round(float(b["pnl_total"]), 4) + b["premium_total"] = round(float(b["premium_total"]), 4) + b["sick_count"] = 0 + b["sick_pct"] = 0.0 + b["pnl_ex_sick"] = b["pnl_total"] + b["avg_win"] = 0.0 + b["avg_loss"] = 0.0 + b["max_win"] = 0.0 + b["max_loss"] = 0.0 + b["win_rate"] = ( + round(b["win_count"] / b["open_count"] * 100, 1) if b["open_count"] else 0.0 + ) + b["profit_loss_ratio"] = 0.0 + b["turnover_total"] = 0.0 + b["commission_total"] = 0.0 + for src, b in by_src.items(): + b["pnl_total"] = round(float(b["pnl_total"]), 4) + st["by_exchange"] = by_ex + st["by_source_type"] = by_src + return st + + +def list_daily_options_trades( + trading_day: str = "", + *, + period: str = "", + date_from: str = "", + date_to: str = "", + exchange_key: str = "", + filter_profit: bool = False, + filter_loss: bool = False, + search: str = "", + source_type: str = "", + db_path: Path | None = None, +) -> dict[str, Any]: + init_options_archive_db(db_path) + p = (period or "today").strip().lower() or "today" + start_ms, end_ms, df, dt, period_label = resolve_period_bounds( + period=p, + trading_day=trading_day, + date_from=date_from, + date_to=date_to, + ) + ex_filter = (exchange_key or "").strip().lower() + src_filter = (source_type or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + where += " AND COALESCE(excluded_as_hedge_leg,0)=0" + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + if src_filter: + where += " AND LOWER(COALESCE(source_type,''))=?" + params.append(src_filter) + rows = conn.execute( + f""" + SELECT * FROM archive_options_trade_cache + WHERE {where} + ORDER BY closed_at_ms DESC, history_key DESC + """, + params, + ).fetchall() + trades: list[dict[str, Any]] = [] + q = (search or "").strip().lower() + for r in rows: + td = _options_row_to_dict(r) + pnl = float(td.get("pnl_amount") or 0) + if filter_profit and pnl <= 0.0001: + continue + if filter_loss and pnl >= -0.0001: + continue + if q: + blob = " ".join( + str(td.get(k) or "") + for k in ( + "underlying", + "inst_id", + "exchange_key", + "source_type", + "source_label", + "opt_type", + "strategy_tag", + "result_tag", + "direction", + ) + ).lower() + if q not in blob: + continue + trades.append(td) + return { + "period": p, + "period_label": period_label, + "trading_day": dt, + "date_from": df, + "date_to": dt, + "product": "options", + "trades": trades, + "stats": _compute_options_period_stats(trades), + } + finally: + conn.close() + + +def list_archive_options_calendar( + year: int, + month: int, + *, + exchange_key: str = "", + db_path: Path | None = None, + reset_hour: int = TRADING_DAY_RESET_HOUR, +) -> dict[str, Any]: + init_options_archive_db(db_path) + y = int(year) + m = int(month) + if m < 1 or m > 12: + raise ValueError("month 无效") + from datetime import datetime, timedelta + + first = f"{y:04d}-{m:02d}-01" + if m == 12: + next_first = datetime(y + 1, 1, 1) + else: + next_first = datetime(y, m + 1, 1) + last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d") + start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour) + _, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour) + ex_filter = (exchange_key or "").strip().lower() + conn = _connect(db_path) + try: + params: list[Any] = [start_ms, end_ms] + where = ( + "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?" + " AND COALESCE(excluded_as_hedge_leg,0)=0" + ) + if ex_filter: + where += " AND exchange_key=?" + params.append(ex_filter) + rows = conn.execute( + f"SELECT * FROM archive_options_trade_cache WHERE {where}", + params, + ).fetchall() + days: dict[str, dict[str, Any]] = {} + for r in rows: + td = _options_row_to_dict(r) + closed_ms = td.get("closed_at_ms") or parse_wall_clock_ms(td.get("closed_at")) + if not closed_ms: + continue + day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour) + if not day or day < first or day > last: + continue + bucket = days.setdefault( + day, + { + "trading_day": day, + "open_count": 0, + "sick_count": 0, + "pnl_total": 0.0, + "turnover_total": 0.0, + "commission_total": 0.0, + "has_sick": False, + }, + ) + bucket["open_count"] += 1 + bucket["pnl_total"] += float(td.get("pnl_amount") or 0) + for d in days.values(): + d["pnl_total"] = round(float(d["pnl_total"]), 4) + month_pnl = sum(float(d["pnl_total"]) for d in days.values()) + month_count = sum(int(d["open_count"]) for d in days.values()) + return { + "year": y, + "month": m, + "date_from": first, + "date_to": last, + "product": "options", + "days": days, + "month_pnl_total": round(month_pnl, 4), + "month_open_count": month_count, + } + finally: + conn.close() + + +def sync_options_exchange_archive( + exchange_key: str, + trades: list[dict[str, Any]], + *, + db_path: Path | None = None, +) -> dict[str, Any]: + """仅缓存期权交易,不做 K 线.""" + r = upsert_options_trades_cache( + exchange_key, trades, db_path=db_path, prune_missing=True + ) + return { + "ok": True, + "exchange_key": (exchange_key or "").strip().lower(), + "product": "options", + "trades_upserted": r.get("upserted", 0), + "trades_removed": r.get("removed", 0), + "trade_count": len(trades or []), + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 8292d61..848d578 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -79,6 +79,12 @@ from lib.hub.hub_symbol_archive_lib import ( update_review_quote, upsert_trade_overlay, ) +from lib.hub.hub_options_archive_lib import ( + init_options_archive_db, + list_archive_options_calendar, + list_daily_options_trades, + sync_options_exchange_archive, +) from lib.hub.hub_entry_plan_lib import ( compute_entry_plan_stats, create_entry_plan, @@ -355,9 +361,11 @@ def _schedule_board_refresh() -> None: async def _run_archive_sync_once() -> dict: global _last_archive_sync init_archive_db() + init_options_archive_db() settings = load_settings() targets = enabled_exchanges(settings) results: list[dict] = [] + options_results: list[dict] = [] for ex in targets: ex_key = str(ex.get("key") or "").strip().lower() if not ex_key: @@ -390,34 +398,71 @@ async def _run_archive_sync_once() -> dict: "msg": msg, } ) + else: + trades = trades_resp.get("trades") or [] + for t in trades: + if isinstance(t, dict): + t["exchange_key"] = ex_key + + def remote_fetch(**kwargs): + return _fetch_instance_ohlcv_sync( + ex, + symbol=kwargs.get("symbol") or "", + timeframe=kwargs.get("timeframe") or "5m", + since_ms=kwargs.get("since_ms"), + limit=int(kwargs.get("limit") or 500), + ) + + r = await asyncio.to_thread( + sync_exchange_symbol_archives, + ex_key, + trades, + remote_fetch, + ) + r["name"] = ex.get("name") + r["trade_count"] = len(trades) + results.append(r) + + caps = [str(x).lower() for x in (ex.get("capabilities") or [])] + if "options" not in caps: continue - trades = trades_resp.get("trades") or [] - for t in trades: + opt_resp = await asyncio.to_thread( + _fetch_instance_options_review_archive_sync, + ex, + days=ARCHIVE_TRADE_DAYS, + limit=ARCHIVE_TRADE_LIMIT, + ) + if not opt_resp.get("ok"): + options_results.append( + { + "exchange_key": ex_key, + "name": ex.get("name"), + "ok": False, + "status": opt_resp.get("status"), + "msg": opt_resp.get("msg") + or opt_resp.get("error") + or opt_resp.get("detail") + or "拉取期权复盘失败", + "product": "options", + } + ) + continue + opt_trades = opt_resp.get("trades") or [] + for t in opt_trades: if isinstance(t, dict): t["exchange_key"] = ex_key - - def remote_fetch(**kwargs): - return _fetch_instance_ohlcv_sync( - ex, - symbol=kwargs.get("symbol") or "", - timeframe=kwargs.get("timeframe") or "5m", - since_ms=kwargs.get("since_ms"), - limit=int(kwargs.get("limit") or 500), - ) - - r = await asyncio.to_thread( - sync_exchange_symbol_archives, + orow = await asyncio.to_thread( + sync_options_exchange_archive, ex_key, - trades, - remote_fetch, + opt_trades, ) - r["name"] = ex.get("name") - r["trade_count"] = len(trades) - results.append(r) + orow["name"] = ex.get("name") + options_results.append(orow) out = { "ok": True, "exchanges": len(targets), "results": results, + "options_results": options_results, "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"), } _last_archive_sync = out @@ -1365,6 +1410,34 @@ def _fetch_instance_trades_archive_sync( return {"ok": False, "msg": str(e)} +def _fetch_instance_options_review_archive_sync( + ex: dict, + *, + days: int = 365, + limit: int = 2000, +) -> dict: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return {"ok": False, "msg": "未配置 flask_url"} + params = {"days": str(int(days)), "limit": str(int(limit))} + url = f"{base}/api/hub/options/review/archive?{urlencode(params)}" + try: + with httpx.Client(timeout=max(HUB_FLASK_TIMEOUT, 120.0)) as client: + r = client.get(url, headers=_hub_headers()) + if r.status_code >= 400: + parsed = _parse_http_json_body(r) + parsed.setdefault("ok", False) + parsed.setdefault("status", r.status_code) + return parsed + data = r.json() if r.content else {} + if isinstance(data, dict): + data.setdefault("ok", True) + return data + return {"ok": False, "msg": "无效 JSON"} + except Exception as e: + return {"ok": False, "msg": str(e)} + + def _fetch_instance_ohlcv_sync( ex: dict, *, @@ -3145,6 +3218,52 @@ def api_archive_calendar( return {"ok": True, **payload} +@app.get("/api/archive/options/daily-trades") +def api_archive_options_daily_trades( + period: str = "", + trading_day: str = "", + date_from: str = "", + date_to: str = "", + exchange_key: str = "", + filter_profit: str = "", + filter_loss: str = "", + search: str = "", + source_type: str = "", +): + init_options_archive_db() + payload = list_daily_options_trades( + trading_day=trading_day, + period=period or "today", + date_from=date_from, + date_to=date_to, + exchange_key=exchange_key, + filter_profit=(filter_profit or "").lower() in ("1", "true", "yes", "on"), + filter_loss=(filter_loss or "").lower() in ("1", "true", "yes", "on"), + search=search, + source_type=source_type, + ) + return {"ok": True, **payload} + + +@app.get("/api/archive/options/calendar") +def api_archive_options_calendar( + year: int = 0, + month: int = 0, + exchange_key: str = "", +): + init_options_archive_db() + if year <= 0 or month <= 0: + td = today_trading_day() + parts = td.split("-") + year = int(parts[0]) + month = int(parts[1]) + try: + payload = list_archive_options_calendar(year, month, exchange_key=exchange_key) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True, **payload} + + @app.get("/api/archive/quotes") def api_archive_quotes(): init_archive_db() diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index e7c0879..1b9c98c 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -8057,6 +8057,34 @@ body.funds-fullscreen-open { gap: 12px; align-items: stretch; } +.archive-product-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 10px; +} +.archive-product-tab { + border: 1px solid var(--border-soft); + background: transparent; + color: inherit; + padding: 8px 18px; + border-radius: 999px; + cursor: pointer; + font-family: var(--font); + font-size: 0.88rem; + font-weight: 600; +} +.archive-product-tab.is-active { + background: rgba(16, 185, 129, 0.2); + border-color: rgba(16, 185, 129, 0.55); + color: var(--text); +} +body.archive-product-options .archive-toolbar-desktop[data-archive-perp-only], +body.archive-product-options #archive-btn-chart-toggle, +body.archive-product-options #archive-filter-sick, +body.archive-product-options #archive-tab-viz { + display: none !important; +} .archive-content-tabs { display: flex; flex-wrap: wrap; diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index 2231372..cbb5c17 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -35,6 +35,7 @@ const elQuoteContent = document.getElementById("archive-quote-content"); const elQuoteSubmit = document.getElementById("archive-quote-submit"); const elContentTabs = document.getElementById("archive-content-tabs"); + const elProductTabs = document.getElementById("archive-product-tabs"); const elPanelViz = document.getElementById("archive-panel-viz"); const elPanelCalendar = document.getElementById("archive-panel-calendar"); const elPanelTrades = document.getElementById("archive-panel-trades"); @@ -76,6 +77,7 @@ let selectedQuoteId = null; let editingQuoteId = null; let archiveContentTab = "trades"; + let archiveProduct = "perp"; let quoteDayTrades = []; let quoteDayTradesDay = ""; let quoteDayTradesReq = 0; @@ -416,6 +418,44 @@ syncPeriodUI(); } + function isOptionsProduct() { + return archiveProduct === "options"; + } + + function syncProductUI() { + document.body.classList.toggle("archive-product-options", isOptionsProduct()); + if (elProductTabs) { + elProductTabs.querySelectorAll(".archive-product-tab").forEach(function (btn) { + const on = btn.getAttribute("data-archive-product") === archiveProduct; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + } + if (isOptionsProduct()) { + setChartOpen(false); + if (archiveContentTab === "viz") setArchiveContentTab("trades"); + } + } + + function setArchiveProduct(product) { + const next = product === "options" ? "options" : "perp"; + if (next === archiveProduct) return; + archiveProduct = next; + selected = null; + selectedTradeKey = null; + syncProductUI(); + void loadDailyTrades(); + void loadCalendar(); + } + + function dailyTradesApiPath() { + return isOptionsProduct() ? "/api/archive/options/daily-trades" : "/api/archive/daily-trades"; + } + + function calendarApiPath() { + return isOptionsProduct() ? "/api/archive/options/calendar" : "/api/archive/calendar"; + } + function queryDailyParams() { const q = new URLSearchParams(); q.set("period", periodMode); @@ -430,7 +470,7 @@ if (ex) q.set("exchange_key", ex); if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1"); if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1"); - if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); + if (!isOptionsProduct() && elFilterSick && elFilterSick.checked) q.set("filter_sick", "1"); if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim()); return q.toString(); } @@ -554,7 +594,7 @@ return q; }, fetchFn: async function (q) { - const r = await apiFetch("/api/archive/calendar?" + q.toString()); + const r = await apiFetch(calendarApiPath() + "?" + q.toString()); return r.json(); }, parseResponse: function (data) { @@ -1089,7 +1129,7 @@ elQuoteDayTradesBody.innerHTML = '

加载当日已平仓…

'; if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day; try { - const r = await apiFetch("/api/archive/daily-trades?" + q.toString()); + const r = await apiFetch(dailyTradesApiPath() + "?" + q.toString()); const j = await r.json(); if (req !== quoteDayTradesReq) return; if (!r.ok) { @@ -1827,6 +1867,80 @@ return; } const pageRows = pagedDailyTrades(); + if (isOptionsProduct()) { + elTrades.innerHTML = + '' + + "" + + "" + + "" + + pageRows + .map(function (t) { + const rowKey = tradeRowKey(t); + const active = rowKey && rowKey === selectedTradeKey ? " is-active" : ""; + const holdMin = + t.hold_minutes != null + ? t.hold_minutes + : t.hold_seconds != null + ? Number(t.hold_seconds) / 60 + : null; + const optLabel = + t.source_label || + t.source_type || + (t.opt_type === "C" || t.opt_type === "CALL" + ? "Call" + : t.opt_type === "P" || t.opt_type === "PUT" + ? "Put" + : "—"); + const pnl = t.pnl_amount != null ? t.pnl_amount : t.realized_pnl_total; + return ( + '' + + "" + + '" + + "" + + '" + + '" + + '" + + "" + + "" + + '" + + "" + + "" + + "" + ); + }) + .join("") + + "
交易所标的合约/来源开仓时间平仓时间持仓类型策略盈亏权利金复盘
" + + esc(tradeRowExchange(t)) + + "' + + esc(t.underlying || "—") + + "" + + esc(t.inst_id || t.source_label || "—") + + "' + + fmtDt(t.opened_at) + + "' + + fmtDt(t.closed_at) + + "' + + fmtDurationMinutes(holdMin) + + "" + + esc(optLabel) + + "" + + esc(t.strategy_tag || "—") + + "' + + fmtPnl(pnl) + + "" + + fmtVolStat(t.premium_total != null ? t.premium_total : t.premium_paid) + + "" + + (t.reviewed ? "已复盘" : "—") + + "
"; + updateTradesPager(); + return; + } elTrades.innerHTML = '' + "" + @@ -2051,7 +2165,7 @@ async function loadDailyTrades() { setStatus("加载交易记录…"); - const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams()); + const r = await apiFetch(dailyTradesApiPath() + "?" + queryDailyParams()); const j = await r.json(); if (!r.ok) { setStatus(j.detail || "加载失败"); @@ -2080,7 +2194,8 @@ void loadCalendar(); if (archiveContentTab === "quotes") void loadQuoteDayTrades(); setStatus( - (periodLabel || tradingDay || "当日") + + (isOptionsProduct() ? "期权 · " : "永续 · ") + + (periodLabel || tradingDay || "当日") + " · 列表 " + dailyTrades.length + " 笔 · " + @@ -2105,6 +2220,7 @@ function formatSyncSummary(j) { const results = j.results || []; + const optResults = j.options_results || []; const okN = results.filter(function (x) { return x.ok !== false; }).length; @@ -2118,6 +2234,19 @@ parts.push(line); } }); + optResults.forEach(function (row) { + const label = (row.exchange_key || row.name || "?") + "期权"; + if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误")); + else { + let line = + label + + " " + + (row.trade_count != null ? row.trade_count : row.trades_upserted || 0) + + " 笔"; + if (row.trades_removed > 0) line += " 清" + row.trades_removed; + parts.push(line); + } + }); return parts.join(" · "); } @@ -2217,6 +2346,13 @@ setArchiveContentTab(btn.getAttribute("data-archive-tab") || "trades"); }); } + if (elProductTabs) { + elProductTabs.addEventListener("click", function (ev) { + const btn = ev.target.closest(".archive-product-tab"); + if (!btn) return; + setArchiveProduct(btn.getAttribute("data-archive-product") || "perp"); + }); + } if (elTfTabs) { elTfTabs.addEventListener("click", function (ev) { const btn = ev.target.closest(".archive-tf-btn"); @@ -2249,6 +2385,7 @@ syncPeriodUI(); syncTradesLayout(); bindEvents(); + syncProductUI(); setArchiveContentTab("trades"); inited = true; } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 51b502b..a7493e9 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -451,7 +451,11 @@
交易所合约开仓类型开仓时间平仓时间持仓时长