Hub monitor: read-only OKX options positions aggregation (Phase B).
Expose /api/hub/options/snapshot from OKX instance and render options summary on hub monitor cards when the options capability is enabled. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9287,6 +9287,7 @@ def _hub_meta_bundle():
|
||||
"alt_leverage": ALT_LEVERAGE,
|
||||
"trade_policy": trade_policy_template_context(TRADE_POLICY),
|
||||
**hub_meta_entry_context(TRADE_POLICY),
|
||||
"options_enabled": OKX_OPTIONS_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -454,6 +454,21 @@ def register_hub_routes(app):
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
@app.route("/api/hub/options/snapshot")
|
||||
@_hub_auth_required
|
||||
def api_hub_options_snapshot():
|
||||
"""中控监控:期权持仓 / 资金 / 本地统计(只读)。"""
|
||||
fn = _ctx().get("options_snapshot_fn")
|
||||
if not callable(fn):
|
||||
return jsonify({"ok": True, "enabled": False})
|
||||
try:
|
||||
data = fn()
|
||||
if not isinstance(data, dict):
|
||||
data = {"ok": False, "enabled": True, "msg": "invalid snapshot"}
|
||||
return jsonify(data)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "enabled": True, "msg": str(e)}), 500
|
||||
|
||||
@app.route("/api/account_risk_status")
|
||||
@_hub_auth_required
|
||||
def api_account_risk_status():
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""中控只读聚合:OKX 期权持仓 / 资金 / 本地统计。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
|
||||
def _compute_options_stats(get_db) -> dict[str, Any]:
|
||||
conn = get_db()
|
||||
try:
|
||||
init_options_tables(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT realized_pnl FROM options_trades
|
||||
WHERE status = 'closed' AND realized_pnl IS NOT NULL
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
for row in rows:
|
||||
pnl = float(row["realized_pnl"])
|
||||
if pnl > 0:
|
||||
wins.append(pnl)
|
||||
elif pnl < 0:
|
||||
losses.append(pnl)
|
||||
total_closed = len(wins) + len(losses)
|
||||
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
|
||||
avg_win = sum(wins) / len(wins) if wins else None
|
||||
avg_loss = sum(losses) / len(losses) if losses else None
|
||||
return {
|
||||
"total_closed": total_closed,
|
||||
"win_rate": win_rate,
|
||||
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
|
||||
"total_profit": round(sum(wins), 4) if wins else 0.0,
|
||||
"total_loss": round(abs(sum(losses)), 4) if losses else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
if not cfg.get("enabled"):
|
||||
return {"ok": True, "enabled": False}
|
||||
ex = cfg.get("exchange_options")
|
||||
ready_fn = cfg.get("options_api_ready")
|
||||
if not callable(ready_fn):
|
||||
return {"ok": False, "enabled": True, "msg": "期权模块未就绪"}
|
||||
ok, reason = ready_fn(ex)
|
||||
if not ok:
|
||||
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
|
||||
try:
|
||||
raw = cfg["fetch_option_positions"](ex)
|
||||
positions = [cfg["format_position_row"](p) for p in raw]
|
||||
upl_total = 0.0
|
||||
has_upl = False
|
||||
for p in positions:
|
||||
upl = p.get("upl")
|
||||
if upl is None:
|
||||
continue
|
||||
has_upl = True
|
||||
upl_total += float(upl)
|
||||
bal = cfg["fetch_options_balances"](ex)
|
||||
stats = _compute_options_stats(cfg["get_db"])
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"positions": positions,
|
||||
"position_count": len(positions),
|
||||
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
|
||||
"balances": bal,
|
||||
"funding_usdc": bal.get("funding_usdc"),
|
||||
"funding_usdt": bal.get("funding_usdt"),
|
||||
"trading_usdc": bal.get("trading_usdc"),
|
||||
"trading_usdt": bal.get("trading_usdt"),
|
||||
"stats": stats,
|
||||
"trade_budget": cfg.get("trade_budget"),
|
||||
"account_label": cfg.get("account_label") or "OKX期权",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "enabled": True, "msg": str(e)}
|
||||
@@ -54,10 +54,22 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
|
||||
cfg = _build_cfg(app_module)
|
||||
app.extensions["options_cfg"] = cfg
|
||||
register_options_routes(app, cfg)
|
||||
_register_options_hub_bridge(app, cfg)
|
||||
if enabled:
|
||||
_start_monitor_thread(app, cfg)
|
||||
|
||||
|
||||
def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
from lib.options.options_hub_lib import build_options_hub_snapshot
|
||||
|
||||
def snapshot_fn():
|
||||
return build_options_hub_snapshot(cfg)
|
||||
|
||||
hub_ctx = dict(app.config.get("HUB_CTX") or {})
|
||||
hub_ctx["options_snapshot_fn"] = snapshot_fn
|
||||
app.config["HUB_CTX"] = hub_ctx
|
||||
|
||||
|
||||
def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||
from lib.exchange.okx_options_lib import (
|
||||
build_option_chain,
|
||||
|
||||
@@ -1627,7 +1627,7 @@ def api_settings_meta():
|
||||
return {
|
||||
"env_disabled_ids": sorted(env_force_disabled_ids()),
|
||||
"hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN),
|
||||
"capability_options": ["key", "trend"],
|
||||
"capability_options": ["key", "trend", "options"],
|
||||
"public_origin": f"{po[0]}://{po[1]}" if po else None,
|
||||
"public_origin_hint": (
|
||||
"未设置 HUB_PUBLIC_ORIGIN 时,复盘链接若为 127.0.0.1,仅服务器本机浏览器可打开"
|
||||
@@ -2306,6 +2306,9 @@ async def _fetch_exchange_flask_bundle(
|
||||
snap = results[2] if has_flask and len(results) > 2 else None
|
||||
account = results[3] if has_flask and len(results) > 3 else None
|
||||
trades_today = results[4] if has_flask and day and len(results) > 4 else None
|
||||
options_snap = None
|
||||
if has_flask and "options" in caps:
|
||||
options_snap = await _fetch_flask_json(client, ex, "/api/hub/options/snapshot")
|
||||
key_prices = None
|
||||
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
|
||||
if want_prices and isinstance(snap, dict):
|
||||
@@ -2317,6 +2320,7 @@ async def _fetch_exchange_flask_bundle(
|
||||
snap if isinstance(snap, dict) else None,
|
||||
account if isinstance(account, dict) else None,
|
||||
trades_today if isinstance(trades_today, dict) else None,
|
||||
options_snap if isinstance(options_snap, dict) else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2342,7 +2346,7 @@ def _day_stats_from_trades_body(body: dict | None) -> dict:
|
||||
async def _assemble_board_row(
|
||||
client: httpx.AsyncClient, ex: dict, agent_row: dict, *, trading_day: str
|
||||
) -> dict:
|
||||
hub_mon, meta, key_prices, snap, account, trades_today = await _fetch_exchange_flask_bundle(
|
||||
hub_mon, meta, key_prices, snap, account, trades_today, options_snap = await _fetch_exchange_flask_bundle(
|
||||
client, ex, trading_day=trading_day
|
||||
)
|
||||
if isinstance(hub_mon, dict):
|
||||
@@ -2372,6 +2376,7 @@ async def _assemble_board_row(
|
||||
"account_ok": acct_ok,
|
||||
"day_stats": _day_stats_from_trades_body(trades_today),
|
||||
"force_close": snap.get("force_close") if isinstance(snap, dict) else None,
|
||||
"options": options_snap,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ DEFAULT_EXCHANGES = [
|
||||
"flask_url": "http://127.0.0.1:5004",
|
||||
"review_url": "http://127.0.0.1:5004/records",
|
||||
"enabled": True,
|
||||
"capabilities": ["key", "trend"],
|
||||
"capabilities": ["key", "trend", "options"],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
@@ -109,6 +109,11 @@ def load_settings() -> dict:
|
||||
ex["env_disabled"] = True
|
||||
else:
|
||||
ex.setdefault("env_disabled", False)
|
||||
if ex.get("key") == "okx":
|
||||
caps = list(ex.get("capabilities") or [])
|
||||
if "options" not in caps:
|
||||
caps.append("options")
|
||||
ex["capabilities"] = caps
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -7896,3 +7896,66 @@ body.funds-fullscreen-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 监控页:OKX 期权只读聚合 ── */
|
||||
.hub-options-title {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.hub-options-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.hub-options-summary strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hub-options-table-wrap {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hub-options-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.hub-options-table th,
|
||||
.hub-options-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hub-options-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.hub-options-inst {
|
||||
font-size: 0.7rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hub-options-actions {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .hub-options-table th {
|
||||
color: #3a5068;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .hub-options-summary {
|
||||
color: #3a5068;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .hub-options-summary strong {
|
||||
color: #142232;
|
||||
}
|
||||
|
||||
|
||||
@@ -3414,6 +3414,62 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function shortOptionsInst(instId) {
|
||||
const s = String(instId || "");
|
||||
if (s.length <= 22) return s;
|
||||
return s.slice(0, 10) + "…" + s.slice(-8);
|
||||
}
|
||||
|
||||
function renderOptionsMonitorSection(row) {
|
||||
const caps = row.capabilities || [];
|
||||
if (!caps.includes("options")) return "";
|
||||
const opt = row.options || {};
|
||||
let html = "";
|
||||
if (opt.enabled === false) {
|
||||
html += '<div class="section-title hub-options-title">期权 · 主账户</div>';
|
||||
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED)</div>';
|
||||
return html;
|
||||
}
|
||||
if (opt.ok === false) {
|
||||
html += '<div class="section-title hub-options-title">期权 · 主账户</div>';
|
||||
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
|
||||
return html;
|
||||
}
|
||||
const pos = Array.isArray(opt.positions) ? opt.positions : [];
|
||||
const upl = opt.upl_total_usdc;
|
||||
const tradingUsdc = opt.trading_usdc != null ? opt.trading_usdc : (opt.balances && opt.balances.trading_usdc);
|
||||
const stats = opt.stats || {};
|
||||
html += `<div class="section-title hub-options-title">期权 · 主账户 · ${pos.length} 仓</div>`;
|
||||
html += `<div class="hub-options-summary">
|
||||
<span>交易户 <strong>${fmt(tradingUsdc, 2)}</strong> USDC</span>
|
||||
<span>浮盈 <strong class="${pnlCls(upl)}">${fmt(upl, 4)}</strong> USDC</span>
|
||||
<span>已平 ${esc(stats.total_closed != null ? stats.total_closed : "—")} · 胜率 ${esc(stats.win_rate != null ? stats.win_rate + "%" : "—")}</span>
|
||||
</div>`;
|
||||
if (pos.length) {
|
||||
html += '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
||||
html += "<th>合约</th><th>类型</th><th>张数</th><th>标记</th><th>浮盈</th><th>浮盈%</th>";
|
||||
html += "</tr></thead><tbody>";
|
||||
pos.forEach((p) => {
|
||||
const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : (p.opt_type || "—");
|
||||
html += `<tr>
|
||||
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}</code></td>
|
||||
<td>${esc(optType)}</td>
|
||||
<td>${esc(p.pos)}</td>
|
||||
<td>${fmt(p.mark_px, 4)}</td>
|
||||
<td class="${pnlCls(p.upl)}">${fmt(p.upl, 4)}</td>
|
||||
<td class="${pnlCls(p.upl)}">${p.upl_ratio_pct != null ? esc(p.upl_ratio_pct) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += "</tbody></table></div>";
|
||||
} else {
|
||||
html += '<div class="empty-hint">暂无期权持仓</div>';
|
||||
}
|
||||
if (row.flask_url_browser || row.flask_url) {
|
||||
html += `<div class="hub-options-actions"><a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">打开期权页</a></div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
|
||||
const tickMap = buildPriceTickMap(row);
|
||||
const intraday = isIntradayDisciplineRow(row);
|
||||
@@ -3432,6 +3488,7 @@
|
||||
} else {
|
||||
inner += '<div class="empty-hint">无持仓</div>';
|
||||
}
|
||||
inner += renderOptionsMonitorSection(row);
|
||||
inner += renderCardStrategyStats(row, hm, flaskOk);
|
||||
inner += intraday
|
||||
? `<div class="card-expand-hint">日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}</div>`
|
||||
@@ -3467,6 +3524,7 @@
|
||||
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>` : ""}
|
||||
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""}
|
||||
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>` : ""}
|
||||
${flaskOpen && (row.capabilities || []).includes("options") ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">期权</a>` : ""}
|
||||
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -3495,6 +3553,7 @@
|
||||
html += '<div class="pos-empty">暂无持仓</div>';
|
||||
}
|
||||
html += "</div>";
|
||||
html += renderOptionsMonitorSection(row);
|
||||
html += '<div class="hub-fs-sections-grid">';
|
||||
if ((row.capabilities || []).includes("key")) {
|
||||
if (!flaskOk) {
|
||||
@@ -3739,6 +3798,13 @@
|
||||
const tsShort = ts ? ts.slice(-8) : "—";
|
||||
const posLine =
|
||||
openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary;
|
||||
const opt = row.options || {};
|
||||
const optCount =
|
||||
opt.enabled !== false && opt.ok !== false
|
||||
? Number(opt.position_count != null ? opt.position_count : (opt.positions || []).length)
|
||||
: 0;
|
||||
const posLineWithOpt =
|
||||
optCount > 0 ? `${posLine} · 期权${optCount}仓` : posLine;
|
||||
const hm = row.hub_monitor || {};
|
||||
const flaskOk = row.flask_ok !== false && hm.ok !== false;
|
||||
const strategyStats = renderCardStrategyStats(row, hm, flaskOk);
|
||||
@@ -3754,7 +3820,7 @@
|
||||
? `<div class="hub-tile-pnl ${pnlCls(upnl)}">${fmt(upnl, 2)} <small>U</small></div>`
|
||||
: ""
|
||||
}
|
||||
<div class="hub-tile-meta">${esc(posLine)}</div>
|
||||
<div class="hub-tile-meta">${esc(posLineWithOpt)}</div>
|
||||
${strategyStats}
|
||||
<div class="hub-tile-foot">UPD ${esc(tsShort)}</div>
|
||||
</div>
|
||||
@@ -3801,6 +3867,10 @@
|
||||
const openReview = flaskOpen
|
||||
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
|
||||
: "";
|
||||
const openOptions =
|
||||
flaskOpen && (row.capabilities || []).includes("options")
|
||||
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">期权</a>`
|
||||
: "";
|
||||
const intraday = isIntradayDisciplineRow(row);
|
||||
const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : "";
|
||||
return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
|
||||
@@ -3816,6 +3886,7 @@
|
||||
${openFlask}
|
||||
${openTrade}
|
||||
${openKey}
|
||||
${openOptions}
|
||||
${openReview}
|
||||
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`}
|
||||
</div>
|
||||
@@ -4253,6 +4324,7 @@
|
||||
<div class="cap-chips">
|
||||
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
|
||||
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/> 监控趋势计划</label>
|
||||
<label><input type="checkbox" class="cap-options" ${caps.includes("options") ? "checked" : ""}/> 监控期权</label>
|
||||
</div>
|
||||
<div class="settings-card-foot">
|
||||
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
|
||||
@@ -4308,6 +4380,7 @@
|
||||
const caps = [];
|
||||
if (card.querySelector(".cap-key").checked) caps.push("key");
|
||||
if (card.querySelector(".cap-trend").checked) caps.push("trend");
|
||||
if (card.querySelector(".cap-options") && card.querySelector(".cap-options").checked) caps.push("options");
|
||||
const id = card.querySelector(".ex-id").value.trim();
|
||||
const stableKey = (card.dataset.key || id).trim();
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,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=20260706-strategy-scroll" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260707-hub-options" />
|
||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=3" />
|
||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -1151,6 +1151,6 @@
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=3"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260704-monitor-stats-v2"></script>
|
||||
<script src="/assets/app.js?v=20260707-hub-options"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.options.options_hub_lib import build_options_hub_snapshot
|
||||
|
||||
|
||||
class OptionsHubLibTests(TestCase):
|
||||
def test_build_options_hub_snapshot_disabled(self):
|
||||
out = build_options_hub_snapshot({"enabled": False})
|
||||
self.assertFalse(out["enabled"])
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_build_options_hub_snapshot_positions(self):
|
||||
cfg = {
|
||||
"enabled": True,
|
||||
"exchange_options": object(),
|
||||
"options_api_ready": lambda ex: (True, ""),
|
||||
"fetch_option_positions": lambda ex: [
|
||||
{"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"}
|
||||
],
|
||||
"format_position_row": lambda p: {
|
||||
"inst_id": p.get("instId"),
|
||||
"pos": 2,
|
||||
"upl": 1.5,
|
||||
"mark_px": 0.1,
|
||||
},
|
||||
"fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0},
|
||||
"get_db": MagicMock(),
|
||||
"trade_budget": 10,
|
||||
"account_label": "OKX期权",
|
||||
}
|
||||
out = build_options_hub_snapshot(cfg)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["position_count"], 1)
|
||||
self.assertEqual(out["upl_total_usdc"], 1.5)
|
||||
self.assertEqual(out["trading_usdc"], 9.5)
|
||||
Reference in New Issue
Block a user