From c781dfef089902d4473c6b63591695fe76a2615d Mon Sep 17 00:00:00 2001 From: aaron <> Date: Fri, 22 May 2026 11:41:42 +0800 Subject: [PATCH] 1 --- app/db/paper_trading.py | 92 +++++++++++++++++++++++++++++++++ app/web/routes_paper_trading.py | 7 +++ static/paper_trading.html | 21 +++++++- tests/test_paper_trading.py | 22 ++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) 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=''+grid+''+bars+''+esc(first.date)+''+esc(last.date)+'权益 '+fmt(minEq,0)+'U - '+fmt(maxEq,0)+'U柱状图:每日实现收益'} 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()