diff --git a/app/services/live_trading_account.py b/app/services/live_trading_account.py index c9bcc0a..52f991c 100644 --- a/app/services/live_trading_account.py +++ b/app/services/live_trading_account.py @@ -97,6 +97,55 @@ def _compact_order(item: dict) -> dict: } +def _normalize_symbol(symbol: str) -> str: + value = str(symbol or "").strip().upper() + if value and "/" not in value and value.endswith("USDT"): + value = value[:-4] + "/USDT" + return value + + +def _order_history_symbols(account: dict, overview: dict) -> list[str]: + """Build the smallest safe symbol set for Binance order-history queries.""" + risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {} + symbols: list[str] = [] + for raw in risk.get("allowed_symbols") or []: + symbol = _normalize_symbol(raw) + if symbol: + symbols.append(symbol) + for row in (overview.get("positions") or []) + (overview.get("open_orders") or []): + symbol = _normalize_symbol(row.get("symbol")) + if symbol: + symbols.append(symbol) + for row in overview.get("intent_history") or []: + symbol = _normalize_symbol(row.get("symbol")) + if symbol: + symbols.append(symbol) + + result = [] + seen = set() + for symbol in symbols: + key = symbol.upper() + if key not in seen: + seen.add(key) + result.append(symbol) + return result[:20] + + +def _fetch_order_history_by_symbol(client, symbols: list[str], limit: int) -> tuple[list[dict], list[str]]: + orders = [] + errors = [] + if not symbols: + return orders, errors + per_symbol_limit = max(1, min(int(limit or 30), 50)) + for symbol in symbols: + try: + orders.extend(_compact_order(o) for o in client.fetch_orders(symbol, limit=per_symbol_limit)) + except Exception as exc: + errors.append(f"订单历史读取失败 {symbol}:{exc}") + orders.sort(key=lambda x: str(x.get("timestamp") or ""), reverse=True) + return orders[: max(1, int(limit or 30))], errors + + def _account_risk_view(account: dict) -> dict: risk = account.get("risk_config") if isinstance(account.get("risk_config"), dict) else {} allowed = [str(x).strip().upper() for x in risk.get("allowed_symbols", []) if str(x).strip()] @@ -176,9 +225,9 @@ def get_live_account_overview(account_id: int, *, history_limit: int = 30, refre overview["open_orders"] = [_compact_order(o) for o in client.fetch_open_orders(None)] except Exception as exc: overview["errors"].append(f"挂单读取失败:{exc}") - try: - overview["order_history"] = [_compact_order(o) for o in client.fetch_orders(None, limit=history_limit)] - except Exception as exc: - overview["errors"].append(f"订单历史读取失败:{exc}") + symbols = _order_history_symbols(account, overview) + 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} return _cache_overview(account_id, overview) diff --git a/static/paper_trading.html b/static/paper_trading.html index 52158ac..72dae8b 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -2,7 +2,7 @@ {% block title %}AlphaX Agent — 策略交易{% endblock %} {% block extra_head_css %} {% endblock %} @@ -19,27 +19,28 @@ +
+ +
+
账本维护
+
用于清理异常测试数据或重置策略交易账本。删除只影响策略交易表,不会删除推荐、筛选和行情数据。
+
+ + +
+
+
策略交易只统计已经进入交易账本的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。
-
-
-
账本维护
-
用于清理异常测试数据或重置策略交易账本。删除只影响策略交易表,不会删除推荐、筛选和行情数据。
-
-
- - -
-
状态加载中
@@ -62,8 +63,8 @@
持仓中
--
- - + +
币种状态方向仓位开仓止盈 / 止损 / 移动止盈最新价平仓价平仓时间价格收益账户收益退出原因来源操作
加载中...
币种状态持仓时间方向仓位开仓止盈 / 止损 / 移动止盈最新价平仓价平仓时间价格收益账户收益退出原因来源操作
加载中...
@@ -85,8 +86,8 @@
已完成
已平仓交易与已结束挂单
- - + +
币种状态方向仓位开仓止盈 / 止损 / 移动止盈最新价平仓价平仓时间价格收益账户收益退出原因来源操作
加载中...
币种状态持仓时间方向仓位开仓止盈 / 止损 / 移动止盈最新价平仓价平仓时间价格收益账户收益退出原因来源操作
加载中...
@@ -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 ''}).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.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")