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 .auth_routes import router as auth_router
|
||||||
from .backup_routes import router as backup_router
|
from .backup_routes import router as backup_router
|
||||||
|
from .funds import router as funds_router
|
||||||
from .market import router as market_router
|
from .market import router as market_router
|
||||||
from .plan import router as plan_router
|
from .plan import router as plan_router
|
||||||
from .settings import router as settings_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(plan_router)
|
||||||
router.include_router(trades_router)
|
router.include_router(trades_router)
|
||||||
router.include_router(stats_router)
|
router.include_router(stats_router)
|
||||||
|
router.include_router(funds_router)
|
||||||
router.include_router(settings_router)
|
router.include_router(settings_router)
|
||||||
router.include_router(backup_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
|
||||||
@@ -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
|
||||||
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS residual_options (
|
|||||||
note TEXT,
|
note TEXT,
|
||||||
FOREIGN KEY(group_id) REFERENCES groups(group_id)
|
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,?)",
|
"INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)",
|
||||||
(now,),
|
(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 = {
|
defaults = {
|
||||||
"fee_rate": str(s.fee_rate),
|
"fee_rate": str(s.fee_rate),
|
||||||
"initial_equity": str(s.initial_equity),
|
"initial_equity": str(s.initial_equity),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -50,6 +50,14 @@ class Ledger:
|
|||||||
(group_id, kind, float(amount), equity, note, now),
|
(group_id, kind, float(amount), equity, note, now),
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
|
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
|
return equity
|
||||||
|
|
||||||
def reset_equity(self, amount: float, *, note: str = "重置模拟资金") -> float:
|
def reset_equity(self, amount: float, *, note: str = "重置模拟资金") -> float:
|
||||||
@@ -68,6 +76,14 @@ class Ledger:
|
|||||||
(None, "reset", amt, amt, note, now),
|
(None, "reset", amt, amt, note, now),
|
||||||
)
|
)
|
||||||
self.db._conn.commit()
|
self.db._conn.commit()
|
||||||
|
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
|
return amt
|
||||||
|
|
||||||
def get_setting_float(self, key: str, default: float) -> float:
|
def get_setting_float(self, key: str, default: float) -> float:
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class StrategyEngine:
|
|||||||
"ledger": self.ledger.snapshot(),
|
"ledger": self.ledger.snapshot(),
|
||||||
"mode": "SIM" if s.is_sim else "LIVE",
|
"mode": "SIM" if s.is_sim else "LIVE",
|
||||||
"sim": s.is_sim,
|
"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": (live_ready()[0] if not s.is_sim else True),
|
||||||
"live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"),
|
"live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"),
|
||||||
"show_manual_trade_buttons": self.ledger.get_setting_bool(
|
"show_manual_trade_buttons": self.ledger.get_setting_bool(
|
||||||
|
|||||||
@@ -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 不展示/不计滑点
|
## 2026-07-29 — 统计拆分手续费;LIVE 不展示/不计滑点
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getUsername,
|
getUsername,
|
||||||
startAuthTokenAutoRefresh,
|
startAuthTokenAutoRefresh,
|
||||||
} from "./api/client";
|
} from "./api/client";
|
||||||
|
import FundsBar from "./components/FundsBar";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
import PlanPage from "./pages/Plan";
|
import PlanPage from "./pages/Plan";
|
||||||
import TradesPage from "./pages/Trades";
|
import TradesPage from "./pages/Trades";
|
||||||
@@ -63,6 +64,7 @@ function Shell({ children }: { children: ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<FundsBar />
|
||||||
<main className="page">{children}</main>
|
<main className="page">{children}</main>
|
||||||
<nav className="bottom-nav" aria-label="手机导航">
|
<nav className="bottom-nav" aria-label="手机导航">
|
||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -168,8 +168,7 @@ export default function PlanPage() {
|
|||||||
<div className="plan-desktop-head">
|
<div className="plan-desktop-head">
|
||||||
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
||||||
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
<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}` : ""} ·
|
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
|
||||||
目标平仓(双腿/远虚只平永续) · 未达标则到期结算
|
目标平仓(双腿/远虚只平永续) · 未达标则到期结算
|
||||||
{plan?.mode === "LIVE" && plan.live_ready === false
|
{plan?.mode === "LIVE" && plan.live_ready === false
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
uploadRestoreBackup,
|
uploadRestoreBackup,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
type Tab = "strategy" | "runtime" | "account" | "backup";
|
type Tab = "strategy" | "runtime" | "funds" | "account" | "backup";
|
||||||
type StratSub =
|
type StratSub =
|
||||||
| "position"
|
| "position"
|
||||||
| "select"
|
| "select"
|
||||||
@@ -104,6 +104,18 @@ export default function SettingsPage() {
|
|||||||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||||
const [restorePhrase, setRestorePhrase] = useState("");
|
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() {
|
function loadRuntime() {
|
||||||
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -426,6 +438,20 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
运行模式
|
运行模式
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={tab === "account" ? "tab active" : "tab"}
|
className={tab === "account" ? "tab active" : "tab"}
|
||||||
@@ -1031,6 +1057,169 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{tab === "account" ? (
|
||||||
<div className="card settings-card settings-card-narrow">
|
<div className="card settings-card settings-card-narrow">
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
|||||||
@@ -80,7 +80,52 @@ input {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
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 {
|
.topnav {
|
||||||
|
|||||||
Reference in New Issue
Block a user