diff --git a/app.py b/app.py index 957150e..05e1674 100644 --- a/app.py +++ b/app.py @@ -4024,7 +4024,9 @@ def render_main_page(page="options", embed_mode=None): try: from lib.options.options_stats_lib import header_options_stats_for_window - opt_header = header_options_stats_for_window(conn, list_window, APP_TZ) + opt_header = header_options_stats_for_window( + conn, list_window, APP_TZ, ex=exchange_options + ) total = opt_header["total"] rate = opt_header["rate"] profit_loss_ratio = opt_header.get("profit_loss_ratio") @@ -4427,7 +4429,7 @@ def api_account_snapshot(): from lib.options.options_stats_lib import header_options_stats_for_window header_trade_stats = header_options_stats_for_window( - conn, _list_window_from_request(), APP_TZ + conn, _list_window_from_request(), APP_TZ, ex=exchange_options ) except Exception: pass diff --git a/lib/options/options_stats_lib.py b/lib/options/options_stats_lib.py index 0cd0092..158949d 100644 --- a/lib/options/options_stats_lib.py +++ b/lib/options/options_stats_lib.py @@ -260,13 +260,41 @@ def compute_options_stats_for_window( return stats -def compute_options_stats(get_db) -> dict[str, Any]: +def _row_get(row: Any, key: str, default: Any = None) -> Any: + try: + if hasattr(row, "keys") and key in row.keys(): + return row[key] + except Exception: + pass + if isinstance(row, dict): + return row.get(key, default) + return default + + +def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for row in closed_rows or []: + out.append( + { + "status": "closed", + "realized_pnl": _row_get(row, "realized_pnl"), + "created_at": _row_get(row, "created_at"), + "closed_at": _row_get(row, "closed_at"), + "inst_id": _row_get(row, "inst_id") or "", + "underlying": _row_get(row, "underlying") or "", + "premium_ccy": _row_get(row, "premium_ccy") or "", + } + ) + return out + + +def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]: conn = get_db() try: init_options_tables(conn) closed_rows = conn.execute( """ - SELECT realized_pnl, created_at, closed_at + SELECT realized_pnl, created_at, closed_at, inst_id, underlying, premium_ccy FROM options_trades WHERE status = 'closed' AND realized_pnl IS NOT NULL """ @@ -276,71 +304,33 @@ def compute_options_stats(get_db) -> dict[str, Any]: SELECT created_at FROM options_trades WHERE status = 'open' """ ).fetchall() - return _stats_from_option_trade_rows(closed_rows, open_rows) + history = _closed_trade_dicts(closed_rows) + for row in open_rows: + history.append({"status": "open", "created_at": _row_get(row, "created_at")}) + # 币本位盈亏按指数折 U,再算胜率/盈亏比(与 /api/options/stats 一致) + return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) finally: conn.close() -def _stats_from_option_trade_rows(closed_rows, open_rows) -> dict[str, Any]: - wins: list[float] = [] - losses: list[float] = [] - win_holds: list[float] = [] - loss_holds: list[float] = [] - all_holds: list[float] = [] - now = datetime.now() - - for row in closed_rows: - pnl = float(row["realized_pnl"]) - hold = _hold_seconds(row["created_at"], row["closed_at"]) - if hold is not None: - all_holds.append(hold) - if pnl > 0: - wins.append(pnl) - if hold is not None: - win_holds.append(hold) - elif pnl < 0: - losses.append(pnl) - if hold is not None: - loss_holds.append(hold) - - open_holds: list[float] = [] - for row in open_rows: - start = _parse_ts(row["created_at"]) - if start is None: - continue - sec = (now - start).total_seconds() - if sec >= 0: - open_holds.append(sec) - - 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 - - total_profit = round(sum(wins), 4) if wins else 0.0 - total_loss = round(abs(sum(losses)), 4) if losses else 0.0 - net_realized = round(sum(wins) + sum(losses), 4) - return { - "total_closed": total_closed, - "win_count": len(wins), - "loss_count": len(losses), - "win_rate": win_rate, - "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), - "avg_win": round(avg_win, 4) if avg_win is not None else None, - "avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None, - "total_profit": total_profit, - "total_loss": total_loss, - "net_realized_pnl": net_realized, - "avg_hold_sec": _avg_seconds(all_holds), - "avg_win_hold_sec": _avg_seconds(win_holds), - "avg_loss_hold_sec": _avg_seconds(loss_holds), - "open_count": len(open_holds), - "avg_open_hold_sec": _avg_seconds(open_holds), - } +def _stats_from_option_trade_rows(closed_rows, open_rows, *, ex: Any = None) -> dict[str, Any]: + """兼容旧调用;内部统一折 U 后走 compute_options_stats_from_history.""" + history = _closed_trade_dicts(closed_rows) + for row in open_rows or []: + history.append({"status": "open", "created_at": _row_get(row, "created_at")}) + return compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) -def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]: - """顶栏总交易/胜率/盈亏比:纯期权(options_trades),全站导航共用,按列表窗过滤.""" +def header_options_stats_for_window( + conn, + list_window: dict[str, Any], + app_tz, + ex: Any = None, +) -> dict[str, Any]: + """顶栏总交易/胜率/盈亏比:纯期权(options_trades),全站导航共用,按列表窗过滤. + + 币本位 realized_pnl 为币数量,须折 U 后再算盈亏比,否则 BTC/ETH 混算会严重失真. + """ from lib.common.history_window_lib import utc_window_to_bj_sql_strings init_options_tables(conn) @@ -349,7 +339,7 @@ def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) - ) closed_rows = conn.execute( """ - SELECT realized_pnl, created_at, closed_at + SELECT realized_pnl, created_at, closed_at, inst_id, underlying, premium_ccy FROM options_trades WHERE status = 'closed' AND realized_pnl IS NOT NULL @@ -361,7 +351,10 @@ def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) - open_rows = conn.execute( "SELECT created_at FROM options_trades WHERE status = 'open'" ).fetchall() - stats = _stats_from_option_trade_rows(closed_rows, open_rows) + history = _closed_trade_dicts(closed_rows) + for row in open_rows: + history.append({"status": "open", "created_at": _row_get(row, "created_at")}) + stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) return { "total": int(stats.get("total_closed") or 0), "rate": float(stats.get("win_rate") or 0), diff --git a/tests/test_options_btc_pnl_usdt.py b/tests/test_options_btc_pnl_usdt.py index 491d173..a4a6396 100644 --- a/tests/test_options_btc_pnl_usdt.py +++ b/tests/test_options_btc_pnl_usdt.py @@ -41,5 +41,76 @@ class DisplayPnlUsdtTests(unittest.TestCase): self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4) +class HeaderPlRatioUsdtTests(unittest.TestCase): + """顶栏盈亏比须先把币数量折 U,避免 ETH/BTC 混算失真.""" + + def test_mixed_coin_pnl_ratio_after_usdt(self): + from lib.options.options_stats_lib import ( + compute_options_stats_from_history, + history_pnl_to_usdt, + ) + + # 赢 0.01 ETH、亏 0.0001 BTC;不折 U 时盈亏比≈0.01/0.0001=100 或反过来极小 + # 按指数折 U: +0.01*3000=30U, -0.0001*100000=-10U → 盈亏比=3 + history = [ + { + "status": "closed", + "inst_id": "ETH-USD-260701-2500-C", + "underlying": "ETH", + "premium_ccy": "ETH", + "realized_pnl": 0.01, + "created_at": "2026-10-01 10:00:00", + "closed_at": "2026-10-01 12:00:00", + }, + { + "status": "closed", + "inst_id": "BTC-USD-260701-100000-P", + "underlying": "BTC", + "premium_ccy": "BTC", + "realized_pnl": -0.0001, + "created_at": "2026-10-01 11:00:00", + "closed_at": "2026-10-01 13:00:00", + }, + ] + + class _Ex: + def public_get_market_ticker(self, params): + inst = (params or {}).get("instId") or "" + if inst.startswith("ETH"): + return {"data": [{"last": "3000"}]} + if inst.startswith("BTC"): + return {"data": [{"last": "100000"}]} + return {"data": []} + + converted = history_pnl_to_usdt(history, _Ex()) + self.assertAlmostEqual(float(converted[0]["realized_pnl"]), 30.0, places=4) + self.assertAlmostEqual(float(converted[1]["realized_pnl"]), -10.0, places=4) + stats = compute_options_stats_from_history(converted) + self.assertEqual(stats["win_count"], 1) + self.assertEqual(stats["loss_count"], 1) + self.assertAlmostEqual(float(stats["profit_loss_ratio"]), 3.0, places=2) + + def test_raw_coin_mix_would_distort_without_convert(self): + from lib.options.options_stats_lib import compute_options_stats_from_history + + raw = [ + { + "status": "closed", + "realized_pnl": 0.01, + "created_at": "2026-10-01 10:00:00", + "closed_at": "2026-10-01 12:00:00", + }, + { + "status": "closed", + "realized_pnl": -0.0001, + "created_at": "2026-10-01 11:00:00", + "closed_at": "2026-10-01 13:00:00", + }, + ] + stats = compute_options_stats_from_history(raw) + # 未折 U: 0.01 / 0.0001 = 100, 与折 U 后的 3 截然不同 + self.assertAlmostEqual(float(stats["profit_loss_ratio"]), 100.0, places=2) + + if __name__ == "__main__": unittest.main()