From f633a95de375f534f776f3857a474d0abd7ef70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Thu, 15 Jan 2026 11:45:35 +0800 Subject: [PATCH] a --- frontend/src/App.jsx | 3 + frontend/src/components/Recommendations.css | 481 ++++++++++++++++++++ frontend/src/components/Recommendations.jsx | 371 +++++++++++++++ frontend/src/services/api.js | 77 ++++ 4 files changed, 932 insertions(+) create mode 100644 frontend/src/components/Recommendations.css create mode 100644 frontend/src/components/Recommendations.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fe5360b..3afeb88 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import ConfigPanel from './components/ConfigPanel' import ConfigGuide from './components/ConfigGuide' import TradeList from './components/TradeList' import StatsDashboard from './components/StatsDashboard' +import Recommendations from './components/Recommendations' import './App.css' function App() { @@ -15,6 +16,7 @@ function App() {

自动交易系统

仪表板 + 交易推荐 配置 交易记录
@@ -24,6 +26,7 @@ function App() {
} /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Recommendations.css b/frontend/src/components/Recommendations.css new file mode 100644 index 0000000..3262da4 --- /dev/null +++ b/frontend/src/components/Recommendations.css @@ -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%; + } +} diff --git a/frontend/src/components/Recommendations.jsx b/frontend/src/components/Recommendations.jsx new file mode 100644 index 0000000..a15192b --- /dev/null +++ b/frontend/src/components/Recommendations.jsx @@ -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 {statusInfo.text} + } + + const getSignalStrengthColor = (strength) => { + if (strength >= 8) return 'signal-strong' + if (strength >= 6) return 'signal-medium' + return 'signal-weak' + } + + return ( +
+
+

交易推荐

+
+ + +
+
+ +
+ + + +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
加载中...
+ ) : recommendations.length === 0 ? ( +
+

暂无推荐记录

+ +
+ ) : ( +
+ {recommendations.map((rec) => ( +
+
+
+ {rec.symbol} + + {rec.direction === 'BUY' ? '做多' : '做空'} + + {getStatusBadge(rec.status)} +
+
+ + 信号强度: {rec.signal_strength}/10 + + {formatTime(rec.recommendation_time)} +
+
+ +
+
+
+ + {parseFloat(rec.current_price || 0).toFixed(4)} USDT +
+ {rec.change_percent && ( +
+ + = 0 ? 'positive' : 'negative'}> + {rec.change_percent >= 0 ? '+' : ''}{parseFloat(rec.change_percent).toFixed(2)}% + +
+ )} +
+ +
+ 推荐原因: +

{rec.recommendation_reason || '-'}

+
+ +
+
+ + {parseFloat(rec.suggested_stop_loss || 0).toFixed(4)} +
+
+ + {parseFloat(rec.suggested_take_profit_1 || 0).toFixed(4)} +
+
+ + {parseFloat(rec.suggested_take_profit_2 || 0).toFixed(4)} +
+
+ + {(parseFloat(rec.suggested_position_percent || 0) * 100).toFixed(2)}% +
+
+ + {rec.suggested_leverage || 10}x +
+
+ + + + {showDetails[rec.id] && ( +
+
+

技术指标

+
+ {rec.rsi && ( +
+ + {parseFloat(rec.rsi).toFixed(2)} +
+ )} + {rec.macd_histogram !== null && rec.macd_histogram !== undefined && ( +
+ + {parseFloat(rec.macd_histogram).toFixed(6)} +
+ )} + {rec.ema20 && ( +
+ + {parseFloat(rec.ema20).toFixed(4)} +
+ )} + {rec.ema50 && ( +
+ + {parseFloat(rec.ema50).toFixed(4)} +
+ )} + {rec.ema20_4h && ( +
+ + {parseFloat(rec.ema20_4h).toFixed(4)} +
+ )} + {rec.atr && ( +
+ + {parseFloat(rec.atr).toFixed(4)} +
+ )} +
+
+ +
+

市场状态

+
+
+ + {rec.market_regime === 'trending' ? '趋势' : rec.market_regime === 'ranging' ? '震荡' : '-'} +
+
+ + + {rec.trend_4h === 'up' ? '向上' : rec.trend_4h === 'down' ? '向下' : rec.trend_4h === 'neutral' ? '中性' : '-'} + +
+ {rec.volume_24h && ( +
+ + {(parseFloat(rec.volume_24h) / 1000000).toFixed(2)}M USDT +
+ )} +
+
+ + {rec.bollinger_upper && ( +
+

布林带

+
+
+ + {parseFloat(rec.bollinger_upper).toFixed(4)} +
+
+ + {parseFloat(rec.bollinger_middle).toFixed(4)} +
+
+ + {parseFloat(rec.bollinger_lower).toFixed(4)} +
+
+
+ )} +
+ )} + + {rec.status === 'active' && ( +
+ + +
+ )} +
+
+ ))} +
+ )} +
+ ) +} + +export default Recommendations diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index d0ea938..4a63832 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -120,4 +120,81 @@ export const api = { } 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(); + }, };