a
This commit is contained in:
parent
fe034891c8
commit
e00cbeb1fe
|
|
@ -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:
|
||||
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")
|
||||
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'
|
||||
|
||||
recommendations = TradeRecommendation.get_all(
|
||||
status=status,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt
|
||||
)
|
||||
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"交易系统模块不存在"
|
||||
)
|
||||
|
||||
# 如果是获取有效推荐,尝试更新实时价格
|
||||
if status == 'active' or (status is None and len(recommendations) > 0):
|
||||
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:
|
||||
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()
|
||||
|
||||
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)}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -100,6 +105,31 @@ function Recommendations() {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -215,17 +245,31 @@ function Recommendations() {
|
|||
|
||||
<div className="filters">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value)
|
||||
setStatusFilter('') // 切换类型时重置状态过滤
|
||||
}}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="active">有效推荐</option>
|
||||
<option value="">全部</option>
|
||||
<option value="executed">已执行</option>
|
||||
<option value="expired">已过期</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<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)}
|
||||
|
|
@ -255,7 +299,7 @@ function Recommendations() {
|
|||
) : (
|
||||
<div className="recommendations-list">
|
||||
{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-title">
|
||||
<span className="symbol">{rec.symbol}</span>
|
||||
|
|
@ -321,14 +365,26 @@ function Recommendations() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-toggle-details"
|
||||
onClick={() => toggleDetails(rec.id)}
|
||||
>
|
||||
{showDetails[rec.id] ? '隐藏' : '显示'}详细信息
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{showDetails[rec.id] && (
|
||||
{showDetails[rec.id || rec.symbol] && (
|
||||
<div className="details-panel">
|
||||
<div className="details-section">
|
||||
<h4>技术指标</h4>
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@ export const api = {
|
|||
|
||||
// 交易推荐
|
||||
getRecommendations: async (params = {}) => {
|
||||
// 默认使用实时推荐
|
||||
if (!params.type) {
|
||||
params.type = 'realtime'
|
||||
}
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const url = query ? `${buildUrl('/api/recommendations')}?${query}` : buildUrl('/api/recommendations');
|
||||
const response = await fetch(url);
|
||||
|
|
@ -133,6 +137,19 @@ export const api = {
|
|||
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 () => {
|
||||
const response = await fetch(buildUrl('/api/recommendations/active'));
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -310,17 +310,12 @@ class TradeRecommender:
|
|||
'volatility': symbol_info.get('volatility')
|
||||
}
|
||||
|
||||
# 保存到数据库
|
||||
if DB_AVAILABLE and TradeRecommendation:
|
||||
try:
|
||||
recommendation_id = TradeRecommendation.create(**recommendation_data)
|
||||
logger.info(
|
||||
f"✓ 推荐已保存: {symbol} {direction} "
|
||||
f"(信号强度: {signal_strength}/10, ID: {recommendation_id})"
|
||||
)
|
||||
recommendation_data['id'] = recommendation_id
|
||||
except Exception as e:
|
||||
logger.error(f"保存推荐到数据库失败: {e}")
|
||||
# 不再自动保存到数据库,只返回推荐数据
|
||||
# 只有用户在前端点击"标记"时才会保存到数据库(用于复盘)
|
||||
logger.debug(
|
||||
f"✓ 生成推荐: {symbol} {direction} "
|
||||
f"(信号强度: {signal_strength}/10)"
|
||||
)
|
||||
|
||||
return recommendation_data
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user