diff --git a/lib/common/static/options_data_stats.js b/lib/common/static/options_data_stats.js index cf94143..1675c51 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,9 +278,10 @@ drawCurve(pts); if (meta) { meta.textContent = - "权益采样 " + + "已实现 " + + (data.trade_count != null ? data.trade_count + " 笔 · " : "") + 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 969a125..3d97bba 100644 --- a/lib/instance/instance_cash_sample_lib.py +++ b/lib/instance/instance_cash_sample_lib.py @@ -170,36 +170,106 @@ def _range_start_ms(range_key: str) -> tuple[str, int]: return "7d", now_ms - 7 * 86400 * 1000 -def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]: +def _closed_at_to_ms(raw: Any) -> int | None: + if raw is None or raw == "": + return None + try: + from lib.options.options_stats_lib import _parse_ts + + d = _parse_ts(raw) + if d is None: + return None + return int(d.timestamp() * 1000) + except Exception: + return None + + +def _trade_realized_usdt(row: Any) -> float | None: + u = _safe_float(row["realized_pnl_usdt"] if hasattr(row, "keys") else None) + if u is not None: + return round(float(u), 4) + # 兼容未冻结 U:币盈亏×平仓价 + try: + coin = _safe_float(row["realized_pnl"]) + px = _safe_float(row["close_index_px"]) + except Exception: + return None + if coin is None or px is None or px <= 0: + return None + return round(float(coin) * float(px), 4) + + +def query_realized_pnl_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]: + """累计已实现盈亏曲线(只随平仓变化,划转/提现不影响).""" rk, start_ms = _range_start_ms(range_key) now_ms = int(time.time() * 1000) - init_cash_sample_table(conn) + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + except Exception: + pass rows = conn.execute( """ - SELECT ts_ms, cash_usdt, equity_usdt - FROM instance_cash_samples - WHERE ts_ms >= ? - ORDER BY ts_ms ASC - """, - (start_ms,), + SELECT id, closed_at, created_at, realized_pnl_usdt, realized_pnl, close_index_px + FROM options_trades + WHERE status = 'closed' + ORDER BY COALESCE(closed_at, created_at) ASC, id ASC + """ ).fetchall() - points = [] + events: list[tuple[int, float]] = [] 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}) + pnl_u = _trade_realized_usdt(r) + if pnl_u is None: + continue + ts = _closed_at_to_ms(r["closed_at"]) or _closed_at_to_ms(r["created_at"]) + if ts is None: + continue + events.append((ts, float(pnl_u))) + + cum = 0.0 + all_points: list[dict[str, Any]] = [] + for ts, pnl in events: + cum = round(cum + pnl, 4) + all_points.append({"t": int(ts), "v": cum}) + + baseline = 0.0 + series: list[dict[str, Any]] = [] + for p in all_points: + if int(p["t"]) < start_ms: + baseline = float(p["v"]) + continue + if not series: + series.append({"t": int(start_ms), "v": baseline}) + series.append({"t": int(p["t"]), "v": float(p["v"])}) + if not series: + # 窗口内无新平仓:画水平线(历史累计或 0) + series = [ + {"t": int(start_ms), "v": baseline}, + {"t": int(now_ms), "v": baseline}, + ] + else: + last_v = float(series[-1]["v"]) + if int(series[-1]["t"]) < now_ms: + series.append({"t": int(now_ms), "v": last_v}) + return { "ok": True, "range": rk, - "metric": "equity", - "points": points, + "metric": "realized_pnl", + "points": series, "start_ms": start_ms, "end_ms": now_ms, - "latest": points[-1]["v"] if points else None, + "latest": series[-1]["v"] if series else None, + "trade_count": len(events), } +def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]: + """兼容旧名:数据统计曲线改为累计已实现盈亏.""" + return query_realized_pnl_series_with_db(conn, range_key=range_key) + + def sample_once(get_db: Callable, fetch_snapshot: Callable) -> bool: """拉取快照并写入采样. diff --git a/lib/instance/instance_data_stats_register.py b/lib/instance/instance_data_stats_register.py index cb8bee4..c306549 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,97 +10,16 @@ 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_equity_snapshot() -> dict[str, Any]: - from lib.instance.instance_cash_sample_lib import ( - cash_usdt_from_balances, - equity_usdt_from_snapshot, - ) - - 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 def api_instance_cash_curve(): - from lib.instance.instance_cash_sample_lib import ( - query_cash_series_with_db, - sample_once, - ) + from lib.instance.instance_cash_sample_lib import query_realized_pnl_series_with_db - try: - sample_once(get_db, _fetch_equity_snapshot) - except Exception: - pass range_key = (request.args.get("range") or "7d").strip() conn = get_db() try: - return jsonify(query_cash_series_with_db(conn, range_key=range_key)) + return jsonify(query_realized_pnl_series_with_db(conn, range_key=range_key)) except Exception as e: - return jsonify({"ok": False, "msg": f"加载资金曲线失败: {e}"}), 500 + return jsonify({"ok": False, "msg": f"加载盈亏曲线失败: {e}"}), 500 finally: conn.close() - - try: - from lib.instance.instance_cash_sample_lib import start_cash_sample_loop - - 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 913d095..935491a 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;后台定时采样。

+
已实现盈亏曲线
+

累计已实现盈亏(期权平仓真盈亏U累加);划转/提现不影响。

@@ -78,8 +78,8 @@ —
- +
- + diff --git a/tests/test_instance_equity_sample.py b/tests/test_instance_equity_sample.py index 5c6895d..289e614 100644 --- a/tests/test_instance_equity_sample.py +++ b/tests/test_instance_equity_sample.py @@ -1,15 +1,19 @@ -"""权益曲线采样:现金+现货币+权利金+浮盈.""" +"""权益采样辅助(曲线已改为已实现盈亏,此处仅覆盖权益计算工具).""" from __future__ import annotations import sqlite3 +import tempfile import unittest +from pathlib import Path class EquitySampleTests(unittest.TestCase): def test_equity_fills_open_cliff(self): - from lib.instance.instance_cash_sample_lib import equity_usdt_from_snapshot + from lib.instance.instance_cash_sample_lib import ( + cash_usdt_from_balances, + equity_usdt_from_snapshot, + ) - # 开仓后:USDT 所剩无几,BTC 缓冲 + 权利金仓位 bal = { "trading_usdt": 50.0, "funding_usdt": 0.0, @@ -26,49 +30,15 @@ class EquitySampleTests(unittest.TestCase): "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): + def test_record_writes_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, ) @@ -86,8 +56,19 @@ class EquitySampleTests(unittest.TestCase): 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) + init_cash_sample_table(conn) + row = conn.execute( + "SELECT cash_usdt, equity_usdt FROM instance_cash_samples ORDER BY ts_ms DESC LIMIT 1" + ).fetchone() + self.assertAlmostEqual(float(row["cash_usdt"]), 200.0, places=2) + self.assertAlmostEqual(float(row["equity_usdt"]), 950.0, places=2) + record_cash_sample(conn, 100.0, equity_usdt=500.0, ts_ms=1_700_000_000_000) + conn.commit() + row2 = conn.execute( + "SELECT equity_usdt FROM instance_cash_samples WHERE ts_ms=?", + (1_700_000_000_000,), + ).fetchone() + self.assertAlmostEqual(float(row2["equity_usdt"]), 500.0, places=2) finally: conn.close() diff --git a/tests/test_instance_realized_pnl_curve.py b/tests/test_instance_realized_pnl_curve.py new file mode 100644 index 0000000..ff8a1c1 --- /dev/null +++ b/tests/test_instance_realized_pnl_curve.py @@ -0,0 +1,75 @@ +"""已实现盈亏曲线:累计平仓真盈亏,不受划转影响.""" +from __future__ import annotations + +import sqlite3 +import unittest + + +class RealizedPnlCurveTests(unittest.TestCase): + def _init(self): + from lib.options.options_db import init_options_tables + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + init_options_tables(conn) + return conn + + def test_cumulative_steps(self): + from lib.instance.instance_cash_sample_lib import query_realized_pnl_series_with_db + + conn = self._init() + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, premium_ccy, + status, realized_pnl_usdt, close_index_px, + created_at, closed_at + ) VALUES + ('BTC-A','BTC','P',1,0.01,0.01,0.02,0.01,'BTC','closed',100.0,80000, + '2026-10-01 10:00:00','2026-10-01 12:00:00'), + ('BTC-B','BTC','P',1,0.01,0.01,0.02,0.01,'BTC','closed',50.0,80000, + '2026-10-02 10:00:00','2026-10-02 12:00:00'), + ('BTC-C','BTC','P',1,0.01,0.01,0.02,-0.01,'BTC','closed',-20.0,80000, + '2026-10-03 10:00:00','2026-10-03 12:00:00') + """ + ) + conn.commit() + out = query_realized_pnl_series_with_db(conn, range_key="all") + self.assertTrue(out["ok"]) + self.assertEqual(out["metric"], "realized_pnl") + self.assertEqual(out["trade_count"], 3) + # 点含区间起点/各笔台阶/延伸到 now;累计最终 130 + self.assertAlmostEqual(float(out["latest"]), 130.0, places=2) + vals = [p["v"] for p in out["points"]] + self.assertIn(100.0, vals) + self.assertIn(150.0, vals) + self.assertIn(130.0, vals) + conn.close() + + def test_range_carries_baseline(self): + from lib.instance.instance_cash_sample_lib import query_realized_pnl_series_with_db + + conn = self._init() + # 一笔很早的盈利,窗口内无新单 → 水平线在 80 + conn.execute( + """ + INSERT INTO options_trades( + inst_id, underlying, opt_type, sheets, eth_amount, + premium_paid, premium_received, realized_pnl, premium_ccy, + status, realized_pnl_usdt, created_at, closed_at + ) VALUES ( + 'BTC-OLD','BTC','P',1,0.01,0.01,0.02,0.01,'BTC', + 'closed', 80.0, '2020-01-01 10:00:00', '2020-01-01 12:00:00' + ) + """ + ) + conn.commit() + out = query_realized_pnl_series_with_db(conn, range_key="7d") + self.assertAlmostEqual(float(out["latest"]), 80.0, places=2) + self.assertTrue(all(abs(float(p["v"]) - 80.0) < 1e-6 for p in out["points"])) + conn.close() + + +if __name__ == "__main__": + unittest.main()