From 11c67b3c442664dd084942dc3f7689026660cc11 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 13 Oct 2026 12:52:00 +0800 Subject: [PATCH] =?UTF-8?q?feat=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E6=94=B9=E4=B8=BA=E6=9D=83=E7=9B=8A=E9=87=87?= =?UTF-8?q?=E6=A0=B7=EF=BC=8C=E7=8E=B0=E9=87=91=E5=8A=A0=E7=8E=B0=E8=B4=A7?= =?UTF-8?q?=E5=B8=81=E6=9D=83=E5=88=A9=E9=87=91=E4=B8=8E=E6=B5=AE=E7=9B=88?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_data_stats.js | 4 +- lib/instance/instance_cash_sample_lib.py | 140 +++++++++++++++--- lib/instance/instance_data_stats_register.py | 79 ++++++++-- .../templates/options_data_stats_panel.html | 8 +- tests/test_instance_equity_sample.py | 96 ++++++++++++ 5 files changed, 291 insertions(+), 36 deletions(-) create mode 100644 tests/test_instance_equity_sample.py diff --git a/lib/common/static/options_data_stats.js b/lib/common/static/options_data_stats.js index c67fd5a..cf94143 100644 --- a/lib/common/static/options_data_stats.js +++ b/lib/common/static/options_data_stats.js @@ -1,5 +1,5 @@ /** - * 实例数据统计:复盘 KPI + 资金曲线. + * 实例数据统计:复盘 KPI + 权益曲线. */ (function (global) { "use strict"; @@ -278,7 +278,7 @@ drawCurve(pts); if (meta) { meta.textContent = - "采样 " + + "权益采样 " + pts.length + " 点 · 最新 " + fmtCash(data.latest != null ? data.latest : pts.length ? pts[pts.length - 1].v : null); diff --git a/lib/instance/instance_cash_sample_lib.py b/lib/instance/instance_cash_sample_lib.py index 6c76a9e..969a125 100644 --- a/lib/instance/instance_cash_sample_lib.py +++ b/lib/instance/instance_cash_sample_lib.py @@ -1,4 +1,4 @@ -"""实例资金曲线采样:现金余额(不含权利金/浮盈),供数据统计页使用.""" +"""实例资金曲线采样:权益(现金+现货币+权利金+浮盈),供数据统计页使用.""" from __future__ import annotations import datetime as dt @@ -36,6 +36,10 @@ def init_cash_sample_table(conn) -> None: ) """ ) + try: + conn.execute("ALTER TABLE instance_cash_samples ADD COLUMN equity_usdt REAL") + except Exception: + pass def _safe_float(v: Any) -> Optional[float]: @@ -48,7 +52,7 @@ def _safe_float(v: Any) -> Optional[float]: def cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]: - """现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC);不含权利金/浮盈.""" + """稳定币现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC).""" if not isinstance(bal, dict): return None parts = [_safe_float(bal.get("trading_usdt")), _safe_float(bal.get("funding_usdt"))] @@ -60,16 +64,93 @@ def cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]: return round(sum(known), 4) -def record_cash_sample(conn, cash_usdt: float, *, ts_ms: int | None = None) -> None: +def spot_coin_usdt_from_balances( + bal: dict[str, Any] | None, + *, + btc_px: float | None = None, + eth_px: float | None = None, +) -> float: + """交易+资金账户 BTC/ETH 现货按指数折 U(缓冲买币占用).""" + if not isinstance(bal, dict): + return 0.0 + total = 0.0 + btc = (_safe_float(bal.get("trading_btc")) or 0.0) + ( + _safe_float(bal.get("funding_btc")) or 0.0 + ) + eth = (_safe_float(bal.get("trading_eth")) or 0.0) + ( + _safe_float(bal.get("funding_eth")) or 0.0 + ) + if btc > 0 and btc_px is not None and btc_px > 0: + total += btc * float(btc_px) + if eth > 0 and eth_px is not None and eth_px > 0: + total += eth * float(eth_px) + return round(total, 4) + + +def option_premium_and_upl_usdt(positions: list[dict[str, Any]] | None) -> tuple[float, float]: + """持仓权利金U + 浮盈U(权利金+浮盈≈期权市值).""" + prem = 0.0 + upl = 0.0 + if not positions: + return 0.0, 0.0 + from lib.options.options_positions_lib import ( + display_pnl_usdt_from_option_row, + premium_paid_usdt_from_option_row, + ) + + for p in positions: + if not isinstance(p, dict): + continue + try: + pu = premium_paid_usdt_from_option_row(p) + except Exception: + pu = None + if pu is not None: + prem += float(pu) + try: + du = display_pnl_usdt_from_option_row(p) + except Exception: + du = None + if du is not None: + upl += float(du) + return round(prem, 4), round(upl, 4) + + +def equity_usdt_from_snapshot( + bal: dict[str, Any] | None, + positions: list[dict[str, Any]] | None = None, + *, + btc_px: float | None = None, + eth_px: float | None = None, +) -> Optional[float]: + """权益 = 稳定币现金 + 现货币折U + 权利金U + 浮盈U.""" + cash = cash_usdt_from_balances(bal) + spot = spot_coin_usdt_from_balances(bal, btc_px=btc_px, eth_px=eth_px) + prem, upl = option_premium_and_upl_usdt(positions) + if cash is None and spot <= 0 and prem == 0 and upl == 0: + return None + return round(float(cash or 0.0) + float(spot) + float(prem) + float(upl), 4) + + +def record_cash_sample( + conn, + cash_usdt: float, + *, + equity_usdt: float | None = None, + ts_ms: int | None = None, +) -> None: init_cash_sample_table(conn) t = int(ts_ms if ts_ms is not None else time.time() * 1000) + eq = float(equity_usdt) if equity_usdt is not None else float(cash_usdt) conn.execute( """ - INSERT INTO instance_cash_samples(ts_ms, cash_usdt) - VALUES (?, ?) - ON CONFLICT(ts_ms) DO UPDATE SET cash_usdt=excluded.cash_usdt + INSERT INTO instance_cash_samples(ts_ms, cash_usdt, equity_usdt) + VALUES (?, ?, ?) + ON CONFLICT(ts_ms) DO UPDATE SET + cash_usdt=excluded.cash_usdt, + equity_usdt=excluded.equity_usdt """, - (t, float(cash_usdt)), + (t, float(cash_usdt), eq), ) keep_ms = cash_sample_keep_days() * 86400 * 1000 cutoff = t - keep_ms @@ -95,17 +176,23 @@ def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]: init_cash_sample_table(conn) rows = conn.execute( """ - SELECT ts_ms, cash_usdt + SELECT ts_ms, cash_usdt, equity_usdt FROM instance_cash_samples WHERE ts_ms >= ? ORDER BY ts_ms ASC """, (start_ms,), ).fetchall() - points = [{"t": int(r["ts_ms"]), "v": float(r["cash_usdt"])} for r in rows] + points = [] + for r in rows: + cash = float(r["cash_usdt"]) + eq = r["equity_usdt"] + v = float(eq) if eq is not None else cash + points.append({"t": int(r["ts_ms"]), "v": v, "cash": cash}) return { "ok": True, "range": rk, + "metric": "equity", "points": points, "start_ms": start_ms, "end_ms": now_ms, @@ -113,20 +200,36 @@ def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]: } -def sample_once(get_db: Callable, fetch_balances: Callable) -> bool: - """拉取余额并写入采样;成功返回 True.""" +def sample_once(get_db: Callable, fetch_snapshot: Callable) -> bool: + """拉取快照并写入采样. + + fetch_snapshot 可返回: + - float: 视为权益(兼容旧现金回调) + - dict: {cash_usdt, equity_usdt} + """ global _last_sample_at try: - bal = fetch_balances() + snap = fetch_snapshot() except Exception: return False - cash = cash_usdt_from_balances(bal if isinstance(bal, dict) else None) - if cash is None: + cash: float | None + equity: float | None + if isinstance(snap, dict): + cash = _safe_float(snap.get("cash_usdt")) + equity = _safe_float(snap.get("equity_usdt")) + else: + cash = _safe_float(snap) + equity = cash + if equity is None and cash is None: return False + if cash is None: + cash = float(equity or 0.0) + if equity is None: + equity = float(cash) with _lock: conn = get_db() try: - record_cash_sample(conn, cash) + record_cash_sample(conn, float(cash), equity_usdt=float(equity)) conn.commit() finally: conn.close() @@ -134,23 +237,22 @@ def sample_once(get_db: Callable, fetch_balances: Callable) -> bool: return True -def start_cash_sample_loop(get_db: Callable, fetch_balances: Callable) -> None: +def start_cash_sample_loop(get_db: Callable, fetch_snapshot: Callable) -> None: global _thread if _thread and _thread.is_alive(): return _stop.clear() def _loop() -> None: - # 启动后立刻采一笔,避免曲线长期空白 try: - sample_once(get_db, fetch_balances) + sample_once(get_db, fetch_snapshot) except Exception: pass while not _stop.is_set(): if _stop.wait(cash_sample_interval_sec()): break try: - sample_once(get_db, fetch_balances) + sample_once(get_db, fetch_snapshot) except Exception: pass diff --git a/lib/instance/instance_data_stats_register.py b/lib/instance/instance_data_stats_register.py index 485a0d4..cb8bee4 100644 --- a/lib/instance/instance_data_stats_register.py +++ b/lib/instance/instance_data_stats_register.py @@ -1,4 +1,4 @@ -"""实例数据统计:复盘 KPI + 资金曲线 API,以及后台现金采样.""" +"""实例数据统计:复盘 KPI + 权益曲线 API,以及后台权益采样.""" from __future__ import annotations from typing import Any, Callable @@ -10,13 +10,72 @@ def install_instance_data_stats(app: Flask, app_module: Any) -> None: get_db: Callable = app_module.get_db login_required = app_module.login_required - def _fetch_balances() -> dict[str, Any]: - ex = getattr(app_module, "exchange_options", None) - if ex is None: - return {} - from lib.exchange.okx_options_lib import fetch_options_balances + def _fetch_equity_snapshot() -> dict[str, Any]: + from lib.instance.instance_cash_sample_lib import ( + cash_usdt_from_balances, + equity_usdt_from_snapshot, + ) - return fetch_options_balances(ex, force=False) or {} + ex = getattr(app_module, "exchange_options", None) + bal: dict[str, Any] = {} + if ex is not None: + try: + from lib.exchange.okx_options_lib import fetch_options_balances + + bal = fetch_options_balances(ex, force=False) or {} + except Exception: + bal = {} + cash = cash_usdt_from_balances(bal if isinstance(bal, dict) else None) + + positions: list[dict[str, Any]] = [] + cfg = None + try: + cfg = app.extensions.get("options_cfg") + except Exception: + cfg = None + if isinstance(cfg, dict) and cfg.get("enabled"): + try: + from lib.options.options_dashboard_lib import ( + fetch_light_option_positions_for_dashboard, + ) + + positions = fetch_light_option_positions_for_dashboard(cfg) or [] + except Exception: + positions = [] + + btc_px = eth_px = None + if ex is not None: + try: + from lib.options.options_stats_lib import _underlying_index_usdt + + btc_px = _underlying_index_usdt(ex, "BTC") + eth_px = _underlying_index_usdt(ex, "ETH") + except Exception: + btc_px = eth_px = None + if (btc_px is None or eth_px is None) and positions: + for p in positions: + if not isinstance(p, dict): + continue + under = str(p.get("underlying") or "").upper() + idx = None + try: + idx = float(p.get("idx_px") or p.get("index_px") or 0) + except (TypeError, ValueError): + idx = None + if idx is None or idx <= 0: + continue + if under == "BTC" and btc_px is None: + btc_px = idx + elif under == "ETH" and eth_px is None: + eth_px = idx + + equity = equity_usdt_from_snapshot( + bal if isinstance(bal, dict) else None, + positions, + btc_px=btc_px, + eth_px=eth_px, + ) + return {"cash_usdt": cash, "equity_usdt": equity} @app.route("/api/instance/cash_curve") @login_required @@ -26,9 +85,8 @@ def install_instance_data_stats(app: Flask, app_module: Any) -> None: sample_once, ) - # 打开页面时尽量补一笔最新点(失败忽略) try: - sample_once(get_db, _fetch_balances) + sample_once(get_db, _fetch_equity_snapshot) except Exception: pass range_key = (request.args.get("range") or "7d").strip() @@ -40,10 +98,9 @@ def install_instance_data_stats(app: Flask, app_module: Any) -> None: finally: conn.close() - # 后台采样(daemon);无密钥时会空转跳过 try: from lib.instance.instance_cash_sample_lib import start_cash_sample_loop - start_cash_sample_loop(get_db, _fetch_balances) + start_cash_sample_loop(get_db, _fetch_equity_snapshot) except Exception as e: print(f"[instance_cash_sample] start skipped: {e}", flush=True) diff --git a/lib/instance/templates/options_data_stats_panel.html b/lib/instance/templates/options_data_stats_panel.html index cb4542e..913d095 100644 --- a/lib/instance/templates/options_data_stats_panel.html +++ b/lib/instance/templates/options_data_stats_panel.html @@ -68,8 +68,8 @@
-
资金曲线
-

现金余额(交易账户+资金账户,不含权利金与浮盈);后台定时采样。

+
权益曲线
+

权益=稳定币现金+现货币折U+持仓权利金U+浮盈U;后台定时采样。

@@ -78,8 +78,8 @@ —
- +
- + diff --git a/tests/test_instance_equity_sample.py b/tests/test_instance_equity_sample.py new file mode 100644 index 0000000..5c6895d --- /dev/null +++ b/tests/test_instance_equity_sample.py @@ -0,0 +1,96 @@ +"""权益曲线采样:现金+现货币+权利金+浮盈.""" +from __future__ import annotations + +import sqlite3 +import unittest + + +class EquitySampleTests(unittest.TestCase): + def test_equity_fills_open_cliff(self): + from lib.instance.instance_cash_sample_lib import equity_usdt_from_snapshot + + # 开仓后:USDT 所剩无几,BTC 缓冲 + 权利金仓位 + bal = { + "trading_usdt": 50.0, + "funding_usdt": 0.0, + "trading_btc": 0.001, + "funding_btc": 0.0, + } + positions = [ + { + "premium_paid": 0.01, + "premium_ccy": "BTC", + "open_index_px": 84000.0, + "idx_px": 84000.0, + "net_pnl": 0.0, + "mark": 0.01, + } + ] + # cash 50 + spot 84 + premium 840 = 974; 浮盈约 0 + eq = equity_usdt_from_snapshot(bal, positions, btc_px=84000.0, eth_px=None) + self.assertIsNotNone(eq) + self.assertGreater(float(eq), 900.0) + # 纯现金会只有 50 + from lib.instance.instance_cash_sample_lib import cash_usdt_from_balances + + self.assertAlmostEqual(float(cash_usdt_from_balances(bal)), 50.0, places=2) + + def test_query_prefers_equity_column(self): + from lib.instance.instance_cash_sample_lib import ( + init_cash_sample_table, + query_cash_series_with_db, + record_cash_sample, + ) + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_cash_sample_table(conn) + record_cash_sample(conn, 100.0, equity_usdt=500.0, ts_ms=1_700_000_000_000) + record_cash_sample(conn, 120.0, equity_usdt=None, ts_ms=1_700_000_060_000) + # 旧点无 equity 列时回退 cash:手动插一条仅 cash + conn.execute( + "INSERT INTO instance_cash_samples(ts_ms, cash_usdt) VALUES (?, ?)", + (1_700_000_120_000, 130.0), + ) + conn.commit() + out = query_cash_series_with_db(conn, range_key="all") + self.assertTrue(out["ok"]) + self.assertEqual(out["metric"], "equity") + pts = out["points"] + self.assertEqual(len(pts), 3) + self.assertAlmostEqual(pts[0]["v"], 500.0, places=2) + self.assertAlmostEqual(pts[1]["v"], 120.0, places=2) # equity 默认写成 cash + self.assertAlmostEqual(pts[2]["v"], 130.0, places=2) # 历史无 equity → cash + conn.close() + + def test_sample_once_dict_snapshot(self): + import tempfile + from pathlib import Path + + from lib.instance.instance_cash_sample_lib import ( + query_cash_series_with_db, + sample_once, + ) + + tmp = Path(tempfile.mkdtemp()) / "eq.db" + + def get_db(): + c = sqlite3.connect(str(tmp)) + c.row_factory = sqlite3.Row + return c + + ok = sample_once( + get_db, + lambda: {"cash_usdt": 200.0, "equity_usdt": 950.0}, + ) + self.assertTrue(ok) + conn = get_db() + try: + out = query_cash_series_with_db(conn, range_key="all") + self.assertAlmostEqual(float(out["latest"]), 950.0, places=2) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main()