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:
+138
-19
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<p class="archive-empty">加载当日已平仓…</p>';
|
||||
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 =
|
||||
'<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 =
|
||||
'<table class="archive-trades-table"><thead><tr>' +
|
||||
"<th>交易所</th><th>合约</th><th>开仓类型</th><th>开仓时间</th><th>平仓时间</th><th>持仓时长</th>" +
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<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'" />
|
||||
<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/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -451,7 +451,11 @@
|
||||
<div id="page-archive" class="page hidden">
|
||||
<div class="page-head">
|
||||
<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 class="archive-toolbar toolbar">
|
||||
<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/compare.js?v=20260723-compare"></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/funds.js?v=20260717-funds-scroll-fix"></script>
|
||||
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
|
||||
|
||||
Reference in New Issue
Block a user