diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py
index 4728d81..acf58ff 100644
--- a/app/db/paper_trading.py
+++ b/app/db/paper_trading.py
@@ -1256,6 +1256,98 @@ def get_paper_trading_summary(days: int = 30) -> dict:
}
+def get_paper_trading_performance(days: int = 30) -> dict:
+ days = max(1, min(_safe_int(days, 30), 365))
+ cfg = paper_trading_config()
+ initial_equity = default_account_equity_usdt(cfg)
+ today = datetime.now().date()
+ start_date = today - timedelta(days=days - 1)
+ conn = get_conn()
+ try:
+ closed_rows = conn.execute(
+ """
+ SELECT closed_at, realized_pnl_usdt
+ FROM paper_trades
+ WHERE status='closed' AND closed_at IS NOT NULL
+ ORDER BY closed_at ASC, id ASC
+ """
+ ).fetchall()
+ open_unrealized = conn.execute(
+ """
+ SELECT COALESCE(SUM(notional_usdt * pnl_pct / 100.0), 0)
+ FROM paper_trades
+ WHERE status='open'
+ """
+ ).fetchone()[0]
+ finally:
+ conn.close()
+
+ daily_realized = {}
+ realized_before = 0.0
+ for row in closed_rows:
+ closed_at = _parse_time(row["closed_at"])
+ pnl = _safe_float(row["realized_pnl_usdt"])
+ if not closed_at:
+ continue
+ closed_date = closed_at.date()
+ if closed_date < start_date:
+ realized_before += pnl
+ continue
+ if closed_date > today:
+ continue
+ key = closed_date.isoformat()
+ daily_realized[key] = daily_realized.get(key, 0.0) + pnl
+
+ points = []
+ cumulative_realized = realized_before
+ prev_equity = initial_equity + realized_before
+ peak_equity = max(initial_equity, prev_equity)
+ max_drawdown_pct = 0.0
+ max_drawdown_usdt = 0.0
+ open_unrealized = _safe_float(open_unrealized)
+
+ for idx in range(days):
+ day = start_date + timedelta(days=idx)
+ key = day.isoformat()
+ realized = round(daily_realized.get(key, 0.0), 8)
+ cumulative_realized += realized
+ unrealized = open_unrealized if day == today else 0.0
+ equity = round(initial_equity + cumulative_realized + unrealized, 8)
+ daily_pnl = round(equity - prev_equity, 8)
+ daily_return_pct = round(daily_pnl / prev_equity * 100, 6) if prev_equity > 0 else 0.0
+ peak_equity = max(peak_equity, equity)
+ drawdown_usdt = round(max(0.0, peak_equity - equity), 8)
+ drawdown_pct = round(drawdown_usdt / peak_equity * 100, 6) if peak_equity > 0 else 0.0
+ max_drawdown_pct = max(max_drawdown_pct, drawdown_pct)
+ max_drawdown_usdt = max(max_drawdown_usdt, drawdown_usdt)
+ points.append(
+ {
+ "date": key,
+ "equity_usdt": equity,
+ "daily_pnl_usdt": daily_pnl,
+ "daily_return_pct": daily_return_pct,
+ "realized_pnl_usdt": round(cumulative_realized, 8),
+ "unrealized_pnl_usdt": round(unrealized, 8),
+ "return_pct": _account_return_pct(equity - initial_equity, initial_equity, cfg),
+ "drawdown_usdt": drawdown_usdt,
+ "drawdown_pct": drawdown_pct,
+ }
+ )
+ prev_equity = equity
+
+ total_pnl = round((points[-1]["equity_usdt"] - initial_equity) if points else 0.0, 8)
+ return {
+ "days": days,
+ "initial_equity_usdt": initial_equity,
+ "current_equity_usdt": points[-1]["equity_usdt"] if points else initial_equity,
+ "total_pnl_usdt": total_pnl,
+ "total_return_pct": _account_return_pct(total_pnl, initial_equity, cfg),
+ "max_drawdown_usdt": round(max_drawdown_usdt, 8),
+ "max_drawdown_pct": round(max_drawdown_pct, 6),
+ "points": points,
+ }
+
+
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dict:
limit = max(1, min(_safe_int(limit, 50), 200))
offset = max(0, _safe_int(offset, 0))
diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py
index c4dbdc3..4c7feec 100644
--- a/app/web/routes_paper_trading.py
+++ b/app/web/routes_paper_trading.py
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Cookie
from app.db.paper_trading import (
+ get_paper_trading_performance,
get_paper_trading_summary,
list_paper_orders,
list_paper_trade_events,
@@ -19,6 +20,12 @@ async def api_paper_trading_summary(days: int = 30, altcoin_session: str = Cooki
return get_paper_trading_summary(days=days)
+@router.get("/api/paper-trading/performance")
+async def api_paper_trading_performance(days: int = 30, altcoin_session: str = Cookie(default="")):
+ require_admin(altcoin_session)
+ return get_paper_trading_performance(days=days)
+
+
@router.get("/api/paper-trading/trades")
async def api_paper_trading_trades(
limit: int = 50,
diff --git a/static/paper_trading.html b/static/paper_trading.html
index 3477fc0..eaebde9 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 %}
{% block content %}
@@ -20,6 +20,16 @@
策略交易只统计已经进入交易账本的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。
+
+
+
+
每日收益与权益曲线
+
蓝线展示账户权益变化,绿色/红色柱展示每日实现收益,右侧同步最大回撤。
+
+
加载中...
+
+
+
@@ -102,7 +112,7 @@ function sideBadge(v){var s=String(v||'long').toLowerCase();return '
=0?'green':'red','初始本金 '+fmt(d.initial_equity_usdt||d.account_equity_usdt||0,0)+'U'),
@@ -113,6 +123,13 @@ async function loadSummary(){try{var d=await api('/api/paper-trading/summary?day
card('收益额',(totalPnl>=0?'+':'')+fmt(totalPnl,2)+'U',totalPnl>=0?'green':'red','已实现 '+fmt(realized,2)+'U · 浮动 '+fmt(unrealized,2)+'U')
].join('')}catch(e){$('kpis').innerHTML='状态加载失败
'}}
function card(label,value,cls,sub){return ''+esc(label)+''+esc(value)+''+(sub?''+esc(sub)+'':'')+'
'}
+async function loadPerformance(){try{var d=await api('/api/paper-trading/performance?days=30');renderPerformance(d)}catch(e){$('performanceChart').innerHTML=''+esc(e.message)+'
';$('perfMeta').innerHTML='加载失败'}}
+function renderPerformance(d){var points=d.points||[];if(!points.length){$('performanceChart').innerHTML='暂无收益曲线数据
';$('perfMeta').innerHTML='暂无数据';return}var ret=Number(d.total_return_pct||0),dd=Number(d.max_drawdown_pct||0),pnl=Number(d.total_pnl_usdt||0);$('perfMeta').innerHTML=[
+ '当前权益 '+fmt(d.current_equity_usdt,2)+'U',
+ '总收益率 '+(ret>0?'+':'')+fmt(ret,2)+'%',
+ '最大回撤 '+fmt(dd,2)+'%',
+ '总收益 '+(pnl>0?'+':'')+fmt(pnl,2)+'U'
+].join('');var w=960,h=260,pad=38,top=22,lineH=150,barBase=224;var equities=points.map(function(p){return Number(p.equity_usdt||0)}),pnls=points.map(function(p){return Number(p.daily_pnl_usdt||0)});var minEq=Math.min.apply(null,equities),maxEq=Math.max.apply(null,equities);if(maxEq===minEq){maxEq+=1;minEq-=1}function x(i){return pad+(points.length===1?0:i*(w-pad*2)/(points.length-1))}function y(v){return top+(maxEq-v)*lineH/(maxEq-minEq)}var line=points.map(function(p,i){return x(i).toFixed(1)+','+y(Number(p.equity_usdt||0)).toFixed(1)}).join(' ');var area=pad+','+barBase+' '+line+' '+x(points.length-1).toFixed(1)+','+barBase;var maxAbs=Math.max.apply(null,[1].concat(pnls.map(function(v){return Math.abs(v)})));var barW=Math.max(3,(w-pad*2)/points.length*.55);var bars=points.map(function(p,i){var v=Number(p.daily_pnl_usdt||0),bh=Math.abs(v)/maxAbs*44,bx=x(i)-barW/2,by=v>=0?barBase-bh:barBase;return ''+esc(p.date)+' 每日收益 '+(v>0?'+':'')+fmt(v,2)+'U'}).join('');var first=points[0],last=points[points.length-1];var grid=[top,top+lineH/2,top+lineH].map(function(gy){return ''}).join('');$('performanceChart').innerHTML=''}
async function loadOrders(){$('orderRows').innerHTML='| 加载中... |
';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');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)+' | '+
diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py
index 3dbff18..9b33213 100644
--- a/tests/test_paper_trading.py
+++ b/tests/test_paper_trading.py
@@ -1,9 +1,11 @@
import json
+from datetime import datetime
import pytest
from app.db import altcoin_db
from app.db.paper_trading import (
+ get_paper_trading_performance,
get_paper_trading_summary,
list_paper_orders,
list_paper_trade_events,
@@ -136,6 +138,26 @@ def test_paper_margin_is_derived_from_notional_and_leverage(monkeypatch):
assert summary["margin_usdt"] == pytest.approx(1000.0)
+def test_paper_performance_returns_daily_equity_curve(monkeypatch, buy_now_rec):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
+ monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0")
+ monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0")
+ now = datetime.now().replace(microsecond=0).isoformat()
+
+ sync_recommendation(buy_now_rec, 100, event_time=now)
+ sync_recommendation(buy_now_rec, 106, event_time=now)
+
+ curve = get_paper_trading_performance(days=7)
+
+ assert curve["points"]
+ assert curve["current_equity_usdt"] == pytest.approx(20006.0)
+ assert curve["total_pnl_usdt"] == pytest.approx(6.0)
+ assert curve["total_return_pct"] == pytest.approx(0.03)
+ assert curve["max_drawdown_pct"] >= 0
+ assert curve["points"][-1]["daily_pnl_usdt"] == pytest.approx(6.0)
+ assert curve["points"][-1]["equity_usdt"] == pytest.approx(20006.0)
+
+
def test_observation_does_not_open_paper_trade(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
altcoin_db.init_db()