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(
+11
View File
@@ -5,6 +5,17 @@
---
## 2026-07-29 — 资金条 + USDT/USDC 兑换划转(SIM/LIVE
### 变更
1. 顶栏增加资金统计条(交易所/交易日/胜率/总资金/资金·交易·期权账户/实时盈亏),**不标注模拟盘**。
2. SIM:多账户钱包(USDT 资金/交易 + USDC 期权资金/交易);设置页可兑换、划转。
3. LIVE(OKX):拉交易所余额;现货市价兑换 USDC-USDT;资金↔交易划转(对齐 crypto_monitor)。
4. USDC 计入总资金按 1:1。
---
## 2026-07-29 — 统计拆分手续费;LIVE 不展示/不计滑点
### 变更
+2
View File
@@ -6,6 +6,7 @@ import {
getUsername,
startAuthTokenAutoRefresh,
} from "./api/client";
import FundsBar from "./components/FundsBar";
import LoginPage from "./pages/Login";
import PlanPage from "./pages/Plan";
import TradesPage from "./pages/Trades";
@@ -63,6 +64,7 @@ function Shell({ children }: { children: ReactNode }) {
</button>
</div>
</header>
<FundsBar />
<main className="page">{children}</main>
<nav className="bottom-nav" aria-label="手机导航">
{NAV.map((item) => (
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import { apiFetch } from "../api/client";
export type FundsSummary = {
ok: boolean;
exchange: string;
trading_day: string;
total_trades: number;
win_rate: number;
profit_loss_ratio: number | null;
total_funds: number | null;
funding_usdt: number | null;
trading_usdt: number | null;
options_funding_label: string;
options_trading_label: string;
realtime_pnl: number | null;
usdc_usdt_rate?: number;
detail?: string;
};
function fmtU(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
function pnlClass(n: number | null | undefined) {
if (n == null || Number.isNaN(n) || n === 0) return "";
return n > 0 ? "pos-pnl-profit" : "pos-pnl-loss";
}
export default function FundsBar() {
const [s, setS] = useState<FundsSummary | null>(null);
const load = useCallback(() => {
apiFetch<FundsSummary>("/api/funds/summary")
.then(setS)
.catch(() => {
/* 顶栏静默失败,避免刷屏 */
});
}, []);
useEffect(() => {
load();
const t = window.setInterval(load, 5000);
return () => window.clearInterval(t);
}, [load]);
if (!s?.ok) return null;
return (
<div className="funds-bar" aria-label="资金与统计">
<div className="funds-item funds-item--primary">
<div className="funds-label"></div>
<div className="funds-value">{s.exchange || "—"}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{s.trading_day}</div>
</div>
<div className="funds-item funds-item--primary">
<div className="funds-label"></div>
<div className="funds-value mono">{s.total_trades}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">
{(s.win_rate * 100).toFixed(0)}%
</div>
</div>
<div className="funds-item" title="平均盈利 ÷ 平均亏损">
<div className="funds-label"></div>
<div className="funds-value mono">
{s.profit_loss_ratio != null ? s.profit_loss_ratio.toFixed(2) : "—"}
</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{fmtU(s.total_funds)}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{fmtU(s.funding_usdt)}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{fmtU(s.trading_usdt)}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{s.options_funding_label}</div>
</div>
<div className="funds-item">
<div className="funds-label"></div>
<div className="funds-value mono">{s.options_trading_label}</div>
</div>
<div className="funds-item funds-item--pnl">
<div className="funds-label"></div>
<div className={`funds-value mono ${pnlClass(s.realtime_pnl)}`}>
{s.realtime_pnl == null ? "—" : fmtU(s.realtime_pnl)}
</div>
</div>
</div>
);
}
+1 -2
View File
@@ -168,8 +168,7 @@ export default function PlanPage() {
<div className="plan-desktop-head">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
{plan?.mode === "LIVE" ? "LIVE 实盘下单" : "SIM 本地撮合"} · {" "}
{(snap?.exchange || "okx").toUpperCase()}
{(snap?.exchange || "okx").toUpperCase()}
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
(/) ·
{plan?.mode === "LIVE" && plan.live_ready === false
+190 -1
View File
@@ -12,7 +12,7 @@ import {
uploadRestoreBackup,
} from "../api/client";
type Tab = "strategy" | "runtime" | "account" | "backup";
type Tab = "strategy" | "runtime" | "funds" | "account" | "backup";
type StratSub =
| "position"
| "select"
@@ -104,6 +104,18 @@ export default function SettingsPage() {
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [restorePhrase, setRestorePhrase] = useState("");
const [fundsOk, setFundsOk] = useState("");
const [fundsErr, setFundsErr] = useState("");
const [convertDir, setConvertDir] = useState<"usdt_to_usdc" | "usdc_to_usdt">(
"usdt_to_usdc",
);
const [convertAmt, setConvertAmt] = useState(100);
const [xferCcy, setXferCcy] = useState<"USDT" | "USDC">("USDC");
const [xferAmt, setXferAmt] = useState(50);
const [xferFrom, setXferFrom] = useState("funding");
const [xferTo, setXferTo] = useState("trading");
const [usdcRate, setUsdcRate] = useState<number | null>(null);
function loadRuntime() {
apiFetch<RuntimeSettings>("/api/settings/runtime")
.then((r) => {
@@ -426,6 +438,20 @@ export default function SettingsPage() {
>
</button>
<button
type="button"
className={tab === "funds" ? "tab active" : "tab"}
onClick={() => {
setTab("funds");
setFundsErr("");
setFundsOk("");
apiFetch<{ usdc_usdt_rate?: number }>("/api/funds/summary")
.then((r) => setUsdcRate(r.usdc_usdt_rate ?? null))
.catch(() => undefined);
}}
>
</button>
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
@@ -1031,6 +1057,169 @@ export default function SettingsPage() {
</div>
) : null}
{tab === "funds" ? (
<div className="card settings-card">
{fundsErr ? <div className="err">{fundsErr}</div> : null}
{fundsOk ? <div className="settings-ok">{fundsOk}</div> : null}
<section className="settings-section">
<h3>USDT USDC</h3>
<p className="meta" style={{ marginTop: 0 }}>
OKX USDC-USDT{" "}
{usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}
USDT USDC
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="convertDir"></label>
<select
id="convertDir"
value={convertDir}
onChange={(e) =>
setConvertDir(
e.target.value === "usdc_to_usdt"
? "usdc_to_usdt"
: "usdt_to_usdc",
)
}
>
<option value="usdt_to_usdc">USDT USDC</option>
<option value="usdc_to_usdt">USDC USDT</option>
</select>
</div>
<div className="field">
<label htmlFor="convertAmt"></label>
<input
id="convertAmt"
type="number"
min={0}
step="0.01"
value={convertAmt}
onChange={(e) => setConvertAmt(Number(e.target.value))}
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading}
onClick={async () => {
setFundsErr("");
setFundsOk("");
setLoading(true);
try {
await apiFetch("/api/funds/convert", {
method: "POST",
body: JSON.stringify({
direction: convertDir,
amount: convertAmt,
}),
});
setFundsOk("兑换成功,顶部资金条将刷新");
} catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
<section className="settings-section">
<h3></h3>
<p className="meta" style={{ marginTop: 0 }}>
USDT/USDC options_funding /
options_trading
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="xferCcy"></label>
<select
id="xferCcy"
value={xferCcy}
onChange={(e) =>
setXferCcy(e.target.value === "USDT" ? "USDT" : "USDC")
}
>
<option value="USDC">USDC</option>
<option value="USDT">USDT</option>
</select>
</div>
<div className="field">
<label htmlFor="xferFrom"></label>
<select
id="xferFrom"
value={xferFrom}
onChange={(e) => setXferFrom(e.target.value)}
>
<option value="funding"></option>
<option value="trading"></option>
<option value="options_funding"></option>
<option value="options_trading"></option>
</select>
</div>
<div className="field">
<label htmlFor="xferTo"></label>
<select
id="xferTo"
value={xferTo}
onChange={(e) => setXferTo(e.target.value)}
>
<option value="funding"></option>
<option value="trading"></option>
<option value="options_funding"></option>
<option value="options_trading"></option>
</select>
</div>
<div className="field">
<label htmlFor="xferAmt"></label>
<input
id="xferAmt"
type="number"
min={0}
step="0.01"
value={xferAmt}
onChange={(e) => setXferAmt(Number(e.target.value))}
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading}
onClick={async () => {
setFundsErr("");
setFundsOk("");
setLoading(true);
try {
await apiFetch("/api/funds/transfer", {
method: "POST",
body: JSON.stringify({
ccy: xferCcy,
amount: xferAmt,
from_account: xferFrom,
to_account: xferTo,
}),
});
setFundsOk("划转成功,顶部资金条将刷新");
} catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
</div>
) : null}
{tab === "account" ? (
<div className="card settings-card settings-card-narrow">
{err ? <div className="err">{err}</div> : null}
+46 -1
View File
@@ -80,7 +80,52 @@ input {
.app-shell {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
grid-template-rows: auto auto 1fr;
}
.funds-bar {
display: flex;
flex-wrap: wrap;
gap: 2px 0;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
background: rgba(18, 22, 28, 0.96);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.funds-item {
flex: 1 1 auto;
min-width: 88px;
padding: 4px 10px;
border-right: 1px solid var(--line);
}
.funds-item:last-child {
border-right: none;
}
.funds-item--primary .funds-value {
color: var(--accent);
}
.funds-label {
font-size: 11px;
color: var(--muted);
line-height: 1.2;
margin-bottom: 2px;
white-space: nowrap;
}
.funds-value {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.funds-item--pnl .funds-value {
font-variant-numeric: tabular-nums;
}
.topnav {