"""资金摘要 / 兑换 / 划转(SIM 本地钱包 + LIVE OKX)。""" from __future__ import annotations from datetime import datetime, timezone from typing import Annotated, Any, Literal from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from ..config import get_settings from ..live.okx_funds import OkxFundsClient, usdc_usdt_mid_rate from ..models.db import get_db from ..sim.funds_wallets import SimFundsWallets from ..sim.ledger import Ledger from ..strategy.engine import get_engine from .auth import require_user router = APIRouter(prefix="/api/funds", tags=["funds"]) SH = ZoneInfo("Asia/Shanghai") def _pl_ratio(pnls: list[float]) -> float | None: wins = [x for x in pnls if x > 0] losses = [abs(x) for x in pnls if x < 0] if not wins or not losses: return None avg_w = sum(wins) / len(wins) avg_l = sum(losses) / len(losses) if avg_l <= 1e-12: return None return round(avg_w / avg_l, 2) def _fmt_opt(usdc: float | None, usdt: float | None) -> str: parts: list[str] = [] if usdc is not None: parts.append(f"{usdc:.2f} USDC") if usdt is not None and abs(usdt) > 1e-8: parts.append(f"{usdt:.2f} USDT") return " + ".join(parts) if parts else "—" class ConvertBody(BaseModel): direction: Literal["usdt_to_usdc", "usdc_to_usdt"] amount: float = Field(gt=0) class TransferBody(BaseModel): ccy: Literal["USDT", "USDC", "usdt", "usdc"] = "USDC" amount: float = Field(gt=0) from_account: str to_account: str @router.get("/summary") async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[str, Any]: s = get_settings() db = get_db() eng = get_engine() st = eng.state() # 不在摘要里强调 SIM/LIVE 文案;仅给前端内部用 mode = "LIVE" if not s.is_sim else "SIM" exchange = str(st.get("exchange") or s.exchange or "okx").upper() trading_day = datetime.now(SH).strftime("%Y-%m-%d") closed = db.fetchall( "SELECT realized_pnl, close_at_ms FROM groups WHERE status='closed'" ) pnls = [float(r["realized_pnl"] or 0) for r in closed] n = len(pnls) wins = sum(1 for x in pnls if x > 0) win_rate = (wins / n) if n else 0.0 # 当日成交组 day_prefix = trading_day.replace("-", "") day_groups = db.fetchall( "SELECT realized_pnl FROM groups WHERE group_id LIKE ? AND status='closed'", (f"G-{day_prefix}-%",), ) day_n = len(day_groups) pos = st.get("position") or {} upl = pos realtime = None if str(pos.get("status") or "") == "open": realtime = float(pos.get("net_pnl") or 0) if s.is_sim: wallets = SimFundsWallets(db) w = wallets.snapshot() # 若钱包全 0 但账本有权益,补种一次 if wallets.total_usdt_equiv(w) < 1e-9: eq = float(Ledger(db).snapshot().get("equity") or 0) if eq > 0: w = wallets.reset_from_equity(eq) funding_usdt = float(w["funding_usdt"]) trading_usdt = float(w["trading_usdt"]) opt_f_usdc = float(w["options_funding_usdc"]) opt_t_usdc = float(w["options_trading_usdc"]) opt_f_usdt = float(w["options_funding_usdt"]) opt_t_usdt = float(w["options_trading_usdt"]) total = wallets.total_usdt_equiv(w) rate = usdc_usdt_mid_rate() else: rate = usdc_usdt_mid_rate() funding_usdt = trading_usdt = None opt_f_usdc = opt_t_usdc = opt_f_usdt = opt_t_usdt = None total = None if exchange == "OKX": client = OkxFundsClient() try: bal = client.fetch_balances() funding_usdt = bal.get("funding_usdt") trading_usdt = bal.get("trading_usdt") opt_f_usdc = bal.get("options_funding_usdc") opt_t_usdc = bal.get("options_trading_usdc") opt_f_usdt = bal.get("options_funding_usdt") opt_t_usdt = bal.get("options_trading_usdt") parts = [ funding_usdt, trading_usdt, opt_f_usdc, opt_t_usdc, opt_f_usdt, opt_t_usdt, ] vals = [float(x) for x in parts if x is not None] total = round(sum(vals), 2) if vals else None except Exception as e: return { "ok": False, "mode": mode, "exchange": exchange, "detail": str(e), } finally: client.close() else: # 非 OKX LIVE:回退本地账本 led = Ledger(db).snapshot() trading_usdt = float(led["equity"]) total = trading_usdt return { "ok": True, "mode": mode, "exchange": exchange, "trading_day": trading_day, "total_trades": day_n if day_n else n, "win_rate": win_rate, "profit_loss_ratio": _pl_ratio(pnls), "total_funds": total, "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, "options_funding_usdc": opt_f_usdc, "options_trading_usdc": opt_t_usdc, "options_funding_usdt": opt_f_usdt, "options_trading_usdt": opt_t_usdt, "options_funding_label": _fmt_opt(opt_f_usdc, opt_f_usdt), "options_trading_label": _fmt_opt(opt_t_usdc, opt_t_usdt), "realtime_pnl": realtime, "usdc_usdt_rate": rate, "perp_inst_id": str( db.get_setting("perp_inst_id") or s.perp_inst_id or "ETH-USDT-SWAP" ), "updated_at_ms": int(datetime.now(timezone.utc).timestamp() * 1000), } @router.post("/convert") async def funds_convert( body: ConvertBody, _user: Annotated[str, Depends(require_user)] ) -> dict[str, Any]: s = get_settings() rate = usdc_usdt_mid_rate() if s.is_sim: r = SimFundsWallets(get_db()).convert( direction=body.direction, amount=float(body.amount), rate=rate ) if not r.get("ok"): raise HTTPException(status_code=400, detail=r.get("detail") or "兑换失败") return r if str(s.exchange).lower() != "okx": raise HTTPException(status_code=400, detail="当前仅 OKX 支持 USDC/USDT 兑换") client = OkxFundsClient() try: r = client.spot_swap_usdt_usdc( direction=body.direction, amount=float(body.amount) ) finally: client.close() if not r.get("ok"): raise HTTPException(status_code=400, detail=r.get("detail") or "兑换失败") return r @router.post("/transfer") async def funds_transfer( body: TransferBody, _user: Annotated[str, Depends(require_user)] ) -> dict[str, Any]: s = get_settings() ccy = str(body.ccy).upper() if s.is_sim: r = SimFundsWallets(get_db()).transfer( ccy=ccy, amount=float(body.amount), from_account=body.from_account, to_account=body.to_account, ) if not r.get("ok"): raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败") return r if str(s.exchange).lower() != "okx": raise HTTPException(status_code=400, detail="当前仅 OKX 支持账户划转") client = OkxFundsClient() try: r = client.transfer( ccy=ccy, amount=float(body.amount), from_account=body.from_account, to_account=body.to_account, ) finally: client.close() if not r.get("ok"): raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败") return r