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 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() {
|
|||
<h1 className="nav-title">自动交易系统</h1>
|
||||
<div className="nav-links">
|
||||
<Link to="/">仪表板</Link>
|
||||
<Link to="/recommendations">交易推荐</Link>
|
||||
<Link to="/config">配置</Link>
|
||||
<Link to="/trades">交易记录</Link>
|
||||
</div>
|
||||
|
|
@ -24,6 +26,7 @@ function App() {
|
|||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<StatsDashboard />} />
|
||||
<Route path="/recommendations" element={<Recommendations />} />
|
||||
<Route path="/config" element={<ConfigPanel />} />
|
||||
<Route path="/config/guide" element={<ConfigGuide />} />
|
||||
<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();
|
||||
},
|
||||
|
||||
// 交易推荐
|
||||
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