a
This commit is contained in:
parent
fe034891c8
commit
e00cbeb1fe
|
|
@ -14,49 +14,114 @@ router = APIRouter(prefix="/api/recommendations", tags=["recommendations"])
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_recommendations(
|
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"),
|
direction: Optional[str] = Query(None, description="方向过滤: BUY, SELL"),
|
||||||
limit: int = Query(50, ge=1, le=200, description="返回数量限制"),
|
limit: int = Query(50, ge=1, le=200, description="返回数量限制"),
|
||||||
start_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)")
|
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD, 仅用于bookmarked类型)"),
|
||||||
|
min_signal_strength: int = Query(5, ge=0, le=10, description="最小信号强度 (仅用于realtime类型)")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取推荐交易对列表
|
获取推荐交易对列表
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
status: 状态过滤
|
type: 类型 - realtime(实时推荐,基于当前行情), bookmarked(已标记的推荐,从数据库查询)
|
||||||
|
status: 状态过滤(仅用于bookmarked类型)
|
||||||
direction: 方向过滤
|
direction: 方向过滤
|
||||||
limit: 返回数量限制
|
limit: 返回数量限制
|
||||||
start_date: 开始日期
|
start_date: 开始日期(仅用于bookmarked类型)
|
||||||
end_date: 结束日期
|
end_date: 结束日期(仅用于bookmarked类型)
|
||||||
|
min_signal_strength: 最小信号强度(仅用于realtime类型)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 转换日期字符串
|
if type == 'realtime':
|
||||||
start_dt = None
|
# 实时生成推荐
|
||||||
end_dt = None
|
import sys
|
||||||
if start_date:
|
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:
|
try:
|
||||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
scanner = MarketScanner(client)
|
||||||
except ValueError:
|
risk_manager = RiskManager(client)
|
||||||
raise HTTPException(status_code=400, detail="开始日期格式错误,应为 YYYY-MM-DD")
|
recommender = TradeRecommender(client, scanner, risk_manager)
|
||||||
if end_date:
|
|
||||||
try:
|
# 生成推荐
|
||||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
recommendations = await recommender.generate_recommendations(
|
||||||
# 设置为当天的23:59:59
|
min_signal_strength=min_signal_strength,
|
||||||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
max_recommendations=limit
|
||||||
except ValueError:
|
)
|
||||||
raise HTTPException(status_code=400, detail="结束日期格式错误,应为 YYYY-MM-DD")
|
|
||||||
|
# 方向过滤
|
||||||
|
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(
|
elif type == 'bookmarked':
|
||||||
status=status,
|
# 从数据库查询已标记的推荐
|
||||||
direction=direction,
|
# 转换日期字符串
|
||||||
limit=limit,
|
start_dt = None
|
||||||
start_date=start_dt,
|
end_dt = None
|
||||||
end_date=end_dt
|
if start_date:
|
||||||
)
|
try:
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
# 如果是获取有效推荐,尝试更新实时价格
|
except ValueError:
|
||||||
if status == 'active' or (status is None and len(recommendations) > 0):
|
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:
|
try:
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -70,29 +135,49 @@ async def get_recommendations(
|
||||||
from binance_client import BinanceClient
|
from binance_client import BinanceClient
|
||||||
import config
|
import config
|
||||||
|
|
||||||
# 创建客户端实例(不需要连接,只需要访问价格缓存)
|
|
||||||
client = BinanceClient(
|
client = BinanceClient(
|
||||||
api_key=config.BINANCE_API_KEY,
|
api_key=config.BINANCE_API_KEY,
|
||||||
api_secret=config.BINANCE_API_SECRET,
|
api_secret=config.BINANCE_API_SECRET,
|
||||||
testnet=config.USE_TESTNET
|
testnet=config.USE_TESTNET
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 连接Redis(如果还没有连接)
|
||||||
|
try:
|
||||||
|
await client.redis_cache.connect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# 更新推荐中的实时价格和涨跌幅
|
# 更新推荐中的实时价格和涨跌幅
|
||||||
for rec in recommendations:
|
for rec in recommendations:
|
||||||
symbol = rec.get('symbol')
|
symbol = rec.get('symbol')
|
||||||
if symbol:
|
if symbol:
|
||||||
# 尝试从WebSocket缓存获取实时价格
|
# 先尝试从内存缓存获取实时价格(同步)
|
||||||
realtime_price = client.get_realtime_price(symbol)
|
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)
|
old_price = rec.get('current_price', 0)
|
||||||
rec['current_price'] = realtime_price
|
rec['current_price'] = realtime_price
|
||||||
|
|
||||||
# 计算新的涨跌幅
|
|
||||||
if old_price > 0:
|
if old_price > 0:
|
||||||
change_percent = ((realtime_price - old_price) / old_price) * 100
|
change_percent = ((realtime_price - old_price) / old_price) * 100
|
||||||
rec['change_percent'] = round(change_percent, 4)
|
rec['change_percent'] = round(change_percent, 4)
|
||||||
rec['price_updated'] = True # 标记价格已更新
|
rec['price_updated'] = True
|
||||||
else:
|
else:
|
||||||
rec['price_updated'] = False
|
rec['price_updated'] = False
|
||||||
else:
|
else:
|
||||||
|
|
@ -100,14 +185,19 @@ async def get_recommendations(
|
||||||
else:
|
else:
|
||||||
rec['price_updated'] = False
|
rec['price_updated'] = False
|
||||||
except Exception as price_update_error:
|
except Exception as price_update_error:
|
||||||
# 如果价格更新失败,不影响返回推荐列表
|
|
||||||
logger.debug(f"更新推荐实时价格失败(不影响返回): {price_update_error}")
|
logger.debug(f"更新推荐实时价格失败(不影响返回): {price_update_error}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"count": len(recommendations),
|
"count": len(recommendations),
|
||||||
"data": recommendations
|
"type": "bookmarked",
|
||||||
}
|
"data": recommendations
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="type参数必须是 'realtime' 或 'bookmarked'")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取推荐列表失败: {e}")
|
logger.error(f"获取推荐列表失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"获取推荐列表失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"获取推荐列表失败: {str(e)}")
|
||||||
|
|
@ -116,7 +206,7 @@ async def get_recommendations(
|
||||||
@router.get("/active")
|
@router.get("/active")
|
||||||
async def get_active_recommendations():
|
async def get_active_recommendations():
|
||||||
"""
|
"""
|
||||||
获取当前有效的推荐(未过期、未执行、未取消)
|
获取当前有效的推荐(已标记的推荐中未过期、未执行、未取消的)
|
||||||
使用WebSocket实时价格更新推荐中的价格信息
|
使用WebSocket实时价格更新推荐中的价格信息
|
||||||
同一交易对只返回最新的推荐(已去重)
|
同一交易对只返回最新的推荐(已去重)
|
||||||
"""
|
"""
|
||||||
|
|
@ -128,11 +218,7 @@ async def get_active_recommendations():
|
||||||
for rec in recommendations:
|
for rec in recommendations:
|
||||||
if rec.get('recommendation_time'):
|
if rec.get('recommendation_time'):
|
||||||
if isinstance(rec['recommendation_time'], datetime):
|
if isinstance(rec['recommendation_time'], datetime):
|
||||||
# 如果是datetime对象,转换为ISO格式字符串(UTC+8)
|
|
||||||
rec['recommendation_time'] = rec['recommendation_time'].isoformat()
|
rec['recommendation_time'] = rec['recommendation_time'].isoformat()
|
||||||
elif isinstance(rec['recommendation_time'], str):
|
|
||||||
# 如果已经是字符串,确保格式正确
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 尝试从WebSocket缓存获取实时价格更新推荐中的价格
|
# 尝试从WebSocket缓存获取实时价格更新推荐中的价格
|
||||||
try:
|
try:
|
||||||
|
|
@ -148,29 +234,49 @@ async def get_active_recommendations():
|
||||||
from binance_client import BinanceClient
|
from binance_client import BinanceClient
|
||||||
import config
|
import config
|
||||||
|
|
||||||
# 创建客户端实例(不需要连接,只需要访问价格缓存)
|
|
||||||
client = BinanceClient(
|
client = BinanceClient(
|
||||||
api_key=config.BINANCE_API_KEY,
|
api_key=config.BINANCE_API_KEY,
|
||||||
api_secret=config.BINANCE_API_SECRET,
|
api_secret=config.BINANCE_API_SECRET,
|
||||||
testnet=config.USE_TESTNET
|
testnet=config.USE_TESTNET
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 连接Redis(如果还没有连接)
|
||||||
|
try:
|
||||||
|
await client.redis_cache.connect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# 更新推荐中的实时价格和涨跌幅
|
# 更新推荐中的实时价格和涨跌幅
|
||||||
for rec in recommendations:
|
for rec in recommendations:
|
||||||
symbol = rec.get('symbol')
|
symbol = rec.get('symbol')
|
||||||
if symbol:
|
if symbol:
|
||||||
# 尝试从WebSocket缓存获取实时价格
|
# 先尝试从内存缓存获取实时价格(同步)
|
||||||
realtime_price = client.get_realtime_price(symbol)
|
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)
|
old_price = rec.get('current_price', 0)
|
||||||
rec['current_price'] = realtime_price
|
rec['current_price'] = realtime_price
|
||||||
|
|
||||||
# 计算新的涨跌幅
|
|
||||||
if old_price > 0:
|
if old_price > 0:
|
||||||
change_percent = ((realtime_price - old_price) / old_price) * 100
|
change_percent = ((realtime_price - old_price) / old_price) * 100
|
||||||
rec['change_percent'] = round(change_percent, 4)
|
rec['change_percent'] = round(change_percent, 4)
|
||||||
rec['price_updated'] = True # 标记价格已更新
|
rec['price_updated'] = True
|
||||||
else:
|
else:
|
||||||
rec['price_updated'] = False
|
rec['price_updated'] = False
|
||||||
else:
|
else:
|
||||||
|
|
@ -178,7 +284,6 @@ async def get_active_recommendations():
|
||||||
else:
|
else:
|
||||||
rec['price_updated'] = False
|
rec['price_updated'] = False
|
||||||
except Exception as price_update_error:
|
except Exception as price_update_error:
|
||||||
# 如果价格更新失败,不影响返回推荐列表
|
|
||||||
logger.debug(f"更新推荐实时价格失败(不影响返回): {price_update_error}")
|
logger.debug(f"更新推荐实时价格失败(不影响返回): {price_update_error}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -387,3 +492,66 @@ async def generate_recommendations(
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=f"生成推荐失败: {str(e)}")
|
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)}")
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ class TradeRecommendation:
|
||||||
ema20=None, ema50=None, ema20_4h=None, atr=None,
|
ema20=None, ema50=None, ema20_4h=None, atr=None,
|
||||||
suggested_stop_loss=None, suggested_take_profit_1=None, suggested_take_profit_2=None,
|
suggested_stop_loss=None, suggested_take_profit_1=None, suggested_take_profit_2=None,
|
||||||
suggested_position_percent=None, suggested_leverage=10,
|
suggested_position_percent=None, suggested_leverage=10,
|
||||||
|
order_type='LIMIT', suggested_limit_price=None,
|
||||||
volume_24h=None, volatility=None, notes=None
|
volume_24h=None, volatility=None, notes=None
|
||||||
):
|
):
|
||||||
"""创建推荐记录(使用北京时间)"""
|
"""创建推荐记录(使用北京时间)"""
|
||||||
|
|
@ -249,24 +250,54 @@ class TradeRecommendation:
|
||||||
# 默认24小时后过期
|
# 默认24小时后过期
|
||||||
expires_at = recommendation_time + timedelta(hours=24)
|
expires_at = recommendation_time + timedelta(hours=24)
|
||||||
|
|
||||||
db.execute_update(
|
# 检查字段是否存在(兼容旧数据库schema)
|
||||||
"""INSERT INTO trade_recommendations
|
try:
|
||||||
(symbol, direction, recommendation_time, current_price, change_percent,
|
db.execute_one("SELECT order_type FROM trade_recommendations LIMIT 1")
|
||||||
recommendation_reason, signal_strength, market_regime, trend_4h,
|
has_order_fields = True
|
||||||
rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower,
|
except:
|
||||||
ema20, ema50, ema20_4h, atr,
|
has_order_fields = False
|
||||||
suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2,
|
|
||||||
suggested_position_percent, suggested_leverage,
|
if has_order_fields:
|
||||||
volume_24h, volatility, expires_at, notes)
|
db.execute_update(
|
||||||
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)""",
|
"""INSERT INTO trade_recommendations
|
||||||
(symbol, direction, recommendation_time, current_price, change_percent,
|
(symbol, direction, recommendation_time, current_price, change_percent,
|
||||||
recommendation_reason, signal_strength, market_regime, trend_4h,
|
recommendation_reason, signal_strength, market_regime, trend_4h,
|
||||||
rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower,
|
rsi, macd_histogram, bollinger_upper, bollinger_middle, bollinger_lower,
|
||||||
ema20, ema50, ema20_4h, atr,
|
ema20, ema50, ema20_4h, atr,
|
||||||
suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2,
|
suggested_stop_loss, suggested_take_profit_1, suggested_take_profit_2,
|
||||||
suggested_position_percent, suggested_leverage,
|
suggested_position_percent, suggested_leverage,
|
||||||
volume_24h, volatility, expires_at, notes)
|
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']
|
return db.execute_one("SELECT LAST_INSERT_ID() as id")['id']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,32 @@
|
||||||
font-weight: 500;
|
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 {
|
.btn-toggle-details {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
|
|
@ -296,7 +322,6 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
align-self: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-toggle-details:hover {
|
.btn-toggle-details:hover {
|
||||||
|
|
@ -459,7 +484,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggested-params {
|
.suggested-params {
|
||||||
grid-template-columns: 1fr;
|
/* grid-template-columns: 1fr; */
|
||||||
|
display: flex;
|
||||||
|
width:80vw;
|
||||||
|
overflow-x: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
.indicators-grid {
|
.indicators-grid {
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,23 @@ function Recommendations() {
|
||||||
const [recommendations, setRecommendations] = useState([])
|
const [recommendations, setRecommendations] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
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 [directionFilter, setDirectionFilter] = useState('')
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [showDetails, setShowDetails] = useState({})
|
const [showDetails, setShowDetails] = useState({})
|
||||||
|
const [bookmarking, setBookmarking] = useState({}) // 记录正在标记的推荐ID
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecommendations()
|
loadRecommendations()
|
||||||
|
|
||||||
// 如果是查看有效推荐,每10秒静默更新价格(不触发loading状态)
|
// 如果是查看实时推荐,每10秒静默更新价格(不触发loading状态)
|
||||||
let interval = null
|
let interval = null
|
||||||
if (statusFilter === 'active') {
|
if (typeFilter === 'realtime') {
|
||||||
interval = setInterval(async () => {
|
interval = setInterval(async () => {
|
||||||
// 静默更新:只更新价格,不显示loading
|
// 静默更新:只更新价格,不显示loading
|
||||||
try {
|
try {
|
||||||
const result = await api.getActiveRecommendations()
|
const result = await api.getRecommendations({ type: 'realtime' })
|
||||||
const newData = result.data || []
|
const newData = result.data || []
|
||||||
|
|
||||||
// 使用setState直接更新,不触发loading状态
|
// 使用setState直接更新,不触发loading状态
|
||||||
|
|
@ -30,30 +32,30 @@ function Recommendations() {
|
||||||
return prevRecommendations
|
return prevRecommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建一个映射,用于快速查找
|
// 实时推荐没有id,使用symbol作为key
|
||||||
const newDataMap = new Map(newData.map(rec => [rec.id, rec]))
|
const newDataMap = new Map(newData.map(rec => [rec.symbol, rec]))
|
||||||
const prevMap = new Map(prevRecommendations.map(rec => [rec.id, rec]))
|
const prevMap = new Map(prevRecommendations.map(rec => [rec.symbol || rec.id, rec]))
|
||||||
|
|
||||||
// 合并数据:优先使用新数据(包含实时价格更新)
|
// 合并数据:优先使用新数据(包含实时价格更新)
|
||||||
const updated = prevRecommendations.map(prevRec => {
|
const updated = prevRecommendations.map(prevRec => {
|
||||||
const newRec = newDataMap.get(prevRec.id)
|
const key = prevRec.symbol || prevRec.id
|
||||||
|
const newRec = newDataMap.get(key)
|
||||||
if (newRec) {
|
if (newRec) {
|
||||||
// 如果新数据中有该推荐,使用新数据(包含价格更新)
|
|
||||||
return newRec
|
return newRec
|
||||||
}
|
}
|
||||||
// 如果新数据中没有,保留旧数据(可能已过期或状态改变)
|
|
||||||
return prevRec
|
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 merged = [...updated, ...newItems]
|
||||||
const uniqueMap = new Map()
|
const uniqueMap = new Map()
|
||||||
merged.forEach(rec => {
|
merged.forEach(rec => {
|
||||||
if (!uniqueMap.has(rec.id)) {
|
const key = rec.symbol || rec.id
|
||||||
uniqueMap.set(rec.id, rec)
|
if (!uniqueMap.has(key)) {
|
||||||
|
uniqueMap.set(key, rec)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -71,26 +73,29 @@ function Recommendations() {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [statusFilter, directionFilter])
|
}, [typeFilter, statusFilter, directionFilter])
|
||||||
|
|
||||||
const loadRecommendations = async () => {
|
const loadRecommendations = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
let data
|
const params = { type: typeFilter }
|
||||||
if (statusFilter === 'active') {
|
if (typeFilter === 'bookmarked') {
|
||||||
const result = await api.getActiveRecommendations()
|
// 已标记的推荐:支持状态和方向过滤
|
||||||
data = result.data || []
|
|
||||||
} else {
|
|
||||||
const params = {}
|
|
||||||
if (statusFilter) params.status = statusFilter
|
if (statusFilter) params.status = statusFilter
|
||||||
if (directionFilter) params.direction = directionFilter
|
if (directionFilter) params.direction = directionFilter
|
||||||
params.limit = 50
|
params.limit = 50
|
||||||
const result = await api.getRecommendations(params)
|
} else {
|
||||||
data = result.data || []
|
// 实时推荐:只支持方向过滤
|
||||||
|
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)
|
setRecommendations(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|
@ -99,6 +104,31 @@ function Recommendations() {
|
||||||
setLoading(false)
|
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 () => {
|
const handleGenerate = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -215,17 +245,31 @@ function Recommendations() {
|
||||||
|
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={typeFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setTypeFilter(e.target.value)
|
||||||
|
setStatusFilter('') // 切换类型时重置状态过滤
|
||||||
|
}}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
>
|
>
|
||||||
<option value="active">有效推荐</option>
|
<option value="realtime">实时推荐</option>
|
||||||
<option value="">全部</option>
|
<option value="bookmarked">已标记推荐</option>
|
||||||
<option value="executed">已执行</option>
|
|
||||||
<option value="expired">已过期</option>
|
|
||||||
<option value="cancelled">已取消</option>
|
|
||||||
</select>
|
</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
|
<select
|
||||||
value={directionFilter}
|
value={directionFilter}
|
||||||
onChange={(e) => setDirectionFilter(e.target.value)}
|
onChange={(e) => setDirectionFilter(e.target.value)}
|
||||||
|
|
@ -255,7 +299,7 @@ function Recommendations() {
|
||||||
) : (
|
) : (
|
||||||
<div className="recommendations-list">
|
<div className="recommendations-list">
|
||||||
{recommendations.map((rec) => (
|
{recommendations.map((rec) => (
|
||||||
<div key={rec.id} className="recommendation-card">
|
<div key={rec.id || rec.symbol} className="recommendation-card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span className="symbol">{rec.symbol}</span>
|
<span className="symbol">{rec.symbol}</span>
|
||||||
|
|
@ -321,14 +365,26 @@ function Recommendations() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="card-actions">
|
||||||
className="btn-toggle-details"
|
{typeFilter === 'realtime' && (
|
||||||
onClick={() => toggleDetails(rec.id)}
|
<button
|
||||||
>
|
className="btn-bookmark"
|
||||||
{showDetails[rec.id] ? '隐藏' : '显示'}详细信息
|
onClick={() => handleBookmark(rec)}
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
{showDetails[rec.id] && (
|
{showDetails[rec.id || rec.symbol] && (
|
||||||
<div className="details-panel">
|
<div className="details-panel">
|
||||||
<div className="details-section">
|
<div className="details-section">
|
||||||
<h4>技术指标</h4>
|
<h4>技术指标</h4>
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,10 @@ export const api = {
|
||||||
|
|
||||||
// 交易推荐
|
// 交易推荐
|
||||||
getRecommendations: async (params = {}) => {
|
getRecommendations: async (params = {}) => {
|
||||||
|
// 默认使用实时推荐
|
||||||
|
if (!params.type) {
|
||||||
|
params.type = 'realtime'
|
||||||
|
}
|
||||||
const query = new URLSearchParams(params).toString();
|
const query = new URLSearchParams(params).toString();
|
||||||
const url = query ? `${buildUrl('/api/recommendations')}?${query}` : buildUrl('/api/recommendations');
|
const url = query ? `${buildUrl('/api/recommendations')}?${query}` : buildUrl('/api/recommendations');
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
@ -133,6 +137,19 @@ export const api = {
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
bookmarkRecommendation: async (recommendationData) => {
|
||||||
|
const response = await fetch(buildUrl('/api/recommendations/bookmark'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(recommendationData)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: '标记推荐失败' }));
|
||||||
|
throw new Error(error.detail || '标记推荐失败');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
getActiveRecommendations: async () => {
|
getActiveRecommendations: async () => {
|
||||||
const response = await fetch(buildUrl('/api/recommendations/active'));
|
const response = await fetch(buildUrl('/api/recommendations/active'));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -310,17 +310,12 @@ class TradeRecommender:
|
||||||
'volatility': symbol_info.get('volatility')
|
'volatility': symbol_info.get('volatility')
|
||||||
}
|
}
|
||||||
|
|
||||||
# 保存到数据库
|
# 不再自动保存到数据库,只返回推荐数据
|
||||||
if DB_AVAILABLE and TradeRecommendation:
|
# 只有用户在前端点击"标记"时才会保存到数据库(用于复盘)
|
||||||
try:
|
logger.debug(
|
||||||
recommendation_id = TradeRecommendation.create(**recommendation_data)
|
f"✓ 生成推荐: {symbol} {direction} "
|
||||||
logger.info(
|
f"(信号强度: {signal_strength}/10)"
|
||||||
f"✓ 推荐已保存: {symbol} {direction} "
|
)
|
||||||
f"(信号强度: {signal_strength}/10, ID: {recommendation_id})"
|
|
||||||
)
|
|
||||||
recommendation_data['id'] = recommendation_id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"保存推荐到数据库失败: {e}")
|
|
||||||
|
|
||||||
return recommendation_data
|
return recommendation_data
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user