Add OKX-style funds bar with USDT/USDC convert and transfer.

SIM uses multi-wallet balances; LIVE hits OKX asset/account APIs and spot USDC-USDT swap. Funds strip hides sim labeling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 17:06:13 +08:00
parent 1c75db4a19
commit 5c3bd4b654
13 changed files with 1020 additions and 6 deletions
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from .auth_routes import router as auth_router
from .backup_routes import router as backup_router
from .funds import router as funds_router
from .market import router as market_router
from .plan import router as plan_router
from .settings import router as settings_router
@@ -16,5 +17,6 @@ router.include_router(sim_router)
router.include_router(plan_router)
router.include_router(trades_router)
router.include_router(stats_router)
router.include_router(funds_router)
router.include_router(settings_router)
router.include_router(backup_router)
+229
View File
@@ -0,0 +1,229 @@
"""资金摘要 / 兑换 / 划转(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,
"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