This commit is contained in:
薇薇安 2026-01-13 22:12:24 +08:00
parent 6bf4ea7a06
commit d9830f395b
4 changed files with 393 additions and 45 deletions

View File

@ -3,6 +3,7 @@
""" """
from fastapi import APIRouter, Query, HTTPException from fastapi import APIRouter, Query, HTTPException
from typing import Optional from typing import Optional
from datetime import datetime, timedelta
import sys import sys
from pathlib import Path from pathlib import Path
@ -15,20 +16,74 @@ from database.models import Trade
router = APIRouter() router = APIRouter()
def get_date_range(period: Optional[str] = None):
"""
根据时间段参数返回开始和结束日期
Args:
period: 时间段 ('1d', '7d', '30d', 'custom')
Returns:
(start_date, end_date) 元组格式为 'YYYY-MM-DD HH:MM:SS'
"""
end_date = datetime.now()
if period == '1d':
start_date = end_date - timedelta(days=1)
elif period == '7d':
start_date = end_date - timedelta(days=7)
elif period == '30d':
start_date = end_date - timedelta(days=30)
else:
return None, None
return start_date.strftime('%Y-%m-%d 00:00:00'), end_date.strftime('%Y-%m-%d %H:%M:%S')
@router.get("/") @router.get("/")
async def get_trades( async def get_trades(
start_date: Optional[str] = Query(None), start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
symbol: Optional[str] = Query(None), period: Optional[str] = Query(None, description="快速时间段筛选: '1d'(最近1天), '7d'(最近7天), '30d'(最近30天)"),
status: Optional[str] = Query(None), symbol: Optional[str] = Query(None, description="交易对筛选"),
limit: int = Query(100, ge=1, le=1000) status: Optional[str] = Query(None, description="状态筛选: 'open', 'closed', 'cancelled'"),
limit: int = Query(100, ge=1, le=1000, description="返回记录数限制")
): ):
"""获取交易记录""" """
获取交易记录
支持两种筛选方式
1. 快速时间段筛选使用 period 参数 ('1d', '7d', '30d')
2. 自定义时间段筛选使用 start_date end_date 参数
如果同时提供了 period start_date/end_dateperiod 优先级更高
"""
try: try:
# 如果提供了 period使用快速时间段筛选
if period:
period_start, period_end = get_date_range(period)
if period_start and period_end:
start_date = period_start
end_date = period_end
# 格式化日期(如果只提供了日期,添加时间部分)
if start_date and len(start_date) == 10: # YYYY-MM-DD
start_date = f"{start_date} 00:00:00"
if end_date and len(end_date) == 10: # YYYY-MM-DD
end_date = f"{end_date} 23:59:59"
trades = Trade.get_all(start_date, end_date, symbol, status) trades = Trade.get_all(start_date, end_date, symbol, status)
return { return {
"total": len(trades), "total": len(trades),
"trades": trades[:limit] "trades": trades[:limit],
"filters": {
"start_date": start_date,
"end_date": end_date,
"period": period,
"symbol": symbol,
"status": status
}
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -36,13 +91,26 @@ async def get_trades(
@router.get("/stats") @router.get("/stats")
async def get_trade_stats( async def get_trade_stats(
start_date: Optional[str] = Query(None), start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
symbol: Optional[str] = Query(None) period: Optional[str] = Query(None, description="快速时间段筛选: '1d', '7d', '30d'"),
symbol: Optional[str] = Query(None, description="交易对筛选")
): ):
"""获取交易统计""" """获取交易统计"""
try: try:
from fastapi import HTTPException # 如果提供了 period使用快速时间段筛选
if period:
period_start, period_end = get_date_range(period)
if period_start and period_end:
start_date = period_start
end_date = period_end
# 格式化日期
if start_date and len(start_date) == 10:
start_date = f"{start_date} 00:00:00"
if end_date and len(end_date) == 10:
end_date = f"{end_date} 23:59:59"
trades = Trade.get_all(start_date, end_date, symbol, None) trades = Trade.get_all(start_date, end_date, symbol, None)
closed_trades = [t for t in trades if t['status'] == 'closed'] closed_trades = [t for t in trades if t['status'] == 'closed']
win_trades = [t for t in closed_trades if float(t['pnl']) > 0] win_trades = [t for t in closed_trades if float(t['pnl']) > 0]
@ -56,9 +124,14 @@ async def get_trade_stats(
"win_rate": len(win_trades) / len(closed_trades) * 100 if closed_trades else 0, "win_rate": len(win_trades) / len(closed_trades) * 100 if closed_trades else 0,
"total_pnl": sum(float(t['pnl']) for t in closed_trades), "total_pnl": sum(float(t['pnl']) for t in closed_trades),
"avg_pnl": sum(float(t['pnl']) for t in closed_trades) / len(closed_trades) if closed_trades else 0, "avg_pnl": sum(float(t['pnl']) for t in closed_trades) / len(closed_trades) if closed_trades else 0,
"filters": {
"start_date": start_date,
"end_date": end_date,
"period": period,
"symbol": symbol
}
} }
return stats return stats
except Exception as e: except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@ -5,6 +5,127 @@
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
.filter-panel {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-end;
}
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-section label {
font-size: 0.9rem;
font-weight: 500;
color: #555;
display: flex;
align-items: center;
gap: 0.5rem;
}
.period-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.period-buttons button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.period-buttons button:hover {
background: #e9ecef;
border-color: #2196F3;
}
.period-buttons button.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.date-inputs {
display: flex;
align-items: center;
gap: 0.5rem;
}
.date-inputs input[type="date"] {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.date-inputs span {
color: #666;
font-size: 0.9rem;
}
.filter-section input[type="text"],
.filter-section select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.filter-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s;
}
.btn-primary {
background: #2196F3;
color: white;
}
.btn-primary:hover {
background: #1976D2;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.no-data {
text-align: center;
padding: 3rem;
color: #999;
font-size: 1.1rem;
}
.stats-summary { .stats-summary {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));

View File

@ -6,16 +6,41 @@ const TradeList = () => {
const [trades, setTrades] = useState([]) const [trades, setTrades] = useState([])
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
//
const [period, setPeriod] = useState(null) // '1d', '7d', '30d', null
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [symbol, setSymbol] = useState('')
const [status, setStatus] = useState('')
const [useCustomDate, setUseCustomDate] = useState(false)
useEffect(() => { useEffect(() => {
loadData() loadData()
}, []) }, [])
const loadData = async () => { const loadData = async () => {
setLoading(true)
try { try {
const params = {
limit: 100
}
// 使
if (!useCustomDate && period) {
params.period = period
} else if (useCustomDate) {
// 使
if (startDate) params.start_date = startDate
if (endDate) params.end_date = endDate
}
if (symbol) params.symbol = symbol
if (status) params.status = status
const [tradesData, statsData] = await Promise.all([ const [tradesData, statsData] = await Promise.all([
api.getTrades({ limit: 100 }), api.getTrades(params),
api.getTradeStats() api.getTradeStats(params)
]) ])
setTrades(tradesData.trades || []) setTrades(tradesData.trades || [])
setStats(statsData) setStats(statsData)
@ -26,12 +51,131 @@ const TradeList = () => {
} }
} }
const handlePeriodChange = (newPeriod) => {
setPeriod(newPeriod)
setUseCustomDate(false)
setStartDate('')
setEndDate('')
}
const handleCustomDateToggle = () => {
setUseCustomDate(!useCustomDate)
if (!useCustomDate) {
setPeriod(null)
}
}
const handleReset = () => {
setPeriod(null)
setStartDate('')
setEndDate('')
setSymbol('')
setStatus('')
setUseCustomDate(false)
}
if (loading) return <div className="loading">加载中...</div> if (loading) return <div className="loading">加载中...</div>
return ( return (
<div className="trade-list"> <div className="trade-list">
<h2>交易记录</h2> <h2>交易记录</h2>
{/* 筛选面板 */}
<div className="filter-panel">
<div className="filter-section">
<label>快速筛选</label>
<div className="period-buttons">
<button
className={period === '1d' ? 'active' : ''}
onClick={() => handlePeriodChange('1d')}
>
最近1天
</button>
<button
className={period === '7d' ? 'active' : ''}
onClick={() => handlePeriodChange('7d')}
>
最近7天
</button>
<button
className={period === '30d' ? 'active' : ''}
onClick={() => handlePeriodChange('30d')}
>
最近30天
</button>
<button
className={period === null && !useCustomDate ? 'active' : ''}
onClick={() => handlePeriodChange(null)}
>
全部
</button>
</div>
</div>
<div className="filter-section">
<label>
<input
type="checkbox"
checked={useCustomDate}
onChange={handleCustomDateToggle}
/>
自定义时间段
</label>
{useCustomDate && (
<div className="date-inputs">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
placeholder="开始日期"
/>
<span></span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
placeholder="结束日期"
min={startDate}
/>
</div>
)}
</div>
<div className="filter-section">
<label>交易对</label>
<input
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="如: BTCUSDT"
style={{ width: '150px' }}
/>
</div>
<div className="filter-section">
<label>状态</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
style={{ width: '120px' }}
>
<option value="">全部</option>
<option value="open">持仓中</option>
<option value="closed">已平仓</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="filter-actions">
<button className="btn-primary" onClick={loadData}>
查询
</button>
<button className="btn-secondary" onClick={handleReset}>
重置
</button>
</div>
</div>
{stats && ( {stats && (
<div className="stats-summary"> <div className="stats-summary">
<div className="stat-card"> <div className="stat-card">
@ -57,38 +201,42 @@ const TradeList = () => {
</div> </div>
)} )}
<table className="trades-table"> {trades.length === 0 ? (
<thead> <div className="no-data">暂无交易记录</div>
<tr> ) : (
<th>交易对</th> <table className="trades-table">
<th>方向</th> <thead>
<th>数量</th> <tr>
<th>入场价</th> <th>交易对</th>
<th>出场价</th> <th>方向</th>
<th>盈亏</th> <th>数量</th>
<th>状态</th> <th>入场价</th>
<th>时间</th> <th>出场价</th>
</tr> <th>盈亏</th>
</thead> <th>状态</th>
<tbody> <th>时间</th>
{trades.map(trade => (
<tr key={trade.id}>
<td>{trade.symbol}</td>
<td className={trade.side === 'BUY' ? 'buy' : 'sell'}>{trade.side}</td>
<td>{parseFloat(trade.quantity).toFixed(4)}</td>
<td>{parseFloat(trade.entry_price).toFixed(4)}</td>
<td>{trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'}</td>
<td className={parseFloat(trade.pnl) >= 0 ? 'positive' : 'negative'}>
{parseFloat(trade.pnl).toFixed(2)} USDT
</td>
<td>
<span className={`status ${trade.status}`}>{trade.status}</span>
</td>
<td>{new Date(trade.entry_time).toLocaleString('zh-CN')}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {trades.map(trade => (
<tr key={trade.id}>
<td>{trade.symbol}</td>
<td className={trade.side === 'BUY' ? 'buy' : 'sell'}>{trade.side}</td>
<td>{parseFloat(trade.quantity).toFixed(4)}</td>
<td>{parseFloat(trade.entry_price).toFixed(4)}</td>
<td>{trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'}</td>
<td className={parseFloat(trade.pnl) >= 0 ? 'positive' : 'negative'}>
{parseFloat(trade.pnl).toFixed(2)} USDT
</td>
<td>
<span className={`status ${trade.status}`}>{trade.status}</span>
</td>
<td>{new Date(trade.entry_time).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
)}
</div> </div>
) )
} }

View File

@ -38,12 +38,18 @@ export const api = {
getTrades: async (params = {}) => { getTrades: async (params = {}) => {
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();
const response = await fetch(`${API_BASE_URL}/api/trades?${query}`); const response = await fetch(`${API_BASE_URL}/api/trades?${query}`);
if (!response.ok) {
throw new Error('获取交易记录失败');
}
return response.json(); return response.json();
}, },
getTradeStats: async (params = {}) => { getTradeStats: async (params = {}) => {
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();
const response = await fetch(`${API_BASE_URL}/api/trades/stats?${query}`); const response = await fetch(`${API_BASE_URL}/api/trades/stats?${query}`);
if (!response.ok) {
throw new Error('获取交易统计失败');
}
return response.json(); return response.json();
}, },