81e51236c0
Co-authored-by: Cursor <cursoragent@cursor.com>
220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
"""资金摘要 / 兑换 / 划转(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_acct(usdt: float | None, usdc: float | None) -> str:
|
|
parts: list[str] = []
|
|
if usdt is not None:
|
|
parts.append(f"{usdt:.2f}U")
|
|
if usdc is not None and abs(usdc) > 1e-8:
|
|
parts.append(f"{usdc:.2f} USDC")
|
|
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: Literal["funding", "trading"] = "funding"
|
|
to_account: Literal["funding", "trading"] = "trading"
|
|
|
|
|
|
@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)
|
|
v = wallets.view()
|
|
funding_usdt = float(v["funding_usdt"])
|
|
trading_usdt = float(v["trading_usdt"])
|
|
funding_usdc = float(v["funding_usdc"])
|
|
trading_usdc = float(v["trading_usdc"])
|
|
total = wallets.total_usdt_equiv()
|
|
rate = usdc_usdt_mid_rate()
|
|
else:
|
|
rate = usdc_usdt_mid_rate()
|
|
funding_usdt = trading_usdt = funding_usdc = trading_usdc = 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")
|
|
funding_usdc = bal.get("funding_usdc")
|
|
trading_usdc = bal.get("trading_usdc")
|
|
parts = [funding_usdt, trading_usdt, funding_usdc, trading_usdc]
|
|
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,
|
|
"funding_usdc": funding_usdc,
|
|
"trading_usdc": trading_usdc,
|
|
"funding_label": _fmt_acct(funding_usdt, funding_usdc),
|
|
"trading_label": _fmt_acct(trading_usdt, trading_usdc),
|
|
"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
|