auto_trade_sys/frontend/src/components/Recommendations.jsx
薇薇安 39e6a46bd3 a
2026-01-17 21:33:20 +08:00

569 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 [typeFilter, setTypeFilter] = useState('realtime') // 'realtime' 或 'bookmarked'
const [statusFilter, setStatusFilter] = useState('')
const [directionFilter, setDirectionFilter] = useState('')
const [generating, setGenerating] = useState(false)
const [showDetails, setShowDetails] = useState({})
const [bookmarking, setBookmarking] = useState({}) // 记录正在标记的推荐ID
useEffect(() => {
loadRecommendations()
// 如果是查看实时推荐每10秒静默更新价格不触发loading状态
let interval = null
if (typeFilter === 'realtime') {
interval = setInterval(async () => {
// 静默更新只更新价格不显示loading
try {
const result = await api.getRecommendations({ type: 'realtime' })
const newData = result.data || []
// 使用setState直接更新不触发loading状态
setRecommendations(prevRecommendations => {
// 如果新数据为空,保持原数据不变
if (newData.length === 0) {
return prevRecommendations
}
// 实时推荐没有id使用symbol作为key
const newDataMap = new Map(newData.map(rec => [rec.symbol, rec]))
const prevMap = new Map(prevRecommendations.map(rec => [rec.symbol || rec.id, rec]))
// 合并数据:优先使用新数据(包含实时价格更新)
const updated = prevRecommendations.map(prevRec => {
const key = prevRec.symbol || prevRec.id
const newRec = newDataMap.get(key)
if (newRec) {
return newRec
}
return prevRec
})
// 添加新出现的推荐
const newItems = newData.filter(newRec => !prevMap.has(newRec.symbol))
// 合并并去重按symbol
const merged = [...updated, ...newItems]
const uniqueMap = new Map()
merged.forEach(rec => {
const key = rec.symbol || rec.id
if (!uniqueMap.has(key)) {
uniqueMap.set(key, rec)
}
})
return Array.from(uniqueMap.values())
})
} catch (err) {
// 静默失败,不显示错误
console.debug('静默更新价格失败:', err)
}
}, 10000) // 每10秒刷新
}
return () => {
if (interval) {
clearInterval(interval)
}
}
}, [typeFilter, statusFilter, directionFilter])
const loadRecommendations = async () => {
try {
setLoading(true)
setError(null)
const params = { type: typeFilter }
if (typeFilter === 'bookmarked') {
// 已标记的推荐:支持状态和方向过滤
if (statusFilter) params.status = statusFilter
if (directionFilter) params.direction = directionFilter
params.limit = 50
} else {
// 实时推荐:只支持方向过滤
if (directionFilter) params.direction = directionFilter
params.limit = 50
params.min_signal_strength = 5
}
const result = await api.getRecommendations(params)
const data = result.data || []
setRecommendations(data)
} catch (err) {
setError(err.message)
console.error('加载推荐失败:', err)
} finally {
setLoading(false)
}
}
const handleBookmark = async (rec) => {
try {
setBookmarking(prev => ({ ...prev, [rec.symbol || rec.id]: true }))
await api.bookmarkRecommendation(rec)
// 标记成功后,可以显示提示或更新状态
alert('推荐已标记到数据库,可用于复盘')
// 如果当前查看的是已标记推荐,重新加载
if (typeFilter === 'bookmarked') {
loadRecommendations()
}
} catch (err) {
alert('标记失败: ' + err.message)
console.error('标记推荐失败:', err)
} finally {
setBookmarking(prev => {
const newState = { ...prev }
delete newState[rec.symbol || rec.id]
return newState
})
}
}
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 {
// 处理时区问题:如果时间字符串包含时区信息,直接解析
// 否则假设是UTC时间转换为本地时间
const date = new Date(timeStr)
// 检查日期是否有效
if (isNaN(date.getTime())) {
return timeStr
}
// 使用本地时区格式化显示北京时间如果服务器返回的是UTC时间
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai' // 明确使用北京时间
})
} 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={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value)
setStatusFilter('') // 切换类型时重置状态过滤
}}
className="filter-select"
>
<option value="realtime">实时推荐</option>
<option value="bookmarked">已标记推荐</option>
</select>
{typeFilter === 'bookmarked' && (
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="filter-select"
>
<option value="">全部状态</option>
<option value="active">有效</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 || rec.symbol} 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">
{/* 推荐分类和风险等级标签 */}
{(rec.recommendation_category || rec.risk_level) && (
<div className="recommendation-tags">
{rec.recommendation_category && (
<span className={`category-tag category-${rec.recommendation_category?.replace(/[\(\)]/g, '').replace(/\s+/g, '-').toLowerCase() || 'default'}`}>
{rec.recommendation_category}
</span>
)}
{rec.risk_level && (
<span className={`risk-tag risk-${rec.risk_level?.replace(/\s+/g, '-').toLowerCase() || 'medium'}`}>
风险: {rec.risk_level}
</span>
)}
</div>
)}
<div className="price-info">
<div className="price-item">
<label>当前价格:</label>
<span>
{parseFloat(rec.current_price || 0).toFixed(4)} USDT
{rec.price_updated && (
<span className="price-updated-badge" title="价格已通过WebSocket实时更新">🟢</span>
)}
</span>
</div>
{rec.change_percent !== undefined && rec.change_percent !== null && (
<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>
{/* 用户指南(人话版计划) */}
{rec.user_guide && (
<div className="user-guide">
<strong>📋 操作计划:</strong>
<pre className="user-guide-content">{rec.user_guide}</pre>
</div>
)}
{/* 交易教程(如果存在) */}
{rec.trading_tutorial && (
<div className="trading-tutorial">
<strong>💡 交易提示:</strong>
<p>{rec.trading_tutorial}</p>
</div>
)}
<div className="recommendation-reason">
<strong>技术分析原因:</strong>
<p>{rec.recommendation_reason || '-'}</p>
</div>
<div className="suggested-params">
<div className="param-item order-type">
<label>订单类型:</label>
<span className="order-type-badge limit">
限价单
</span>
</div>
{rec.suggested_limit_price && (
<div className="param-item limit-price highlight">
<label>建议挂单价:</label>
<span className="limit-price-value highlight-price">
{parseFloat(rec.suggested_limit_price || 0).toFixed(4)} USDT
{rec.current_price && (
<span className="price-diff">
({rec.direction === 'BUY' ? '低于' : '高于'}当前价
{Math.abs(((rec.suggested_limit_price - rec.current_price) / rec.current_price) * 100).toFixed(2)}%)
</span>
)}
</span>
</div>
)}
<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>
<div className="card-actions">
{typeFilter === 'realtime' && (
<button
className="btn-bookmark"
onClick={() => handleBookmark(rec)}
disabled={bookmarking[rec.symbol || rec.id]}
title="标记到数据库用于复盘"
>
{bookmarking[rec.symbol || rec.id] ? '标记中...' : '📌 标记'}
</button>
)}
<button
className="btn-toggle-details"
onClick={() => toggleDetails(rec.id || rec.symbol)}
>
{showDetails[rec.id || rec.symbol] ? '隐藏' : '显示'}详细信息
</button>
</div>
{/* 预期持仓时间(如果存在) */}
{rec.expected_hold_time && (
<div className="expected-hold-time">
<strong> 预期持仓时间:</strong>
<span>{rec.expected_hold_time}</span>
</div>
)}
{/* 最大持仓天数提醒 */}
{rec.max_hold_days && (
<div className="max-hold-warning">
<strong> 退出提醒:</strong>
<span>若持仓超过{rec.max_hold_days}天仍未触及第一目标建议平仓离场重新评估</span>
</div>
)}
{showDetails[rec.id || rec.symbol] && (
<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