This commit is contained in:
薇薇安 2026-01-15 11:45:35 +08:00
parent 272b4cdc19
commit f633a95de3
4 changed files with 932 additions and 0 deletions

View File

@ -4,6 +4,7 @@ import ConfigPanel from './components/ConfigPanel'
import ConfigGuide from './components/ConfigGuide' import ConfigGuide from './components/ConfigGuide'
import TradeList from './components/TradeList' import TradeList from './components/TradeList'
import StatsDashboard from './components/StatsDashboard' import StatsDashboard from './components/StatsDashboard'
import Recommendations from './components/Recommendations'
import './App.css' import './App.css'
function App() { function App() {
@ -15,6 +16,7 @@ function App() {
<h1 className="nav-title">自动交易系统</h1> <h1 className="nav-title">自动交易系统</h1>
<div className="nav-links"> <div className="nav-links">
<Link to="/">仪表板</Link> <Link to="/">仪表板</Link>
<Link to="/recommendations">交易推荐</Link>
<Link to="/config">配置</Link> <Link to="/config">配置</Link>
<Link to="/trades">交易记录</Link> <Link to="/trades">交易记录</Link>
</div> </div>
@ -24,6 +26,7 @@ function App() {
<main className="main-content"> <main className="main-content">
<Routes> <Routes>
<Route path="/" element={<StatsDashboard />} /> <Route path="/" element={<StatsDashboard />} />
<Route path="/recommendations" element={<Recommendations />} />
<Route path="/config" element={<ConfigPanel />} /> <Route path="/config" element={<ConfigPanel />} />
<Route path="/config/guide" element={<ConfigGuide />} /> <Route path="/config/guide" element={<ConfigGuide />} />
<Route path="/trades" element={<TradeList />} /> <Route path="/trades" element={<TradeList />} />

View File

@ -0,0 +1,481 @@
.recommendations-container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.recommendations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.recommendations-header h2 {
margin: 0;
color: #333;
}
.header-actions {
display: flex;
gap: 10px;
}
.btn-generate,
.btn-refresh {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-generate {
background-color: #4CAF50;
color: white;
}
.btn-generate:hover:not(:disabled) {
background-color: #45a049;
}
.btn-generate:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.btn-refresh {
background-color: #2196F3;
color: white;
}
.btn-refresh:hover:not(:disabled) {
background-color: #0b7dda;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background-color: white;
cursor: pointer;
}
.error-message {
padding: 12px;
background-color: #ffebee;
color: #c62828;
border-radius: 4px;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state p {
margin-bottom: 20px;
font-size: 16px;
}
.recommendations-list {
display: grid;
gap: 20px;
}
.recommendation-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s;
}
.recommendation-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.symbol {
font-size: 20px;
font-weight: bold;
color: #333;
}
.direction {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.direction.buy {
background-color: #4CAF50;
color: white;
}
.direction.sell {
background-color: #f44336;
color: white;
}
.status-badge {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.status-active {
background-color: #4CAF50;
color: white;
}
.status-executed {
background-color: #2196F3;
color: white;
}
.status-expired {
background-color: #9E9E9E;
color: white;
}
.status-cancelled {
background-color: #FF9800;
color: white;
}
.card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5px;
}
.signal-strength {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.signal-strong {
background-color: #4CAF50;
color: white;
}
.signal-medium {
background-color: #FF9800;
color: white;
}
.signal-weak {
background-color: #9E9E9E;
color: white;
}
.time {
font-size: 12px;
color: #666;
}
.card-content {
display: flex;
flex-direction: column;
gap: 15px;
}
.price-info {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.price-item {
display: flex;
align-items: center;
gap: 8px;
}
.price-item label {
font-weight: bold;
color: #666;
}
.price-item .positive {
color: #4CAF50;
}
.price-item .negative {
color: #f44336;
}
.recommendation-reason {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
}
.recommendation-reason strong {
display: block;
margin-bottom: 8px;
color: #333;
}
.recommendation-reason p {
margin: 0;
color: #666;
line-height: 1.6;
}
.suggested-params {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 4px;
}
.param-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.param-item label {
font-size: 12px;
color: #666;
font-weight: bold;
}
.param-item span {
font-size: 14px;
color: #333;
font-weight: 500;
}
.btn-toggle-details {
padding: 8px 16px;
background-color: #e0e0e0;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
align-self: flex-start;
}
.btn-toggle-details:hover {
background-color: #d0d0d0;
}
.details-panel {
margin-top: 15px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.details-section {
margin-bottom: 20px;
}
.details-section:last-child {
margin-bottom: 0;
}
.details-section h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 14px;
}
.indicators-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.indicator-item {
display: flex;
justify-content: space-between;
padding: 8px;
background-color: white;
border-radius: 4px;
}
.indicator-item label {
font-size: 12px;
color: #666;
}
.indicator-item span {
font-size: 12px;
color: #333;
font-weight: 500;
}
.market-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 8px;
background-color: white;
border-radius: 4px;
}
.info-item label {
font-size: 12px;
color: #666;
}
.info-item span {
font-size: 12px;
color: #333;
font-weight: 500;
}
.bollinger-info {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.bollinger-item {
display: flex;
flex-direction: column;
padding: 8px;
background-color: white;
border-radius: 4px;
}
.bollinger-item label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.bollinger-item span {
font-size: 12px;
color: #333;
font-weight: 500;
}
.card-actions {
display: flex;
gap: 10px;
margin-top: 10px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.btn-execute,
.btn-cancel {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-execute {
background-color: #2196F3;
color: white;
}
.btn-execute:hover {
background-color: #0b7dda;
}
.btn-cancel {
background-color: #9E9E9E;
color: white;
}
.btn-cancel:hover {
background-color: #757575;
}
/* 响应式设计 */
@media (max-width: 768px) {
.recommendations-container {
padding: 10px;
}
.recommendations-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.card-header {
flex-direction: column;
gap: 10px;
}
.card-meta {
align-items: flex-start;
}
.suggested-params {
grid-template-columns: 1fr;
}
.indicators-grid {
grid-template-columns: 1fr;
}
.bollinger-info {
grid-template-columns: 1fr;
}
.card-actions {
flex-direction: column;
}
.btn-execute,
.btn-cancel {
width: 100%;
}
}

View File

@ -0,0 +1,371 @@
import React, { useState, useEffect } from 'react'
import { api } from '../services/api'
import './Recommendations.css'
function Recommendations() {
const [recommendations, setRecommendations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [statusFilter, setStatusFilter] = useState('active')
const [directionFilter, setDirectionFilter] = useState('')
const [generating, setGenerating] = useState(false)
const [showDetails, setShowDetails] = useState({})
useEffect(() => {
loadRecommendations()
}, [statusFilter, directionFilter])
const loadRecommendations = async () => {
try {
setLoading(true)
setError(null)
let data
if (statusFilter === 'active') {
const result = await api.getActiveRecommendations()
data = result.data || []
} else {
const params = {}
if (statusFilter) params.status = statusFilter
if (directionFilter) params.direction = directionFilter
params.limit = 50
const result = await api.getRecommendations(params)
data = result.data || []
}
setRecommendations(data)
} catch (err) {
setError(err.message)
console.error('加载推荐失败:', err)
} finally {
setLoading(false)
}
}
const handleGenerate = async () => {
try {
setGenerating(true)
setError(null)
await api.generateRecommendations(5, 20)
await loadRecommendations()
alert('推荐生成成功!')
} catch (err) {
setError(err.message)
alert(`生成推荐失败: ${err.message}`)
} finally {
setGenerating(false)
}
}
const handleMarkExecuted = async (id) => {
if (!window.confirm('确认标记为已执行?')) return
try {
await api.markRecommendationExecuted(id)
await loadRecommendations()
alert('已标记为执行')
} catch (err) {
alert(`标记失败: ${err.message}`)
}
}
const handleCancel = async (id) => {
const notes = window.prompt('请输入取消原因(可选):')
if (notes === null) return
try {
await api.cancelRecommendation(id, notes || null)
await loadRecommendations()
alert('推荐已取消')
} catch (err) {
alert(`取消失败: ${err.message}`)
}
}
const toggleDetails = (id) => {
setShowDetails(prev => ({
...prev,
[id]: !prev[id]
}))
}
const formatTime = (timeStr) => {
if (!timeStr) return '-'
try {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch (e) {
return timeStr
}
}
const getStatusBadge = (status) => {
const statusMap = {
active: { text: '有效', class: 'status-active' },
executed: { text: '已执行', class: 'status-executed' },
expired: { text: '已过期', class: 'status-expired' },
cancelled: { text: '已取消', class: 'status-cancelled' }
}
const statusInfo = statusMap[status] || { text: status, class: 'status-unknown' }
return <span className={`status-badge ${statusInfo.class}`}>{statusInfo.text}</span>
}
const getSignalStrengthColor = (strength) => {
if (strength >= 8) return 'signal-strong'
if (strength >= 6) return 'signal-medium'
return 'signal-weak'
}
return (
<div className="recommendations-container">
<div className="recommendations-header">
<h2>交易推荐</h2>
<div className="header-actions">
<button
className="btn-generate"
onClick={handleGenerate}
disabled={generating}
>
{generating ? '生成中...' : '生成推荐'}
</button>
<button
className="btn-refresh"
onClick={loadRecommendations}
disabled={loading}
>
刷新
</button>
</div>
</div>
<div className="filters">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="filter-select"
>
<option value="active">有效推荐</option>
<option value="">全部</option>
<option value="executed">已执行</option>
<option value="expired">已过期</option>
<option value="cancelled">已取消</option>
</select>
<select
value={directionFilter}
onChange={(e) => setDirectionFilter(e.target.value)}
className="filter-select"
>
<option value="">全部方向</option>
<option value="BUY">做多</option>
<option value="SELL">做空</option>
</select>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
{loading ? (
<div className="loading">加载中...</div>
) : recommendations.length === 0 ? (
<div className="empty-state">
<p>暂无推荐记录</p>
<button className="btn-generate" onClick={handleGenerate}>
生成推荐
</button>
</div>
) : (
<div className="recommendations-list">
{recommendations.map((rec) => (
<div key={rec.id} className="recommendation-card">
<div className="card-header">
<div className="card-title">
<span className="symbol">{rec.symbol}</span>
<span className={`direction ${rec.direction.toLowerCase()}`}>
{rec.direction === 'BUY' ? '做多' : '做空'}
</span>
{getStatusBadge(rec.status)}
</div>
<div className="card-meta">
<span className={`signal-strength ${getSignalStrengthColor(rec.signal_strength)}`}>
信号强度: {rec.signal_strength}/10
</span>
<span className="time">{formatTime(rec.recommendation_time)}</span>
</div>
</div>
<div className="card-content">
<div className="price-info">
<div className="price-item">
<label>当前价格:</label>
<span>{parseFloat(rec.current_price || 0).toFixed(4)} USDT</span>
</div>
{rec.change_percent && (
<div className="price-item">
<label>24h涨跌:</label>
<span className={rec.change_percent >= 0 ? 'positive' : 'negative'}>
{rec.change_percent >= 0 ? '+' : ''}{parseFloat(rec.change_percent).toFixed(2)}%
</span>
</div>
)}
</div>
<div className="recommendation-reason">
<strong>推荐原因:</strong>
<p>{rec.recommendation_reason || '-'}</p>
</div>
<div className="suggested-params">
<div className="param-item">
<label>建议止损:</label>
<span>{parseFloat(rec.suggested_stop_loss || 0).toFixed(4)}</span>
</div>
<div className="param-item">
<label>第一目标:</label>
<span>{parseFloat(rec.suggested_take_profit_1 || 0).toFixed(4)}</span>
</div>
<div className="param-item">
<label>第二目标:</label>
<span>{parseFloat(rec.suggested_take_profit_2 || 0).toFixed(4)}</span>
</div>
<div className="param-item">
<label>建议仓位:</label>
<span>{(parseFloat(rec.suggested_position_percent || 0) * 100).toFixed(2)}%</span>
</div>
<div className="param-item">
<label>建议杠杆:</label>
<span>{rec.suggested_leverage || 10}x</span>
</div>
</div>
<button
className="btn-toggle-details"
onClick={() => toggleDetails(rec.id)}
>
{showDetails[rec.id] ? '隐藏' : '显示'}详细信息
</button>
{showDetails[rec.id] && (
<div className="details-panel">
<div className="details-section">
<h4>技术指标</h4>
<div className="indicators-grid">
{rec.rsi && (
<div className="indicator-item">
<label>RSI:</label>
<span>{parseFloat(rec.rsi).toFixed(2)}</span>
</div>
)}
{rec.macd_histogram !== null && rec.macd_histogram !== undefined && (
<div className="indicator-item">
<label>MACD:</label>
<span>{parseFloat(rec.macd_histogram).toFixed(6)}</span>
</div>
)}
{rec.ema20 && (
<div className="indicator-item">
<label>EMA20:</label>
<span>{parseFloat(rec.ema20).toFixed(4)}</span>
</div>
)}
{rec.ema50 && (
<div className="indicator-item">
<label>EMA50:</label>
<span>{parseFloat(rec.ema50).toFixed(4)}</span>
</div>
)}
{rec.ema20_4h && (
<div className="indicator-item">
<label>EMA20(4H):</label>
<span>{parseFloat(rec.ema20_4h).toFixed(4)}</span>
</div>
)}
{rec.atr && (
<div className="indicator-item">
<label>ATR:</label>
<span>{parseFloat(rec.atr).toFixed(4)}</span>
</div>
)}
</div>
</div>
<div className="details-section">
<h4>市场状态</h4>
<div className="market-info">
<div className="info-item">
<label>市场状态:</label>
<span>{rec.market_regime === 'trending' ? '趋势' : rec.market_regime === 'ranging' ? '震荡' : '-'}</span>
</div>
<div className="info-item">
<label>4H趋势:</label>
<span>
{rec.trend_4h === 'up' ? '向上' : rec.trend_4h === 'down' ? '向下' : rec.trend_4h === 'neutral' ? '中性' : '-'}
</span>
</div>
{rec.volume_24h && (
<div className="info-item">
<label>24h成交量:</label>
<span>{(parseFloat(rec.volume_24h) / 1000000).toFixed(2)}M USDT</span>
</div>
)}
</div>
</div>
{rec.bollinger_upper && (
<div className="details-section">
<h4>布林带</h4>
<div className="bollinger-info">
<div className="bollinger-item">
<label>上轨:</label>
<span>{parseFloat(rec.bollinger_upper).toFixed(4)}</span>
</div>
<div className="bollinger-item">
<label>中轨:</label>
<span>{parseFloat(rec.bollinger_middle).toFixed(4)}</span>
</div>
<div className="bollinger-item">
<label>下轨:</label>
<span>{parseFloat(rec.bollinger_lower).toFixed(4)}</span>
</div>
</div>
</div>
)}
</div>
)}
{rec.status === 'active' && (
<div className="card-actions">
<button
className="btn-execute"
onClick={() => handleMarkExecuted(rec.id)}
>
标记已执行
</button>
<button
className="btn-cancel"
onClick={() => handleCancel(rec.id)}
>
取消推荐
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
export default Recommendations

View File

@ -120,4 +120,81 @@ export const api = {
} }
return response.json(); return response.json();
}, },
// 交易推荐
getRecommendations: async (params = {}) => {
const query = new URLSearchParams(params).toString();
const url = query ? `${buildUrl('/api/recommendations')}?${query}` : buildUrl('/api/recommendations');
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取推荐失败' }));
throw new Error(error.detail || '获取推荐失败');
}
return response.json();
},
getActiveRecommendations: async () => {
const response = await fetch(buildUrl('/api/recommendations/active'));
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取有效推荐失败' }));
throw new Error(error.detail || '获取有效推荐失败');
}
return response.json();
},
getRecommendation: async (id) => {
const response = await fetch(buildUrl(`/api/recommendations/${id}`));
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取推荐详情失败' }));
throw new Error(error.detail || '获取推荐详情失败');
}
return response.json();
},
generateRecommendations: async (minSignalStrength = 5, maxRecommendations = 20) => {
const response = await fetch(
buildUrl(`/api/recommendations/generate?min_signal_strength=${minSignalStrength}&max_recommendations=${maxRecommendations}`),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '生成推荐失败' }));
throw new Error(error.detail || '生成推荐失败');
}
return response.json();
},
markRecommendationExecuted: async (id, tradeId = null) => {
const response = await fetch(buildUrl(`/api/recommendations/${id}/execute`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ trade_id: tradeId }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '标记执行失败' }));
throw new Error(error.detail || '标记执行失败');
}
return response.json();
},
cancelRecommendation: async (id, notes = null) => {
const response = await fetch(buildUrl(`/api/recommendations/${id}/cancel`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ notes }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '取消推荐失败' }));
throw new Error(error.detail || '取消推荐失败');
}
return response.json();
},
}; };