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
+191
View File
@@ -0,0 +1,191 @@
"""OKX 资金:余额 / USDT↔USDC 现货兑换 / 账户划转(对齐 crypto_monitor)。"""
from __future__ import annotations
import logging
from typing import Any
from .okx_trade import OkxTradeClient
logger = logging.getLogger(__name__)
# OKX acct: 6=资金, 18=交易
_ACCT_CODE = {
"funding": "6",
"trading": "18",
"spot": "18",
"options_funding": "6",
"options_trading": "18",
}
def _f(v: Any) -> float | None:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
class OkxFundsClient:
def __init__(self, trade: OkxTradeClient | None = None) -> None:
self.trade = trade or OkxTradeClient()
def close(self) -> None:
self.trade.close()
def fetch_balances(self) -> dict[str, float | None]:
"""
拉取资金账户 + 交易账户 USDT/USDC。
资金:GET /api/v5/asset/balances
交易:GET /api/v5/account/balance
"""
out: dict[str, float | None] = {
"funding_usdt": None,
"funding_usdc": None,
"trading_usdt": None,
"trading_usdc": None,
"options_funding_usdc": None,
"options_trading_usdc": None,
"options_funding_usdt": None,
"options_trading_usdt": None,
}
try:
rows = self.trade._request("GET", "/api/v5/asset/balances")
for row in rows:
ccy = str(row.get("ccy") or "").upper()
bal = _f(row.get("bal")) or _f(row.get("availBal"))
if ccy == "USDT":
out["funding_usdt"] = bal
out["options_funding_usdt"] = bal
elif ccy == "USDC":
out["funding_usdc"] = bal
out["options_funding_usdc"] = bal
except Exception as e:
logger.warning("OKX asset balances failed: %s", e)
try:
rows = self.trade._request("GET", "/api/v5/account/balance")
for block in rows:
details = block.get("details") or []
if not isinstance(details, list):
continue
for row in details:
if not isinstance(row, dict):
continue
ccy = str(row.get("ccy") or "").upper()
eq = _f(row.get("eq")) or _f(row.get("cashBal")) or _f(row.get("availBal"))
if ccy == "USDT":
out["trading_usdt"] = eq
out["options_trading_usdt"] = eq
elif ccy == "USDC":
out["trading_usdc"] = eq
out["options_trading_usdc"] = eq
except Exception as e:
logger.warning("OKX account balance failed: %s", e)
return out
def spot_swap_usdt_usdc(self, *, direction: str, amount: float) -> dict[str, Any]:
"""现货市价兑换 USDC-USDT(与 crypto_monitor spot_market_swap_usdt_usdc 同口径)。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "数量须大于 0"}
d = (direction or "").strip().lower()
inst_id = "USDC-USDT"
if d == "usdt_to_usdc":
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": str(amt),
"tgtCcy": "quote_ccy",
}
elif d == "usdc_to_usdt":
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "sell",
"ordType": "market",
"sz": str(amt),
"tgtCcy": "base_ccy",
}
else:
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
try:
rows = self.trade._request("POST", "/api/v5/trade/order", body)
if not rows:
return {"ok": False, "detail": "兑换下单无返回"}
row = rows[0]
if str(row.get("sCode") or "0") not in ("0", ""):
return {
"ok": False,
"detail": str(row.get("sMsg") or row.get("sCode") or "兑换失败"),
"raw": row,
}
return {"ok": True, "detail": "converted", "data": row}
except Exception as e:
return {"ok": False, "detail": str(e)}
def transfer(
self,
*,
ccy: str,
amount: float,
from_account: str,
to_account: str,
) -> dict[str, Any]:
"""同一 API Key 下资金↔交易划转。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "划转金额须大于 0"}
fa = (from_account or "").strip().lower()
ta = (to_account or "").strip().lower()
if fa == ta:
return {"ok": False, "detail": "来源与目标账户不能相同"}
from_code = _ACCT_CODE.get(fa)
to_code = _ACCT_CODE.get(ta)
if not from_code or not to_code:
return {"ok": False, "detail": "账户须为 funding/trading(或 options_* 别名)"}
body = {
"ccy": str(ccy).upper(),
"amt": str(amt),
"from": from_code,
"to": to_code,
"type": "0",
}
try:
rows = self.trade._request("POST", "/api/v5/asset/transfer", body)
if not rows:
return {"ok": False, "detail": "划转无返回"}
return {"ok": True, "detail": "transferred", "data": rows[0]}
except Exception as e:
return {"ok": False, "detail": str(e)}
def usdc_usdt_mid_rate() -> float:
"""公共盘口中间价:1 USDC ≈ ? USDT;失败则 1.0。"""
try:
import httpx
from ..config import get_settings
s = get_settings()
proxy = (s.okx_http_proxy or "").strip() or None
with httpx.Client(base_url=s.okx_rest_base.rstrip("/"), timeout=8.0, proxy=proxy) as c:
r = c.get("/api/v5/market/ticker", params={"instId": "USDC-USDT"})
r.raise_for_status()
rows = (r.json() or {}).get("data") or []
if not rows:
return 1.0
bid = _f(rows[0].get("bidPx"))
ask = _f(rows[0].get("askPx"))
last = _f(rows[0].get("last"))
if bid and ask and bid > 0 and ask > 0:
return (bid + ask) / 2.0
if last and last > 0:
return last
except Exception as e:
logger.warning("USDC-USDT mid failed: %s", e)
return 1.0
+23
View File
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS residual_options (
note TEXT,
FOREIGN KEY(group_id) REFERENCES groups(group_id)
);
CREATE TABLE IF NOT EXISTS funds_wallets (
id INTEGER PRIMARY KEY CHECK (id = 1),
funding_usdt REAL NOT NULL DEFAULT 0,
trading_usdt REAL NOT NULL DEFAULT 0,
options_funding_usdc REAL NOT NULL DEFAULT 0,
options_trading_usdc REAL NOT NULL DEFAULT 0,
options_funding_usdt REAL NOT NULL DEFAULT 0,
options_trading_usdt REAL NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL
);
"""
@@ -192,6 +202,19 @@ class Database:
"INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)",
(now,),
)
fw = self._conn.execute("SELECT id FROM funds_wallets WHERE id=1").fetchone()
if fw is None:
led = self._conn.execute(
"SELECT equity FROM ledger_meta WHERE id=1"
).fetchone()
eq = float(led["equity"]) if led else float(s.initial_equity)
self._conn.execute(
"""INSERT INTO funds_wallets(
id, funding_usdt, trading_usdt, options_funding_usdc, options_trading_usdc,
options_funding_usdt, options_trading_usdt, updated_at_ms
) VALUES (1,?,0,0,0,0,0,?)""",
(eq, now),
)
defaults = {
"fee_rate": str(s.fee_rate),
"initial_equity": str(s.initial_equity),
+202
View File
@@ -0,0 +1,202 @@
"""SIM 多账户资金:资金/交易 USDT + 期权资金/交易 USDC(对齐 OKX 展示)。"""
from __future__ import annotations
import time
from typing import Any
from ..models.db import Database, get_db
WALLET_KEYS = (
"funding_usdt",
"trading_usdt",
"options_funding_usdc",
"options_trading_usdc",
"options_funding_usdt",
"options_trading_usdt",
)
# 划转账户映射
_ACCT_MAP = {
("funding", "usdt"): "funding_usdt",
("trading", "usdt"): "trading_usdt",
("options_funding", "usdc"): "options_funding_usdc",
("options_trading", "usdc"): "options_trading_usdc",
("options_funding", "usdt"): "options_funding_usdt",
("options_trading", "usdt"): "options_trading_usdt",
# 简化别名:funding/trading + usdc → 期权侧
("funding", "usdc"): "options_funding_usdc",
("trading", "usdc"): "options_trading_usdc",
}
def _now_ms() -> int:
return int(time.time() * 1000)
class SimFundsWallets:
def __init__(self, db: Database | None = None) -> None:
self.db = db or get_db()
def snapshot(self) -> dict[str, float]:
row = self.db.fetchone("SELECT * FROM funds_wallets WHERE id=1")
if row is None:
return {k: 0.0 for k in WALLET_KEYS}
return {k: float(row[k] or 0) for k in WALLET_KEYS}
def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float:
"""USDC 按 1:1 计入总资金(与 crypto_monitor 一致)。"""
s = snap or self.snapshot()
return round(sum(float(s.get(k) or 0) for k in WALLET_KEYS), 8)
def reset_from_equity(self, equity: float) -> dict[str, float]:
"""重置:全部放入资金账户 USDT。"""
amt = max(0.0, float(equity))
now = _now_ms()
self.db.execute(
"""UPDATE funds_wallets SET
funding_usdt=?, trading_usdt=0, options_funding_usdc=0, options_trading_usdc=0,
options_funding_usdt=0, options_trading_usdt=0, updated_at_ms=?
WHERE id=1""",
(amt, now),
)
return self.snapshot()
def _set(self, **kwargs: float) -> dict[str, float]:
snap = self.snapshot()
for k, v in kwargs.items():
if k in WALLET_KEYS:
snap[k] = float(v)
now = _now_ms()
self.db.execute(
"""UPDATE funds_wallets SET
funding_usdt=?, trading_usdt=?, options_funding_usdc=?, options_trading_usdc=?,
options_funding_usdt=?, options_trading_usdt=?, updated_at_ms=?
WHERE id=1""",
(
snap["funding_usdt"],
snap["trading_usdt"],
snap["options_funding_usdc"],
snap["options_trading_usdc"],
snap["options_funding_usdt"],
snap["options_trading_usdt"],
now,
),
)
return snap
def mirror_cash(self, amount: float, *, kind: str) -> None:
"""策略账本变动时镜像到对应钱包(SIM)。"""
amt = float(amount)
if abs(amt) < 1e-12:
return
snap = self.snapshot()
k = (kind or "").lower()
if "option" in k:
key = "options_trading_usdc"
elif "perp" in k or "funding" in k:
key = "trading_usdt"
else:
key = "trading_usdt"
snap[key] = float(snap.get(key) or 0) + amt
# 允许短暂为负(与 ledger allow_negative 对齐时由调用方保证);展示侧夹到合理范围不在此做
self._set(**snap)
def sync_ledger_equity(self) -> float:
"""兑换/划转后把总权益同步到 ledger_meta(不重置钱包分配)。"""
total = self.total_usdt_equiv()
now = _now_ms()
self.db.execute(
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
(total, total, now),
)
return total
def convert(
self,
*,
direction: str,
amount: float,
rate: float = 1.0,
) -> dict[str, Any]:
"""
SIM 兑换(默认在「资金账户」内 USDT↔USDC,对齐 crypto_monitor 主路径)。
direction: usdt_to_usdc | usdc_to_usdt
rate: 1 USDC = rate USDT(默认 1.0
"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "数量须大于 0"}
r = float(rate) if rate and rate > 0 else 1.0
d = (direction or "").strip().lower()
snap = self.snapshot()
if d == "usdt_to_usdc":
src = float(snap["funding_usdt"])
if amt > src + 1e-9:
return {"ok": False, "detail": f"资金账户 USDT 不足(可用 {src:.4f}"}
usdc = amt / r
snap["funding_usdt"] = src - amt
snap["options_funding_usdc"] = float(snap["options_funding_usdc"]) + usdc
elif d == "usdc_to_usdt":
src = float(snap["options_funding_usdc"])
if amt > src + 1e-9:
return {"ok": False, "detail": f"期权资金账户 USDC 不足(可用 {src:.4f}"}
usdt = amt * r
snap["options_funding_usdc"] = src - amt
snap["funding_usdt"] = float(snap["funding_usdt"]) + usdt
else:
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
self._set(**snap)
total = self.sync_ledger_equity()
return {
"ok": True,
"detail": "converted",
"direction": d,
"amount": amt,
"rate": r,
"wallets": self.snapshot(),
"total_usdt_equiv": total,
}
def transfer(
self,
*,
ccy: str,
amount: float,
from_account: str,
to_account: str,
) -> dict[str, Any]:
"""SIM 划转:funding/trading/options_funding/options_trading × USDT|USDC。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "划转金额须大于 0"}
ccy_l = (ccy or "USDC").strip().lower()
fa = (from_account or "").strip().lower()
ta = (to_account or "").strip().lower()
if fa == ta:
return {"ok": False, "detail": "来源与目标账户不能相同"}
src_key = _ACCT_MAP.get((fa, ccy_l))
dst_key = _ACCT_MAP.get((ta, ccy_l))
if not src_key or not dst_key:
return {
"ok": False,
"detail": "账户须为 funding/trading/options_funding/options_trading,币种 USDT|USDC",
}
snap = self.snapshot()
src_bal = float(snap[src_key])
if amt > src_bal + 1e-9:
return {"ok": False, "detail": f"{src_key} 余额不足(可用 {src_bal:.4f}"}
snap[src_key] = src_bal - amt
snap[dst_key] = float(snap[dst_key]) + amt
self._set(**snap)
total = self.sync_ledger_equity()
return {
"ok": True,
"detail": "transferred",
"ccy": ccy_l.upper(),
"amount": amt,
"from": fa,
"to": ta,
"wallets": self.snapshot(),
"total_usdt_equiv": total,
}
+18 -2
View File
@@ -50,7 +50,15 @@ class Ledger:
(group_id, kind, float(amount), equity, note, now),
)
self.db._conn.commit()
return equity
try:
from ..config import get_settings
from .funds_wallets import SimFundsWallets
if get_settings().is_sim:
SimFundsWallets(self.db).mirror_cash(float(amount), kind=kind)
except Exception:
pass
return equity
def reset_equity(self, amount: float, *, note: str = "重置模拟资金") -> float:
"""将权益与可用资金重置为 amount(reserved 清零)。须在无持仓时调用。"""
@@ -68,7 +76,15 @@ class Ledger:
(None, "reset", amt, amt, note, now),
)
self.db._conn.commit()
return amt
try:
from ..config import get_settings
from .funds_wallets import SimFundsWallets
if get_settings().is_sim:
SimFundsWallets(self.db).reset_from_equity(amt)
except Exception:
pass
return amt
def get_setting_float(self, key: str, default: float) -> float:
v = self.db.get_setting(key)
+1
View File
@@ -126,6 +126,7 @@ class StrategyEngine:
"ledger": self.ledger.snapshot(),
"mode": "SIM" if s.is_sim else "LIVE",
"sim": s.is_sim,
"exchange": str(s.exchange or "okx").lower(),
"live_ready": (live_ready()[0] if not s.is_sim else True),
"live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"),
"show_manual_trade_buttons": self.ledger.get_setting_bool(