diff --git a/backend/api/routes/recommendations.py b/backend/api/routes/recommendations.py index 327ccd0..969879d 100644 --- a/backend/api/routes/recommendations.py +++ b/backend/api/routes/recommendations.py @@ -14,49 +14,114 @@ router = APIRouter(prefix="/api/recommendations", tags=["recommendations"]) @router.get("") async def get_recommendations( - status: Optional[str] = Query(None, description="状态过滤: active, executed, expired, cancelled"), + type: Optional[str] = Query('realtime', description="类型: realtime(实时推荐), bookmarked(已标记的推荐)"), + status: Optional[str] = Query(None, description="状态过滤: active, executed, expired, cancelled (仅用于bookmarked类型)"), direction: Optional[str] = Query(None, description="方向过滤: BUY, SELL"), limit: int = Query(50, ge=1, le=200, description="返回数量限制"), - start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"), - end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)") + start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD, 仅用于bookmarked类型)"), + end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD, 仅用于bookmarked类型)"), + min_signal_strength: int = Query(5, ge=0, le=10, description="最小信号强度 (仅用于realtime类型)") ): """ 获取推荐交易对列表 Args: - status: 状态过滤 + type: 类型 - realtime(实时推荐,基于当前行情), bookmarked(已标记的推荐,从数据库查询) + status: 状态过滤(仅用于bookmarked类型) direction: 方向过滤 limit: 返回数量限制 - start_date: 开始日期 - end_date: 结束日期 + start_date: 开始日期(仅用于bookmarked类型) + end_date: 结束日期(仅用于bookmarked类型) + min_signal_strength: 最小信号强度(仅用于realtime类型) """ try: - # 转换日期字符串 - start_dt = None - end_dt = None - if start_date: + if type == 'realtime': + # 实时生成推荐 + import sys + from pathlib import Path + current_file = Path(__file__) + backend_path = current_file.parent.parent.parent + project_root = backend_path.parent + trading_system_path = project_root / 'trading_system' + + if not trading_system_path.exists(): + alternative_path = backend_path / 'trading_system' + if alternative_path.exists(): + trading_system_path = alternative_path + else: + raise HTTPException( + status_code=500, + detail=f"交易系统模块不存在" + ) + + sys.path.insert(0, str(trading_system_path)) + sys.path.insert(0, str(project_root)) + + from binance_client import BinanceClient + from market_scanner import MarketScanner + from risk_manager import RiskManager + from trade_recommender import TradeRecommender + import config + + # 初始化组件 + client = BinanceClient( + api_key=config.BINANCE_API_KEY, + api_secret=config.BINANCE_API_SECRET, + testnet=config.USE_TESTNET + ) + + await client.connect() + try: - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - except ValueError: - raise HTTPException(status_code=400, detail="开始日期格式错误,应为 YYYY-MM-DD") - if end_date: - try: - end_dt = datetime.strptime(end_date, "%Y-%m-%d") - # 设置为当天的23:59:59 - end_dt = end_dt.replace(hour=23, minute=59, second=59) - except ValueError: - raise HTTPException(status_code=400, detail="结束日期格式错误,应为 YYYY-MM-DD") + scanner = MarketScanner(client) + risk_manager = RiskManager(client) + recommender = TradeRecommender(client, scanner, risk_manager) + + # 生成推荐 + recommendations = await recommender.generate_recommendations( + min_signal_strength=min_signal_strength, + max_recommendations=limit + ) + + # 方向过滤 + if direction: + recommendations = [r for r in recommendations if r.get('direction') == direction] + + return { + "success": True, + "count": len(recommendations), + "type": "realtime", + "data": recommendations + } + finally: + await client.disconnect() - recommendations = TradeRecommendation.get_all( - status=status, - direction=direction, - limit=limit, - start_date=start_dt, - end_date=end_dt - ) - - # 如果是获取有效推荐,尝试更新实时价格 - if status == 'active' or (status is None and len(recommendations) > 0): + elif type == 'bookmarked': + # 从数据库查询已标记的推荐 + # 转换日期字符串 + start_dt = None + end_dt = None + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + except ValueError: + raise HTTPException(status_code=400, detail="开始日期格式错误,应为 YYYY-MM-DD") + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + end_dt = end_dt.replace(hour=23, minute=59, second=59) + except ValueError: + raise HTTPException(status_code=400, detail="结束日期格式错误,应为 YYYY-MM-DD") + + recommendations = TradeRecommendation.get_all( + status=status, + direction=direction, + limit=limit, + start_date=start_dt, + end_date=end_dt + ) + + # 更新实时价格 try: import sys from pathlib import Path @@ -70,29 +135,49 @@ async def get_recommendations( from binance_client import BinanceClient import config - # 创建客户端实例(不需要连接,只需要访问价格缓存) client = BinanceClient( api_key=config.BINANCE_API_KEY, api_secret=config.BINANCE_API_SECRET, testnet=config.USE_TESTNET ) + # 连接Redis(如果还没有连接) + try: + await client.redis_cache.connect() + except: + pass + # 更新推荐中的实时价格和涨跌幅 for rec in recommendations: symbol = rec.get('symbol') if symbol: - # 尝试从WebSocket缓存获取实时价格 + # 先尝试从内存缓存获取实时价格(同步) realtime_price = client.get_realtime_price(symbol) - if realtime_price is not None: - # 更新价格 + + # 如果内存缓存没有,尝试从Valkey异步读取 + if realtime_price is None: + try: + realtime_price = await client.get_realtime_price_async(symbol) + except: + pass + + # 如果WebSocket/Valkey都没有,尝试从REST API获取 + if realtime_price is None: + try: + ticker = await client.get_ticker_24h(symbol) + if ticker: + realtime_price = float(ticker.get('lastPrice', 0)) + except: + pass + + if realtime_price is not None and realtime_price > 0: old_price = rec.get('current_price', 0) rec['current_price'] = realtime_price - # 计算新的涨跌幅 if old_price > 0: change_percent = ((realtime_price - old_price) / old_price) * 100 rec['change_percent'] = round(change_percent, 4) - rec['price_updated'] = True # 标记价格已更新 + rec['price_updated'] = True else: rec['price_updated'] = False else: @@ -100,14 +185,19 @@ async def get_recommendations( else: rec['price_updated'] = False except Exception as price_update_error: - # 如果价格更新失败,不影响返回推荐列表 logger.debug(f"更新推荐实时价格失败(不影响返回): {price_update_error}") - - return { - "success": True, - "count": len(recommendations), - "data": recommendations - } + + return { + "success": True, + "count": len(recommendations), + "type": "bookmarked", + "data": recommendations + } + else: + raise HTTPException(status_code=400, detail="type参数必须是 'realtime' 或 'bookmarked'") + + except HTTPException: + raise except Exception as e: logger.error(f"获取推荐列表失败: {e}") raise HTTPException(status_code=500, detail=f"获取推荐列表失败: {str(e)}") @@ -116,7 +206,7 @@ async def get_recommendations( @router.get("/active") async def get_active_recommendations(): """ - 获取当前有效的推荐(未过期、未执行、未取消) + 获取当前有效的推荐(已标记的推荐中未过期、未执行、未取消的) 使用WebSocket实时价格更新推荐中的价格信息 同一交易对只返回最新的推荐(已去重) """ @@ -128,11 +218,7 @@ async def get_active_recommendations(): for rec in recommendations: if rec.get('recommendation_time'): if isinstance(rec['recommendation_time'], datetime): - # 如果是datetime对象,转换为ISO格式字符串(UTC+8) rec['recommendation_time'] = rec['recommendation_time'].isoformat() - elif isinstance(rec['recommendation_time'], str): - # 如果已经是字符串,确保格式正确 - pass # 尝试从WebSocket缓存获取实时价格更新推荐中的价格 try: @@ -148,29 +234,49 @@ async def get_active_recommendations(): from binance_client import BinanceClient import config - # 创建客户端实例(不需要连接,只需要访问价格缓存) client = BinanceClient( api_key=config.BINANCE_API_KEY, api_secret=config.BINANCE_API_SECRET, testnet=config.USE_TESTNET ) + # 连接Redis(如果还没有连接) + try: + await client.redis_cache.connect() + except: + pass + # 更新推荐中的实时价格和涨跌幅 for rec in recommendations: symbol = rec.get('symbol') if symbol: - # 尝试从WebSocket缓存获取实时价格 + # 先尝试从内存缓存获取实时价格(同步) realtime_price = client.get_realtime_price(symbol) - if realtime_price is not None: - # 更新价格 + + # 如果内存缓存没有,尝试从Valkey异步读取 + if realtime_price is None: + try: + realtime_price = await client.get_realtime_price_async(symbol) + except: + pass + + # 如果WebSocket/Valkey都没有,尝试从REST API获取 + if realtime_price is None: + try: + ticker = await client.get_ticker_24h(symbol) + if ticker: + realtime_price = float(ticker.get('lastPrice', 0)) + except: + pass + + if realtime_price is not None and realtime_price > 0: old_price = rec.get('current_price', 0) rec['current_price'] = realtime_price - # 计算新的涨跌幅 if old_price > 0: change_percent = ((realtime_price - old_price) / old_price) * 100 rec['change_percent'] = round(change_percent, 4) - rec['price_updated'] = True # 标记价格已更新 + rec['price_updated'] = True else: rec['price_updated'] = False else: @@ -178,7 +284,6 @@ async def get_active_recommendations(): else: rec['price_updated'] = False except Exception as price_update_error: - # 如果价格更新失败,不影响返回推荐列表 logger.debug(f"更新推荐实时价格失败(不影响返回): {price_update_error}") return { @@ -387,3 +492,66 @@ async def generate_recommendations( import traceback logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=f"生成推荐失败: {str(e)}") + + +@router.post("/bookmark") +async def bookmark_recommendation(recommendation_data: dict): + """ + 标记推荐到数据库(用于复盘) + + Args: + recommendation_data: 推荐数据(包含所有推荐字段) + """ + try: + # 提取必需字段 + required_fields = ['symbol', 'direction', 'current_price', 'change_percent', + 'recommendation_reason', 'signal_strength'] + for field in required_fields: + if field not in recommendation_data: + raise HTTPException(status_code=400, detail=f"缺少必需字段: {field}") + + # 保存到数据库 + recommendation_id = TradeRecommendation.create( + symbol=recommendation_data.get('symbol'), + direction=recommendation_data.get('direction'), + current_price=recommendation_data.get('current_price'), + change_percent=recommendation_data.get('change_percent'), + recommendation_reason=recommendation_data.get('recommendation_reason'), + signal_strength=recommendation_data.get('signal_strength'), + market_regime=recommendation_data.get('market_regime'), + trend_4h=recommendation_data.get('trend_4h'), + rsi=recommendation_data.get('rsi'), + macd_histogram=recommendation_data.get('macd_histogram'), + bollinger_upper=recommendation_data.get('bollinger_upper'), + bollinger_middle=recommendation_data.get('bollinger_middle'), + bollinger_lower=recommendation_data.get('bollinger_lower'), + ema20=recommendation_data.get('ema20'), + ema50=recommendation_data.get('ema50'), + ema20_4h=recommendation_data.get('ema20_4h'), + atr=recommendation_data.get('atr'), + suggested_stop_loss=recommendation_data.get('suggested_stop_loss'), + suggested_take_profit_1=recommendation_data.get('suggested_take_profit_1'), + suggested_take_profit_2=recommendation_data.get('suggested_take_profit_2'), + suggested_position_percent=recommendation_data.get('suggested_position_percent'), + suggested_leverage=recommendation_data.get('suggested_leverage', 10), + order_type=recommendation_data.get('order_type', 'LIMIT'), + suggested_limit_price=recommendation_data.get('suggested_limit_price'), + volume_24h=recommendation_data.get('volume_24h'), + volatility=recommendation_data.get('volatility'), + notes=recommendation_data.get('notes', '用户标记用于复盘') + ) + + logger.info(f"✓ 推荐已标记到数据库: {recommendation_data.get('symbol')} {recommendation_data.get('direction')} (ID: {recommendation_id})") + + return { + "success": True, + "message": "推荐已标记到数据库", + "recommendation_id": recommendation_id + } + except HTTPException: + raise + except Exception as e: + logger.error(f"标记推荐失败: {e}") + import traceback + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=f"标记推荐失败: {str(e)}") diff --git a/backend/database/models.py b/backend/database/models.py index 99c502f..9d62d01 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -242,6 +242,7 @@ class TradeRecommendation: ema20=None, ema50=None, ema20_4h=None, atr=None, suggested_stop_loss=None, suggested_take_profit_1=None, suggested_take_profit_2=None, suggested_position_percent=None, suggested_leverage=10, + order_type='LIMIT', suggested_limit_price=None, volume_24h=None, volatility=None, notes=None ): """创建推荐记录(使用北京时间)""" @@ -249,24 +250,54 @@ class TradeRecommendation: # 默认24小时后过期 expires_at = recommendation_time + timedelta(hours=24) - db.execute_update( - """INSERT INTO trade_recommendations - (symbol, direction, recommendation_time, current_price, change_percent, - recommendation_reason, signal_strength, market_regime, trend_4h, - rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower, - ema20, ema50, ema20_4h, atr, - suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2, - suggested_position_percent, suggested_leverage, - volume_24h, volatility, expires_at, notes) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", - (symbol, direction, recommendation_time, current_price, change_percent, - recommendation_reason, signal_strength, market_regime, trend_4h, - rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower, - ema20, ema50, ema20_4h, atr, - suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2, - suggested_position_percent, suggested_leverage, - volume_24h, volatility, expires_at, notes) - ) + # 检查字段是否存在(兼容旧数据库schema) + try: + db.execute_one("SELECT order_type FROM trade_recommendations LIMIT 1") + has_order_fields = True + except: + has_order_fields = False + + if has_order_fields: + db.execute_update( + """INSERT INTO trade_recommendations + (symbol, direction, recommendation_time, current_price, change_percent, + recommendation_reason, signal_strength, market_regime, trend_4h, + rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower, + ema20, ema50, ema20_4h, atr, + suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2, + suggested_position_percent, suggested_leverage, + order_type, suggested_limit_price, + volume_24h, volatility, expires_at, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (symbol, direction, recommendation_time, current_price, change_percent, + recommendation_reason, signal_strength, market_regime, trend_4h, + rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower, + ema20, ema50, ema20_4h, atr, + suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2, + suggested_position_percent, suggested_leverage, + order_type, suggested_limit_price, + volume_24h, volatility, expires_at, notes) + ) + else: + # 兼容旧schema + db.execute_update( + """INSERT INTO trade_recommendations + (symbol, direction, recommendation_time, current_price, change_percent, + recommendation_reason, signal_strength, market_regime, trend_4h, + rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower, + ema20, ema50, ema20_4h, atr, + suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2, + suggested_position_percent, suggested_leverage, + volume_24h, volatility, expires_at, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (symbol, direction, recommendation_time, current_price, change_percent, + recommendation_reason, signal_strength, market_regime, trend_4h, + rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower, + ema20, ema50, ema20_4h, atr, + suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2, + suggested_position_percent, suggested_leverage, + volume_24h, volatility, expires_at, notes) + ) return db.execute_one("SELECT LAST_INSERT_ID() as id")['id'] @staticmethod diff --git a/frontend/src/components/Recommendations.css b/frontend/src/components/Recommendations.css index 3262da4..d0b68df 100644 --- a/frontend/src/components/Recommendations.css +++ b/frontend/src/components/Recommendations.css @@ -288,6 +288,32 @@ font-weight: 500; } +.card-actions { + display: flex; + gap: 10px; + margin-top: 15px; +} + +.btn-bookmark { + padding: 8px 16px; + background-color: #FF9800; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s; +} + +.btn-bookmark:hover:not(:disabled) { + background-color: #F57C00; +} + +.btn-bookmark:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + .btn-toggle-details { padding: 8px 16px; background-color: #e0e0e0; @@ -296,7 +322,6 @@ cursor: pointer; font-size: 14px; transition: background-color 0.3s; - align-self: flex-start; } .btn-toggle-details:hover { @@ -459,7 +484,10 @@ } .suggested-params { - grid-template-columns: 1fr; + /* grid-template-columns: 1fr; */ + display: flex; + width:80vw; + overflow-x: scroll; } .indicators-grid { diff --git a/frontend/src/components/Recommendations.jsx b/frontend/src/components/Recommendations.jsx index fc00ac6..f848b06 100644 --- a/frontend/src/components/Recommendations.jsx +++ b/frontend/src/components/Recommendations.jsx @@ -6,21 +6,23 @@ function Recommendations() { const [recommendations, setRecommendations] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [statusFilter, setStatusFilter] = useState('active') + 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状态) + // 如果是查看实时推荐,每10秒静默更新价格(不触发loading状态) let interval = null - if (statusFilter === 'active') { + if (typeFilter === 'realtime') { interval = setInterval(async () => { // 静默更新:只更新价格,不显示loading try { - const result = await api.getActiveRecommendations() + const result = await api.getRecommendations({ type: 'realtime' }) const newData = result.data || [] // 使用setState直接更新,不触发loading状态 @@ -30,30 +32,30 @@ function Recommendations() { return prevRecommendations } - // 创建一个映射,用于快速查找 - const newDataMap = new Map(newData.map(rec => [rec.id, rec])) - const prevMap = new Map(prevRecommendations.map(rec => [rec.id, rec])) + // 实时推荐没有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 newRec = newDataMap.get(prevRec.id) + 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.id)) + const newItems = newData.filter(newRec => !prevMap.has(newRec.symbol)) - // 合并并去重(按id) + // 合并并去重(按symbol) const merged = [...updated, ...newItems] const uniqueMap = new Map() merged.forEach(rec => { - if (!uniqueMap.has(rec.id)) { - uniqueMap.set(rec.id, rec) + const key = rec.symbol || rec.id + if (!uniqueMap.has(key)) { + uniqueMap.set(key, rec) } }) @@ -71,26 +73,29 @@ function Recommendations() { clearInterval(interval) } } - }, [statusFilter, directionFilter]) + }, [typeFilter, 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 = {} + const params = { type: typeFilter } + if (typeFilter === 'bookmarked') { + // 已标记的推荐:支持状态和方向过滤 if (statusFilter) params.status = statusFilter if (directionFilter) params.direction = directionFilter params.limit = 50 - const result = await api.getRecommendations(params) - data = result.data || [] + } 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) @@ -99,6 +104,31 @@ function Recommendations() { 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 { @@ -215,17 +245,31 @@ function Recommendations() {
+ {typeFilter === 'bookmarked' && ( + + )} +