105 lines
15 KiB
HTML
105 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}AlphaX Agent — 模拟交易{% endblock %}
|
|
{% block extra_head_css %}
|
|
<style>
|
|
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:-.7px}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select,.input{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.input{width:150px}.btn{cursor:pointer}.kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}.kpi{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:14px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:11px;font-weight:900}.kpi b{display:block;margin-top:7px;color:var(--ink);font-size:23px;line-height:1;font-weight:950;letter-spacing:-.5px}.kpi small{display:block;margin-top:7px;color:var(--stone);font-size:11px;font-weight:800}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.note{border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);padding:11px 12px;color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;margin-top:14px}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.table-wrap{overflow:auto}.table{width:100%;border-collapse:collapse;min-width:1180px}.table th,.table td{padding:11px 10px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.table th{font-size:11px;color:var(--stone);font-weight:900;background:var(--surface)}.sym{font-weight:950;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;padding:0 8px;font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--slate);white-space:nowrap}.badge.open{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.closed{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.event-open{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.event-close{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.event-trailing{background:rgba(245,158,11,.12);border-color:rgba(245,158,11,.22);color:#a05a00}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:850}.pos{color:var(--green)}.neg{color:var(--red)}.muted{color:var(--stone)}.riskline{display:grid;gap:3px}.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;color:var(--stone);font-size:12px}.pagination button{height:34px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:12px;font-weight:850;cursor:pointer}.pagination button:disabled{opacity:.45;cursor:default}.empty,.loading{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}.event-list{padding:4px 14px 12px}.event{display:grid;grid-template-columns:130px 140px 1fr 170px;gap:12px;align-items:flex-start;padding:13px 0;border-bottom:1px solid var(--hairline-soft)}.event:last-child{border-bottom:0}.event-time{font-size:12px;color:var(--stone);font-weight:850}.event-main{min-width:0}.event-title{font-size:13px;font-weight:950;color:var(--ink);margin-bottom:4px}.event-msg{font-size:12px;color:var(--slate);line-height:1.45}.event-detail{font-size:11px;color:var(--stone);line-height:1.5}.event-price{text-align:right;font-size:12px}.event-price b{display:block;color:var(--ink);font-size:13px}@media(max-width:1100px){.kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.event{grid-template-columns:110px 120px 1fr}}@media(max-width:700px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.shell{width:min(100% - 24px,1280px)}.event{grid-template-columns:1fr}.event-price{text-align:left}}@media(max-width:520px){.kpis{grid-template-columns:1fr}.page-head h1{font-size:22px}}
|
|
</style>
|
|
{% endblock %}
|
|
{% block content %}
|
|
<div class="shell">
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>模拟交易</h1>
|
|
<p>这里展示的是 paper trading 账本:只有系统把可买信号模拟成交后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。</p>
|
|
</div>
|
|
<div class="actions">
|
|
<select class="select" id="statusFilter" onchange="loadTrades(0)">
|
|
<option value="">全部</option>
|
|
<option value="open">持仓中</option>
|
|
<option value="closed">已平仓</option>
|
|
</select>
|
|
<button class="btn" onclick="loadAll()">刷新</button>
|
|
</div>
|
|
</div>
|
|
<div class="note" id="paperNote">模拟交易只统计已经进入 paper trading 的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。</div>
|
|
<div class="kpis" id="kpis"><div class="kpi"><span>状态</span><b>加载中</b></div></div>
|
|
<section class="panel">
|
|
<div class="panel-head"><div class="panel-title">交易账本</div><div class="panel-note" id="pageInfo">--</div></div>
|
|
<div class="table-wrap">
|
|
<table class="table">
|
|
<thead><tr><th>币种</th><th>状态</th><th>仓位</th><th>开仓</th><th>止盈 / 止损</th><th>最新价</th><th>平仓价</th><th>平仓时间</th><th>价格收益</th><th>账户收益</th><th>退出原因</th><th>来源</th></tr></thead>
|
|
<tbody id="tradeRows"><tr><td colspan="12" class="loading">加载中...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination" id="pager"></div>
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-head">
|
|
<div><div class="panel-title">操作日志</div><div class="panel-note">开仓、平仓、移动止盈激活与上移都会记录在这里</div></div>
|
|
<div class="actions">
|
|
<input class="input" id="eventSymbol" placeholder="币种,如 LINK/USDT" onkeydown="if(event.key==='Enter')loadEvents(0)">
|
|
<select class="select" id="eventType" onchange="loadEvents(0)">
|
|
<option value="">全部动作</option>
|
|
<option value="open">开仓</option>
|
|
<option value="close">平仓</option>
|
|
<option value="trailing_activate">移动止盈激活</option>
|
|
<option value="trailing_move">移动止盈上移</option>
|
|
</select>
|
|
<button class="btn" onclick="loadEvents(0)">查询</button>
|
|
</div>
|
|
</div>
|
|
<div class="event-list" id="eventRows"><div class="loading">加载中...</div></div>
|
|
<div class="pagination" id="eventPager"></div>
|
|
</section>
|
|
</div>
|
|
{% endblock %}
|
|
{% block extra_script %}
|
|
<script>
|
|
var LIMIT=50,offset=0,total=0,EVENT_LIMIT=80,eventOffset=0,eventTotal=0;
|
|
function $(id){return document.getElementById(id)}
|
|
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
|
function fmt(v,d){v=Number(v||0);return v.toLocaleString(undefined,{maximumFractionDigits:d==null?4:d,minimumFractionDigits:0})}
|
|
function pct(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span class="mono '+cls+'">'+(v>0?'+':'')+fmt(v,2)+'%</span>'}
|
|
function money(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span class="mono '+cls+'">'+(v>0?'+':'')+fmt(v,2)+' USDT</span>'}
|
|
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')}
|
|
async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
|
|
async function loadAll(){await Promise.all([loadSummary(),loadTrades(offset),loadEvents(eventOffset)])}
|
|
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=[
|
|
card('当前账户余额',fmt(d.current_balance_usdt||d.account_equity_usdt||0,2)+'U',totalPnl>=0?'green':'red','初始本金 '+fmt(d.initial_equity_usdt||d.account_equity_usdt||0,0)+'U'),
|
|
card('当前持仓价值',fmt(d.open_position_value_usdt||0,0)+'U','blue',(d.open_count||0)+' 个持仓中'),
|
|
card('持仓累计杠杆',fmt(d.cumulative_leverage||0,2)+'x','', '占账户余额的名义暴露'),
|
|
card('收益率',fmt(ret,2)+'%',ret>=0?'green':'red','按初始本金计算'),
|
|
card('收益额',(totalPnl>=0?'+':'')+fmt(totalPnl,2)+'U',totalPnl>=0?'green':'red','已实现 '+fmt(realized,2)+'U · 浮动 '+fmt(unrealized,2)+'U'),
|
|
card('胜率',(d.win_rate||0)+'%','green',(d.closed_count||0)+' 个已平仓')
|
|
].join('')}catch(e){$('kpis').innerHTML='<div class="kpi"><span>状态</span><b>加载失败</b></div>'}}
|
|
function card(label,value,cls,sub){return '<div class="kpi"><span>'+esc(label)+'</span><b class="'+esc(cls||'')+'">'+esc(value)+'</b>'+(sub?'<small>'+esc(sub)+'</small>':'')+'</div>'}
|
|
async function loadTrades(nextOffset){offset=Math.max(0,nextOffset||0);$('tradeRows').innerHTML='<tr><td colspan="12" class="loading">加载中...</td></tr>';try{var s=$('statusFilter').value;var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+offset+'&status='+encodeURIComponent(s));total=d.total||0;renderTrades(d.items||[]);renderPager()}catch(e){$('tradeRows').innerHTML='<tr><td colspan="12" class="empty">'+esc(e.message)+'</td></tr>'}}
|
|
function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='<tr><td colspan="12" class="empty">暂无模拟交易</td></tr>';return}$('tradeRows').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 '<tr>'+
|
|
'<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+
|
|
'<td><span class="badge '+esc(x.status)+'">'+st+'</span></td>'+
|
|
'<td><div class="mono">'+fmt(x.notional_usdt,0)+'U</div><div class="muted">'+fmt(x.leverage,1)+'x · 保证金 '+fmt(x.margin_usdt,0)+'U</div></td>'+
|
|
'<td><div class="mono">$'+fmt(x.entry_price,6)+'</div><div class="muted">'+time(x.opened_at)+'</div></td>'+
|
|
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
|
|
'<td><div class="mono">$'+fmt(latest,6)+'</div><div class="muted">'+(x.latest_price_updated_at?time(x.latest_price_updated_at):'最新')+'</div></td>'+
|
|
'<td><div class="mono">'+(x.status==='closed'?'$'+fmt(x.exit_price,6):'--')+'</div><div class="muted">'+(x.status==='closed'?'实际退出':'未平仓')+'</div></td>'+
|
|
'<td><div class="mono">'+(x.closed_at?time(x.closed_at):'--')+'</div></td>'+
|
|
'<td>'+pct(x.status==='closed'?x.realized_pnl_pct:x.pnl_pct)+'</td>'+
|
|
'<td><div>'+money(pnlUsdt)+'</div><div class="muted">账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%</div></td>'+
|
|
'<td>'+esc(x.exit_reason||'--')+'</td>'+
|
|
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.strategy_version||'')+'</div></td>'+
|
|
'</tr>'}).join('')}
|
|
function renderPager(){var page=Math.floor(offset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(total/LIMIT));$('pageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+total+' 条';$('pager').innerHTML='<button '+(offset===0?'disabled':'')+' onclick="loadTrades('+(offset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((offset+LIMIT>=total)?'disabled':'')+' onclick="loadTrades('+(offset+LIMIT)+')">下一页</button>'}
|
|
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ));eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
|
|
function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'}
|
|
function eventCls(t){if(t==='open')return'event-open';if(t==='close')return'event-close';if(String(t).indexOf('trailing')===0)return'event-trailing';return''}
|
|
function renderEvents(items){if(!items.length){$('eventRows').innerHTML='<div class="empty">暂无操作日志</div>';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 '<div class="event">'+
|
|
'<div class="event-time">'+time(e.event_time)+'<br><span class="muted">#'+esc(e.trade_id)+'</span></div>'+
|
|
'<div><div class="sym">'+esc(e.symbol)+'</div><span class="badge '+eventCls(e.event_type)+'">'+esc(eventLabel(e.event_type))+'</span></div>'+
|
|
'<div class="event-main"><div class="event-title">'+esc(e.message||eventLabel(e.event_type))+'</div><div class="event-msg">交易状态 '+esc(e.trade_status||'--')+' · 来源推荐 '+esc(e.recommendation_id||'--')+'</div><div class="event-detail">'+esc(detail.join(' · ')||'无附加参数')+'</div></div>'+
|
|
'<div class="event-price"><b>$'+fmt(e.price,6)+'</b><span>'+pct(e.pnl_pct)+'</span></div>'+
|
|
'</div>'}).join('')}
|
|
function renderEventPager(){var page=Math.floor(eventOffset/EVENT_LIMIT)+1,totalPages=Math.max(1,Math.ceil(eventTotal/EVENT_LIMIT));$('eventPager').innerHTML='<button '+(eventOffset===0?'disabled':'')+' onclick="loadEvents('+(eventOffset-EVENT_LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页 · 共 '+eventTotal+' 条</span><button '+((eventOffset+EVENT_LIMIT>=eventTotal)?'disabled':'')+' onclick="loadEvents('+(eventOffset+EVENT_LIMIT)+')">下一页</button>'}
|
|
loadAll();
|
|
</script>
|
|
{% endblock %}
|