feat(instance): add exchange account ledger tab with SSE sync
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9619,6 +9619,10 @@ register_instance_dashboard_routes(
|
|||||||
hedge_enabled=False,
|
hedge_enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_register import install_account_ledger
|
||||||
|
|
||||||
|
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="binance")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/journals")
|
@app.route("/api/journals")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -9461,6 +9461,10 @@ register_instance_dashboard_routes(
|
|||||||
hedge_enabled=False,
|
hedge_enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_register import install_account_ledger
|
||||||
|
|
||||||
|
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="gate")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/journals")
|
@app.route("/api/journals")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -9200,6 +9200,10 @@ register_instance_dashboard_routes(
|
|||||||
hedge_enabled=hedge_module_enabled,
|
hedge_enabled=hedge_module_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_register import install_account_ledger
|
||||||
|
|
||||||
|
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/journals")
|
@app.route("/api/journals")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 审计修复报告:账户流水(2026-08-10)
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
新增「账户流水」功能:`lib/account_ledger/*`、`lib/exchange/*_ledger_lib.py`、三所 `app.py` 安装、导航显示开关、前端 SSE 页。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
**可上线。** 认证、SQL、SSE 载荷范围、单实例隔离与现有实例模式一致。发现 1 项中危并已在同批修复。
|
||||||
|
|
||||||
|
## 发现与处理
|
||||||
|
|
||||||
|
| 级别 | 问题 | 处理 |
|
||||||
|
|------|------|------|
|
||||||
|
| 中 | `POST /api/account_ledger/refresh` 可把 `start_ms` 拉到极早,触发大量交易所分页请求;无冷却 | 同步窗口强制 `LOOKBACK_DAYS` 下限;手动同步默认 30s 冷却 |
|
||||||
|
| 低 | 导航关闭仍可直连 URL(与数据看板相同,仅 UI 隐藏) | 保持与现有 display pref 一致;embed `tab_allowed` 仍 403 |
|
||||||
|
| 信息 | 交易所异常文案写入 `last_error` 展示 | 可接受;未记录密钥 |
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `python -m unittest tests.test_account_ledger_normalize` 通过
|
||||||
|
- 三所仅增加 `install_account_ledger`,不改动开仓/风控主路径
|
||||||
|
|
||||||
|
## 使用提醒
|
||||||
|
|
||||||
|
默认导航关闭;需在系统设置打开「账户流水」。数据来自交易所,首次打开可能需等待一轮后台同步或点「立即同步」。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 账户流水(三所统一)
|
||||||
|
|
||||||
|
从**交易所 API**拉取资金账户与交易账户账单,在实例内展示。
|
||||||
|
不使用程序本地 `transfer_logs` 作为主数据源。
|
||||||
|
|
||||||
|
## 能力概览
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 导航 | 「账户流水」Tab;默认关闭,在 **系统设置 → 导航显示** 打开 |
|
||||||
|
| Tab | **资金账户** / **交易账户** |
|
||||||
|
| 分页 | 每页 10 条,时间倒序 |
|
||||||
|
| 时间窗 | 跟随顶栏 UTC **预设**(与列表 `list_window` 一致) |
|
||||||
|
| 同步 | 后台约 **120s** 拉一次交易所;完成后 **SSE** 推版本,前端自动刷新 |
|
||||||
|
| 币种 | USDT;OKX 另含 **USDC** |
|
||||||
|
| 三所 | Binance / OKX / Gate 同一套 UI 与路由 |
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 系统设置 → 导航显示 → 勾选「账户流水」→ 保存
|
||||||
|
2. 顶栏选预设时间并点「应用」
|
||||||
|
3. 打开「账户流水」,切换资金/交易 Tab;可点「立即同步」
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 路由 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/account_ledger?account=funding\|trading&page=1` | 按当前 session 时间窗分页查询缓存 |
|
||||||
|
| `GET /api/account_ledger/stream` | SSE,`event: ledger`,载荷含 `ledger_version` |
|
||||||
|
| `POST /api/account_ledger/refresh` | 手动触发同步(有冷却,默认 30s) |
|
||||||
|
|
||||||
|
均需登录(与实例其他 API 相同)。
|
||||||
|
|
||||||
|
## 交易所数据源
|
||||||
|
|
||||||
|
| 所 | 资金账户 | 交易账户 |
|
||||||
|
|----|----------|----------|
|
||||||
|
| Gate | spot `account_book`(USDT) | USDT 永续 `account_book` |
|
||||||
|
| OKX | `asset/bills`(USDT+USDC) | `account/bills` + `bills-archive`(USDT+USDC) |
|
||||||
|
| Binance | 充提 + `fetch_transfers`(USDT) | U 本位 `fapi` income(USDT) |
|
||||||
|
|
||||||
|
后台默认回看 **90 天**(`ACCOUNT_LEDGER_LOOKBACK_DAYS`),写入本地 SQLite 缓存后再按顶栏时间窗过滤展示。
|
||||||
|
「全部 / 近 6 月」等超出回看窗口的部分,仅能看到缓存内数据。
|
||||||
|
|
||||||
|
## 环境变量(可选)
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ACCOUNT_LEDGER_POLL_SEC` | `120` | 后台轮询秒数 |
|
||||||
|
| `ACCOUNT_LEDGER_LOOKBACK_DAYS` | `90` | 拉取与手动同步上限天数 |
|
||||||
|
| `ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC` | `30` | 手动同步冷却 |
|
||||||
|
| `ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC` | `25` | SSE 心跳 |
|
||||||
|
|
||||||
|
## 代码位置
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/account_ledger/ # DB / 同步 / SSE / 注册 / 面板
|
||||||
|
lib/exchange/*_ledger_lib.py # 三所拉取适配
|
||||||
|
lib/common/static/account_ledger.js
|
||||||
|
```
|
||||||
|
|
||||||
|
三所 `app.py` 调用:`install_account_ledger(..., exchange_key=...)`。
|
||||||
|
|
||||||
|
## 审计摘要(2026-08-10)
|
||||||
|
|
||||||
|
- 路由均 `@login_required`;SSE 仅推版本号,不含账单正文
|
||||||
|
- SQL 参数化;`account` 白名单;币种服务端固定
|
||||||
|
- 前端表格字段 `escapeHtml`
|
||||||
|
- **已修复**:手动同步强制套用 lookback 上限 + 冷却,避免滥用刷交易所 API
|
||||||
|
|
||||||
|
详见同目录旁注或 PR 说明;安全复查子代理结论:修复后无未关闭的中高危项。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""实例账户流水(交易所资金/交易账户账单)."""
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""账户流水 SQLite 缓存."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_account_ledger_tables(conn) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS account_ledger_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account TEXT NOT NULL,
|
||||||
|
ccy TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
balance_after REAL,
|
||||||
|
kind TEXT,
|
||||||
|
raw_type TEXT,
|
||||||
|
symbol TEXT,
|
||||||
|
ref_id TEXT NOT NULL,
|
||||||
|
ts_ms INTEGER NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
synced_at REAL,
|
||||||
|
UNIQUE(account, ref_id, ccy, ts_ms)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts "
|
||||||
|
"ON account_ledger_entries(account, ts_ms DESC)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS account_ledger_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def meta_get(conn, key: str, default: str = "") -> str:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM account_ledger_meta WHERE key=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return str(row[0] if not hasattr(row, "keys") else row["value"])
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def meta_set(conn, key: str, value: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(key, str(value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_entries(conn, rows: list[dict[str, Any]]) -> int:
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
now = time.time()
|
||||||
|
n = 0
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO account_ledger_entries(
|
||||||
|
account, ccy, amount, balance_after, kind, raw_type,
|
||||||
|
symbol, ref_id, ts_ms, note, synced_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET
|
||||||
|
amount=excluded.amount,
|
||||||
|
balance_after=excluded.balance_after,
|
||||||
|
kind=excluded.kind,
|
||||||
|
raw_type=excluded.raw_type,
|
||||||
|
symbol=excluded.symbol,
|
||||||
|
note=excluded.note,
|
||||||
|
synced_at=excluded.synced_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
r["account"],
|
||||||
|
r["ccy"],
|
||||||
|
float(r["amount"]),
|
||||||
|
r.get("balance_after"),
|
||||||
|
r.get("kind") or "other",
|
||||||
|
r.get("raw_type") or "",
|
||||||
|
r.get("symbol") or "",
|
||||||
|
r["ref_id"],
|
||||||
|
int(r["ts_ms"]),
|
||||||
|
r.get("note") or "",
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
conn.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def query_entries(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
account: str,
|
||||||
|
start_ms: int,
|
||||||
|
end_ms: int,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = PAGE_SIZE,
|
||||||
|
currencies: Optional[list[str]] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
acc = (account or "").strip().lower()
|
||||||
|
if acc not in VALID_ACCOUNTS:
|
||||||
|
return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0}
|
||||||
|
page = max(1, int(page or 1))
|
||||||
|
page_size = max(1, min(50, int(page_size or PAGE_SIZE)))
|
||||||
|
start_ms = int(start_ms)
|
||||||
|
end_ms = int(end_ms)
|
||||||
|
params: list[Any] = [acc, start_ms, end_ms]
|
||||||
|
ccy_sql = ""
|
||||||
|
if currencies:
|
||||||
|
ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()]
|
||||||
|
if ccy_list:
|
||||||
|
placeholders = ",".join("?" for _ in ccy_list)
|
||||||
|
ccy_sql = f" AND ccy IN ({placeholders})"
|
||||||
|
params.extend(ccy_list)
|
||||||
|
total = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM account_ledger_entries "
|
||||||
|
f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}",
|
||||||
|
params,
|
||||||
|
).fetchone()[0]
|
||||||
|
total = int(total or 0)
|
||||||
|
pages = (total + page_size - 1) // page_size if total else 0
|
||||||
|
if pages and page > pages:
|
||||||
|
page = pages
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT account, ccy, amount, balance_after, kind, raw_type, symbol,
|
||||||
|
ref_id, ts_ms, note
|
||||||
|
FROM account_ledger_entries
|
||||||
|
WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}
|
||||||
|
ORDER BY ts_ms DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
params + [page_size, offset],
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
if hasattr(r, "keys"):
|
||||||
|
d = {k: r[k] for k in r.keys()}
|
||||||
|
else:
|
||||||
|
d = {
|
||||||
|
"account": r[0],
|
||||||
|
"ccy": r[1],
|
||||||
|
"amount": r[2],
|
||||||
|
"balance_after": r[3],
|
||||||
|
"kind": r[4],
|
||||||
|
"raw_type": r[5],
|
||||||
|
"symbol": r[6],
|
||||||
|
"ref_id": r[7],
|
||||||
|
"ts_ms": r[8],
|
||||||
|
"note": r[9],
|
||||||
|
}
|
||||||
|
from lib.account_ledger.account_ledger_normalize import kind_label_zh
|
||||||
|
|
||||||
|
d["kind_label"] = kind_label_zh(d.get("kind") or "")
|
||||||
|
items.append(d)
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"pages": pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prune_older_than(conn, min_ts_ms: int) -> None:
|
||||||
|
conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),))
|
||||||
|
conn.commit()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""账户流水:交易所原始记录 → 统一行模型."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT_FUNDING = "funding"
|
||||||
|
ACCOUNT_TRADING = "trading"
|
||||||
|
VALID_ACCOUNTS = frozenset({ACCOUNT_FUNDING, ACCOUNT_TRADING})
|
||||||
|
|
||||||
|
PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(v: Any) -> Optional[float]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(v: Any) -> Optional[int]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = float(v)
|
||||||
|
if n > 1e12:
|
||||||
|
return int(n)
|
||||||
|
if n > 1e9:
|
||||||
|
return int(n)
|
||||||
|
return int(n)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_ms(v: Any) -> Optional[int]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if n > 1e12:
|
||||||
|
return int(n)
|
||||||
|
if n > 1e10:
|
||||||
|
return int(n)
|
||||||
|
return int(n * 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def kind_from_raw(raw_type: str, amount: Optional[float] = None) -> str:
|
||||||
|
t = (raw_type or "").strip().lower()
|
||||||
|
if not t:
|
||||||
|
return "other"
|
||||||
|
if "deposit" in t or t in ("1", "funding_deposit"):
|
||||||
|
return "deposit"
|
||||||
|
if "withdraw" in t or "withdrawal" in t:
|
||||||
|
return "withdraw"
|
||||||
|
if "transfer" in t or "dnw" in t or t in ("2", "18", "19"):
|
||||||
|
if amount is not None and amount < 0:
|
||||||
|
return "transfer_out"
|
||||||
|
if amount is not None and amount > 0:
|
||||||
|
return "transfer_in"
|
||||||
|
return "transfer"
|
||||||
|
if "funding" in t and "fee" in t:
|
||||||
|
return "funding_fee"
|
||||||
|
if t in ("funding_fee", "fundingfee", "8"):
|
||||||
|
return "funding_fee"
|
||||||
|
if "commission" in t or "fee" in t or t in ("commission", "5", "fee"):
|
||||||
|
return "commission"
|
||||||
|
if "realiz" in t or "pnl" in t or t in ("realized_pnl", "realizedpnl", "3"):
|
||||||
|
return "realized_pnl"
|
||||||
|
if "liqui" in t:
|
||||||
|
return "liquidate"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def kind_label_zh(kind: str) -> str:
|
||||||
|
return {
|
||||||
|
"deposit": "充值",
|
||||||
|
"withdraw": "提现",
|
||||||
|
"transfer": "划转",
|
||||||
|
"transfer_in": "划入",
|
||||||
|
"transfer_out": "划出",
|
||||||
|
"realized_pnl": "已实现盈亏",
|
||||||
|
"funding_fee": "资金费",
|
||||||
|
"commission": "手续费",
|
||||||
|
"liquidate": "强平",
|
||||||
|
"other": "其他",
|
||||||
|
}.get((kind or "").strip().lower(), "其他")
|
||||||
|
|
||||||
|
|
||||||
|
def make_ref_id(*parts: Any) -> str:
|
||||||
|
bits = []
|
||||||
|
for p in parts:
|
||||||
|
if p is None:
|
||||||
|
continue
|
||||||
|
s = str(p).strip()
|
||||||
|
if s:
|
||||||
|
bits.append(s)
|
||||||
|
return "|".join(bits) if bits else ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_row(
|
||||||
|
*,
|
||||||
|
account: str,
|
||||||
|
ccy: str,
|
||||||
|
amount: Any,
|
||||||
|
ts_ms: Any,
|
||||||
|
ref_id: str,
|
||||||
|
raw_type: str = "",
|
||||||
|
balance_after: Any = None,
|
||||||
|
symbol: str = "",
|
||||||
|
note: str = "",
|
||||||
|
kind: str = "",
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
acc = (account or "").strip().lower()
|
||||||
|
if acc not in VALID_ACCOUNTS:
|
||||||
|
return None
|
||||||
|
ccy_u = (ccy or "").strip().upper()
|
||||||
|
if not ccy_u:
|
||||||
|
return None
|
||||||
|
amt = _safe_float(amount)
|
||||||
|
if amt is None:
|
||||||
|
return None
|
||||||
|
ts = _ts_ms(ts_ms)
|
||||||
|
if ts is None or ts <= 0:
|
||||||
|
return None
|
||||||
|
rid = (ref_id or "").strip() or make_ref_id(acc, ccy_u, ts, amt, raw_type)
|
||||||
|
k = (kind or "").strip().lower() or kind_from_raw(raw_type, amt)
|
||||||
|
bal = _safe_float(balance_after)
|
||||||
|
return {
|
||||||
|
"account": acc,
|
||||||
|
"ccy": ccy_u,
|
||||||
|
"amount": amt,
|
||||||
|
"balance_after": bal,
|
||||||
|
"kind": k,
|
||||||
|
"kind_label": kind_label_zh(k),
|
||||||
|
"raw_type": (raw_type or "").strip()[:120],
|
||||||
|
"symbol": (symbol or "").strip()[:80],
|
||||||
|
"ref_id": rid[:200],
|
||||||
|
"ts_ms": int(ts),
|
||||||
|
"note": (note or "").strip()[:240],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def from_ccxt_ledger_entry(entry: dict[str, Any], *, account: 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")
|
||||||
|
if amount is None:
|
||||||
|
amount = entry.get("change")
|
||||||
|
if amount is None:
|
||||||
|
amount = info.get("balChg") or info.get("change") or info.get("income") or info.get("amount")
|
||||||
|
ts = entry.get("timestamp") or entry.get("datetime")
|
||||||
|
if ts is None:
|
||||||
|
ts = info.get("time") or info.get("uTime") or info.get("ts") or info.get("create_time") or info.get("createDate")
|
||||||
|
ccy = entry.get("currency") or info.get("ccy") or info.get("asset") or info.get("currency") or "USDT"
|
||||||
|
raw_type = (
|
||||||
|
entry.get("type")
|
||||||
|
or entry.get("status")
|
||||||
|
or info.get("type")
|
||||||
|
or info.get("incomeType")
|
||||||
|
or info.get("change_type")
|
||||||
|
or info.get("subType")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if isinstance(raw_type, (int, float)):
|
||||||
|
raw_type = str(raw_type)
|
||||||
|
balance_after = entry.get("balance") or info.get("bal") or info.get("balance")
|
||||||
|
symbol = entry.get("symbol") or info.get("instId") or info.get("symbol") or info.get("contract") or ""
|
||||||
|
ref = (
|
||||||
|
entry.get("id")
|
||||||
|
or info.get("billId")
|
||||||
|
or info.get("tranId")
|
||||||
|
or info.get("id")
|
||||||
|
or info.get("trade_id")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
note = entry.get("description") or info.get("info") or info.get("text") or ""
|
||||||
|
return normalize_row(
|
||||||
|
account=account,
|
||||||
|
ccy=str(ccy),
|
||||||
|
amount=amount,
|
||||||
|
ts_ms=ts,
|
||||||
|
ref_id=str(ref) if ref != "" else make_ref_id(account, ccy, ts, amount, raw_type),
|
||||||
|
raw_type=str(raw_type),
|
||||||
|
balance_after=balance_after,
|
||||||
|
symbol=str(symbol or ""),
|
||||||
|
note=str(note or ""),
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""三所统一:账户流水路由 + 后台同步安装."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, request, session, stream_with_context
|
||||||
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_db import ensure_account_ledger_tables, query_entries
|
||||||
|
from lib.account_ledger.account_ledger_normalize import (
|
||||||
|
ACCOUNT_FUNDING,
|
||||||
|
ACCOUNT_TRADING,
|
||||||
|
PAGE_SIZE,
|
||||||
|
VALID_ACCOUNTS,
|
||||||
|
)
|
||||||
|
from lib.account_ledger.account_ledger_sync import account_ledger_store
|
||||||
|
from lib.common.history_window_lib import resolve_list_window
|
||||||
|
|
||||||
|
|
||||||
|
def attach_account_ledger_templates(app: Flask, repo_root: str) -> None:
|
||||||
|
tpl_dir = os.path.join(repo_root, "lib", "account_ledger", "templates")
|
||||||
|
if not os.path.isdir(tpl_dir):
|
||||||
|
return
|
||||||
|
existing = app.jinja_loader
|
||||||
|
loaders = [FileSystemLoader(tpl_dir)]
|
||||||
|
if existing is not None:
|
||||||
|
if isinstance(existing, ChoiceLoader):
|
||||||
|
loaders = list(existing.loaders) + loaders
|
||||||
|
else:
|
||||||
|
loaders.insert(0, existing)
|
||||||
|
app.jinja_loader = ChoiceLoader(loaders)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fetch_fn(exchange_key: str, app_module: Any) -> Callable:
|
||||||
|
ex_key = (exchange_key or "").strip().lower()
|
||||||
|
exchange = getattr(app_module, "exchange", None)
|
||||||
|
ensure_markets = getattr(app_module, "ensure_markets_loaded", None)
|
||||||
|
|
||||||
|
def _fetch(*, start_ms: int, end_ms: int):
|
||||||
|
if exchange is None:
|
||||||
|
return [], ["exchange missing"]
|
||||||
|
if ex_key == "okx":
|
||||||
|
from lib.exchange.okx_ledger_lib import fetch_okx_account_ledger
|
||||||
|
|
||||||
|
return fetch_okx_account_ledger(
|
||||||
|
exchange,
|
||||||
|
start_ms=start_ms,
|
||||||
|
end_ms=end_ms,
|
||||||
|
ensure_markets=ensure_markets,
|
||||||
|
)
|
||||||
|
if ex_key == "binance":
|
||||||
|
from lib.exchange.binance_ledger_lib import fetch_binance_account_ledger
|
||||||
|
|
||||||
|
return fetch_binance_account_ledger(
|
||||||
|
exchange,
|
||||||
|
start_ms=start_ms,
|
||||||
|
end_ms=end_ms,
|
||||||
|
ensure_markets=ensure_markets,
|
||||||
|
)
|
||||||
|
from lib.exchange.gate_ledger_lib import fetch_gate_account_ledger
|
||||||
|
|
||||||
|
return fetch_gate_account_ledger(
|
||||||
|
exchange,
|
||||||
|
start_ms=start_ms,
|
||||||
|
end_ms=end_ms,
|
||||||
|
ensure_markets=ensure_markets,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _fetch
|
||||||
|
|
||||||
|
|
||||||
|
def _currencies_for_exchange(exchange_key: str) -> list[str]:
|
||||||
|
if (exchange_key or "").strip().lower() == "okx":
|
||||||
|
return ["USDT", "USDC"]
|
||||||
|
return ["USDT"]
|
||||||
|
|
||||||
|
|
||||||
|
def install_account_ledger(
|
||||||
|
app: Flask,
|
||||||
|
repo_root: str,
|
||||||
|
app_module: Any,
|
||||||
|
*,
|
||||||
|
exchange_key: str = "",
|
||||||
|
) -> None:
|
||||||
|
ex = (exchange_key or "").strip().lower()
|
||||||
|
if not ex:
|
||||||
|
mod_name = getattr(app_module, "__name__", "") or ""
|
||||||
|
if "okx" in mod_name.lower():
|
||||||
|
ex = "okx"
|
||||||
|
elif "binance" in mod_name.lower():
|
||||||
|
ex = "binance"
|
||||||
|
else:
|
||||||
|
ex = "gate"
|
||||||
|
exchange_key = ex
|
||||||
|
|
||||||
|
attach_account_ledger_templates(app, repo_root)
|
||||||
|
get_db = app_module.get_db
|
||||||
|
login_required = app_module.login_required
|
||||||
|
|
||||||
|
# 初始化表
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
ensure_account_ledger_tables(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
account_ledger_store.configure(
|
||||||
|
get_db=get_db,
|
||||||
|
fetch_fn=_build_fetch_fn(exchange_key, app_module),
|
||||||
|
exchange_key=str(exchange_key),
|
||||||
|
)
|
||||||
|
account_ledger_store.start()
|
||||||
|
app.extensions["account_ledger_exchange"] = str(exchange_key).lower()
|
||||||
|
|
||||||
|
def _list_window():
|
||||||
|
resolve = getattr(app_module, "_list_window_from_request", None)
|
||||||
|
if callable(resolve):
|
||||||
|
return resolve()
|
||||||
|
return resolve_list_window(request.args, session)
|
||||||
|
|
||||||
|
@app.route("/api/account_ledger")
|
||||||
|
@login_required
|
||||||
|
def api_account_ledger():
|
||||||
|
account = (request.args.get("account") or ACCOUNT_FUNDING).strip().lower()
|
||||||
|
if account not in VALID_ACCOUNTS:
|
||||||
|
account = ACCOUNT_FUNDING
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page") or 1)
|
||||||
|
except Exception:
|
||||||
|
page = 1
|
||||||
|
win = _list_window()
|
||||||
|
start_ms = int(win.get("start_ms") or 0)
|
||||||
|
end_ms = int(win.get("end_ms") or 0)
|
||||||
|
ccys = _currencies_for_exchange(app.extensions.get("account_ledger_exchange") or "")
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
ensure_account_ledger_tables(conn)
|
||||||
|
data = query_entries(
|
||||||
|
conn,
|
||||||
|
account=account,
|
||||||
|
start_ms=start_ms,
|
||||||
|
end_ms=end_ms,
|
||||||
|
page=page,
|
||||||
|
page_size=PAGE_SIZE,
|
||||||
|
currencies=ccys,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
st = account_ledger_store.status_dict()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"account": account,
|
||||||
|
"window": {
|
||||||
|
"preset": win.get("preset"),
|
||||||
|
"label": win.get("label"),
|
||||||
|
"start_ms": start_ms,
|
||||||
|
"end_ms": end_ms,
|
||||||
|
},
|
||||||
|
"currencies": ccys,
|
||||||
|
**data,
|
||||||
|
**st,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/account_ledger/stream")
|
||||||
|
@login_required
|
||||||
|
def api_account_ledger_stream():
|
||||||
|
return Response(
|
||||||
|
stream_with_context(account_ledger_store.iter_sse()),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/account_ledger/refresh", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_account_ledger_refresh():
|
||||||
|
win = _list_window()
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
start_ms = body.get("start_ms", win.get("start_ms"))
|
||||||
|
end_ms = body.get("end_ms", win.get("end_ms"))
|
||||||
|
try:
|
||||||
|
start_i = int(start_ms) if start_ms is not None else None
|
||||||
|
end_i = int(end_ms) if end_ms is not None else None
|
||||||
|
except Exception:
|
||||||
|
start_i, end_i = None, None
|
||||||
|
result = account_ledger_store.sync_once(
|
||||||
|
reason="manual", start_ms=start_i, end_ms=end_i
|
||||||
|
)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@app.route("/account_ledger")
|
||||||
|
@login_required
|
||||||
|
def account_ledger_page():
|
||||||
|
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
|
||||||
|
|
||||||
|
redir = redirect_to_embed_shell_if_enabled("account_ledger")
|
||||||
|
if redir is not None:
|
||||||
|
return redir
|
||||||
|
return app_module.render_main_page("account_ledger")
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""账户流水:后台定时拉取交易所 + SSE 版本推送."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_db import (
|
||||||
|
ensure_account_ledger_tables,
|
||||||
|
meta_get,
|
||||||
|
meta_set,
|
||||||
|
prune_older_than,
|
||||||
|
upsert_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_LEDGER_POLL_SEC = float(os.getenv("ACCOUNT_LEDGER_POLL_SEC", "120"))
|
||||||
|
ACCOUNT_LEDGER_LOOKBACK_DAYS = int(os.getenv("ACCOUNT_LEDGER_LOOKBACK_DAYS", "90"))
|
||||||
|
ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC = float(os.getenv("ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC", "25"))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountLedgerStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.version = 0
|
||||||
|
self._subscribers: list[queue.Queue[str | None]] = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._syncing = False
|
||||||
|
self._get_db: Optional[Callable] = None
|
||||||
|
self._fetch_fn: Optional[Callable[..., tuple[list[dict[str, Any]], list[str]]]] = None
|
||||||
|
self._exchange_key = ""
|
||||||
|
self.last_sync_at: Optional[float] = None
|
||||||
|
self.last_error: str = ""
|
||||||
|
self.last_upserted: int = 0
|
||||||
|
self._last_manual_at: float = 0.0
|
||||||
|
self._manual_cooldown_sec = float(os.getenv("ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC", "30"))
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
get_db: Callable,
|
||||||
|
fetch_fn: Callable[..., tuple[list[dict[str, Any]], list[str]]],
|
||||||
|
exchange_key: str,
|
||||||
|
) -> None:
|
||||||
|
self._get_db = get_db
|
||||||
|
self._fetch_fn = fetch_fn
|
||||||
|
self._exchange_key = (exchange_key or "").strip().lower()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
if not self._get_db or not self._fetch_fn:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._loop, daemon=True, name=f"account-ledger-{self._exchange_key or 'x'}"
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._broadcast(close=True)
|
||||||
|
|
||||||
|
def lookback_bounds_ms(self, start_ms: Optional[int] = None, end_ms: Optional[int] = None) -> tuple[int, int]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
end = int(end_ms) if end_ms is not None else int(now.timestamp() * 1000)
|
||||||
|
floor = int(end - ACCOUNT_LEDGER_LOOKBACK_DAYS * 86400 * 1000)
|
||||||
|
if start_ms is not None:
|
||||||
|
start = max(int(start_ms), floor)
|
||||||
|
else:
|
||||||
|
start = floor
|
||||||
|
if start > end:
|
||||||
|
start, end = end, start
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def sync_once(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
reason: str = "poll",
|
||||||
|
start_ms: Optional[int] = None,
|
||||||
|
end_ms: Optional[int] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self._get_db or not self._fetch_fn:
|
||||||
|
return {"ok": False, "msg": "未配置"}
|
||||||
|
with self._lock:
|
||||||
|
if self._syncing:
|
||||||
|
return {"ok": True, "busy": True, "ledger_version": self.version}
|
||||||
|
if reason == "manual":
|
||||||
|
gap = time.time() - self._last_manual_at
|
||||||
|
if gap < self._manual_cooldown_sec:
|
||||||
|
wait = int(self._manual_cooldown_sec - gap) + 1
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"msg": f"同步过于频繁,请 {wait}s 后再试",
|
||||||
|
"ledger_version": self.version,
|
||||||
|
}
|
||||||
|
self._syncing = True
|
||||||
|
try:
|
||||||
|
start, end = self.lookback_bounds_ms(start_ms, end_ms)
|
||||||
|
rows, errors = self._fetch_fn(start_ms=start, end_ms=end)
|
||||||
|
conn = self._get_db()
|
||||||
|
try:
|
||||||
|
ensure_account_ledger_tables(conn)
|
||||||
|
n = upsert_entries(conn, rows or [])
|
||||||
|
# 保留略宽于 lookback 的缓存
|
||||||
|
prune_ms = int(
|
||||||
|
(datetime.now(timezone.utc).timestamp() - (ACCOUNT_LEDGER_LOOKBACK_DAYS + 7) * 86400)
|
||||||
|
* 1000
|
||||||
|
)
|
||||||
|
prune_older_than(conn, prune_ms)
|
||||||
|
self.last_sync_at = time.time()
|
||||||
|
self.last_upserted = n
|
||||||
|
self.last_error = "; ".join(errors[:3]) if errors else ""
|
||||||
|
meta_set(conn, "last_sync_at", str(self.last_sync_at))
|
||||||
|
meta_set(conn, "last_error", self.last_error)
|
||||||
|
meta_set(conn, "last_upserted", str(n))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if reason == "manual":
|
||||||
|
self._last_manual_at = time.time()
|
||||||
|
ver = self.bump(reason)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"ledger_version": ver,
|
||||||
|
"upserted": n,
|
||||||
|
"errors": errors,
|
||||||
|
"start_ms": start,
|
||||||
|
"end_ms": end,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
self.last_error = str(e)
|
||||||
|
try:
|
||||||
|
conn = self._get_db()
|
||||||
|
try:
|
||||||
|
ensure_account_ledger_tables(conn)
|
||||||
|
meta_set(conn, "last_error", self.last_error)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": False, "msg": str(e), "ledger_version": self.version}
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._syncing = False
|
||||||
|
|
||||||
|
def bump(self, reason: str = "poll") -> int:
|
||||||
|
with self._lock:
|
||||||
|
self.version += 1
|
||||||
|
ver = self.version
|
||||||
|
payload = json.dumps(
|
||||||
|
{"ledger_version": ver, "reason": reason, "exchange": self._exchange_key},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
self._broadcast(payload)
|
||||||
|
return ver
|
||||||
|
|
||||||
|
def status_dict(self) -> dict[str, Any]:
|
||||||
|
last_at = self.last_sync_at
|
||||||
|
if last_at is None and self._get_db:
|
||||||
|
try:
|
||||||
|
conn = self._get_db()
|
||||||
|
try:
|
||||||
|
ensure_account_ledger_tables(conn)
|
||||||
|
raw = meta_get(conn, "last_sync_at", "")
|
||||||
|
if raw:
|
||||||
|
last_at = float(raw)
|
||||||
|
self.last_error = meta_get(conn, "last_error", self.last_error)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"ledger_version": self.version,
|
||||||
|
"poll_sec": ACCOUNT_LEDGER_POLL_SEC,
|
||||||
|
"lookback_days": ACCOUNT_LEDGER_LOOKBACK_DAYS,
|
||||||
|
"last_sync_at": last_at,
|
||||||
|
"last_error": self.last_error,
|
||||||
|
"last_upserted": self.last_upserted,
|
||||||
|
"exchange": self._exchange_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
# 启动后稍等再拉,避免和启动高峰撞车
|
||||||
|
if self._stop.wait(3):
|
||||||
|
return
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
self.sync_once(reason="poll")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._stop.wait(ACCOUNT_LEDGER_POLL_SEC):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
|
||||||
|
with self._lock:
|
||||||
|
subs = list(self._subscribers)
|
||||||
|
dead: list[queue.Queue[str | None]] = []
|
||||||
|
for q in subs:
|
||||||
|
try:
|
||||||
|
q.put_nowait(None if close else event)
|
||||||
|
except Exception:
|
||||||
|
dead.append(q)
|
||||||
|
if dead:
|
||||||
|
with self._lock:
|
||||||
|
for q in dead:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def _subscribe(self) -> queue.Queue[str | None]:
|
||||||
|
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.append(q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def iter_sse(self) -> Iterator[str]:
|
||||||
|
q = self._subscribe()
|
||||||
|
try:
|
||||||
|
yield f"event: ledger\ndata: {json.dumps({'ledger_version': self.version, 'reason': 'hello'}, ensure_ascii=False)}\n\n"
|
||||||
|
last_hb = time.time()
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
item = q.get(timeout=1.0)
|
||||||
|
except queue.Empty:
|
||||||
|
item = "timeout"
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
if item != "timeout":
|
||||||
|
yield f"event: ledger\ndata: {item}\n\n"
|
||||||
|
last_hb = time.time()
|
||||||
|
elif time.time() - last_hb >= ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC:
|
||||||
|
yield ": heartbeat\n\n"
|
||||||
|
last_hb = time.time()
|
||||||
|
finally:
|
||||||
|
self._unsubscribe(q)
|
||||||
|
|
||||||
|
|
||||||
|
account_ledger_store = AccountLedgerStore()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #}
|
||||||
|
<div class="card full account-ledger-card" id="account-ledger-root" data-account-ledger="1">
|
||||||
|
<div class="account-ledger-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom:4px">账户流水</h2>
|
||||||
|
<p class="muted account-ledger-desc">拉取交易所资金账户与交易账户账单 · 时间跟随顶栏 UTC 预设 · 约 2 分钟自动同步</p>
|
||||||
|
</div>
|
||||||
|
<div class="account-ledger-head-actions">
|
||||||
|
<span class="muted" id="account-ledger-sync">—</span>
|
||||||
|
<button type="button" class="btn-sm" id="account-ledger-refresh">立即同步</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="account-ledger-tabs" role="tablist">
|
||||||
|
<button type="button" class="account-ledger-tab active" data-ledger-account="funding" role="tab" aria-selected="true">资金账户</button>
|
||||||
|
<button type="button" class="account-ledger-tab" data-ledger-account="trading" role="tab" aria-selected="false">交易账户</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted account-ledger-status" id="account-ledger-status"></p>
|
||||||
|
<div class="account-ledger-table-wrap panel-scroll">
|
||||||
|
<table class="account-ledger-table" id="account-ledger-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间(北京)</th>
|
||||||
|
<th>币种</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>变动</th>
|
||||||
|
<th>余额</th>
|
||||||
|
<th>合约/备注</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="account-ledger-tbody">
|
||||||
|
<tr><td colspan="6" class="muted">加载中…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="account-ledger-pager" id="account-ledger-pager">
|
||||||
|
<button type="button" class="btn-sm" id="account-ledger-prev" disabled>上一页</button>
|
||||||
|
<span class="muted" id="account-ledger-page-info">—</span>
|
||||||
|
<button type="button" class="btn-sm" id="account-ledger-next" disabled>下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.account-ledger-card { grid-column: 1 / -1; }
|
||||||
|
.account-ledger-head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.account-ledger-head-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.account-ledger-tabs {
|
||||||
|
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.account-ledger-tab {
|
||||||
|
border: 1px solid rgba(140,160,200,.35);
|
||||||
|
background: transparent; color: #c5cbe0;
|
||||||
|
border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: .9rem;
|
||||||
|
}
|
||||||
|
.account-ledger-tab.active {
|
||||||
|
background: #1f3a5a; border-color: #3d6f9c; color: #e8f1ff;
|
||||||
|
}
|
||||||
|
.account-ledger-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||||
|
.account-ledger-table th, .account-ledger-table td {
|
||||||
|
padding: 8px 10px; border-bottom: 1px solid rgba(120,130,160,.2); text-align: left;
|
||||||
|
}
|
||||||
|
.account-ledger-table th { color: #9aa3bd; font-weight: 600; }
|
||||||
|
.account-ledger-amt-pos { color: #3ecf8e; }
|
||||||
|
.account-ledger-amt-neg { color: #f07178; }
|
||||||
|
.account-ledger-pager {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.account-ledger-status { min-height: 1.2em; margin: 0 0 8px; }
|
||||||
|
.account-ledger-table-wrap { max-height: min(60vh, 560px); overflow: auto; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
let account = "funding";
|
||||||
|
let page = 1;
|
||||||
|
let pages = 0;
|
||||||
|
let localVersion = 0;
|
||||||
|
let es = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let loading = false;
|
||||||
|
let booted = false;
|
||||||
|
|
||||||
|
function root() {
|
||||||
|
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]');
|
||||||
|
if (active) return active;
|
||||||
|
return document.getElementById("account-ledger-root");
|
||||||
|
}
|
||||||
|
|
||||||
|
function $(id) {
|
||||||
|
const r = root();
|
||||||
|
return (r && r.querySelector("#" + id)) || document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listWindowQs() {
|
||||||
|
if (typeof global.listWindowQueryString === "function") {
|
||||||
|
const q = global.listWindowQueryString();
|
||||||
|
return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(location.search).toString();
|
||||||
|
} catch (_) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBj(ms) {
|
||||||
|
const n = Number(ms);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(n);
|
||||||
|
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(d);
|
||||||
|
const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
|
||||||
|
return (
|
||||||
|
get("year") +
|
||||||
|
"-" +
|
||||||
|
get("month") +
|
||||||
|
"-" +
|
||||||
|
get("day") +
|
||||||
|
" " +
|
||||||
|
get("hour") +
|
||||||
|
":" +
|
||||||
|
get("minute") +
|
||||||
|
":" +
|
||||||
|
get("second")
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAmt(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : "";
|
||||||
|
const sign = n > 0 ? "+" : "";
|
||||||
|
return '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBal(v) {
|
||||||
|
if (v == null || v === "") return "—";
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
return n.toFixed(6).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isErr) {
|
||||||
|
const el = $("account-ledger-status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg || "";
|
||||||
|
el.style.color = isErr ? "#f07178" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSyncLabel(data) {
|
||||||
|
const el = $("account-ledger-sync");
|
||||||
|
if (!el) return;
|
||||||
|
const ts = data && data.last_sync_at;
|
||||||
|
if (!ts) {
|
||||||
|
el.textContent = "尚未同步";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = "同步 " + fmtBj(Number(ts) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(items) {
|
||||||
|
const tbody = $("account-ledger-tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!items || !items.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = items
|
||||||
|
.map(function (it) {
|
||||||
|
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
|
||||||
|
return (
|
||||||
|
"<tr>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(fmtBj(it.ts_ms)) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(it.ccy || "") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(it.kind_label || it.kind || "") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
fmtAmt(it.amount) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(fmtBal(it.balance_after)) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(note || "—") +
|
||||||
|
"</td>" +
|
||||||
|
"</tr>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(data) {
|
||||||
|
pages = Number(data.pages || 0);
|
||||||
|
page = Number(data.page || 1);
|
||||||
|
const info = $("account-ledger-page-info");
|
||||||
|
const prev = $("account-ledger-prev");
|
||||||
|
const next = $("account-ledger-next");
|
||||||
|
if (info) {
|
||||||
|
info.textContent =
|
||||||
|
"第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE;
|
||||||
|
}
|
||||||
|
if (prev) prev.disabled = page <= 1;
|
||||||
|
if (next) next.disabled = !pages || page >= pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList(opts) {
|
||||||
|
const r = root();
|
||||||
|
if (!r) return;
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
const force = opts && opts.force;
|
||||||
|
try {
|
||||||
|
if (!force) setStatus("加载中…");
|
||||||
|
const qs = new URLSearchParams(listWindowQs());
|
||||||
|
qs.set("account", account);
|
||||||
|
qs.set("page", String(page));
|
||||||
|
const res = await fetch("/api/account_ledger?" + qs.toString(), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(function () {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data.msg || res.statusText || "加载失败");
|
||||||
|
}
|
||||||
|
if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion;
|
||||||
|
renderRows(data.items || []);
|
||||||
|
renderPager(data);
|
||||||
|
setSyncLabel(data);
|
||||||
|
const winLabel = (data.window && data.window.label) || "";
|
||||||
|
const err = data.last_error ? " · 同步提示: " + data.last_error : "";
|
||||||
|
setStatus(
|
||||||
|
(winLabel ? "时间窗 " + winLabel + " · " : "") +
|
||||||
|
(account === "trading" ? "交易账户" : "资金账户") +
|
||||||
|
err,
|
||||||
|
!!data.last_error
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || String(e), true);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNow() {
|
||||||
|
setStatus("正在从交易所同步…");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account_ledger/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(function () {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data.msg || "同步失败");
|
||||||
|
}
|
||||||
|
await loadList({ force: true });
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || String(e), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUi() {
|
||||||
|
const r = root();
|
||||||
|
if (!r || r.getAttribute("data-ledger-bound") === "1") return;
|
||||||
|
r.setAttribute("data-ledger-bound", "1");
|
||||||
|
r.querySelectorAll(".account-ledger-tab").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
const acc = btn.getAttribute("data-ledger-account") || "funding";
|
||||||
|
if (acc === account) return;
|
||||||
|
account = acc;
|
||||||
|
page = 1;
|
||||||
|
r.querySelectorAll(".account-ledger-tab").forEach(function (b) {
|
||||||
|
const on = b.getAttribute("data-ledger-account") === account;
|
||||||
|
b.classList.toggle("active", on);
|
||||||
|
b.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
});
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const prev = $("account-ledger-prev");
|
||||||
|
const next = $("account-ledger-next");
|
||||||
|
const ref = $("account-ledger-refresh");
|
||||||
|
if (prev)
|
||||||
|
prev.addEventListener("click", function () {
|
||||||
|
if (page > 1) {
|
||||||
|
page -= 1;
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (next)
|
||||||
|
next.addEventListener("click", function () {
|
||||||
|
if (!pages || page < pages) {
|
||||||
|
page += 1;
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (ref) ref.addEventListener("click", refreshNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSse() {
|
||||||
|
if (es) {
|
||||||
|
try {
|
||||||
|
es.close();
|
||||||
|
} catch (_) {}
|
||||||
|
es = null;
|
||||||
|
}
|
||||||
|
if (typeof EventSource === "undefined") return;
|
||||||
|
try {
|
||||||
|
es = new EventSource("/api/account_ledger/stream");
|
||||||
|
es.addEventListener("ledger", function (ev) {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(ev.data || "{}");
|
||||||
|
} catch (_) {}
|
||||||
|
const ver = Number(data.ledger_version || 0);
|
||||||
|
if (ver && ver !== localVersion) {
|
||||||
|
localVersion = ver;
|
||||||
|
loadList({ force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.onerror = function () {
|
||||||
|
try {
|
||||||
|
es.close();
|
||||||
|
} catch (_) {}
|
||||||
|
es = null;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(connectSse, 5000);
|
||||||
|
};
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
const r = root();
|
||||||
|
if (!r) return;
|
||||||
|
bindUi();
|
||||||
|
if (!booted) {
|
||||||
|
booted = true;
|
||||||
|
connectSse();
|
||||||
|
}
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabActivated(tab) {
|
||||||
|
if (tab !== "account_ledger") return;
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
global.AccountLedgerPage = {
|
||||||
|
boot: boot,
|
||||||
|
onTabActivated: onTabActivated,
|
||||||
|
reload: function () {
|
||||||
|
page = 1;
|
||||||
|
loadList();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const page =
|
||||||
|
(document.body && document.body.getAttribute("data-page")) ||
|
||||||
|
(document.body && document.body.getAttribute("data-initial-tab")) ||
|
||||||
|
"";
|
||||||
|
if (page === "account_ledger" || root()) {
|
||||||
|
// embed 延后到 tab 激活;独立页直接 boot
|
||||||
|
if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") {
|
||||||
|
boot();
|
||||||
|
} else if (page === "account_ledger") {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("instance-embed-tab-activated", function (ev) {
|
||||||
|
const tab = ev && ev.detail && ev.detail.tab;
|
||||||
|
onTabActivated(tab);
|
||||||
|
});
|
||||||
|
})(window);
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
const TAB_PATH = {
|
const TAB_PATH = {
|
||||||
dashboard: "/dashboard",
|
dashboard: "/dashboard",
|
||||||
|
account_ledger: "/account_ledger",
|
||||||
key_monitor: "/key_monitor",
|
key_monitor: "/key_monitor",
|
||||||
trade: "/trade",
|
trade: "/trade",
|
||||||
strategy: "/strategy",
|
strategy: "/strategy",
|
||||||
@@ -114,6 +115,9 @@
|
|||||||
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
|
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
|
||||||
global.InstanceDashboard.init(!!revisit);
|
global.InstanceDashboard.init(!!revisit);
|
||||||
}
|
}
|
||||||
|
if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") {
|
||||||
|
global.AccountLedgerPage.boot();
|
||||||
|
}
|
||||||
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||||
global.initStrategyRollForm();
|
global.initStrategyRollForm();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
|
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
|
||||||
const NAV_DEFAULT_OFF = { show_nav_dashboard: true, show_nav_system_guide: true };
|
const NAV_DEFAULT_OFF = {
|
||||||
|
show_nav_dashboard: true,
|
||||||
|
show_nav_account_ledger: true,
|
||||||
|
show_nav_system_guide: true,
|
||||||
|
};
|
||||||
|
|
||||||
function navPrefShow(display, key) {
|
function navPrefShow(display, key) {
|
||||||
if (!key) return true;
|
if (!key) return true;
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
function applyDisplayToNav(display) {
|
function applyDisplayToNav(display) {
|
||||||
const map = {
|
const map = {
|
||||||
dashboard: "show_nav_dashboard",
|
dashboard: "show_nav_dashboard",
|
||||||
|
account_ledger: "show_nav_account_ledger",
|
||||||
key_monitor: "show_nav_key_monitor",
|
key_monitor: "show_nav_key_monitor",
|
||||||
trade: "show_nav_trade",
|
trade: "show_nav_trade",
|
||||||
strategy: "show_nav_strategy",
|
strategy: "show_nav_strategy",
|
||||||
@@ -66,6 +71,7 @@
|
|||||||
const d = DISPLAY();
|
const d = DISPLAY();
|
||||||
const map = {
|
const map = {
|
||||||
dashboard: "show_nav_dashboard",
|
dashboard: "show_nav_dashboard",
|
||||||
|
account_ledger: "show_nav_account_ledger",
|
||||||
key_monitor: "show_nav_key_monitor",
|
key_monitor: "show_nav_key_monitor",
|
||||||
trade: "show_nav_trade",
|
trade: "show_nav_trade",
|
||||||
strategy: "show_nav_strategy",
|
strategy: "show_nav_strategy",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -76,6 +76,7 @@ def install_instance_theme_static(app) -> None:
|
|||||||
"instance_live.js": "application/javascript; charset=utf-8",
|
"instance_live.js": "application/javascript; charset=utf-8",
|
||||||
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
|
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
|
||||||
"instance_dashboard.js": "application/javascript; charset=utf-8",
|
"instance_dashboard.js": "application/javascript; charset=utf-8",
|
||||||
|
"account_ledger.js": "application/javascript; charset=utf-8",
|
||||||
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
|
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
|
||||||
"options_panel.js": "application/javascript; charset=utf-8",
|
"options_panel.js": "application/javascript; charset=utf-8",
|
||||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ DISPLAY_RUNTIME_PREFIX = "display."
|
|||||||
|
|
||||||
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||||
"show_nav_dashboard": False,
|
"show_nav_dashboard": False,
|
||||||
|
"show_nav_account_ledger": False,
|
||||||
"show_nav_key_monitor": True,
|
"show_nav_key_monitor": True,
|
||||||
"show_nav_trade": True,
|
"show_nav_trade": True,
|
||||||
"show_nav_strategy": True,
|
"show_nav_strategy": True,
|
||||||
@@ -30,6 +31,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
|||||||
|
|
||||||
DISPLAY_LABELS: dict[str, str] = {
|
DISPLAY_LABELS: dict[str, str] = {
|
||||||
"show_nav_dashboard": "数据看板",
|
"show_nav_dashboard": "数据看板",
|
||||||
|
"show_nav_account_ledger": "账户流水",
|
||||||
"show_nav_key_monitor": "关键位监控",
|
"show_nav_key_monitor": "关键位监控",
|
||||||
"show_nav_trade": "实盘下单",
|
"show_nav_trade": "实盘下单",
|
||||||
"show_nav_strategy": "策略交易",
|
"show_nav_strategy": "策略交易",
|
||||||
@@ -51,6 +53,7 @@ DISPLAY_LABELS: dict[str, str] = {
|
|||||||
|
|
||||||
NAV_TAB_ALLOWED: dict[str, str] = {
|
NAV_TAB_ALLOWED: dict[str, str] = {
|
||||||
"dashboard": "show_nav_dashboard",
|
"dashboard": "show_nav_dashboard",
|
||||||
|
"account_ledger": "show_nav_account_ledger",
|
||||||
"key_monitor": "show_nav_key_monitor",
|
"key_monitor": "show_nav_key_monitor",
|
||||||
"trade": "show_nav_trade",
|
"trade": "show_nav_trade",
|
||||||
"strategy": "show_nav_strategy",
|
"strategy": "show_nav_strategy",
|
||||||
@@ -116,6 +119,7 @@ def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
|||||||
def display_meta_for_ui() -> list[dict[str, Any]]:
|
def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||||
nav_keys = [
|
nav_keys = [
|
||||||
"show_nav_dashboard",
|
"show_nav_dashboard",
|
||||||
|
"show_nav_account_ledger",
|
||||||
"show_nav_key_monitor",
|
"show_nav_key_monitor",
|
||||||
"show_nav_trade",
|
"show_nav_trade",
|
||||||
"show_nav_strategy",
|
"show_nav_strategy",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from jinja2 import ChoiceLoader, FileSystemLoader
|
|||||||
|
|
||||||
EMBED_TABS: tuple[str, ...] = (
|
EMBED_TABS: tuple[str, ...] = (
|
||||||
"dashboard",
|
"dashboard",
|
||||||
|
"account_ledger",
|
||||||
"key_monitor",
|
"key_monitor",
|
||||||
"trade",
|
"trade",
|
||||||
"strategy",
|
"strategy",
|
||||||
@@ -31,6 +32,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
|
|||||||
"/": "trade",
|
"/": "trade",
|
||||||
"/trade": "trade",
|
"/trade": "trade",
|
||||||
"/dashboard": "dashboard",
|
"/dashboard": "dashboard",
|
||||||
|
"/account_ledger": "account_ledger",
|
||||||
"/key_monitor": "key_monitor",
|
"/key_monitor": "key_monitor",
|
||||||
"/strategy": "strategy",
|
"/strategy": "strategy",
|
||||||
"/strategy/trend": "strategy",
|
"/strategy/trend": "strategy",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="display-prefs-checks">
|
<div class="display-prefs-checks">
|
||||||
{% for item in group.entries %}
|
{% for item in group.entries %}
|
||||||
<label class="chk-label">
|
<label class="chk-label">
|
||||||
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
|
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_account_ledger', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -119,6 +119,8 @@
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% if page == 'dashboard' %}
|
{% if page == 'dashboard' %}
|
||||||
{% include 'dashboard_panel.html' %}
|
{% include 'dashboard_panel.html' %}
|
||||||
|
{% elif page == 'account_ledger' %}
|
||||||
|
{% include 'account_ledger_panel.html' %}
|
||||||
{% elif page == 'key_monitor' %}
|
{% elif page == 'key_monitor' %}
|
||||||
{% include 'key_monitor_panel.html' %}
|
{% include 'key_monitor_panel.html' %}
|
||||||
{% elif page == 'trade' %}
|
{% elif page == 'trade' %}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||||
|
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if initial_tab == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||||
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
||||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
<p class="inst-mobile-more-hint">次要页面 · 完整界面请用电脑</p>
|
<p class="inst-mobile-more-hint">次要页面 · 完整界面请用电脑</p>
|
||||||
<nav class="inst-mobile-more-nav" aria-label="更多页面">
|
<nav class="inst-mobile-more-nav" aria-label="更多页面">
|
||||||
<a href="/dashboard" data-embed-tab="dashboard"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
<a href="/dashboard" data-embed-tab="dashboard"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||||
|
<a href="/account_ledger" data-embed-tab="account_ledger"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||||
{% if display.show_nav_records %}
|
{% if display.show_nav_records %}
|
||||||
<a href="/records" data-embed-tab="records">交易记录</a>
|
<a href="/records" data-embed-tab="records">交易记录</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -164,12 +166,13 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
|||||||
<script src="/static/records_review_page.js?v=4"></script>
|
<script src="/static/records_review_page.js?v=4"></script>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||||
|
<script src="/static/account_ledger.js?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/instance_settings_prefs.js?v=18"></script>
|
<script src="/static/instance_settings_prefs.js?v=19"></script>
|
||||||
<script src="/static/instance_live.js?v=6"></script>
|
<script src="/static/instance_live.js?v=6"></script>
|
||||||
<script src="/static/instance_embed.js?v=30"></script>
|
<script src="/static/instance_embed.js?v=31"></script>
|
||||||
<script src="/static/instance_mobile_nav.js?v=2"></script>
|
<script src="/static/instance_mobile_nav.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||||
|
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||||
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||||
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
|
||||||
{% if not intraday_discipline and display.show_nav_strategy %}
|
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||||
@@ -197,6 +198,8 @@
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
{% if page == 'dashboard' %}
|
{% if page == 'dashboard' %}
|
||||||
{% include 'dashboard_panel.html' %}
|
{% include 'dashboard_panel.html' %}
|
||||||
|
{% elif page == 'account_ledger' %}
|
||||||
|
{% include 'account_ledger_panel.html' %}
|
||||||
{% elif page == 'key_monitor' %}
|
{% elif page == 'key_monitor' %}
|
||||||
{% include 'key_monitor_panel.html' %}
|
{% include 'key_monitor_panel.html' %}
|
||||||
{% elif page == 'trade' %}
|
{% elif page == 'trade' %}
|
||||||
@@ -2028,6 +2031,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
|
|||||||
<script src="/static/records_review_page.js?v=4"></script>
|
<script src="/static/records_review_page.js?v=4"></script>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||||
|
<script src="/static/account_ledger.js?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
{% if page == 'dashboard' %}
|
{% if page == 'dashboard' %}
|
||||||
@@ -2035,7 +2039,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (window.InstanceDashboard) InstanceDashboard.init(true);
|
if (window.InstanceDashboard) InstanceDashboard.init(true);
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if page == 'account_ledger' %}
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (window.AccountLedgerPage) AccountLedgerPage.boot();
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/instance_settings_prefs.js?v=18"></script>
|
<script src="/static/instance_settings_prefs.js?v=19"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""账户流水归一化与分页查询."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from lib.account_ledger.account_ledger_db import (
|
||||||
|
ensure_account_ledger_tables,
|
||||||
|
query_entries,
|
||||||
|
upsert_entries,
|
||||||
|
)
|
||||||
|
from lib.account_ledger.account_ledger_normalize import (
|
||||||
|
ACCOUNT_FUNDING,
|
||||||
|
ACCOUNT_TRADING,
|
||||||
|
PAGE_SIZE,
|
||||||
|
from_ccxt_ledger_entry,
|
||||||
|
kind_from_raw,
|
||||||
|
normalize_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountLedgerNormalize(unittest.TestCase):
|
||||||
|
def test_kind_from_raw(self):
|
||||||
|
self.assertEqual(kind_from_raw("REALIZED_PNL", 1.0), "realized_pnl")
|
||||||
|
self.assertEqual(kind_from_raw("FUNDING_FEE", -0.1), "funding_fee")
|
||||||
|
self.assertEqual(kind_from_raw("TRANSFER", 5.0), "transfer_in")
|
||||||
|
self.assertEqual(kind_from_raw("TRANSFER", -5.0), "transfer_out")
|
||||||
|
|
||||||
|
def test_normalize_row_and_ccxt(self):
|
||||||
|
row = normalize_row(
|
||||||
|
account=ACCOUNT_TRADING,
|
||||||
|
ccy="usdt",
|
||||||
|
amount="-1.25",
|
||||||
|
ts_ms=1_700_000_000_000,
|
||||||
|
ref_id="abc",
|
||||||
|
raw_type="COMMISSION",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
assert row is not None
|
||||||
|
self.assertEqual(row["ccy"], "USDT")
|
||||||
|
self.assertEqual(row["kind"], "commission")
|
||||||
|
self.assertEqual(row["account"], "trading")
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"id": "9",
|
||||||
|
"currency": "USDC",
|
||||||
|
"amount": 2.5,
|
||||||
|
"timestamp": 1_700_000_000_000,
|
||||||
|
"type": "transfer",
|
||||||
|
"info": {"billId": "9", "bal": "10"},
|
||||||
|
}
|
||||||
|
n = from_ccxt_ledger_entry(entry, account=ACCOUNT_FUNDING)
|
||||||
|
self.assertIsNotNone(n)
|
||||||
|
assert n is not None
|
||||||
|
self.assertEqual(n["ccy"], "USDC")
|
||||||
|
self.assertEqual(n["balance_after"], 10.0)
|
||||||
|
|
||||||
|
def test_upsert_and_paginate(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
ensure_account_ledger_tables(conn)
|
||||||
|
rows = []
|
||||||
|
for i in range(25):
|
||||||
|
rows.append(
|
||||||
|
normalize_row(
|
||||||
|
account=ACCOUNT_FUNDING,
|
||||||
|
ccy="USDT",
|
||||||
|
amount=1.0 + i,
|
||||||
|
ts_ms=1_700_000_000_000 + i * 1000,
|
||||||
|
ref_id=f"r{i}",
|
||||||
|
raw_type="deposit",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(upsert_entries(conn, [r for r in rows if r]), 25)
|
||||||
|
page1 = query_entries(
|
||||||
|
conn,
|
||||||
|
account=ACCOUNT_FUNDING,
|
||||||
|
start_ms=0,
|
||||||
|
end_ms=9_000_000_000_000,
|
||||||
|
page=1,
|
||||||
|
page_size=PAGE_SIZE,
|
||||||
|
)
|
||||||
|
self.assertEqual(page1["total"], 25)
|
||||||
|
self.assertEqual(len(page1["items"]), 10)
|
||||||
|
self.assertEqual(page1["pages"], 3)
|
||||||
|
# 倒序:最新在前
|
||||||
|
self.assertGreater(page1["items"][0]["ts_ms"], page1["items"][1]["ts_ms"])
|
||||||
|
page3 = query_entries(
|
||||||
|
conn,
|
||||||
|
account=ACCOUNT_FUNDING,
|
||||||
|
start_ms=0,
|
||||||
|
end_ms=9_000_000_000_000,
|
||||||
|
page=3,
|
||||||
|
page_size=PAGE_SIZE,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(page3["items"]), 5)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -32,10 +32,12 @@ def test_embed_tabs_cover_main_nav():
|
|||||||
assert "risk_policy" in EMBED_TABS
|
assert "risk_policy" in EMBED_TABS
|
||||||
assert "system_guide" in EMBED_TABS
|
assert "system_guide" in EMBED_TABS
|
||||||
assert "settings" in EMBED_TABS
|
assert "settings" in EMBED_TABS
|
||||||
|
assert "account_ledger" in EMBED_TABS
|
||||||
assert path_to_embed_tab("/env_config") == "env_config"
|
assert path_to_embed_tab("/env_config") == "env_config"
|
||||||
assert path_to_embed_tab("/risk_policy") == "risk_policy"
|
assert path_to_embed_tab("/risk_policy") == "risk_policy"
|
||||||
assert path_to_embed_tab("/system_guide") == "system_guide"
|
assert path_to_embed_tab("/system_guide") == "system_guide"
|
||||||
assert path_to_embed_tab("/settings") == "settings"
|
assert path_to_embed_tab("/settings") == "settings"
|
||||||
|
assert path_to_embed_tab("/account_ledger") == "account_ledger"
|
||||||
|
|
||||||
|
|
||||||
def test_embed_context_extras_unified_ui_flags():
|
def test_embed_context_extras_unified_ui_flags():
|
||||||
|
|||||||
Reference in New Issue
Block a user