Sync OKX options closed trades into hub archive with a separate tab.
Mirror perpetual archive flow into archive_options_trade_cache for offline calendar and review. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
「内照明心」页(`/archive`)用于 **复盘语录 + 交易记录回顾 + 按需 K 线**.左侧维护每日复盘语录(最多 100 条);右侧按日期区间列出开仓记录,展示区间统计,并可展开 K 线图表对照单笔交易.
|
「内照明心」页(`/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 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留.
|
与行情区 `hub_kline.db`(15 天滚动缓存)**完全独立**:档案库只增不删,从建档起永久保留.
|
||||||
|
|
||||||
## 页面布局
|
## 页面布局
|
||||||
|
|||||||
@@ -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")
|
@app.route("/api/hub/trades/today")
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_trades_today():
|
def api_hub_trades_today():
|
||||||
|
|||||||
@@ -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 []),
|
||||||
|
}
|
||||||
+138
-19
@@ -79,6 +79,12 @@ from lib.hub.hub_symbol_archive_lib import (
|
|||||||
update_review_quote,
|
update_review_quote,
|
||||||
upsert_trade_overlay,
|
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 (
|
from lib.hub.hub_entry_plan_lib import (
|
||||||
compute_entry_plan_stats,
|
compute_entry_plan_stats,
|
||||||
create_entry_plan,
|
create_entry_plan,
|
||||||
@@ -355,9 +361,11 @@ def _schedule_board_refresh() -> None:
|
|||||||
async def _run_archive_sync_once() -> dict:
|
async def _run_archive_sync_once() -> dict:
|
||||||
global _last_archive_sync
|
global _last_archive_sync
|
||||||
init_archive_db()
|
init_archive_db()
|
||||||
|
init_options_archive_db()
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
targets = enabled_exchanges(settings)
|
targets = enabled_exchanges(settings)
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
|
options_results: list[dict] = []
|
||||||
for ex in targets:
|
for ex in targets:
|
||||||
ex_key = str(ex.get("key") or "").strip().lower()
|
ex_key = str(ex.get("key") or "").strip().lower()
|
||||||
if not ex_key:
|
if not ex_key:
|
||||||
@@ -390,34 +398,71 @@ async def _run_archive_sync_once() -> dict:
|
|||||||
"msg": msg,
|
"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
|
continue
|
||||||
trades = trades_resp.get("trades") or []
|
opt_resp = await asyncio.to_thread(
|
||||||
for t in trades:
|
_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):
|
if isinstance(t, dict):
|
||||||
t["exchange_key"] = ex_key
|
t["exchange_key"] = ex_key
|
||||||
|
orow = await asyncio.to_thread(
|
||||||
def remote_fetch(**kwargs):
|
sync_options_exchange_archive,
|
||||||
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,
|
ex_key,
|
||||||
trades,
|
opt_trades,
|
||||||
remote_fetch,
|
|
||||||
)
|
)
|
||||||
r["name"] = ex.get("name")
|
orow["name"] = ex.get("name")
|
||||||
r["trade_count"] = len(trades)
|
options_results.append(orow)
|
||||||
results.append(r)
|
|
||||||
out = {
|
out = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"exchanges": len(targets),
|
"exchanges": len(targets),
|
||||||
"results": results,
|
"results": results,
|
||||||
|
"options_results": options_results,
|
||||||
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
_last_archive_sync = out
|
_last_archive_sync = out
|
||||||
@@ -1365,6 +1410,34 @@ def _fetch_instance_trades_archive_sync(
|
|||||||
return {"ok": False, "msg": str(e)}
|
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(
|
def _fetch_instance_ohlcv_sync(
|
||||||
ex: dict,
|
ex: dict,
|
||||||
*,
|
*,
|
||||||
@@ -3145,6 +3218,52 @@ def api_archive_calendar(
|
|||||||
return {"ok": True, **payload}
|
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")
|
@app.get("/api/archive/quotes")
|
||||||
def api_archive_quotes():
|
def api_archive_quotes():
|
||||||
init_archive_db()
|
init_archive_db()
|
||||||
|
|||||||
@@ -8057,6 +8057,34 @@ body.funds-fullscreen-open {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: stretch;
|
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 {
|
.archive-content-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
const elQuoteContent = document.getElementById("archive-quote-content");
|
const elQuoteContent = document.getElementById("archive-quote-content");
|
||||||
const elQuoteSubmit = document.getElementById("archive-quote-submit");
|
const elQuoteSubmit = document.getElementById("archive-quote-submit");
|
||||||
const elContentTabs = document.getElementById("archive-content-tabs");
|
const elContentTabs = document.getElementById("archive-content-tabs");
|
||||||
|
const elProductTabs = document.getElementById("archive-product-tabs");
|
||||||
const elPanelViz = document.getElementById("archive-panel-viz");
|
const elPanelViz = document.getElementById("archive-panel-viz");
|
||||||
const elPanelCalendar = document.getElementById("archive-panel-calendar");
|
const elPanelCalendar = document.getElementById("archive-panel-calendar");
|
||||||
const elPanelTrades = document.getElementById("archive-panel-trades");
|
const elPanelTrades = document.getElementById("archive-panel-trades");
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
let selectedQuoteId = null;
|
let selectedQuoteId = null;
|
||||||
let editingQuoteId = null;
|
let editingQuoteId = null;
|
||||||
let archiveContentTab = "trades";
|
let archiveContentTab = "trades";
|
||||||
|
let archiveProduct = "perp";
|
||||||
let quoteDayTrades = [];
|
let quoteDayTrades = [];
|
||||||
let quoteDayTradesDay = "";
|
let quoteDayTradesDay = "";
|
||||||
let quoteDayTradesReq = 0;
|
let quoteDayTradesReq = 0;
|
||||||
@@ -416,6 +418,44 @@
|
|||||||
syncPeriodUI();
|
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() {
|
function queryDailyParams() {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
q.set("period", periodMode);
|
q.set("period", periodMode);
|
||||||
@@ -430,7 +470,7 @@
|
|||||||
if (ex) q.set("exchange_key", ex);
|
if (ex) q.set("exchange_key", ex);
|
||||||
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
|
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
|
||||||
if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "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());
|
if (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim());
|
||||||
return q.toString();
|
return q.toString();
|
||||||
}
|
}
|
||||||
@@ -554,7 +594,7 @@
|
|||||||
return q;
|
return q;
|
||||||
},
|
},
|
||||||
fetchFn: async function (q) {
|
fetchFn: async function (q) {
|
||||||
const r = await apiFetch("/api/archive/calendar?" + q.toString());
|
const r = await apiFetch(calendarApiPath() + "?" + q.toString());
|
||||||
return r.json();
|
return r.json();
|
||||||
},
|
},
|
||||||
parseResponse: function (data) {
|
parseResponse: function (data) {
|
||||||
@@ -1089,7 +1129,7 @@
|
|||||||
elQuoteDayTradesBody.innerHTML = '<p class="archive-empty">加载当日已平仓…</p>';
|
elQuoteDayTradesBody.innerHTML = '<p class="archive-empty">加载当日已平仓…</p>';
|
||||||
if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day;
|
if (elQuoteDayTradesMeta) elQuoteDayTradesMeta.textContent = day;
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch("/api/archive/daily-trades?" + q.toString());
|
const r = await apiFetch(dailyTradesApiPath() + "?" + q.toString());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (req !== quoteDayTradesReq) return;
|
if (req !== quoteDayTradesReq) return;
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -1827,6 +1867,80 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pageRows = pagedDailyTrades();
|
const pageRows = pagedDailyTrades();
|
||||||
|
if (isOptionsProduct()) {
|
||||||
|
elTrades.innerHTML =
|
||||||
|
'<table class="archive-trades-table"><thead><tr>' +
|
||||||
|
"<th>交易所</th><th>标的</th><th>合约/来源</th><th>开仓时间</th><th>平仓时间</th><th>持仓</th>" +
|
||||||
|
"<th>类型</th><th>策略</th><th>盈亏</th><th>权利金</th><th>复盘</th>" +
|
||||||
|
"</tr></thead><tbody>" +
|
||||||
|
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 (
|
||||||
|
'<tr class="archive-trade-row' +
|
||||||
|
active +
|
||||||
|
'" data-key="' +
|
||||||
|
esc(rowKey) +
|
||||||
|
'">' +
|
||||||
|
"<td>" +
|
||||||
|
esc(tradeRowExchange(t)) +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="archive-symbol">' +
|
||||||
|
esc(t.underlying || "—") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
esc(t.inst_id || t.source_label || "—") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="archive-dt">' +
|
||||||
|
fmtDt(t.opened_at) +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="archive-dt">' +
|
||||||
|
fmtDt(t.closed_at) +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="archive-hold">' +
|
||||||
|
fmtDurationMinutes(holdMin) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
esc(optLabel) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
esc(t.strategy_tag || "—") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="' +
|
||||||
|
pnlClass(pnl) +
|
||||||
|
'">' +
|
||||||
|
fmtPnl(pnl) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
fmtVolStat(t.premium_total != null ? t.premium_total : t.premium_paid) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
(t.reviewed ? "已复盘" : "—") +
|
||||||
|
"</td>" +
|
||||||
|
"</tr>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("") +
|
||||||
|
"</tbody></table>";
|
||||||
|
updateTradesPager();
|
||||||
|
return;
|
||||||
|
}
|
||||||
elTrades.innerHTML =
|
elTrades.innerHTML =
|
||||||
'<table class="archive-trades-table"><thead><tr>' +
|
'<table class="archive-trades-table"><thead><tr>' +
|
||||||
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
|
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
|
||||||
@@ -2051,7 +2165,7 @@
|
|||||||
|
|
||||||
async function loadDailyTrades() {
|
async function loadDailyTrades() {
|
||||||
setStatus("加载交易记录…");
|
setStatus("加载交易记录…");
|
||||||
const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams());
|
const r = await apiFetch(dailyTradesApiPath() + "?" + queryDailyParams());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setStatus(j.detail || "加载失败");
|
setStatus(j.detail || "加载失败");
|
||||||
@@ -2080,7 +2194,8 @@
|
|||||||
void loadCalendar();
|
void loadCalendar();
|
||||||
if (archiveContentTab === "quotes") void loadQuoteDayTrades();
|
if (archiveContentTab === "quotes") void loadQuoteDayTrades();
|
||||||
setStatus(
|
setStatus(
|
||||||
(periodLabel || tradingDay || "当日") +
|
(isOptionsProduct() ? "期权 · " : "永续 · ") +
|
||||||
|
(periodLabel || tradingDay || "当日") +
|
||||||
" · 列表 " +
|
" · 列表 " +
|
||||||
dailyTrades.length +
|
dailyTrades.length +
|
||||||
" 笔 · " +
|
" 笔 · " +
|
||||||
@@ -2105,6 +2220,7 @@
|
|||||||
|
|
||||||
function formatSyncSummary(j) {
|
function formatSyncSummary(j) {
|
||||||
const results = j.results || [];
|
const results = j.results || [];
|
||||||
|
const optResults = j.options_results || [];
|
||||||
const okN = results.filter(function (x) {
|
const okN = results.filter(function (x) {
|
||||||
return x.ok !== false;
|
return x.ok !== false;
|
||||||
}).length;
|
}).length;
|
||||||
@@ -2118,6 +2234,19 @@
|
|||||||
parts.push(line);
|
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(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2217,6 +2346,13 @@
|
|||||||
setArchiveContentTab(btn.getAttribute("data-archive-tab") || "trades");
|
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) {
|
if (elTfTabs) {
|
||||||
elTfTabs.addEventListener("click", function (ev) {
|
elTfTabs.addEventListener("click", function (ev) {
|
||||||
const btn = ev.target.closest(".archive-tf-btn");
|
const btn = ev.target.closest(".archive-tf-btn");
|
||||||
@@ -2249,6 +2385,7 @@
|
|||||||
syncPeriodUI();
|
syncPeriodUI();
|
||||||
syncTradesLayout();
|
syncTradesLayout();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
syncProductUI();
|
||||||
setArchiveContentTab("trades");
|
setArchiveContentTab("trades");
|
||||||
inited = true;
|
inited = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260724-display-hide" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260724-opt-archive" />
|
||||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
|
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
|
||||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||||
@@ -451,7 +451,11 @@
|
|||||||
<div id="page-archive" class="page hidden">
|
<div id="page-archive" class="page hidden">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><span class="head-tag">IN</span> 内照明心</h1>
|
<h1><span class="head-tag">IN</span> 内照明心</h1>
|
||||||
<p class="page-desc">交易记录 · 交易日历 · 图表概览 · 复盘语录</p>
|
<p class="page-desc">永续 / 期权交易记录 · 交易日历 · 图表概览 · 复盘语录</p>
|
||||||
|
</div>
|
||||||
|
<div class="archive-product-tabs" id="archive-product-tabs" role="tablist" aria-label="品种">
|
||||||
|
<button type="button" class="archive-product-tab is-active" role="tab" aria-selected="true" data-archive-product="perp">永续</button>
|
||||||
|
<button type="button" class="archive-product-tab" role="tab" aria-selected="false" data-archive-product="options">期权</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="archive-toolbar toolbar">
|
<div class="archive-toolbar toolbar">
|
||||||
<label class="chk-label archive-toolbar-desktop"><input type="checkbox" id="archive-filter-profit" /> 盈利单</label>
|
<label class="chk-label archive-toolbar-desktop"><input type="checkbox" id="archive-filter-profit" /> 盈利单</label>
|
||||||
@@ -1673,7 +1677,7 @@
|
|||||||
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
|
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
|
||||||
<script src="/assets/compare.js?v=20260723-compare"></script>
|
<script src="/assets/compare.js?v=20260723-compare"></script>
|
||||||
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
||||||
<script src="/assets/archive.js?v=20260717-archive-cal-chart"></script>
|
<script src="/assets/archive.js?v=20260724-opt-archive"></script>
|
||||||
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
|
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
|
||||||
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
|
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
|
||||||
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
|
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""期权档案缓存 upsert / 列表 / 日历."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lib.hub.hub_options_archive_lib import (
|
||||||
|
init_options_archive_db,
|
||||||
|
list_archive_options_calendar,
|
||||||
|
list_daily_options_trades,
|
||||||
|
upsert_options_trades_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubOptionsArchive(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._td = tempfile.TemporaryDirectory()
|
||||||
|
self.db = Path(self._td.name) / "hub_symbol_archive.db"
|
||||||
|
init_options_archive_db(self.db)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._td.cleanup()
|
||||||
|
|
||||||
|
def test_upsert_and_list_daily(self) -> None:
|
||||||
|
trades = [
|
||||||
|
{
|
||||||
|
"history_key": "local_opt:1",
|
||||||
|
"source_type": "option_spot",
|
||||||
|
"source_label": "纯期权",
|
||||||
|
"underlying": "ETH",
|
||||||
|
"inst_id": "ETH-USD-250725-3200-C",
|
||||||
|
"opt_type": "C",
|
||||||
|
"opened_at": "2026-07-20 10:00:00",
|
||||||
|
"closed_at": "2026-07-20 16:00:00",
|
||||||
|
"hold_seconds": 21600,
|
||||||
|
"realized_pnl_total": 12.5,
|
||||||
|
"premium_paid": 8.0,
|
||||||
|
"reviewed": True,
|
||||||
|
"strategy_tag": "假突破",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"history_key": "local_opt:2",
|
||||||
|
"source_type": "option_spot",
|
||||||
|
"underlying": "ETH",
|
||||||
|
"opened_at": "2026-07-19 10:00:00",
|
||||||
|
"closed_at": "2026-07-19 12:00:00",
|
||||||
|
"realized_pnl_total": -3.0,
|
||||||
|
"excluded_as_hedge_leg": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
r = upsert_options_trades_cache("okx", trades, db_path=self.db)
|
||||||
|
self.assertEqual(r["upserted"], 1)
|
||||||
|
payload = list_daily_options_trades(
|
||||||
|
"2026-07-20",
|
||||||
|
period="today",
|
||||||
|
db_path=self.db,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(payload["trades"]), 1)
|
||||||
|
self.assertEqual(payload["trades"][0]["history_key"], "local_opt:1")
|
||||||
|
self.assertAlmostEqual(payload["stats"]["pnl_total"], 12.5)
|
||||||
|
cal = list_archive_options_calendar(2026, 7, db_path=self.db)
|
||||||
|
self.assertIn("2026-07-20", cal["days"])
|
||||||
|
self.assertEqual(cal["days"]["2026-07-20"]["open_count"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user