@@ -129,6 +130,11 @@ function fmt(v,d){v=Number(v||0);return v.toLocaleString(undefined,{maximumFract
function pct(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '
'+(v>0?'+':'')+fmt(v,2)+'% '}
function money(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '
'+(v>0?'+':'')+fmt(v,2)+' USDT '}
function time(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return String(t).slice(0,16).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')}
+function durationText(start,end){if(!start)return'--';var a=new Date(start),b=end?new Date(end):new Date();if(isNaN(a.getTime())||isNaN(b.getTime()))return'--';var mins=Math.max(0,Math.floor((b-a)/60000));var days=Math.floor(mins/1440),hours=Math.floor((mins%1440)/60),m=mins%60;if(days>0)return days+'天 '+hours+'小时';if(hours>0)return hours+'小时 '+m+'分';return Math.max(1,m)+'分'}
+function opportunityHref(x){var symbol=encodeURIComponent((x&&x.symbol)||'');var rec=(x&&x.recommendation_id)!=null?encodeURIComponent(x.recommendation_id):'';return '/opportunity?symbol='+symbol+(rec?'&rec_id='+rec:'')}
+function symbolCell(x){return '
'+esc((x&&x.symbol)||'--')+' #'+esc((x&&x.id)||'--')+' · Rec '+esc((x&&x.recommendation_id)||'--')+'
'}
+function toggleMaintenanceMenu(ev){if(ev)ev.stopPropagation();var pop=$('maintenancePopover');if(pop)pop.classList.toggle('open')}
+document.addEventListener('click',function(ev){var menu=document.querySelector('.maintenance-menu'),pop=$('maintenancePopover');if(pop&&menu&&!menu.contains(ev.target))pop.classList.remove('open')})
function sideText(v){return String(v||'long').toLowerCase()==='short'?'空':'多'}
function sideBadge(v){var s=String(v||'long').toLowerCase();return '
'+sideText(s)+' '}
function setTradeTab(tab){['open','orders','completed','events'].forEach(function(k){var on=k===tab;$('tab-'+k).classList.toggle('active',on);$('tab-'+k).setAttribute('aria-selected',on?'true':'false');$('panel-'+k).classList.toggle('active',on)})}
@@ -141,7 +147,7 @@ async function loadAll(){await Promise.all([loadStrategies(),loadSummary(),loadP
function onStrategyFilterChange(){openOffset=0;eventOffset=0;loadAll()}
async function loadStrategies(){try{var d=await api('/api/paper-trading/strategies?days=120');var sel=$('strategyFilter');var current=sel.value||'';var rows=(d.strategies||[]).filter(function(x){return (x.signal_count||0)||(x.opportunity_count||0)||(x.trade_count||0)||(x.order_count||0)});sel.innerHTML='
全部策略 '+rows.map(function(x){return '
'+esc(x.strategy_name||x.strategy_code)+' · '+esc(x.decision_label||'观察')+' '}).join('');sel.value=Array.prototype.some.call(sel.options,function(o){return o.value===current})?current:''}catch(e){}}
async function sendReport(){var btn=$('sendReportBtn'),note=$('reportNote');btn.disabled=true;btn.textContent='发送中...';note.style.display='block';note.textContent='正在汇总当前交易数据并发送飞书报告...';try{var d=await postApi('/api/paper-trading/report?days=30');note.textContent=d.ok?'报告已发送到飞书。':'报告生成完成,但飞书发送未成功:'+String(d.push_result||'未知原因');await loadSummary()}catch(e){note.textContent='发送失败:'+e.message}finally{btn.disabled=false;btn.textContent='发送策略交易报告'}}
-async function resetLedger(){var scope=$('resetScope').value||'all';var label=$('resetScope').selectedOptions[0]?$('resetScope').selectedOptions[0].textContent:scope;if(!confirm('确认重置“'+label+'”?这个操作会删除策略交易账本里的对应数据,不能从页面恢复。'))return;try{var d=await postApi('/api/paper-trading/reset?scope='+encodeURIComponent(scope));var del=d.deleted||{};alert('已重置:持仓/历史 '+(del.trades||0)+' 条,挂单 '+(del.orders||0)+' 条,日志 '+(del.events||0)+' 条。');await loadAll()}catch(e){alert('重置失败:'+e.message)}}
+async function resetLedger(){var scope=$('resetScope').value||'all';var label=$('resetScope').selectedOptions[0]?$('resetScope').selectedOptions[0].textContent:scope;if(!confirm('确认重置“'+label+'”?这个操作会删除策略交易账本里的对应数据,不能从页面恢复。'))return;try{var d=await postApi('/api/paper-trading/reset?scope='+encodeURIComponent(scope));var del=d.deleted||{};alert('已重置:持仓/历史 '+(del.trades||0)+' 条,挂单 '+(del.orders||0)+' 条,日志 '+(del.events||0)+' 条。');var pop=$('maintenancePopover');if(pop)pop.classList.remove('open');await loadAll()}catch(e){alert('重置失败:'+e.message)}}
async function deleteTrade(id,symbol,status){if(!confirm('确认删除 '+symbol+' 的'+(status==='open'?'持仓':'历史仓位')+'记录?相关操作日志也会一起删除。'))return;try{await deleteApi('/api/paper-trading/trades/'+encodeURIComponent(id));await loadAll()}catch(e){alert('删除失败:'+e.message)}}
async function deleteOrder(id,symbol){if(!confirm('确认删除 '+symbol+' 的挂单记录?'))return;try{await deleteApi('/api/paper-trading/orders/'+encodeURIComponent(id));await loadAll()}catch(e){alert('删除失败:'+e.message)}}
async function loadSummary(){try{var d=await api('/api/paper-trading/summary?days=30');var totalPnl=Number(d.total_pnl_usdt||0),realized=Number(d.realized_pnl_usdt||0),unrealized=Number(d.open_unrealized_pnl_usdt||0),ret=Number(d.account_total_return_pct||0);$('paperNote').textContent='当前账户余额 = 初始本金 + 已实现收益 + 持仓浮动收益。累计杠杆按当前持仓名义价值 ÷ 当前账户余额计算,用来衡量账户整体暴露。';$('kpis').innerHTML=[
@@ -162,7 +168,7 @@ function renderPerformance(d){var points=d.points||[];if(!points.length){$('perf
].join('');AlphaXCharts.renderEquity($('performanceChart'),d)}
async function loadOrders(){$('orderRows').innerHTML='
加载中... ';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending'+strategyQuery());renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML='
'+esc(e.message)+' '}}
function renderOrders(items){if(!items.length){$('orderRows').innerHTML='
暂无等待触价的策略挂单 ';return}$('orderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0);return '
'+
- ''+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+
+ ''+symbolCell(x)+' '+
''+esc(x.status==='pending'?'等待成交':x.status)+' '+
''+sideBadge(x.side)+' '+
'$'+fmt(x.target_price,6)+'
'+
@@ -176,13 +182,14 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML=' '}).join('')}
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'移动止盈 $'+fmt(trail,6)+' ':'移动止盈未启动 ';return 'TP $'+fmt(x.tp1,6)+' SL $'+fmt(x.stop_loss,6)+' '+trailHtml+'
'}
function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))}
-async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML=' 加载中... ';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open'+strategyQuery());openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML='
'+esc(e.message)+' '}}
+async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='
加载中... ';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open'+strategyQuery());openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML='
'+esc(e.message)+' '}}
async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])}
-async function loadCompletedTrades(){$('completedTradeRows').innerHTML='
加载中... ';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+strategyQuery());renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='
'+esc(e.message)+' '}}
+async function loadCompletedTrades(){$('completedTradeRows').innerHTML='
加载中... ';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+strategyQuery());renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='
'+esc(e.message)+' '}}
async function loadCompletedOrders(){$('completedOrderRows').innerHTML='
加载中... ';try{var sets=await Promise.all(['filled','expired','canceled','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s+strategyQuery())}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCompletedOrders(items)}catch(e){$('completedOrderRows').innerHTML='
'+esc(e.message)+' '}}
-function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML='
'+esc(emptyText||'暂无策略交易')+' ';return}$(targetId).innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return '
'+
- ''+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+
+function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML=' '+esc(emptyText||'暂无策略交易')+' ';return}$(targetId).innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return '
'+
+ ''+symbolCell(x)+' '+
''+st+' '+
+ ''+esc(durationText(x.opened_at,x.closed_at))+'
'+(x.status==='closed'?'已结束':'持仓中')+'
'+
''+sideBadge(x.side)+' '+
''+fmt(x.notional_usdt,0)+'U
'+fmt(x.leverage,1)+'x · 保证金 '+fmt(x.margin_usdt,0)+'U
'+
'$'+fmt(x.entry_price,6)+'
'+time(x.opened_at)+'
'+
@@ -197,7 +204,7 @@ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId)
'删除 '+
' '}).join('')}
function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML='
暂无已结束策略挂单 ';return}$('completedOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0),ended=x.filled_at||x.canceled_at||x.updated_at;return '
'+
- ''+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+
+ ''+symbolCell(x)+' '+
''+esc(orderStatus(x))+' '+
''+sideBadge(x.side)+' '+
'$'+fmt(x.target_price,6)+'
'+
@@ -219,7 +226,7 @@ function contextPill(text,cls){return ''
function renderStrategyContext(d){var bits=[];var rg=d.market_regime||{};var gr=d.global_risk||{};var sc=d.score_components||{};if(rg.regime){var rc=rg.risk_level==='critical'||rg.risk_level==='high'?'risk':(rg.regime==='altcoin_rotation'?'good':'warn');bits.push(contextPill((rg.label||rg.regime)+' · '+(rg.risk_level||'medium'),rc))}if(gr.decision){bits.push(contextPill('风控 '+gr.decision,(gr.allow_new_entries===false?'risk':'good')))}if(sc.opportunity_score!=null||sc.entry_score!=null||sc.risk_score!=null){bits.push(contextPill('机会 '+fmt(sc.opportunity_score||0,1)+' / 买点 '+fmt(sc.entry_score||0,1)+' / 风险 '+fmt(sc.risk_score||0,1),'warn'))}var dl=d.decision_log||{};if(dl.decision){bits.push(contextPill('决策 '+dl.decision,''))}return bits.length?''+bits.join('')+'
':''}
function renderEvents(items){if(!items.length){$('eventRows').innerHTML='暂无操作日志
';return}$('eventRows').innerHTML=items.map(function(e){var d=e.detail||{};var detail=[];if(d.notional_usdt)detail.push('名义仓位 '+fmt(d.notional_usdt,0)+'U');if(d.margin_usdt)detail.push('保证金 '+fmt(d.margin_usdt,0)+'U');if(d.leverage)detail.push(fmt(d.leverage,1)+'x');if(d.trailing_stop)detail.push('保护价 $'+fmt(d.trailing_stop,6));if(d.previous_trailing_stop)detail.push('原保护 $'+fmt(d.previous_trailing_stop,6));if(d.distance_pct)detail.push('回撤距离 '+fmt(d.distance_pct,2)+'%');if(d.realized_pnl_usdt!=null)detail.push('实现盈亏 '+fmt(d.realized_pnl_usdt,2)+'U');return ''+
'
'+time(e.event_time)+'#'+esc(e.trade_id)+'
'+
- '
'+esc(e.symbol)+'
'+esc(eventLabel(e.event_type))+' '+
+ '
'+
'
'+esc(e.message||eventLabel(e.event_type))+'
交易状态 '+esc(e.trade_status||'--')+' · 来源推荐 '+esc(e.recommendation_id||'--')+'
'+esc(detail.join(' · ')||'无附加参数')+'
'+renderStrategyContext(d)+'
'+
'
$'+fmt(e.price,6)+' '+pct(e.pnl_pct)+'
'+
'
'}).join('')}
diff --git a/static/review_center.html b/static/review_center.html
index 70b382f..4a20576 100644
--- a/static/review_center.html
+++ b/static/review_center.html
@@ -2,7 +2,7 @@
{% block title %}复盘中心 — AlphaX Agent{% endblock %}
{% block extra_head_css %}
{% endblock %}
{% block content %}
diff --git a/tests/test_live_trading.py b/tests/test_live_trading.py
index 9ed02a9..05311de 100644
--- a/tests/test_live_trading.py
+++ b/tests/test_live_trading.py
@@ -161,6 +161,7 @@ def test_live_account_overview_refresh_compacts_position_value_side_and_leverage
return []
def fetch_orders(self, symbol=None, limit=30):
+ assert symbol == "BTC/USDT"
return []
overview = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: Client())
@@ -173,6 +174,53 @@ def test_live_account_overview_refresh_compacts_position_value_side_and_leverage
assert pos["leverage_source"] == "account_config"
+def test_live_account_overview_fetches_order_history_per_symbol():
+ account = upsert_live_account(
+ account_code="binance_overview_orders_by_symbol",
+ status="enabled",
+ risk_config={"max_order_margin_usdt": 10, "max_symbol_leverage": 2, "allowed_symbols": ["BTC/USDT", "ETHUSDT"]},
+ )
+
+ class Client:
+ def __init__(self):
+ self.order_symbols = []
+
+ def load_markets(self):
+ return {}
+
+ def fetch_balance(self):
+ return {"total": {"USDT": 1000}, "free": {"USDT": 1000}, "used": {"USDT": 0}}
+
+ def fetch_positions(self, symbols=None):
+ return []
+
+ def fetch_open_orders(self, symbol=None):
+ return []
+
+ def fetch_orders(self, symbol=None, limit=30):
+ if symbol is None:
+ raise AssertionError("fetch_orders must be called with a symbol for binanceusdm")
+ self.order_symbols.append(symbol)
+ return [{
+ "id": f"{symbol}-1",
+ "symbol": symbol,
+ "type": "limit",
+ "side": "buy",
+ "status": "closed",
+ "price": 1,
+ "amount": 2,
+ "filled": 2,
+ "datetime": "2026-05-30T08:00:00",
+ }]
+
+ client = Client()
+ overview = get_live_account_overview(account["id"], refresh=True, client_factory=lambda account: client)
+
+ assert client.order_symbols == ["BTC/USDT", "ETH/USDT"]
+ assert len(overview["order_history"]) == 2
+ assert overview["errors"] == []
+
+
def test_live_account_can_be_deleted_without_deleting_history_contract():
account = upsert_live_account(account_code="binance_delete_me", status="disabled")