update
This commit is contained in:
parent
eaa2e90f55
commit
e155274828
186
backend/app/api/signals.py
Normal file
186
backend/app/api/signals.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""
|
||||
信号 API - 提供加密货币和美股信号查询接口(数据库版本)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from app.services.signal_database_service import get_signal_db_service
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/signals", tags=["信号管理"])
|
||||
|
||||
|
||||
@router.get("/crypto")
|
||||
async def get_crypto_signals(
|
||||
limit: int = Query(50, ge=1, le=200, description="返回数量限制"),
|
||||
symbol: Optional[str] = Query(None, description="过滤指定交易对"),
|
||||
days: int = Query(7, ge=1, le=30, description="查询最近多少天的信号")
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取加密货币信号列表
|
||||
|
||||
Args:
|
||||
limit: 返回数量限制(默认50)
|
||||
symbol: 过滤指定交易对
|
||||
days: 查询最近多少天的信号(默认7天)
|
||||
|
||||
Returns:
|
||||
信号列表
|
||||
"""
|
||||
try:
|
||||
service = get_signal_db_service()
|
||||
|
||||
if symbol:
|
||||
# 获取指定交易对的最新信号
|
||||
signal = service.get_latest_signal('crypto', symbol)
|
||||
return {
|
||||
'success': True,
|
||||
'symbol': symbol,
|
||||
'signal': signal,
|
||||
'count': 1 if signal else 0
|
||||
}
|
||||
else:
|
||||
# 获取所有信号
|
||||
signals = service.get_crypto_signals(limit=limit, days=days)
|
||||
return {
|
||||
'success': True,
|
||||
'signals': signals,
|
||||
'count': len(signals)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取加密货币信号失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stock")
|
||||
async def get_stock_signals(
|
||||
limit: int = Query(50, ge=1, le=200, description="返回数量限制"),
|
||||
symbol: Optional[str] = Query(None, description="过滤指定股票"),
|
||||
days: int = Query(7, ge=1, le=30, description="查询最近多少天的信号")
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取美股信号列表
|
||||
|
||||
Args:
|
||||
limit: 返回数量限制(默认50)
|
||||
symbol: 过滤指定股票
|
||||
days: 查询最近多少天的信号(默认7天)
|
||||
|
||||
Returns:
|
||||
信号列表
|
||||
"""
|
||||
try:
|
||||
service = get_signal_db_service()
|
||||
|
||||
if symbol:
|
||||
# 获取指定股票的最新信号
|
||||
signal = service.get_latest_signal('stock', symbol)
|
||||
return {
|
||||
'success': True,
|
||||
'symbol': symbol,
|
||||
'signal': signal,
|
||||
'count': 1 if signal else 0
|
||||
}
|
||||
else:
|
||||
# 获取所有信号
|
||||
signals = service.get_stock_signals(limit=limit, days=days)
|
||||
return {
|
||||
'success': True,
|
||||
'signals': signals,
|
||||
'count': len(signals)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取美股信号失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_signals(
|
||||
limit: int = Query(50, ge=1, le=200, description="每种类型返回数量限制"),
|
||||
days: int = Query(7, ge=1, le=30, description="查询最近多少天的信号")
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取所有信号(加密货币 + 美股)
|
||||
|
||||
Args:
|
||||
limit: 每种类型返回数量限制(默认50)
|
||||
days: 查询最近多少天的信号(默认7天)
|
||||
|
||||
Returns:
|
||||
所有信号
|
||||
"""
|
||||
try:
|
||||
service = get_signal_db_service()
|
||||
signals = service.get_all_signals(limit=limit, days=days)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'crypto': {
|
||||
'signals': signals['crypto'],
|
||||
'count': len(signals['crypto'])
|
||||
},
|
||||
'stock': {
|
||||
'signals': signals['stock'],
|
||||
'count': len(signals['stock'])
|
||||
},
|
||||
'total_count': len(signals['crypto']) + len(signals['stock'])
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取所有信号失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/latest")
|
||||
async def get_latest_signals(
|
||||
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
|
||||
days: int = Query(7, ge=1, le=30, description="查询最近多少天的信号")
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取最新的所有信号(按时间排序)
|
||||
|
||||
Args:
|
||||
limit: 返回数量限制(默认20)
|
||||
days: 查询最近多少天的信号(默认7天)
|
||||
|
||||
Returns:
|
||||
最新信号列表
|
||||
"""
|
||||
try:
|
||||
service = get_signal_db_service()
|
||||
signals = service.get_latest_signals(limit=limit, days=days)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'signals': signals,
|
||||
'count': len(signals)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取最新信号失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_signal_stats(
|
||||
days: int = Query(7, ge=1, le=30, description="统计最近多少天的信号")
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取信号统计信息
|
||||
|
||||
Args:
|
||||
days: 统计最近多少天的信号(默认7天)
|
||||
|
||||
Returns:
|
||||
统计数据
|
||||
"""
|
||||
try:
|
||||
service = get_signal_db_service()
|
||||
stats = service.get_signal_stats(days=days)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
**stats
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取信号统计失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@ -13,6 +13,7 @@ from app.services.feishu_service import get_feishu_service
|
||||
from app.services.telegram_service import get_telegram_service
|
||||
from app.services.paper_trading_service import get_paper_trading_service
|
||||
from app.services.price_monitor_service import get_price_monitor_service
|
||||
from app.services.signal_database_service import get_signal_db_service
|
||||
from app.crypto_agent.llm_signal_analyzer import LLMSignalAnalyzer
|
||||
|
||||
|
||||
@ -26,6 +27,7 @@ class CryptoAgent:
|
||||
self.feishu = get_feishu_service()
|
||||
self.telegram = get_telegram_service()
|
||||
self.llm_analyzer = LLMSignalAnalyzer()
|
||||
self.signal_db = get_signal_db_service() # 信号数据库服务
|
||||
|
||||
# 模拟交易服务
|
||||
self.paper_trading_enabled = self.settings.paper_trading_enabled
|
||||
@ -352,6 +354,13 @@ class CryptoAgent:
|
||||
|
||||
logger.info(f" ✅ 已发送信号通知")
|
||||
|
||||
# 保存信号到数据库
|
||||
signal_to_save = best_signal.copy()
|
||||
signal_to_save['signal_type'] = 'crypto'
|
||||
signal_to_save['symbol'] = symbol
|
||||
signal_to_save['current_price'] = current_price
|
||||
self.signal_db.add_signal(signal_to_save)
|
||||
|
||||
# 更新状态
|
||||
self.last_signals[symbol] = best_signal
|
||||
self.signal_cooldown[symbol] = datetime.now()
|
||||
|
||||
@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from app.config import get_settings
|
||||
from app.utils.logger import logger
|
||||
from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks
|
||||
from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals
|
||||
import os
|
||||
|
||||
|
||||
@ -233,6 +233,7 @@ app.include_router(skills.router, prefix="/api/skills", tags=["技能管理"])
|
||||
app.include_router(llm.router, tags=["LLM模型"])
|
||||
app.include_router(paper_trading.router, tags=["模拟交易"])
|
||||
app.include_router(stocks.router, prefix="/api/stocks", tags=["美股分析"])
|
||||
app.include_router(signals.router, tags=["信号管理"])
|
||||
|
||||
# 挂载静态文件
|
||||
frontend_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend")
|
||||
@ -268,6 +269,14 @@ async def paper_trading_page():
|
||||
return FileResponse(page_path)
|
||||
return {"message": "页面不存在"}
|
||||
|
||||
@app.get("/signals")
|
||||
async def signals_page():
|
||||
"""信号列表页面"""
|
||||
page_path = os.path.join(frontend_path, "signals.html")
|
||||
if os.path.exists(page_path):
|
||||
return FileResponse(page_path)
|
||||
return {"message": "页面不存在"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
|
||||
91
backend/app/models/signal.py
Normal file
91
backend/app/models/signal.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""
|
||||
交易信号数据库模型
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Float, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.database import Base
|
||||
|
||||
|
||||
class TradingSignal(Base):
|
||||
"""交易信号表"""
|
||||
__tablename__ = "trading_signals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 信号基本信息
|
||||
signal_type = Column(String(20), nullable=False, index=True) # 'crypto' or 'stock'
|
||||
symbol = Column(String(50), nullable=False, index=True) # 交易对或股票代码
|
||||
|
||||
# 信号方向和评级
|
||||
action = Column(String(10), nullable=False) # 'buy', 'sell', 'hold'
|
||||
grade = Column(String(5), nullable=False) # 'A', 'B', 'C', 'D'
|
||||
confidence = Column(Float, nullable=False) # 置信度 0-100
|
||||
|
||||
# 价格信息
|
||||
entry_price = Column(Float, nullable=True)
|
||||
stop_loss = Column(Float, nullable=True)
|
||||
take_profit = Column(Float, nullable=True)
|
||||
current_price = Column(Float, nullable=True) # 信号生成时的当前价格
|
||||
|
||||
# 信号详情
|
||||
signal_type_detail = Column(String(20), nullable=True) # 'short_term', 'medium_term', 'long_term'
|
||||
entry_type = Column(String(10), nullable=True) # 'market', 'limit'
|
||||
position_size = Column(String(20), nullable=True) # 'light', 'medium', 'heavy'
|
||||
|
||||
# 分析信息
|
||||
reason = Column(Text, nullable=True) # 信号理由
|
||||
risk_warning = Column(Text, nullable=True) # 风险提示
|
||||
analysis_summary = Column(Text, nullable=True) # 分析摘要
|
||||
news_sentiment = Column(String(20), nullable=True) # 新闻情绪
|
||||
news_impact = Column(String(100), nullable=True) # 消息影响
|
||||
|
||||
# 关键价位
|
||||
key_levels = Column(JSON, nullable=True) # 支撑位和阻力位
|
||||
|
||||
# 技术指标(JSON 格式存储)
|
||||
indicators = Column(JSON, nullable=True)
|
||||
|
||||
# 状态
|
||||
is_active = Column(Boolean, default=True) # 信号是否有效
|
||||
notified = Column(Boolean, default=False) # 是否已发送通知
|
||||
notification_sent_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TradingSignal({self.signal_type} {self.symbol} {self.action} {self.grade} {self.confidence}%)>"
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'signal_type': self.signal_type,
|
||||
'symbol': self.symbol,
|
||||
'action': self.action,
|
||||
'grade': self.grade,
|
||||
'confidence': self.confidence,
|
||||
'entry_price': self.entry_price,
|
||||
'stop_loss': self.stop_loss,
|
||||
'take_profit': self.take_profit,
|
||||
'current_price': self.current_price,
|
||||
'signal_type_detail': self.signal_type_detail,
|
||||
'entry_type': self.entry_type,
|
||||
'position_size': self.position_size,
|
||||
'reason': self.reason,
|
||||
'risk_warning': self.risk_warning,
|
||||
'analysis_summary': self.analysis_summary,
|
||||
'news_sentiment': self.news_sentiment,
|
||||
'news_impact': self.news_impact,
|
||||
'key_levels': self.key_levels,
|
||||
'indicators': self.indicators,
|
||||
'is_active': self.is_active,
|
||||
'notified': self.notified,
|
||||
'notification_sent_at': self.notification_sent_at.isoformat() if self.notification_sent_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'timestamp': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
290
backend/app/services/signal_database_service.py
Normal file
290
backend/app/services/signal_database_service.py
Normal file
@ -0,0 +1,290 @@
|
||||
"""
|
||||
交易信号数据库服务
|
||||
"""
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, and_, or_
|
||||
|
||||
from app.models.signal import TradingSignal
|
||||
from app.models.database import SessionLocal, engine, Base
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
class SignalDatabaseService:
|
||||
"""交易信号数据库服务"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化服务"""
|
||||
self._ensure_tables()
|
||||
|
||||
def _ensure_tables(self):
|
||||
"""确保表已创建"""
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("交易信号表已创建")
|
||||
except Exception as e:
|
||||
logger.error(f"创建交易信号表失败: {e}")
|
||||
|
||||
def _get_db(self) -> Session:
|
||||
"""获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db
|
||||
except Exception as e:
|
||||
logger.error(f"获取数据库会话失败: {e}")
|
||||
raise
|
||||
|
||||
def add_signal(self, signal_data: Dict[str, Any]) -> Optional[TradingSignal]:
|
||||
"""添加信号到数据库"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
# 创建信号对象
|
||||
signal = TradingSignal(
|
||||
signal_type=signal_data.get('signal_type', 'crypto'),
|
||||
symbol=signal_data.get('symbol', ''),
|
||||
action=signal_data.get('action', 'hold'),
|
||||
grade=signal_data.get('grade', 'D'),
|
||||
confidence=signal_data.get('confidence', 0),
|
||||
entry_price=signal_data.get('entry_price'),
|
||||
stop_loss=signal_data.get('stop_loss'),
|
||||
take_profit=signal_data.get('take_profit'),
|
||||
current_price=signal_data.get('current_price'),
|
||||
signal_type_detail=signal_data.get('type'),
|
||||
entry_type=signal_data.get('entry_type'),
|
||||
position_size=signal_data.get('position_size'),
|
||||
reason=signal_data.get('reason'),
|
||||
risk_warning=signal_data.get('risk_warning'),
|
||||
analysis_summary=signal_data.get('analysis_summary'),
|
||||
news_sentiment=signal_data.get('news_sentiment'),
|
||||
news_impact=signal_data.get('news_impact'),
|
||||
key_levels=signal_data.get('key_levels'),
|
||||
indicators=signal_data.get('indicators'),
|
||||
notified=True,
|
||||
notification_sent_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(signal)
|
||||
db.commit()
|
||||
db.refresh(signal)
|
||||
|
||||
logger.info(f"保存信号到数据库: {signal.signal_type} {signal.symbol} {signal.action} {signal.grade}")
|
||||
return signal
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"保存信号失败: {e}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_crypto_signals(
|
||||
self,
|
||||
limit: int = 50,
|
||||
symbol: Optional[str] = None,
|
||||
days: int = 7
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取加密货币信号"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
query = db.query(TradingSignal).filter(
|
||||
TradingSignal.signal_type == 'crypto',
|
||||
TradingSignal.created_at >= cutoff_time
|
||||
)
|
||||
|
||||
if symbol:
|
||||
query = query.filter(TradingSignal.symbol == symbol.upper())
|
||||
|
||||
signals = query.order_by(desc(TradingSignal.created_at)).limit(limit).all()
|
||||
|
||||
return [signal.to_dict() for signal in signals]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取加密货币信号失败: {e}")
|
||||
return []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_stock_signals(
|
||||
self,
|
||||
limit: int = 50,
|
||||
symbol: Optional[str] = None,
|
||||
days: int = 7
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取美股信号"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
query = db.query(TradingSignal).filter(
|
||||
TradingSignal.signal_type == 'stock',
|
||||
TradingSignal.created_at >= cutoff_time
|
||||
)
|
||||
|
||||
if symbol:
|
||||
query = query.filter(TradingSignal.symbol == symbol.upper())
|
||||
|
||||
signals = query.order_by(desc(TradingSignal.created_at)).limit(limit).all()
|
||||
|
||||
return [signal.to_dict() for signal in signals]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取美股信号失败: {e}")
|
||||
return []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_all_signals(self, limit: int = 100, days: int = 7) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""获取所有信号"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
signals = db.query(TradingSignal).filter(
|
||||
TradingSignal.created_at >= cutoff_time
|
||||
).order_by(desc(TradingSignal.created_at)).limit(limit).all()
|
||||
|
||||
crypto_signals = []
|
||||
stock_signals = []
|
||||
|
||||
for signal in signals:
|
||||
signal_dict = signal.to_dict()
|
||||
if signal.signal_type == 'crypto':
|
||||
crypto_signals.append(signal_dict)
|
||||
else:
|
||||
stock_signals.append(signal_dict)
|
||||
|
||||
return {
|
||||
'crypto': crypto_signals,
|
||||
'stock': stock_signals
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取所有信号失败: {e}")
|
||||
return {'crypto': [], 'stock': []}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_latest_signals(self, limit: int = 20, days: int = 7) -> List[Dict[str, Any]]:
|
||||
"""获取最新信号(混合)"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
signals = db.query(TradingSignal).filter(
|
||||
TradingSignal.created_at >= cutoff_time
|
||||
).order_by(desc(TradingSignal.created_at)).limit(limit).all()
|
||||
|
||||
return [signal.to_dict() for signal in signals]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取最新信号失败: {e}")
|
||||
return []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_signal_stats(self, days: int = 7) -> Dict[str, Any]:
|
||||
"""获取信号统计"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# 获取所有信号
|
||||
all_signals = db.query(TradingSignal).filter(
|
||||
TradingSignal.created_at >= cutoff_time
|
||||
).all()
|
||||
|
||||
# 统计加密货币信号
|
||||
crypto_signals = [s for s in all_signals if s.signal_type == 'crypto']
|
||||
crypto_buy = sum(1 for s in crypto_signals if s.action == 'buy')
|
||||
crypto_sell = sum(1 for s in crypto_signals if s.action == 'sell')
|
||||
|
||||
# 统计美股信号
|
||||
stock_signals = [s for s in all_signals if s.signal_type == 'stock']
|
||||
stock_buy = sum(1 for s in stock_signals if s.action == 'buy')
|
||||
stock_sell = sum(1 for s in stock_signals if s.action == 'sell')
|
||||
|
||||
# 按等级统计
|
||||
grade_stats = {}
|
||||
for signal in all_signals:
|
||||
grade_stats[signal.grade] = grade_stats.get(signal.grade, 0) + 1
|
||||
|
||||
# 最近24小时信号
|
||||
recent_cutoff = datetime.utcnow() - timedelta(hours=24)
|
||||
recent_crypto = sum(1 for s in crypto_signals if s.created_at >= recent_cutoff)
|
||||
recent_stock = sum(1 for s in stock_signals if s.created_at >= recent_cutoff)
|
||||
|
||||
return {
|
||||
'crypto': {
|
||||
'total': len(crypto_signals),
|
||||
'buy': crypto_buy,
|
||||
'sell': crypto_sell,
|
||||
'recent_24h': recent_crypto
|
||||
},
|
||||
'stock': {
|
||||
'total': len(stock_signals),
|
||||
'buy': stock_buy,
|
||||
'sell': stock_sell,
|
||||
'recent_24h': recent_stock
|
||||
},
|
||||
'grades': grade_stats,
|
||||
'total': len(all_signals)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取信号统计失败: {e}")
|
||||
return {}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_latest_signal(self, signal_type: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取指定交易对的最新信号"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
signal = db.query(TradingSignal).filter(
|
||||
TradingSignal.signal_type == signal_type,
|
||||
TradingSignal.symbol == symbol.upper()
|
||||
).order_by(desc(TradingSignal.created_at)).first()
|
||||
|
||||
if signal:
|
||||
return signal.to_dict()
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取最新信号失败: {e}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def clear_old_signals(self, days: int = 30):
|
||||
"""清理旧信号"""
|
||||
db = self._get_db()
|
||||
try:
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
deleted = db.query(TradingSignal).filter(
|
||||
TradingSignal.created_at < cutoff_time
|
||||
).delete()
|
||||
|
||||
db.commit()
|
||||
logger.info(f"清理了 {deleted} 条旧信号(超过 {days} 天)")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"清理旧信号失败: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# 全局单例
|
||||
_signal_db_service: Optional[SignalDatabaseService] = None
|
||||
|
||||
|
||||
def get_signal_db_service() -> SignalDatabaseService:
|
||||
"""获取信号数据库服务单例"""
|
||||
global _signal_db_service
|
||||
if _signal_db_service is None:
|
||||
_signal_db_service = SignalDatabaseService()
|
||||
return _signal_db_service
|
||||
147
backend/app/services/signal_storage_service.py
Normal file
147
backend/app/services/signal_storage_service.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""
|
||||
信号存储服务 - 保存加密货币和美股的交易信号
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
from app.utils.logger import logger
|
||||
|
||||
|
||||
class SignalStorageService:
|
||||
"""信号存储服务"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化服务"""
|
||||
self.storage_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'data', 'signals')
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
|
||||
# 信号文件
|
||||
self.crypto_file = os.path.join(self.storage_dir, 'crypto_signals.json')
|
||||
self.stock_file = os.path.join(self.storage_dir, 'stock_signals.json')
|
||||
|
||||
# 加载现有信号
|
||||
self._crypto_signals = self._load_signals(self.crypto_file)
|
||||
self._stock_signals = self._load_signals(self.stock_file)
|
||||
|
||||
logger.info(f"信号存储服务初始化完成,加密货币信号: {len(self._crypto_signals)},美股信号: {len(self._stock_signals)}")
|
||||
|
||||
def _load_signals(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
"""从文件加载信号"""
|
||||
if not os.path.exists(file_path):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载信号失败 {file_path}: {e}")
|
||||
return []
|
||||
|
||||
def _save_signals(self, file_path: str, signals: List[Dict[str, Any]]):
|
||||
"""保存信号到文件"""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(signals, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"保存信号失败 {file_path}: {e}")
|
||||
|
||||
def add_crypto_signal(self, signal: Dict[str, Any]):
|
||||
"""添加加密货币信号"""
|
||||
# 添加时间戳和类型
|
||||
signal['timestamp'] = datetime.now().isoformat()
|
||||
signal['signal_type'] = 'crypto'
|
||||
|
||||
# 保存到内存
|
||||
self._crypto_signals.insert(0, signal)
|
||||
|
||||
# 只保留最近 100 条
|
||||
if len(self._crypto_signals) > 100:
|
||||
self._crypto_signals = self._crypto_signals[:100]
|
||||
|
||||
# 持久化
|
||||
self._save_signals(self.crypto_file, self._crypto_signals)
|
||||
|
||||
logger.info(f"添加加密货币信号: {signal.get('symbol', 'N/A')} - {signal.get('action', 'N/A')}")
|
||||
|
||||
def add_stock_signal(self, signal: Dict[str, Any]):
|
||||
"""添加美股信号"""
|
||||
# 添加时间戳和类型
|
||||
signal['timestamp'] = datetime.now().isoformat()
|
||||
signal['signal_type'] = 'stock'
|
||||
|
||||
# 保存到内存
|
||||
self._stock_signals.insert(0, signal)
|
||||
|
||||
# 只保留最近 100 条
|
||||
if len(self._stock_signals) > 100:
|
||||
self._stock_signals = self._stock_signals[:100]
|
||||
|
||||
# 持久化
|
||||
self._save_signals(self.stock_file, self._stock_signals)
|
||||
|
||||
logger.info(f"添加美股信号: {signal.get('symbol', 'N/A')} - {signal.get('action', 'N/A')}")
|
||||
|
||||
def get_crypto_signals(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""获取加密货币信号列表"""
|
||||
return self._crypto_signals[:limit]
|
||||
|
||||
def get_stock_signals(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""获取美股信号列表"""
|
||||
return self._stock_signals[:limit]
|
||||
|
||||
def get_all_signals(self, limit: int = 100) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""获取所有信号"""
|
||||
return {
|
||||
'crypto': self._crypto_signals[:limit],
|
||||
'stock': self._stock_signals[:limit]
|
||||
}
|
||||
|
||||
def get_latest_signal(self, signal_type: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取指定交易对的最新信号"""
|
||||
if signal_type == 'crypto':
|
||||
signals = self._crypto_signals
|
||||
elif signal_type == 'stock':
|
||||
signals = self._stock_signals
|
||||
else:
|
||||
return None
|
||||
|
||||
for signal in signals:
|
||||
if signal.get('symbol') == symbol:
|
||||
return signal
|
||||
|
||||
return None
|
||||
|
||||
def clear_old_signals(self, days: int = 7):
|
||||
"""清理旧信号"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_time = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
# 清理加密货币信号
|
||||
self._crypto_signals = [
|
||||
s for s in self._crypto_signals
|
||||
if s.get('timestamp', '') >= cutoff_time
|
||||
]
|
||||
self._save_signals(self.crypto_file, self._crypto_signals)
|
||||
|
||||
# 清理美股信号
|
||||
self._stock_signals = [
|
||||
s for s in self._stock_signals
|
||||
if s.get('timestamp', '') >= cutoff_time
|
||||
]
|
||||
self._save_signals(self.stock_file, self._stock_signals)
|
||||
|
||||
logger.info(f"清理旧信号完成,保留 {days} 天内的信号")
|
||||
|
||||
|
||||
# 全局单例
|
||||
_signal_storage: Optional[SignalStorageService] = None
|
||||
|
||||
|
||||
def get_signal_storage() -> SignalStorageService:
|
||||
"""获取信号存储服务单例"""
|
||||
global _signal_storage
|
||||
if _signal_storage is None:
|
||||
_signal_storage = SignalStorageService()
|
||||
return _signal_storage
|
||||
@ -12,6 +12,7 @@ from app.config import get_settings
|
||||
from app.services.yfinance_service import get_yfinance_service
|
||||
from app.services.feishu_service import get_feishu_service
|
||||
from app.services.telegram_service import get_telegram_service
|
||||
from app.services.signal_database_service import get_signal_db_service
|
||||
from app.crypto_agent.llm_signal_analyzer import LLMSignalAnalyzer
|
||||
|
||||
|
||||
@ -25,6 +26,7 @@ class StockAgent:
|
||||
self.feishu = get_feishu_service()
|
||||
self.telegram = get_telegram_service()
|
||||
self.llm_analyzer = LLMSignalAnalyzer()
|
||||
self.signal_db = get_signal_db_service() # 信号数据库服务
|
||||
|
||||
# 状态管理
|
||||
self.last_signals: Dict[str, Dict[str, Any]] = {}
|
||||
@ -299,6 +301,13 @@ class StockAgent:
|
||||
|
||||
logger.info(f"✅ 信号通知已发送: {title}")
|
||||
|
||||
# 保存信号到数据库
|
||||
signal_to_save = signal.copy()
|
||||
signal_to_save['signal_type'] = 'stock'
|
||||
signal_to_save['symbol'] = symbol
|
||||
signal_to_save['current_price'] = current_price
|
||||
self.signal_db.add_signal(signal_to_save)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送通知失败: {e}")
|
||||
|
||||
|
||||
459
frontend/signals.html
Normal file
459
frontend/signals.html
Normal file
@ -0,0 +1,459 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>交易信号 - Stock Agent</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.stock {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-card .sub-value {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.signals-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.signal-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid #667eea;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.signal-card:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.signal-card.buy {
|
||||
border-left-color: #00c853;
|
||||
}
|
||||
|
||||
.signal-card.sell {
|
||||
border-left-color: #ff1744;
|
||||
}
|
||||
|
||||
.signal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.signal-symbol {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.signal-action {
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-action.buy {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.signal-action.sell {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.signal-grade {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.signal-grade.A {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.signal-grade.B {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.signal-grade.C {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.signal-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.signal-detail {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.signal-detail strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.signal-reason {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.signal-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.confidence-bar {
|
||||
height: 4px;
|
||||
background: #eee;
|
||||
border-radius: 2px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confidence-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.signal-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 头部 -->
|
||||
<div class="header">
|
||||
<h1>🎯 交易信号中心</h1>
|
||||
<div class="stats" id="stats">
|
||||
<div class="stat-card">
|
||||
<h3>加密货币信号</h3>
|
||||
<div class="value" id="crypto-total">-</div>
|
||||
<div class="sub-value" id="crypto-recent">最近24小时: -</div>
|
||||
</div>
|
||||
<div class="stat-card stock">
|
||||
<h3>美股信号</h3>
|
||||
<div class="value" id="stock-total">-</div>
|
||||
<div class="sub-value" id="stock-recent">最近24小时: -</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>总信号数</h3>
|
||||
<div class="value" id="total-signals">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('crypto')">加密货币</button>
|
||||
<button class="tab" onclick="switchTab('stock')">美股</button>
|
||||
<button class="tab" onclick="switchTab('all')">全部</button>
|
||||
</div>
|
||||
|
||||
<!-- 信号列表 -->
|
||||
<div class="signals-container">
|
||||
<div id="signals-list" class="loading">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentTab = 'crypto';
|
||||
|
||||
// 切换标签页
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// 更新标签样式
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
// 加载数据
|
||||
loadSignals();
|
||||
}
|
||||
|
||||
// 加载信号数据
|
||||
async function loadSignals() {
|
||||
const container = document.getElementById('signals-list');
|
||||
container.innerHTML = '<div class="loading">加载中...</div>';
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (currentTab === 'crypto') {
|
||||
const response = await fetch('/api/signals/crypto?limit=50');
|
||||
data = await response.json();
|
||||
} else if (currentTab === 'stock') {
|
||||
const response = await fetch('/api/signals/stock?limit=50');
|
||||
data = await response.json();
|
||||
} else {
|
||||
const response = await fetch('/api/signals/latest?limit=50');
|
||||
data = await response.json();
|
||||
}
|
||||
|
||||
if (data.success && data.signals && data.signals.length > 0) {
|
||||
renderSignals(data.signals);
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<p>暂无信号</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="empty-state"><p>加载失败: ${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染信号列表
|
||||
function renderSignals(signals) {
|
||||
const container = document.getElementById('signals-list');
|
||||
container.innerHTML = signals.map(signal => createSignalCard(signal)).join('');
|
||||
}
|
||||
|
||||
// 创建信号卡片
|
||||
function createSignalCard(signal) {
|
||||
const action = signal.action || 'hold';
|
||||
const grade = signal.grade || 'N';
|
||||
const confidence = signal.confidence || 0;
|
||||
const isBuy = action === 'buy';
|
||||
|
||||
const time = signal.timestamp ? new Date(signal.timestamp).toLocaleString('zh-CN') : '-';
|
||||
|
||||
let prices = '';
|
||||
if (signal.entry_price) {
|
||||
prices = `
|
||||
<div class="signal-detail">
|
||||
<strong>入场:</strong> $${signal.entry_price.toFixed(2)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (signal.stop_loss) {
|
||||
prices += `
|
||||
<div class="signal-detail">
|
||||
<strong>止损:</strong> $${signal.stop_loss.toFixed(2)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (signal.take_profit) {
|
||||
prices += `
|
||||
<div class="signal-detail">
|
||||
<strong>止盈:</strong> $${signal.take_profit.toFixed(2)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="signal-card ${action}">
|
||||
<div class="signal-header">
|
||||
<div>
|
||||
<span class="signal-symbol">${signal.symbol || 'N/A'}</span>
|
||||
<span class="signal-grade ${grade}">${grade}级</span>
|
||||
${signal.signal_type === 'stock' ? '<span style="font-size:12px;color:#999;margin-left:8px;">美股</span>' : '<span style="font-size:12px;color:#999;margin-left:8px;">加密货币</span>'}
|
||||
</div>
|
||||
<span class="signal-action ${action}">${isBuy ? '🟢 做多' : '🔴 做空'}</span>
|
||||
</div>
|
||||
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="width: ${confidence}%"></div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:14px;color:#666;">
|
||||
置信度: ${confidence}%
|
||||
</div>
|
||||
|
||||
${prices ? `<div class="signal-details">${prices}</div>` : ''}
|
||||
|
||||
${signal.reason ? `
|
||||
<div class="signal-reason">
|
||||
<strong>分析理由:</strong> ${signal.reason}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="signal-time">${time}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('/api/signals/stats');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('crypto-total').textContent = data.crypto.total;
|
||||
document.getElementById('crypto-recent').textContent = `最近24小时: ${data.crypto.recent_24h}`;
|
||||
document.getElementById('stock-total').textContent = data.stock.total;
|
||||
document.getElementById('stock-recent').textContent = `最近24小时: ${data.stock.recent_24h}`;
|
||||
document.getElementById('total-signals').textContent = data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadStats();
|
||||
loadSignals();
|
||||
|
||||
// 每30秒刷新一次
|
||||
setInterval(() => {
|
||||
loadStats();
|
||||
loadSignals();
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user