feat(instance): add exchange account ledger tab with SSE sync

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-10 09:51:45 +08:00
parent 425fc701bc
commit 245b85ad27
26 changed files with 1994 additions and 5 deletions
+187
View File
@@ -0,0 +1,187 @@
"""Binance:交易账户 futures income;资金账户 deposits/withdrawals/transfers.USDT."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
kind_from_raw,
make_ref_id,
normalize_row,
)
def _paginate_income(exchange, *, start_ms: int, end_ms: int, max_pages: int = 15) -> list[dict]:
out: list[dict] = []
cursor = int(start_ms)
end = int(end_ms)
for _ in range(max_pages):
try:
if hasattr(exchange, "fapiPrivateGetIncome"):
batch = exchange.fapiPrivateGetIncome(
{"startTime": cursor, "endTime": end, "limit": 1000}
)
else:
batch = exchange.fetch_ledger(
"USDT", cursor, 1000, {"type": "swap", "until": end}
)
# already unified
return batch or []
except Exception:
break
if not batch:
break
out.extend(batch)
if len(batch) < 1000:
break
last_t = batch[-1].get("time") or batch[-1].get("timestamp")
try:
last_i = int(float(last_t))
except Exception:
break
if last_i >= end:
break
cursor = last_i + 1
return out
def _income_to_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
# raw fapi income
if "income" in raw or "incomeType" in raw:
amt = raw.get("income")
ts = raw.get("time")
ccy = raw.get("asset") or "USDT"
raw_type = str(raw.get("incomeType") or "")
ref = str(raw.get("tranId") or raw.get("tradeId") or "")
return normalize_row(
account=ACCOUNT_TRADING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("trading", ccy, ts, amt, raw_type),
raw_type=raw_type,
symbol=str(raw.get("symbol") or ""),
note=str(raw.get("info") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
return from_ccxt_ledger_entry(raw, account=ACCOUNT_TRADING)
def _dep_wd_to_row(entry: dict, *, kind: str) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
ts = entry.get("timestamp") or info.get("insertTime") or info.get("applyTime")
ccy = entry.get("currency") or info.get("coin") or "USDT"
status = entry.get("status") or info.get("status") or ""
ref = str(entry.get("id") or info.get("txId") or info.get("id") or "")
amt = amount
try:
af = float(amount)
if kind == "withdraw" and af > 0:
af = -af
amt = af
except Exception:
pass
return normalize_row(
account=ACCOUNT_FUNDING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", kind, ccy, ts, amount),
raw_type=kind,
note=str(status),
kind=kind,
)
def _transfer_to_row(entry: dict) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
ts = entry.get("timestamp") or info.get("timestamp")
ccy = entry.get("currency") or info.get("asset") or "USDT"
ref = str(entry.get("id") or info.get("tranId") or info.get("id") or "")
frm = str(entry.get("fromAccount") or info.get("from") or "")
to = str(entry.get("toAccount") or info.get("to") or "")
try:
amt = float(amount)
except Exception:
return None
# 资金侧视角:从资金转出为负,转入为正(粗分)
note = f"{frm}->{to}".strip("->")
raw_type = "transfer"
return normalize_row(
account=ACCOUNT_FUNDING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", "transfer", ccy, ts, amt),
raw_type=raw_type,
note=note,
kind=kind_from_raw("transfer", amt),
)
def fetch_binance_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
# 交易账户
try:
raw = _paginate_income(exchange, start_ms=start_ms, end_ms=end_ms)
for e in raw:
n = _income_to_row(e)
if n and n["ccy"] == "USDT":
rows.append(n)
except Exception as e:
errors.append(f"trading:{e}")
# 资金账户:充提 + 划转
for label, fn, kind in (
("deposits", "fetch_deposits", "deposit"),
("withdrawals", "fetch_withdrawals", "withdraw"),
):
try:
meth = getattr(exchange, fn, None)
if not callable(meth):
continue
batch = meth("USDT", int(start_ms), 1000, {"until": int(end_ms)}) or []
for e in batch:
n = _dep_wd_to_row(e, kind=kind)
if n:
rows.append(n)
except Exception as e:
errors.append(f"{label}:{e}")
try:
if hasattr(exchange, "fetch_transfers"):
batch = (
exchange.fetch_transfers("USDT", int(start_ms), 1000, {"until": int(end_ms)})
or []
)
for e in batch:
n = _transfer_to_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"transfers:{e}")
return rows, errors
+212
View File
@@ -0,0 +1,212 @@
"""Gate:资金账户(spot account_book) + 交易账户(futures account_book),USDT."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
kind_from_raw,
make_ref_id,
normalize_row,
)
def _sec(ms: int) -> int:
return max(0, int(int(ms) // 1000))
def _paginate_spot_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
out: list[dict] = []
# Gate spot account_book: from/to 为秒
cursor = _sec(start_ms)
end = _sec(end_ms)
for _ in range(max_pages):
try:
batch = exchange.privateSpotGetAccountBook(
{
"currency": "USDT",
"from": cursor,
"to": end,
"limit": 100,
}
)
except Exception:
break
if not batch:
break
if isinstance(batch, dict):
batch = batch.get("data") or batch.get("result") or []
if not isinstance(batch, list) or not batch:
break
out.extend(batch)
if len(batch) < 100:
break
last_t = batch[-1].get("time") or batch[-1].get("create_time")
try:
last_i = int(float(last_t))
except Exception:
break
# spot 返回秒
if last_i > 1e12:
last_i = last_i // 1000
if last_i >= end:
break
cursor = last_i + 1
return out
def _paginate_swap_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
out: list[dict] = []
cursor = _sec(start_ms)
end = _sec(end_ms)
for _ in range(max_pages):
try:
batch = exchange.privateFuturesGetSettleAccountBook(
{
"settle": "usdt",
"from": cursor,
"to": end,
"limit": 100,
}
)
except Exception:
break
if not batch:
break
if isinstance(batch, dict):
batch = batch.get("data") or batch.get("result") or []
if not isinstance(batch, list) or not batch:
break
out.extend(batch)
if len(batch) < 100:
break
last_t = batch[-1].get("time")
try:
last_i = int(float(last_t))
except Exception:
break
if last_i > 1e12:
last_i = last_i // 1000
if last_i >= end:
break
cursor = last_i + 1
return out
def _spot_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
amt = raw.get("change")
ts = raw.get("time") or raw.get("create_time")
# 秒 → 毫秒
try:
t = float(ts)
if t < 1e12:
t = t * 1000.0
ts = t
except Exception:
pass
raw_type = str(raw.get("type") or raw.get("change_type") or "")
bal = raw.get("balance")
ref = str(raw.get("id") or raw.get("txid") or "")
return normalize_row(
account=ACCOUNT_FUNDING,
ccy="USDT",
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", raw_type, ts, amt),
raw_type=raw_type,
balance_after=bal,
note=str(raw.get("text") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
def _swap_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
# futures account_book: change, balance, type, text, time, contract...
amt = raw.get("change")
ts = raw.get("time")
try:
t = float(ts)
if t < 1e12:
t = t * 1000.0
ts = t
except Exception:
pass
raw_type = str(raw.get("type") or "")
bal = raw.get("balance")
ref = str(raw.get("id") or "")
return normalize_row(
account=ACCOUNT_TRADING,
ccy="USDT",
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("trading", raw_type, ts, amt, raw.get("contract")),
raw_type=raw_type,
balance_after=bal,
symbol=str(raw.get("contract") or ""),
note=str(raw.get("text") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
def fetch_gate_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
try:
for e in _paginate_spot_book(exchange, start_ms=start_ms, end_ms=end_ms):
n = _spot_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"funding:{e}")
# 回退 ccxt fetch_ledger
try:
batch = exchange.fetch_ledger(
"USDT", int(start_ms), 100, {"type": "spot", "until": int(end_ms)}
) or []
for e in batch:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
if n:
rows.append(n)
except Exception as e2:
errors.append(f"funding_fallback:{e2}")
try:
for e in _paginate_swap_book(exchange, start_ms=start_ms, end_ms=end_ms):
n = _swap_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"trading:{e}")
try:
batch = exchange.fetch_ledger(
"USDT",
int(start_ms),
100,
{"type": "swap", "settle": "usdt", "until": int(end_ms)},
) or []
for e in batch:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
if n:
rows.append(n)
except Exception as e2:
errors.append(f"trading_fallback:{e2}")
return rows, errors
+99
View File
@@ -0,0 +1,99 @@
"""OKX:资金账户 asset bills + 交易账户 account bills;USDT + USDC."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
)
OKX_LEDGER_CCYS = ("USDT", "USDC")
def _fetch_one(
exchange,
*,
code: str,
since: int,
until: int,
method: str,
max_pages: int = 10,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
after = None
for _ in range(max_pages):
params: dict[str, Any] = {"method": method, "until": int(until)}
if after:
params["after"] = after
try:
batch = exchange.fetch_ledger(code, int(since), 100, params) or []
except Exception:
# archive / bills 窗口差异:失败则停
break
if not batch:
break
out.extend(batch)
if len(batch) < 100:
break
# OKX 翻页用 billId
last = batch[-1]
info = last.get("info") if isinstance(last.get("info"), dict) else {}
bid = last.get("id") or info.get("billId")
if not bid:
break
after = str(bid)
return out
def fetch_okx_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
for ccy in OKX_LEDGER_CCYS:
# 资金账户
try:
raw = _fetch_one(
exchange,
code=ccy,
since=start_ms,
until=end_ms,
method="privateGetAssetBills",
)
for e in raw:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
if n:
rows.append(n)
except Exception as e:
errors.append(f"funding:{ccy}:{e}")
# 交易账户:近 3 月 archive + 近 7 日 bills(去重靠 upsert)
for method in ("privateGetAccountBillsArchive", "privateGetAccountBills"):
try:
raw = _fetch_one(
exchange,
code=ccy,
since=start_ms,
until=end_ms,
method=method,
)
for e in raw:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
if n:
rows.append(n)
except Exception as e:
errors.append(f"trading:{ccy}:{method}:{e}")
return rows, errors