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 typing import Optional
from datetime import datetime, timedelta
import sys
from pathlib import Path
@ -15,20 +16,74 @@ from database.models import Trade
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("/")
async def get_trades(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
symbol: Optional[str] = Query(None),
status: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000)
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
period: Optional[str] = Query(None, description="快速时间段筛选: '1d'(最近1天), '7d'(最近7天), '30d'(最近30天)"),
symbol: Optional[str] = Query(None, description="交易对筛选"),
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:
# 如果提供了 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)
return {
"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:
raise HTTPException(status_code=500, detail=str(e))
@ -36,13 +91,26 @@ async def get_trades(
@router.get("/stats")
async def get_trade_stats(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
symbol: 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, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
period: Optional[str] = Query(None, description="快速时间段筛选: '1d', '7d', '30d'"),
symbol: Optional[str] = Query(None, description="交易对筛选")
):
"""获取交易统计"""
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)
closed_trades = [t for t in trades if t['status'] == 'closed']
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,
"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,
"filters": {
"start_date": start_date,
"end_date": end_date,
"period": period,
"symbol": symbol
}
}
return stats
except Exception as e:
from fastapi import HTTPException
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);
}
.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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));

View File

@ -7,15 +7,40 @@ const TradeList = () => {
const [stats, setStats] = useState(null)
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(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
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([
api.getTrades({ limit: 100 }),
api.getTradeStats()
api.getTrades(params),
api.getTradeStats(params)
])
setTrades(tradesData.trades || [])
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>
return (
<div className="trade-list">
<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 && (
<div className="stats-summary">
<div className="stat-card">
@ -57,38 +201,42 @@ const TradeList = () => {
</div>
)}
<table className="trades-table">
<thead>
<tr>
<th>交易对</th>
<th>方向</th>
<th>数量</th>
<th>入场价</th>
<th>出场价</th>
<th>盈亏</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{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>
{trades.length === 0 ? (
<div className="no-data">暂无交易记录</div>
) : (
<table className="trades-table">
<thead>
<tr>
<th>交易对</th>
<th>方向</th>
<th>数量</th>
<th>入场价</th>
<th>出场价</th>
<th>盈亏</th>
<th>状态</th>
<th>时间</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{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>
)
}

View File

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