Files

279 lines
9.4 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 * FROM groups WHERE status='closed'")
pnls: list[float] = []
try:
from .trades import _enrich_group, persist_expiry_overlay_if_needed
for r in closed:
g = dict(r)
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
(g["group_id"],),
)
gr = _enrich_group(g, fills)
try:
persist_expiry_overlay_if_needed(db, gr, list(fills))
except Exception:
pass
pnls.append(float(gr.get("net_pnl") or gr.get("realized_pnl") or 0))
except Exception:
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
pos = st.get("position") or {}
realtime = None
pos_st = str(pos.get("status") or "")
if pos_st in ("open", "half_open", "option_closed_perp_pending"):
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")
r = float(rate) if rate and rate > 0 else 1.0
total = round(
(funding_usdt or 0.0)
+ (trading_usdt or 0.0)
+ ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r,
2,
)
except Exception as e:
return {
"ok": False,
"mode": mode,
"exchange": exchange,
"detail": str(e),
}
finally:
client.close()
elif exchange in ("BINANCE", "BN"):
from ..live.binance_trade import BinanceTradeClient
client = BinanceTradeClient()
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")
r = float(rate) if rate and rate > 0 else 1.0
total = round(
(funding_usdt or 0.0)
+ (trading_usdt or 0.0)
+ ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r,
2,
)
except Exception as e:
return {
"ok": False,
"mode": mode,
"exchange": exchange,
"detail": str(e),
}
finally:
try:
client.close()
except Exception:
pass
else:
# 其它交易所:不回退模拟账本(避免实盘显示假资金)
return {
"ok": False,
"mode": mode,
"exchange": exchange,
"detail": f"{exchange} 实盘资金摘要暂未接入,请用交易所 App 查看",
}
return {
"ok": True,
"mode": mode,
"exchange": exchange,
"trading_day": trading_day,
"total_trades": 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]:
"""USDT↔USDC 市价兑换:一律在交易账户(对齐 OKX 现货 cash;SIM 同口径)。"""
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,
account="trading",
)
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()
try:
from ..strategy.open_capacity import invalidate_live_balance_cache
invalidate_live_balance_cache()
except Exception:
pass
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()
try:
from ..strategy.open_capacity import invalidate_live_balance_cache
invalidate_live_balance_cache()
except Exception:
pass
if not r.get("ok"):
raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败")
return r