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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user