This commit is contained in:
aaron 2026-06-07 23:43:54 +08:00
parent 0e8cf51f64
commit 75ff75a9a6
11 changed files with 373 additions and 10 deletions

View File

@ -99,6 +99,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
负责策略交易账本同步和 paper 执行适配。TP/SL、移动止盈、仓位健康、仓位 sizing、账户级风控等可复用交易能力不应长期绑定在 paper trading 层;新增能力优先沉到 `app/core/*` 或独立 execution/risk 模块,再由 paper/live 适配调用。
8. `app/db/live_trading.py` / `app/web/routes_live_trading.py`
负责实盘控制台:多交易所/多 API 账户配置、账号级风控、交易所接口验收和执行审计事件。页面不再使用“订单意图”作为产品概念,也不区分 Demo/正式环境,实际环境由 endpoint/API key 配置决定。
实盘控制台页面默认只读取 PostgreSQL 中的账户快照,不应在首屏加载时直接阻塞调用交易所 API。`live-trading-sync` 调度任务负责定时同步余额、持仓、挂单、订单历史到 `live_account_snapshots`,并按配置把策略交易 open 仓位同步到实盘账户;手动“立即同步”只是强制刷新同一份 DB 快照。
9. `app/services/review_engine.py`
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
@ -244,7 +245,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `app/db/paper_trading.py`
- 模拟交易账本、仓位、成交事件和资金口径。
- `app/db/live_trading.py`
- 实盘控制台账本,多 API 账户、账号级风控、交易所接口验收与执行事件;不保存真实 API secret。
- 实盘控制台账本,多 API 账户、账号级风控、交易所接口验收、账户快照与执行事件;不保存真实 API secret。
- `app/db/market_db.py`
- 市场快照。
- `app/db/system_logs.py`
@ -273,6 +274,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `paper_orders`
- `paper_trade_events`
- `live_trade_accounts`
- `live_account_snapshots`
- `live_order_intents`
- `live_order_events`
- `market_snapshots`
@ -388,6 +390,7 @@ python -m app.cli market
python -m app.cli review
python -m app.cli event
python -m app.cli sentiment --collect
python -m app.cli live-trading-sync
python -m app.cli llm-insights --scope sentiment --limit 40
```
@ -401,6 +404,14 @@ docker compose exec alphax-web python -m app.cli live-trading-smoke --account-id
该命令会依次测试余额/行情、设置杠杆、市价单、止盈单、止损单、限价挂单、撤单、最后市价平仓,并写入 `live_order_events`。不要把真实 API key 写入数据库或聊天;只在环境变量中保存密钥,`live_trade_accounts` 只保存 env key 名。
实盘日常同步入口:
```bash
docker compose exec alphax-web python -m app.cli live-trading-sync --limit 20
```
该命令会先同步所有启用账号的交易所快照到 `live_account_snapshots`,再根据 `live_trading` 配置把未同步的策略交易 open 仓位写入/提交实盘执行。页面读取快照,不应为了展示余额、持仓、挂单而直接访问交易所。
### 8.3 测试与校验
常用回归命令:

View File

@ -3,7 +3,7 @@
import argparse
import sys
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, live_trading_smoke, market_overview, paper_trader, price_streamer, price_tracker, review_engine, sentiment_monitor
from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, live_trading_smoke, live_trading_sync, market_overview, paper_trader, price_streamer, price_tracker, review_engine, sentiment_monitor
def build_parser():
@ -51,6 +51,12 @@ def build_parser():
live_smoke.add_argument("--notional-usdt", type=float, default=10.0, help="测试名义金额,默认 10U")
live_smoke.add_argument("--leverage", type=float, default=1.0, help="测试杠杆,默认 1x")
live_sync = subparsers.add_parser("live-trading-sync", help="同步实盘账户快照,并把策略交易同步到实盘")
live_sync.add_argument("--limit", type=int, default=20, help="本轮最多同步的策略交易持仓数量")
live_sync.add_argument("--no-execute", action="store_true", help="只创建/检查同步意图,不提交交易所")
live_sync.add_argument("--skip-snapshots", action="store_true", help="跳过账户余额/持仓/订单快照同步")
live_sync.add_argument("--skip-paper-sync", action="store_true", help="跳过策略交易到实盘同步")
repair_strategy = subparsers.add_parser("repair-strategy-direction", help="修复策略方向与交易方向不一致的推荐数据")
repair_strategy.add_argument("--limit", type=int, default=500, help="最多扫描的 recommendation 数量")
repair_strategy.add_argument("--dry-run", action="store_true", help="只预览不写库")
@ -126,6 +132,15 @@ def main():
notional_usdt=args.notional_usdt,
leverage=args.leverage,
)
if args.command == "live-trading-sync":
result = live_trading_sync.run_live_trading_sync(
limit=args.limit,
execute=not args.no_execute,
sync_snapshots=not args.skip_snapshots,
sync_paper=not args.skip_paper_sync,
)
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2, default=str))
return result
if args.command == "repair-strategy-direction":
from app.db.strategy_direction_repair import repair_strategy_direction_mismatches

View File

@ -65,7 +65,7 @@ def _row(row) -> dict:
if not row:
return {}
item = dict(row)
for key in ("permissions_json", "risk_config_json", "risk_check_json", "request_json", "response_json", "payload_json"):
for key in ("permissions_json", "risk_config_json", "risk_check_json", "request_json", "response_json", "payload_json", "snapshot_json"):
if key in item:
item[key.replace("_json", "")] = _loads(item.pop(key), {})
for key in ("testnet", "reduce_only"):
@ -270,6 +270,68 @@ def list_enabled_live_accounts() -> list[dict]:
return [_row(r) for r in rows]
def upsert_live_account_snapshot(
account_id: int,
snapshot: dict,
*,
status: str = "ok",
error_message: str = "",
synced_at: str = "",
) -> dict:
account_id = _safe_int(account_id)
if account_id <= 0:
return {"ok": False, "reason": "invalid_account_id"}
now = _now()
synced_at = synced_at or now
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO live_account_snapshots (
account_id, status, error_message, snapshot_json, synced_at, created_at, updated_at
)
VALUES (%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(account_id) DO UPDATE SET
status=excluded.status,
error_message=excluded.error_message,
snapshot_json=excluded.snapshot_json,
synced_at=excluded.synced_at,
updated_at=excluded.updated_at
RETURNING *
""",
(
account_id,
str(status or "ok"),
str(error_message or "")[:1000],
_dumps(snapshot or {}),
synced_at,
now,
now,
),
).fetchone()
conn.commit()
finally:
conn.close()
item = _row(row)
item["ok"] = True
return item
def get_live_account_snapshot(account_id: int) -> dict:
account_id = _safe_int(account_id)
if account_id <= 0:
return {}
conn = get_conn()
try:
row = conn.execute(
"SELECT * FROM live_account_snapshots WHERE account_id=%s",
(account_id,),
).fetchone()
finally:
conn.close()
return _row(row)
def _config_for_account(account: dict | None = None) -> dict:
cfg = get_effective_live_trading_config()
if account:
@ -565,6 +627,14 @@ def get_live_trading_summary() -> dict:
"SELECT * FROM live_order_intents ORDER BY updated_at DESC, id DESC LIMIT 8"
).fetchall()
account_count = conn.execute("SELECT COUNT(*) FROM live_trade_accounts").fetchone()[0]
snapshot_rows = conn.execute(
"""
SELECT status, COUNT(*) AS count, MAX(synced_at) AS latest_synced_at
FROM live_account_snapshots
GROUP BY status
ORDER BY status
"""
).fetchall()
finally:
conn.close()
return {
@ -580,6 +650,8 @@ def get_live_trading_summary() -> dict:
"max_cumulative_leverage": risk["max_cumulative_leverage"],
"max_daily_order_count": risk["max_daily_order_count"],
"account_count": account_count,
"snapshot_status": {r["status"]: r["count"] for r in snapshot_rows},
"latest_snapshot_synced_at": max([str(r["latest_synced_at"] or "") for r in snapshot_rows] or [""]),
"intent_status": {r["status"]: r["count"] for r in status_rows},
"latest_intents": [_row(r) for r in latest_rows],
}

View File

@ -0,0 +1,15 @@
-- Persist exchange account snapshots so the live-trading console reads DB state
-- instead of blocking page load on exchange API calls.
CREATE TABLE IF NOT EXISTS live_account_snapshots (
account_id INTEGER PRIMARY KEY REFERENCES live_trade_accounts(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'ok',
error_message TEXT NOT NULL DEFAULT '',
snapshot_json TEXT NOT NULL DEFAULT '{}',
synced_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_live_account_snapshots_synced_at
ON live_account_snapshots(synced_at DESC);

View File

@ -51,6 +51,16 @@ DEFAULT_JOBS = [
"description": "策略交易账本同步",
"sort_order": 25,
},
{
"job_name": "live-trading-sync",
"command": "live-trading-sync",
"args": ["--limit", "20"],
"every_seconds": 120,
"initial_delay": 45,
"lock_group": "live_trading_write",
"description": "实盘账户快照与策略交易同步",
"sort_order": 27,
},
{
"job_name": "market",
"command": "market",

View File

@ -2,12 +2,26 @@
from __future__ import annotations
from app.db.live_trading import _safe_float, get_live_account, list_live_order_events, list_live_order_intents
from datetime import datetime
from app.db.live_trading import (
_safe_float,
get_live_account,
get_live_account_snapshot,
list_enabled_live_accounts,
list_live_order_events,
list_live_order_intents,
upsert_live_account_snapshot,
)
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
_ACCOUNT_OVERVIEW_CACHE: dict[int, dict] = {}
def _now() -> str:
return datetime.now().isoformat()
def _compact_balance(balance: dict) -> dict:
total = balance.get("total") if isinstance(balance.get("total"), dict) else {}
free = balance.get("free") if isinstance(balance.get("free"), dict) else {}
@ -176,6 +190,41 @@ def _cached_overview(account_id: int) -> dict | None:
return cached
def _exchange_snapshot_payload(overview: dict) -> dict:
return {
"balance": overview.get("balance") or {"assets": [], "usdt": {"free": 0, "used": 0, "total": 0}},
"positions": overview.get("positions") or [],
"open_orders": overview.get("open_orders") or [],
"order_history": overview.get("order_history") or [],
"exchange_cache": overview.get("exchange_cache") or {},
"errors": overview.get("errors") or [],
}
def _merge_snapshot(overview: dict, snapshot_row: dict) -> dict:
payload = snapshot_row.get("snapshot") if isinstance(snapshot_row.get("snapshot"), dict) else {}
if not payload:
return overview
for key in ("balance", "positions", "open_orders", "order_history", "errors"):
if key in payload:
overview[key] = payload.get(key)
synced_at = snapshot_row.get("synced_at") or ""
status = snapshot_row.get("status") or ""
error_message = snapshot_row.get("error_message") or ""
overview["exchange_cache"] = {
**(payload.get("exchange_cache") or {}),
"cached": True,
"loaded": bool((payload.get("exchange_cache") or {}).get("loaded", True)),
"requires_refresh": False,
"source": "database",
"status": status,
"synced_at": synced_at,
}
if status == "error" and error_message and error_message not in (overview.get("errors") or []):
overview.setdefault("errors", []).append(error_message)
return overview
def get_live_account_overview(account_id: int, *, history_limit: int = 30, refresh: bool = False, client_factory=None) -> dict:
account = get_live_account(account_id)
if not account:
@ -195,6 +244,9 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre
if account.get("status") != "enabled":
return overview
if not refresh:
snapshot = get_live_account_snapshot(account_id)
if snapshot:
return _merge_snapshot(overview, snapshot)
cached = _cached_overview(account_id)
if cached:
cached["account"] = account
@ -202,13 +254,29 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre
cached["intent_history"] = overview["intent_history"]
cached["events"] = overview["events"]
return cached
overview["exchange_cache"]["reason"] = "点击刷新交易所数据后读取余额、持仓和订单"
overview["exchange_cache"]["reason"] = "等待后台实盘同步生成账户快照"
return overview
synced_at = _now()
try:
client = client_factory(account) if client_factory else build_binance_client(account, require_testnet=True)
client.load_markets()
except Exception as exc:
overview["errors"].append(f"账户连接失败:{exc}")
overview["exchange_cache"] = {
"cached": False,
"loaded": False,
"requires_refresh": True,
"source": "exchange",
"status": "error",
"synced_at": synced_at,
}
upsert_live_account_snapshot(
account_id,
_exchange_snapshot_payload(overview),
status="error",
error_message=overview["errors"][0],
synced_at=synced_at,
)
return overview
try:
overview["balance"] = _compact_balance(client.fetch_balance())
@ -229,5 +297,64 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre
order_history, order_history_errors = _fetch_order_history_by_symbol(client, symbols, history_limit)
overview["order_history"] = order_history
overview["errors"].extend(order_history_errors)
overview["exchange_cache"] = {"cached": False, "loaded": True, "requires_refresh": False}
overview["exchange_cache"] = {
"cached": False,
"loaded": True,
"requires_refresh": False,
"source": "exchange",
"status": "error" if overview["errors"] else "ok",
"synced_at": synced_at,
}
upsert_live_account_snapshot(
account_id,
_exchange_snapshot_payload(overview),
status="error" if overview["errors"] else "ok",
error_message="".join(overview["errors"][:3]),
synced_at=synced_at,
)
return _cache_overview(account_id, overview)
def sync_live_account_snapshots(*, account_ids: list[int] | None = None, history_limit: int = 30, client_factory=None) -> dict:
selected = {int(x) for x in (account_ids or []) if int(x or 0) > 0}
accounts = list_enabled_live_accounts()
if selected:
accounts = [account for account in accounts if int(account.get("id") or 0) in selected]
items = []
ok_count = 0
error_count = 0
for account in accounts:
try:
overview = get_live_account_overview(
account["id"],
history_limit=history_limit,
refresh=True,
client_factory=client_factory,
)
status = (overview.get("exchange_cache") or {}).get("status") or ("error" if overview.get("errors") else "ok")
if status == "ok":
ok_count += 1
else:
error_count += 1
items.append({
"account_id": account["id"],
"account_code": account.get("account_code"),
"status": status,
"synced_at": (overview.get("exchange_cache") or {}).get("synced_at"),
"errors": overview.get("errors") or [],
})
except Exception as exc:
error_count += 1
items.append({
"account_id": account.get("id"),
"account_code": account.get("account_code"),
"status": "error",
"errors": [str(exc)],
})
return {
"ok": error_count == 0,
"total": len(accounts),
"ok_count": ok_count,
"error_count": error_count,
"items": items,
}

View File

@ -18,6 +18,7 @@ from app.db.live_trading import (
from app.config.system_config import live_trading_config
from app.db.schema import get_conn, init_db
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
from app.services.live_trading_account import sync_live_account_snapshots
def _now() -> str:
@ -279,3 +280,36 @@ def sync_open_paper_trades_to_live(*, limit: int = 20, execute: bool = True, cli
"execute": execute,
"results": results,
}
def run_live_trading_sync(
*,
limit: int = 20,
execute: bool = True,
sync_snapshots: bool = True,
sync_paper: bool = True,
client_factory=None,
) -> dict:
"""Single scheduler entrypoint for live account snapshots and paper sync."""
init_db()
cfg = live_trading_config()
snapshot_result = {"skipped": True, "reason": "snapshot_sync_disabled"}
paper_result = {"skipped": True, "reason": "paper_sync_disabled"}
if sync_snapshots:
snapshot_result = sync_live_account_snapshots(client_factory=client_factory)
if sync_paper:
if not bool(cfg.get("enabled")):
paper_result = {"ok": False, "skipped": True, "reason": "live_trading_disabled", "results": []}
else:
paper_result = sync_open_paper_trades_to_live(
limit=limit,
execute=execute,
client_factory=client_factory,
)
return {
"ok": bool(snapshot_result.get("ok", True)) and bool(paper_result.get("ok", True)),
"enabled": bool(cfg.get("enabled")),
"execute": execute,
"snapshots": snapshot_result,
"paper_sync": paper_result,
}

View File

@ -37,7 +37,7 @@
<section class="tab-panel active" id="overviewPane">
<section class="panel">
<div class="panel-head"><div><div class="panel-title">资金与持仓</div><div class="panel-note" id="exchangeCacheNote">默认显示本地缓存,手动刷新才请求交易所</div></div><button class="btn" id="refreshExchangeBtn" onclick="refreshExchangeData()">刷新交易所数据</button></div>
<div class="panel-head"><div><div class="panel-title">资金与持仓</div><div class="panel-note" id="exchangeCacheNote">默认读取数据库快照,后台定时同步交易所数据</div></div><button class="btn" id="refreshExchangeBtn" onclick="refreshExchangeData()">立即同步</button></div>
<div class="panel-body">
<div class="grid" id="accountInfo"></div>
<div style="height:12px"></div>
@ -119,7 +119,9 @@ function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},risk=o.ris
function fillAccountForm(id){var x=(state.accounts||[]).find(function(a){return Number(a.id)===Number(id)})||{},r=x.risk_config||{};$('accountCode').value=x.account_code||'';$('accountStatus').value=x.status||'disabled';$('accountExchange').value=x.exchange||'binance';$('accountMarket').value=x.market_type||'um_futures';$('apiKeyEnv').value=x.api_key_env||'ALPHAX_BINANCE_API_KEY';$('apiSecretEnv').value=x.api_secret_env||'ALPHAX_BINANCE_API_SECRET';$('maxOrderMargin').value=r.max_order_margin_usdt||10;$('maxSymbolLeverage').value=r.max_symbol_leverage||1;$('maxCumulativeLeverage').value=r.max_cumulative_leverage||1;$('allowedSymbols').value=(r.allowed_symbols||[]).join(',');if($('saveAccountBtn'))$('saveAccountBtn').textContent=Number(id)>0?'保存修改':'新增账号'}
function resetForm(){state.selectedId=0;state.overview=null;renderAccounts();fillAccountForm(0);renderKpis();renderOverview();document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});document.querySelector('.tab[onclick*="config"]').classList.add('active');$('configPane').classList.add('active')}
function info(k,v){return '<div class="info"><span>'+esc(k)+'</span><b>'+esc(v)+'</b></div>'}
function renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},errors=o.errors||[],cache=o.exchange_cache||{};if($('exchangeCacheNote'))$('exchangeCacheNote').textContent=cache.loaded?(cache.cached?'交易所数据来自缓存':'交易所数据已刷新'):(cache.reason||'默认显示本地缓存,手动刷新才请求交易所');$('accountInfo').innerHTML=[info('账号状态',a.status||'--'),info('API Key 变量',a.api_key_env||'--'),info('每单保证金上限',fmt(risk.max_order_margin_usdt,2)+' USDT'),info('单币杠杆上限',fmt(risk.max_symbol_leverage,2)+'x'),info('累计杠杆上限',fmt(risk.max_cumulative_leverage,2)+'x'),info('允许交易币种',risk.symbol_policy==='all'?'全部币种':(risk.allowed_symbols||[]).join(', '))].join('')+(errors.length?'<div class="note" style="grid-column:1/-1">账户数据读取异常:'+esc(errors[0])+'</div>':'')+(!cache.loaded&&a.status==='enabled'?'<div class="note" style="grid-column:1/-1">为避免页面打开被 Binance API 拖慢,余额、持仓和订单默认不自动刷新。点击“刷新交易所数据”后读取。</div>':'');renderPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
function renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},errors=o.errors||[],cache=o.exchange_cache||{},syncText=cache.synced_at?(' · 同步 '+time(cache.synced_at)):'';
if($('exchangeCacheNote'))$('exchangeCacheNote').textContent=cache.loaded?(cache.source==='database'?'读取数据库快照'+syncText:'交易所数据已同步'+syncText):(cache.reason||'等待后台同步生成账户快照');
$('accountInfo').innerHTML=[info('账号状态',a.status||'--'),info('API Key 变量',a.api_key_env||'--'),info('每单保证金上限',fmt(risk.max_order_margin_usdt,2)+' USDT'),info('单币杠杆上限',fmt(risk.max_symbol_leverage,2)+'x'),info('累计杠杆上限',fmt(risk.max_cumulative_leverage,2)+'x'),info('允许交易币种',risk.symbol_policy==='all'?'全部币种':(risk.allowed_symbols||[]).join(', '))].join('')+(errors.length?'<div class="note" style="grid-column:1/-1">账户数据读取异常:'+esc(errors[0])+'</div>':'')+(!cache.loaded&&a.status==='enabled'?'<div class="note" style="grid-column:1/-1">后台实盘同步还没有生成该账号的账户快照。可以等待调度器下一轮同步,或点击“立即同步”。</div>':'');renderPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
function table(headers,rows,empty){if(!rows.length)return '<div class="empty">'+esc(empty||'暂无数据')+'</div>';return '<table><thead><tr>'+headers.map(function(h){return '<th>'+esc(h)+'</th>'}).join('')+'</tr></thead><tbody>'+rows.join('')+'</tbody></table>'}
function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return '<tr><td>'+esc(x.symbol)+'</td><td>'+badge(x.side_label||sideText(x.side))+'</td><td>'+fmt(x.contracts,6)+'</td><td>'+fmt(x.position_value_usdt,2)+' U</td><td>'+fmt(x.entry_price,6)+'</td><td>'+fmt(x.mark_price,6)+'</td><td>'+fmt(x.unrealized_pnl,4)+'</td><td>'+fmt(lev,2)+'x</td></tr>'});$('positions').innerHTML=table(['币种','方向','数量','仓位价值','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')}
function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return '<tr><td>'+time(x.timestamp)+'</td><td>'+esc(x.symbol)+'</td><td>'+esc(x.type)+'</td><td>'+badge(sideText(x.side))+'</td><td>'+fmt(x.price,8)+'</td><td>'+fmt(x.amount,6)+'</td><td>'+badge(x.status)+'</td></tr>'});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')}
@ -131,7 +133,7 @@ async function saveAccount(){var allowed=String($('allowedSymbols').value||'').s
async function deleteAccount(){if(!state.selectedId){alert('请先选择要删除的账号');return}var acct=selectedAccountObj();if(!confirm('确认删除账号配置:'+(acct.account_code||state.selectedId)+'\\n历史订单和调用日志会保留。'))return;var resp=await fetch('/api/live-trading/accounts/'+state.selectedId,{method:'DELETE'});var d=await resp.json().catch(function(){return {}});if(!resp.ok){alert('删除失败:'+(d.detail||'unknown_error'));return}state.selectedId=0;await loadAll()}
async function runSmoke(){if(!state.selectedId){alert('请先选择账号');return}var acct=selectedAccountObj(),risk=acct.risk_config||{},lev=Number(risk.max_symbol_leverage||1),margin=Number($('smokeMargin').value||risk.max_order_margin_usdt||10),notional=margin*Math.max(1,lev);var btn=$('smokeBtn');btn.disabled=true;btn.textContent='验收中...';try{var resp=await fetch('/api/live-trading/smoke/binance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account_id:state.selectedId,symbol:$('smokeSymbol').value,notional_usdt:notional,leverage:lev})});var d=await resp.json();if(!resp.ok||d.detail){alert('接口验收失败:'+(d.detail||JSON.stringify(d).slice(0,220)))}await loadAll()}catch(e){alert('接口验收请求失败:'+e.message)}finally{btn.disabled=false;btn.textContent='开始验收'}}
async function loadOverview(refresh){if(!state.selectedId){state.overview=null;renderAll();return}state.overview=await (await fetch('/api/live-trading/accounts/'+state.selectedId+'/overview?refresh='+(refresh?1:0)+'&_ts='+Date.now(),{cache:'no-store'})).json();renderAll()}
async function refreshExchangeData(){if(!state.selectedId){alert('请先选择账号');return}var btn=$('refreshExchangeBtn');if(btn){btn.disabled=true;btn.textContent='刷新中...'}try{await loadOverview(true)}finally{if(btn){btn.disabled=false;btn.textContent='刷新交易所数据'}}}
async function refreshExchangeData(){if(!state.selectedId){alert('请先选择账号');return}var btn=$('refreshExchangeBtn');if(btn){btn.disabled=true;btn.textContent='同步中...'}try{await loadOverview(true)}finally{if(btn){btn.disabled=false;btn.textContent='立即同步'}}}
async function loadAll(){try{var s=await (await fetch('/api/live-trading/summary?_ts='+Date.now(),{cache:'no-store'})).json();var a=await (await fetch('/api/live-trading/accounts?_ts='+Date.now(),{cache:'no-store'})).json();var e=await (await fetch('/api/live-trading/events?limit=100&_ts='+Date.now(),{cache:'no-store'})).json();state.summary=s;state.accounts=a.items||[];state.events=e.items||[];if(!state.selectedId&&state.accounts[0])state.selectedId=state.accounts[0].id;await loadOverview()}catch(e){$('kpis').innerHTML='<div class="kpi"><span>状态</span><b>加载失败</b><small>'+esc(e.message)+'</small></div>'}}
loadAll();
</script>

View File

@ -77,6 +77,7 @@ _ID_TABLES = {
"paper_orders",
"paper_trades",
"paper_trade_events",
"live_account_snapshots",
"live_trade_accounts",
"live_order_intents",
"live_order_events",

View File

@ -5,6 +5,7 @@ from app.db.live_trading import (
create_live_order_intents_for_accounts,
delete_live_account,
get_live_account,
get_live_account_snapshot,
list_live_accounts,
list_live_order_intents,
upsert_live_account,
@ -14,7 +15,7 @@ from app.integrations.binance_live import build_binance_client
from app.services.live_trading_account import get_live_account_overview
from app.services import live_trading_account
from app.services.live_trading_smoke import run_binance_testnet_smoke
from app.services.live_trading_sync import sync_paper_trade_to_live
from app.services.live_trading_sync import run_live_trading_sync, sync_paper_trade_to_live
from app.web import web_server
from app.db.schema import get_conn
@ -174,6 +175,46 @@ def test_live_account_overview_refresh_compacts_position_value_side_and_leverage
assert pos["leverage_source"] == "account_config"
def test_live_account_overview_refresh_persists_database_snapshot_and_reads_without_exchange(monkeypatch):
account = upsert_live_account(
account_code="binance_snapshot_cache",
status="enabled",
risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": ["BTC/USDT"]},
)
class Client:
def load_markets(self):
return {}
def fetch_balance(self):
return {"total": {"USDT": 1234}, "free": {"USDT": 1200}, "used": {"USDT": 34}}
def fetch_positions(self, symbols=None):
return []
def fetch_open_orders(self, symbol=None):
return []
def fetch_orders(self, symbol=None, limit=30):
return []
refreshed = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: Client())
snapshot = get_live_account_snapshot(account["id"])
def fail_build(*args, **kwargs):
raise AssertionError("database snapshot read must not call exchange")
monkeypatch.setattr(live_trading_account, "build_binance_client", fail_build)
cached = get_live_account_overview(account["id"], refresh=False)
assert refreshed["balance"]["usdt"]["total"] == 1234
assert snapshot["status"] == "ok"
assert snapshot["snapshot"]["balance"]["usdt"]["total"] == 1234
assert cached["balance"]["usdt"]["total"] == 1234
assert cached["exchange_cache"]["source"] == "database"
assert cached["exchange_cache"]["cached"] is True
def test_live_account_overview_fetches_order_history_per_symbol():
account = upsert_live_account(
account_code="binance_overview_orders_by_symbol",
@ -496,6 +537,39 @@ def test_paper_trade_sync_executes_scaled_live_order_once():
assert again["items"][0]["reason"] == "already_synced"
def test_live_trading_sync_job_refreshes_snapshots_and_syncs_open_paper_trade():
set_config("system", "live_trading", {
"enabled": True,
"execution_mode": "exchange_api",
"require_human_approval": False,
"exchange": "binance",
"market_type": "um_futures",
"testnet": True,
"risk": {"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "allowed_symbols": []},
}, source="test")
account = upsert_live_account(
account_code="binance_scheduler_sync",
status="enabled",
risk_config={"max_order_margin_usdt": 20, "max_symbol_leverage": 2, "max_cumulative_leverage": 5, "allowed_symbols": []},
)
trade = _insert_paper_trade(symbol="BTC/USDT")
fake = _FakeBinanceClient()
fake.fetch_balance = lambda: {"total": {"USDT": 1000}, "free": {"USDT": 900}, "used": {"USDT": 100}}
fake.fetch_open_orders = lambda symbol=None: []
fake.fetch_orders = lambda symbol=None, limit=30: []
result = run_live_trading_sync(limit=20, execute=True, client_factory=lambda acct: fake)
snapshot = get_live_account_snapshot(account["id"])
intents = list_live_order_intents(account_id=account["id"])["items"]
assert result["ok"] is True
assert result["snapshots"]["ok_count"] == 1
assert result["paper_sync"]["processed_count"] == 1
assert snapshot["snapshot"]["balance"]["usdt"]["total"] == 1000
assert intents[0]["paper_trade_id"] == trade["id"]
assert intents[0]["status"] == "submitted"
def test_binance_testnet_smoke_covers_market_limit_cancel_tp_sl_interfaces():
account = upsert_live_account(
account_code="binance_testnet",

View File

@ -29,6 +29,8 @@ def test_scheduler_tables_seed_defaults(monkeypatch, tmp_path):
assert jobs["confirm"]["lock_group"] == "recommendation_write"
assert jobs["tracker"]["every_seconds"] == 180
assert jobs["paper-trader"]["lock_group"] == "paper_trading_write"
assert jobs["live-trading-sync"]["command"] == "live-trading-sync"
assert jobs["live-trading-sync"]["lock_group"] == "live_trading_write"
assert "onchain" not in jobs