@@ -111,21 +125,25 @@ function fmt(n,d){n=Number(n||0);return n.toLocaleString(undefined,{maximumFract
function time(s){return esc(String(s||'').replace('T',' ').slice(0,19))}
function badge(v){var cls=v==='enabled'||v==='ok'||v==='closed'?'green':(v==='disabled'||v==='error'?'red':(v==='open'||v==='exchange_api'?'blue':'warn'));return ''+esc(v||'--')+''}
function sideText(v){v=String(v||'').toLowerCase();if(v==='long'||v==='buy')return '多';if(v==='short'||v==='sell')return '空';return v||'--'}
+function sidePill(v){var s=String(v||'').toLowerCase(),isShort=s==='short'||s==='sell'||s==='空';return ''+(isShort?'空':'多')+''}
+function pnlClass(n){n=Number(n||0);return n>0?'green':n<0?'red':''}
+function signed(n,d,suffix){n=Number(n||0);var txt=(n>0?'+':'')+fmt(n,d==null?2:d)+(suffix||'');return ''+txt+''}
function selectedAccountObj(){return (state.accounts||[]).find(function(x){return Number(x.id)===Number(state.selectedId)})||{}}
function showTab(id,btn){document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});btn.classList.add('active');$(id+'Pane').classList.add('active')}
function card(k,v,cls,s){return ''+esc(k)+''+esc(v)+''+esc(s||'')+'
'}
function renderAccounts(){var rows=state.accounts||[];if(!rows.length){$('accounts').innerHTML='暂无账号配置
';return}$('accounts').innerHTML=rows.map(function(x){var r=x.risk_config||{},allowed=r.allowed_symbols||[];return ''+esc(x.account_code)+''+badge(x.status)+'
'+esc(x.exchange)+' · '+esc(x.market_type)+' · '+esc(x.api_key_env||'--')+'
保证金 '+fmt(r.max_order_margin_usdt||0,2)+'U杠杆 '+fmt(r.max_symbol_leverage||1,2)+'x'+(allowed.length?'限制 '+allowed.length+' 个币':'全部币种')+'
'}).join('')}
-function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},b=(o.balance||{}).usdt||{},pos=o.positions||[];$('kpis').innerHTML=[card('当前账号',a.account_code||'未选择','blue',a.exchange?esc(a.exchange)+' · '+esc(a.market_type):'--'),card('USDT 总额',fmt(b.total,2),'','可用 '+fmt(b.free,2)+' / 占用 '+fmt(b.used,2)),card('持仓数量',pos.length,'','当前未平仓合约'),card('币种权限',risk.symbol_policy==='all'?'全部币种':'白名单 '+(risk.allowed_symbols||[]).length+' 个',risk.symbol_policy==='all'?'green':'blue','留空即不限制')].join('')}
+function renderKpis(){var a=selectedAccountObj(),o=state.overview||{},p=o.performance||{},pos=o.positions||[];$('kpis').innerHTML=[card('当前净值',fmt(p.equity_usdt,2)+'U','blue',a.account_code||'未选择账号'),card('总盈亏',(Number(p.total_pnl_usdt||0)>0?'+':'')+fmt(p.total_pnl_usdt,2)+'U',pnlClass(p.total_pnl_usdt),'按首次同步净值计算'),card('收益率',(Number(p.return_pct||0)>0?'+':'')+fmt(p.return_pct,2)+'%',pnlClass(p.return_pct),'基准 '+fmt(p.baseline_equity_usdt,2)+'U'),card('当前/最大回撤',fmt(p.current_drawdown_pct,2)+'% / '+fmt(p.max_drawdown_pct,2)+'%','red','持仓 '+pos.length+' 个 · 浮盈 '+(Number(p.unrealized_pnl_usdt||0)>0?'+':'')+fmt(p.unrealized_pnl_usdt,2)+'U')].join('')}
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 ''+esc(k)+''+esc(v)+'
'}
-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)):'';
+function renderOverview(){var a=selectedAccountObj(),o=state.overview||{},risk=o.risk||{},p=o.performance||{},b=(o.balance||{}).usdt||{},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?'账户数据读取异常:'+esc(errors[0])+'
':'')+(!cache.loaded&&a.status==='enabled'?'后台实盘同步还没有生成该账号的账户快照。可以等待调度器下一轮同步,或点击“立即同步”。
':'');renderPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
+$('accountInfo').innerHTML=[info('可用资金',fmt(b.free,2)+' USDT'),info('保证金占用',fmt(b.used||p.used_margin_usdt,2)+' USDT'),info('持仓名义价值',fmt(p.open_position_value_usdt,2)+' USDT'),info('浮动盈亏',(Number(p.unrealized_pnl_usdt||0)>0?'+':'')+fmt(p.unrealized_pnl_usdt,2)+' USDT'),info('权益峰值',fmt(p.peak_equity_usdt,2)+' USDT'),info('统计口径',p.basis||'等待首次同步')].join('')+(errors.length?'账户数据读取异常:'+esc(errors[0])+'
':'')+(!cache.loaded&&a.status==='enabled'?'后台实盘同步还没有生成该账号的账户快照。可以等待调度器下一轮同步,或点击“立即同步”。
':'');renderPositions();renderHistoricalPositions();renderOpenOrders();renderOrderHistory();renderEvents()}
function table(headers,rows,empty){if(!rows.length)return ''+esc(empty||'暂无数据')+'
';return ''+headers.map(function(h){return '| '+esc(h)+' | '}).join('')+'
'+rows.join('')+'
'}
-function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return '| '+esc(x.symbol)+' | '+badge(x.side_label||sideText(x.side))+' | '+fmt(x.contracts,6)+' | '+fmt(x.position_value_usdt,2)+' U | '+fmt(x.entry_price,6)+' | '+fmt(x.mark_price,6)+' | '+fmt(x.unrealized_pnl,4)+' | '+fmt(lev,2)+'x |
'});$('positions').innerHTML=table(['币种','方向','数量','仓位价值','开仓价','标记价','未实现盈亏','杠杆'],rows,'当前账号暂无持仓')}
-function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return '| '+time(x.timestamp)+' | '+esc(x.symbol)+' | '+esc(x.type)+' | '+badge(sideText(x.side))+' | '+fmt(x.price,8)+' | '+fmt(x.amount,6)+' | '+badge(x.status)+' |
'});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')}
-function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return '| '+time(x.timestamp)+' | '+esc(x.symbol)+' | '+esc(x.type)+' | '+badge(sideText(x.side))+' | '+fmt(x.average||x.price,8)+' | '+fmt(x.filled||x.amount,6)+' | '+badge(x.status)+' |
'});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','状态'],orders,'当前账号暂无订单历史')}
+function renderPositions(){var rows=(state.overview?.positions||[]).map(function(x){var lev=Number(x.leverage||0);return '| '+esc(x.symbol)+' | '+sidePill(x.side)+' | '+fmt(x.position_value_usdt,2)+' U | '+fmt(x.margin_usdt,2)+' U | '+fmt(x.entry_price,6)+' | '+fmt(x.mark_price,6)+' | '+signed(x.unrealized_pnl,4,' U')+' | '+signed(x.pnl_pct,2,'%')+' | '+fmt(lev,2)+'x |
'});$('positions').innerHTML=table(['币种','方向','仓位价值','保证金','开仓价','标记价','浮盈亏','收益率','杠杆'],rows,'当前账号暂无持仓')}
+function renderHistoricalPositions(){var rows=(state.overview?.historical_positions||[]).map(function(x){var cls=Number(x.realized_pnl||0)>0?'profit':Number(x.realized_pnl||0)<0?'loss':'flat';return '| '+time(x.time)+' | '+esc(x.symbol)+' | '+sidePill(x.side)+' | '+fmt(x.price,8)+' | '+fmt(x.amount,6)+' | '+signed(x.realized_pnl,4,' U')+' | '+esc(x.result||'未知')+' |
'});$('historicalPositions').innerHTML=table(['时间','币种','方向','成交价','数量','已实现盈亏','结果'],rows,'暂无可识别的历史仓位盈亏。若交易所订单未返回 realizedPnl,只能在订单历史查看成交记录。')}
+function renderOpenOrders(){var rows=(state.overview?.open_orders||[]).map(function(x){return '| '+time(x.timestamp)+' | '+esc(x.symbol)+' | '+esc(x.type)+' | '+sidePill(x.side)+' | '+fmt(x.price,8)+' | '+fmt(x.amount,6)+' | '+badge(x.status)+' |
'});$('openOrders').innerHTML=table(['时间','币种','类型','方向','价格','数量','状态'],rows,'当前账号暂无挂单')}
+function renderOrderHistory(){var orders=(state.overview?.order_history||[]).map(function(x){return '| '+time(x.timestamp)+' | '+esc(x.symbol)+' | '+esc(x.type)+' | '+sidePill(x.side)+' | '+fmt(x.average||x.price,8)+' | '+fmt(x.filled||x.amount,6)+' | '+signed(x.realized_pnl,4,' U')+' | '+badge(x.status)+' |
'});$('orderHistory').innerHTML=table(['时间','币种','类型','方向','均价/价格','成交/数量','已实现盈亏','状态'],orders,'当前账号暂无订单历史')}
function renderEvents(){var rows=(state.events||[]).slice(0,20).map(function(e){return '| '+time(e.event_time)+' | '+esc(e.event_type)+' | '+badge(e.status)+' | '+esc(e.message||'--')+' |
'});$('events').innerHTML=table(['时间','事件','状态','说明'],rows,'暂无维护日志')}
function renderAll(){if(!state.selectedId && state.accounts[0])state.selectedId=state.accounts[0].id;renderAccounts();fillAccountForm(state.selectedId);renderKpis();renderOverview()}
async function selectAccount(id){state.selectedId=Number(id);renderAccounts();fillAccountForm(id);await loadOverview()}
diff --git a/tests/conftest.py b/tests/conftest.py
index f73044a..0d0a2cc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -78,6 +78,7 @@ _ID_TABLES = {
"paper_trades",
"paper_trade_events",
"live_account_snapshots",
+ "live_account_equity_history",
"live_trade_accounts",
"live_order_intents",
"live_order_events",
diff --git a/tests/test_live_trading.py b/tests/test_live_trading.py
index 9f4a535..95d54c1 100644
--- a/tests/test_live_trading.py
+++ b/tests/test_live_trading.py
@@ -60,7 +60,9 @@ def test_live_trading_admin_can_access_page_and_seed_account():
assert page.status_code == 200
assert "实盘控制台" in page.text
- assert "账户总览" in page.text
+ assert "账户表现" in page.text
+ assert "当前净值" in page.text
+ assert "历史仓位" in page.text
assert "留空=全部" in page.text
assert created.status_code == 200
assert created.json()["account_code"] == "binance_sub_1"
@@ -173,6 +175,9 @@ def test_live_account_overview_refresh_compacts_position_value_side_and_leverage
assert pos["position_value_usdt"] == 1520
assert pos["leverage"] == 3
assert pos["leverage_source"] == "account_config"
+ assert round(pos["pnl_pct"], 2) == 1.32
+ assert overview["performance"]["equity_usdt"] == 1000
+ assert overview["performance"]["unrealized_pnl_usdt"] == 20
def test_live_account_overview_refresh_persists_database_snapshot_and_reads_without_exchange(monkeypatch):
@@ -252,6 +257,7 @@ def test_live_account_overview_fetches_order_history_per_symbol():
"amount": 2,
"filled": 2,
"datetime": "2026-05-30T08:00:00",
+ "info": {"realizedPnl": "3.5"},
}]
client = Client()
@@ -259,6 +265,7 @@ def test_live_account_overview_fetches_order_history_per_symbol():
assert client.order_symbols == ["BTC/USDT", "ETH/USDT"]
assert len(overview["order_history"]) == 2
+ assert overview["historical_positions"][0]["result"] == "盈利"
assert overview["errors"] == []