a
This commit is contained in:
parent
272b4cdc19
commit
f633a95de3
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
481
frontend/src/components/Recommendations.css
Normal file
481
frontend/src/components/Recommendations.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
371
frontend/src/components/Recommendations.jsx
Normal file
371
frontend/src/components/Recommendations.jsx
Normal 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
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user