commit
This commit is contained in:
commit
65276c5beb
123
.gitignore
vendored
Normal file
123
.gitignore
vendored
Normal file
@ -0,0 +1,123 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery
|
||||
celerybeat-schedule
|
||||
celery.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Spyder
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Trading AI specific
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
trading.db
|
||||
coin_selection.log
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Configuration files with secrets
|
||||
config.py
|
||||
.env
|
||||
|
||||
# Data files
|
||||
data/
|
||||
*.csv
|
||||
*.json
|
||||
backups/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
temp/
|
||||
tmp/
|
||||
172
DOCKER_DEPLOYMENT.md
Normal file
172
DOCKER_DEPLOYMENT.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Trading AI - Docker 部署指南
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 准备配置文件
|
||||
|
||||
复制配置模板并填写你的配置:
|
||||
```bash
|
||||
cp config_example.py config.py
|
||||
# 编辑 config.py,填入你的钉钉webhook配置
|
||||
```
|
||||
|
||||
### 2. 环境变量配置(可选)
|
||||
|
||||
创建 `.env` 文件:
|
||||
```bash
|
||||
# 钉钉机器人配置
|
||||
DINGTALK_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN
|
||||
DINGTALK_WEBHOOK_SECRET=SEC_YOUR_SECRET
|
||||
|
||||
# 选币配置
|
||||
USE_MARKET_CAP_RANKING=true
|
||||
COIN_LIMIT=50
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 📋 服务说明
|
||||
|
||||
### 服务架构
|
||||
- **coin-selector**: 选币引擎,执行技术分析和选币逻辑
|
||||
- **web-dashboard**: Web仪表板,端口5000,提供可视化界面
|
||||
- **scheduler**: 定时调度服务,自动执行选币任务
|
||||
|
||||
### 端口映射
|
||||
- Web界面: http://localhost:5000
|
||||
|
||||
## 🛠️ 管理命令
|
||||
|
||||
```bash
|
||||
# 查看运行状态
|
||||
docker-compose ps
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 查看特定服务日志
|
||||
docker-compose logs coin-selector
|
||||
docker-compose logs web-dashboard
|
||||
docker-compose logs scheduler
|
||||
|
||||
# 进入容器调试
|
||||
docker-compose exec coin-selector bash
|
||||
|
||||
# 更新服务
|
||||
docker-compose pull
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## 📁 数据持久化
|
||||
|
||||
- `./data/`: 数据文件目录
|
||||
- `./logs/`: 日志文件目录
|
||||
- `trading-db`: 数据库卷
|
||||
- `config.py`: 配置文件(只读挂载)
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量优先级
|
||||
1. `.env` 文件中的环境变量
|
||||
2. `config.py` 文件中的配置
|
||||
3. 默认值
|
||||
|
||||
### 重要配置项
|
||||
- `USE_MARKET_CAP_RANKING`: 是否按市值排名选币
|
||||
- `COIN_LIMIT`: 分析币种数量限制
|
||||
- `DINGTALK_WEBHOOK_URL`: 钉钉机器人地址
|
||||
- `DINGTALK_WEBHOOK_SECRET`: 钉钉加签密钥
|
||||
|
||||
## 🔍 健康检查
|
||||
|
||||
所有服务都配置了健康检查:
|
||||
- **coin-selector**: 检查数据库文件存在性
|
||||
- **web-dashboard**: HTTP健康检查端点
|
||||
- **scheduler**: 进程存活检查
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
```bash
|
||||
# 实时查看所有服务日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看特定时间范围的日志
|
||||
docker-compose logs --since="2h" coin-selector
|
||||
|
||||
# 查看系统资源使用
|
||||
docker stats
|
||||
```
|
||||
|
||||
## 🚨 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **TA-Lib安装失败**
|
||||
```bash
|
||||
# 重新构建镜像
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
2. **数据库权限问题**
|
||||
```bash
|
||||
# 检查数据卷权限
|
||||
docker-compose exec coin-selector ls -la /app/
|
||||
```
|
||||
|
||||
3. **钉钉通知失败**
|
||||
```bash
|
||||
# 检查配置
|
||||
docker-compose exec coin-selector python setup_dingtalk.py test
|
||||
```
|
||||
|
||||
4. **端口冲突**
|
||||
```bash
|
||||
# 修改docker-compose.yml中的端口映射
|
||||
ports:
|
||||
- "5001:5000" # 改为5001端口
|
||||
```
|
||||
|
||||
### 调试模式
|
||||
|
||||
```bash
|
||||
# 以调试模式启动
|
||||
docker-compose -f docker-compose.yml -f docker-compose.debug.yml up
|
||||
|
||||
# 单独测试选币功能
|
||||
docker-compose run --rm coin-selector python coin_selection_engine.py
|
||||
```
|
||||
|
||||
## 🔄 定期维护
|
||||
|
||||
```bash
|
||||
# 清理未使用的Docker资源
|
||||
docker system prune -a
|
||||
|
||||
# 备份数据库
|
||||
docker-compose exec coin-selector cp /app/trading.db /app/data/backup_$(date +%Y%m%d).db
|
||||
|
||||
# 清理旧日志
|
||||
find ./logs -name "*.log" -mtime +7 -delete
|
||||
```
|
||||
|
||||
## 📈 生产环境建议
|
||||
|
||||
1. **资源限制**: 在docker-compose.yml中添加资源限制
|
||||
2. **日志轮转**: 配置日志轮转避免磁盘空间不足
|
||||
3. **监控报警**: 集成监控系统(如Prometheus)
|
||||
4. **备份策略**: 定期备份数据库和配置文件
|
||||
5. **安全加固**: 使用非root用户运行容器
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@ -0,0 +1,49 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 安装系统依赖(TA-Lib需要)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
wget \
|
||||
tar \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装TA-Lib
|
||||
RUN wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz && \
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz && \
|
||||
cd ta-lib/ && \
|
||||
./configure --prefix=/usr && \
|
||||
make && \
|
||||
make install && \
|
||||
cd .. && \
|
||||
rm -rf ta-lib ta-lib-0.4.0-src.tar.gz
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装Python依赖
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建必要的目录
|
||||
RUN mkdir -p logs data
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 5000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:5000/health', timeout=5)" || exit 1
|
||||
|
||||
# 启动命令(可通过docker-compose覆盖)
|
||||
CMD ["python", "coin_selection_engine.py"]
|
||||
357
coin_selection_engine.py
Normal file
357
coin_selection_engine.py
Normal file
@ -0,0 +1,357 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import List, Optional
|
||||
from data_fetcher import BinanceDataFetcher
|
||||
from technical_analyzer import TechnicalAnalyzer, CoinSignal
|
||||
from database import DatabaseManager
|
||||
from dingtalk_notifier import DingTalkNotifier
|
||||
import os
|
||||
|
||||
# 东八区时区
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
def get_beijing_time():
|
||||
"""获取当前东八区时间用于显示"""
|
||||
return datetime.now(BEIJING_TZ)
|
||||
|
||||
class CoinSelectionEngine:
|
||||
def __init__(self, api_key=None, secret=None, db_path="trading.db", use_market_cap_ranking=True, dingtalk_webhook=None, dingtalk_secret=None):
|
||||
"""初始化选币引擎
|
||||
|
||||
Args:
|
||||
api_key: Binance API密钥
|
||||
secret: Binance API密钥
|
||||
db_path: 数据库路径
|
||||
use_market_cap_ranking: 是否使用市值排名(True=市值排名,False=交易量排名)
|
||||
dingtalk_webhook: 钉钉机器人webhook URL
|
||||
dingtalk_secret: 钉钉机器人加签密钥
|
||||
"""
|
||||
self.data_fetcher = BinanceDataFetcher(api_key, secret)
|
||||
self.analyzer = TechnicalAnalyzer()
|
||||
self.db = DatabaseManager(db_path)
|
||||
self.use_market_cap_ranking = use_market_cap_ranking
|
||||
|
||||
# 初始化钉钉通知器
|
||||
# 优先使用传入的参数,其次使用环境变量
|
||||
webhook_url = dingtalk_webhook or os.getenv('DINGTALK_WEBHOOK_URL')
|
||||
webhook_secret = dingtalk_secret or os.getenv('DINGTALK_WEBHOOK_SECRET')
|
||||
self.dingtalk_notifier = DingTalkNotifier(webhook_url, webhook_secret)
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(), # 确保输出到控制台
|
||||
logging.FileHandler('coin_selection.log')
|
||||
],
|
||||
force=True # 强制重新配置日志
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run_coin_selection(self) -> List[CoinSignal]:
|
||||
"""执行完整的选币流程 - 使用动态时间周期"""
|
||||
self.logger.info("开始执行选币流程...")
|
||||
|
||||
try:
|
||||
# 1. 获取Top50 USDT交易对(按市值或交易量排序)
|
||||
if self.use_market_cap_ranking:
|
||||
self.logger.info("获取市值排名Top50 USDT交易对...")
|
||||
top_symbols = self.data_fetcher.get_top_market_cap_usdt_pairs(50)
|
||||
else:
|
||||
self.logger.info("获取交易量Top50 USDT交易对...")
|
||||
top_symbols = self.data_fetcher.get_top_usdt_pairs(50)
|
||||
|
||||
if not top_symbols:
|
||||
self.logger.error("无法获取交易对数据")
|
||||
return []
|
||||
|
||||
# 2. 获取动态时间周期配置
|
||||
optimal_timeframes = self.analyzer.get_optimal_timeframes_for_analysis()
|
||||
self.logger.info(f"使用动态时间周期: {optimal_timeframes}")
|
||||
|
||||
# 3. 批量获取市场数据
|
||||
self.logger.info(f"获取{len(top_symbols)}个币种的市场数据...")
|
||||
market_data = self.data_fetcher.batch_fetch_data(top_symbols, optimal_timeframes)
|
||||
|
||||
if not market_data:
|
||||
self.logger.error("无法获取市场数据")
|
||||
return []
|
||||
|
||||
# 4. 执行技术分析选币 - 同时输出多空信号
|
||||
self.logger.info("执行技术分析,寻找多空投资机会...")
|
||||
|
||||
# 先测试策略分布(调试用)
|
||||
self.logger.info("测试策略分布...")
|
||||
self.analyzer.test_strategy_distribution(market_data)
|
||||
|
||||
signals_result = self.analyzer.select_coins(market_data)
|
||||
|
||||
# 获取所有信号
|
||||
all_signals = signals_result.get('all', [])
|
||||
long_signals = signals_result.get('long', [])
|
||||
short_signals = signals_result.get('short', [])
|
||||
|
||||
if not all_signals:
|
||||
self.logger.warning("没有找到符合条件的多空信号")
|
||||
return []
|
||||
|
||||
# 5. 打印策略分布统计
|
||||
self._log_strategy_distribution(all_signals)
|
||||
|
||||
# 6. 保存选币结果到数据库
|
||||
self.logger.info(f"保存{len(all_signals)}个选币结果到数据库...")
|
||||
saved_count = 0
|
||||
|
||||
for signal in all_signals:
|
||||
try:
|
||||
selection_id = self.db.insert_coin_selection(
|
||||
symbol=signal.symbol,
|
||||
score=signal.score,
|
||||
reason=signal.reason,
|
||||
entry_price=signal.entry_price,
|
||||
stop_loss=signal.stop_loss,
|
||||
take_profit=signal.take_profit,
|
||||
timeframe=signal.timeframe,
|
||||
strategy_type=signal.strategy_type,
|
||||
holding_period=signal.holding_period,
|
||||
risk_reward_ratio=signal.risk_reward_ratio,
|
||||
expiry_hours=signal.expiry_hours,
|
||||
action_suggestion=signal.action_suggestion,
|
||||
signal_type=signal.signal_type,
|
||||
direction=signal.direction
|
||||
)
|
||||
signal_type_cn = "多头" if signal.signal_type == "LONG" else "空头"
|
||||
self.logger.info(f"保存{signal.symbol}({signal.strategy_type}-{signal_type_cn})选币结果,ID: {selection_id}")
|
||||
saved_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"保存{signal.symbol}选币结果失败: {e}")
|
||||
|
||||
# 检查并标记过期的选币
|
||||
self.db.check_and_expire_selections()
|
||||
|
||||
self.logger.info(f"选币完成!成功保存{saved_count}个信号(多头: {len(long_signals)}个, 空头: {len(short_signals)}个)")
|
||||
|
||||
# # 发送钉钉通知
|
||||
# try:
|
||||
# self.logger.info("发送钉钉通知...")
|
||||
# notification_sent = self.dingtalk_notifier.send_coin_selection_notification(all_signals)
|
||||
# if notification_sent:
|
||||
# self.logger.info("✅ 钉钉通知发送成功")
|
||||
# else:
|
||||
# self.logger.info("📱 钉钉通知发送失败或未配置")
|
||||
# except Exception as e:
|
||||
# self.logger.error(f"发送钉钉通知时出错: {e}")
|
||||
|
||||
return all_signals
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"选币流程执行失败: {e}")
|
||||
return []
|
||||
|
||||
def _log_strategy_distribution(self, signals: List[CoinSignal]):
|
||||
"""统计并记录策略分布"""
|
||||
strategy_stats = {}
|
||||
for signal in signals:
|
||||
strategy = signal.strategy_type
|
||||
signal_type = signal.signal_type
|
||||
key = f"{strategy}-{signal_type}"
|
||||
|
||||
if key not in strategy_stats:
|
||||
strategy_stats[key] = {'count': 0, 'avg_score': 0, 'scores': []}
|
||||
|
||||
strategy_stats[key]['count'] += 1
|
||||
strategy_stats[key]['scores'].append(signal.score)
|
||||
|
||||
# 计算平均分数
|
||||
for key, stats in strategy_stats.items():
|
||||
stats['avg_score'] = sum(stats['scores']) / len(stats['scores'])
|
||||
|
||||
self.logger.info("策略分布统计:")
|
||||
for key, stats in sorted(strategy_stats.items(), key=lambda x: x[1]['count'], reverse=True):
|
||||
self.logger.info(f" {key}: {stats['count']}个信号, 平均分数: {stats['avg_score']:.1f}")
|
||||
|
||||
def run_strategy_specific_analysis(self, symbols: List[str], strategy_name: str) -> List[CoinSignal]:
|
||||
"""针对特定策略运行专门的分析"""
|
||||
try:
|
||||
# 获取该策略需要的时间周期
|
||||
required_timeframes = self.analyzer.get_required_timeframes().get(strategy_name, ['1h', '4h', '1d'])
|
||||
|
||||
self.logger.info(f"对{len(symbols)}个币种执行{strategy_name}策略分析,使用时间周期: {required_timeframes}")
|
||||
|
||||
# 获取对应的市场数据
|
||||
market_data = self.data_fetcher.batch_fetch_data(symbols, required_timeframes)
|
||||
|
||||
if not market_data:
|
||||
self.logger.error("无法获取市场数据")
|
||||
return []
|
||||
|
||||
# 强制使用指定策略进行分析
|
||||
signals = []
|
||||
for symbol, data in market_data.items():
|
||||
timeframe_data = data.get('timeframes', {})
|
||||
volume_24h_usd = data.get('volume_24h_usd', 0)
|
||||
|
||||
# 直接使用指定策略分析
|
||||
symbol_signals = self.analyzer.analyze_single_coin_with_strategy(
|
||||
symbol, timeframe_data, volume_24h_usd, strategy_name
|
||||
)
|
||||
signals.extend(symbol_signals)
|
||||
|
||||
self.logger.info(f"{strategy_name}策略分析完成,找到{len(signals)}个信号")
|
||||
return signals
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"{strategy_name}策略分析失败: {e}")
|
||||
return []
|
||||
|
||||
def get_latest_selections(self, limit=20, offset=0) -> List[dict]:
|
||||
"""获取最新的选币结果 - 支持分页"""
|
||||
try:
|
||||
results = self.db.get_active_selections(limit, offset)
|
||||
selections = []
|
||||
|
||||
for row in results:
|
||||
# 使用字段名而不是索引来避免错误
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, symbol, score, reason, entry_price, stop_loss, take_profit,
|
||||
timeframe, selection_time, status, actual_entry_price, exit_price,
|
||||
exit_time, pnl_percentage, notes, strategy_type, holding_period,
|
||||
risk_reward_ratio, expiry_time, is_expired, action_suggestion,
|
||||
signal_type, direction
|
||||
FROM coin_selections
|
||||
WHERE id = ?
|
||||
''', (row[0],))
|
||||
|
||||
detailed_row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if detailed_row:
|
||||
selection = {
|
||||
'id': detailed_row[0],
|
||||
'symbol': detailed_row[1],
|
||||
'score': detailed_row[2],
|
||||
'reason': detailed_row[3],
|
||||
'entry_price': detailed_row[4],
|
||||
'stop_loss': detailed_row[5],
|
||||
'take_profit': detailed_row[6],
|
||||
'timeframe': detailed_row[7],
|
||||
'selection_time': detailed_row[8], # 保持UTC时间,在Web层转换
|
||||
'status': detailed_row[9],
|
||||
'actual_entry_price': detailed_row[10],
|
||||
'exit_price': detailed_row[11],
|
||||
'exit_time': detailed_row[12],
|
||||
'pnl_percentage': detailed_row[13],
|
||||
'notes': detailed_row[14],
|
||||
'strategy_type': detailed_row[15] or '中线',
|
||||
'holding_period': detailed_row[16] or 7,
|
||||
'risk_reward_ratio': detailed_row[17] or 2.0,
|
||||
'expiry_time': detailed_row[18],
|
||||
'is_expired': detailed_row[19] or False,
|
||||
'action_suggestion': detailed_row[20] or '等待回调买入',
|
||||
'signal_type': detailed_row[21] or 'LONG',
|
||||
'direction': detailed_row[22] or 'BUY'
|
||||
}
|
||||
|
||||
# 获取当前价格
|
||||
current_price = self.data_fetcher.get_current_price(detailed_row[1])
|
||||
if current_price:
|
||||
selection['current_price'] = current_price
|
||||
selection['price_change_percent'] = ((current_price - detailed_row[4]) / detailed_row[4]) * 100
|
||||
|
||||
selections.append(selection)
|
||||
|
||||
return selections
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取选币结果失败: {e}")
|
||||
return []
|
||||
|
||||
def update_selection_status(self, selection_id: int, status: str, exit_price: Optional[float] = None):
|
||||
"""更新选币状态"""
|
||||
try:
|
||||
pnl_percentage = None
|
||||
|
||||
if exit_price and status in ['completed', 'stopped']:
|
||||
# 计算收益率
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT entry_price FROM coin_selections WHERE id = ?', (selection_id,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
entry_price = result[0]
|
||||
pnl_percentage = ((exit_price - entry_price) / entry_price) * 100
|
||||
|
||||
conn.close()
|
||||
|
||||
self.db.update_selection_status(selection_id, status, exit_price, pnl_percentage)
|
||||
self.logger.info(f"更新选币{selection_id}状态为{status}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"更新选币状态失败: {e}")
|
||||
|
||||
def set_dingtalk_webhook(self, webhook_url: str, webhook_secret: str = None):
|
||||
"""设置钉钉webhook URL和密钥
|
||||
|
||||
Args:
|
||||
webhook_url: 钉钉机器人webhook URL
|
||||
webhook_secret: 钉钉机器人加签密钥(可选)
|
||||
"""
|
||||
self.dingtalk_notifier = DingTalkNotifier(webhook_url, webhook_secret)
|
||||
self.logger.info("钉钉webhook配置已更新")
|
||||
|
||||
def test_dingtalk_notification(self) -> bool:
|
||||
"""测试钉钉通知功能
|
||||
|
||||
Returns:
|
||||
bool: 测试是否成功
|
||||
"""
|
||||
return self.dingtalk_notifier.send_test_message()
|
||||
|
||||
def print_selection_summary(self, signals: List[CoinSignal]):
|
||||
"""打印选币结果摘要"""
|
||||
if not signals:
|
||||
self.logger.warning("没有找到符合条件的币种")
|
||||
print("没有找到符合条件的币种")
|
||||
return
|
||||
|
||||
summary = f"\n=== 选币结果 ({get_beijing_time().strftime('%Y-%m-%d %H:%M:%S CST')}) ===\n"
|
||||
summary += f"共选出 {len(signals)} 个币种:\n\n"
|
||||
|
||||
for i, signal in enumerate(signals, 1):
|
||||
summary += f"{i}. {signal.symbol} [{signal.strategy_type}] - {signal.action_suggestion}\n"
|
||||
summary += f" 评分: {signal.score:.1f}分 ({signal.confidence}信心)\n"
|
||||
summary += f" 理由: {signal.reason}\n"
|
||||
summary += f" 入场: ${signal.entry_price:.4f}\n"
|
||||
summary += f" 止损: ${signal.stop_loss:.4f} ({((signal.stop_loss - signal.entry_price) / signal.entry_price * 100):.2f}%)\n"
|
||||
summary += f" 止盈: ${signal.take_profit:.4f} ({((signal.take_profit - signal.entry_price) / signal.entry_price * 100):.2f}%)\n"
|
||||
summary += f" 风险回报比: 1:{signal.risk_reward_ratio:.2f}\n"
|
||||
summary += f" 持仓周期: {signal.holding_period}天 | 有效期: {signal.expiry_hours}小时\n"
|
||||
summary += "-" * 50 + "\n"
|
||||
|
||||
self.logger.info("选币结果摘要:")
|
||||
print(summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 创建选币引擎实例
|
||||
from config import *
|
||||
|
||||
engine = CoinSelectionEngine(
|
||||
api_key=BINANCE_API_KEY,
|
||||
secret=BINANCE_SECRET,
|
||||
db_path=DATABASE_PATH,
|
||||
use_market_cap_ranking=USE_MARKET_CAP_RANKING,
|
||||
dingtalk_webhook=DINGTALK_WEBHOOK_URL,
|
||||
dingtalk_secret=DINGTALK_WEBHOOK_SECRET
|
||||
)
|
||||
|
||||
# 执行选币
|
||||
selected_coins = engine.run_coin_selection()
|
||||
|
||||
# 打印结果
|
||||
engine.print_selection_summary(selected_coins)
|
||||
297
data_fetcher.py
Normal file
297
data_fetcher.py
Normal file
@ -0,0 +1,297 @@
|
||||
from binance.client import Client
|
||||
from binance.exceptions import BinanceAPIException, BinanceOrderException
|
||||
import pandas as pd
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
import requests
|
||||
|
||||
class BinanceDataFetcher:
|
||||
def __init__(self, api_key=None, secret=None):
|
||||
"""初始化Binance数据获取器"""
|
||||
try:
|
||||
# 如果没有提供API密钥,使用公共客户端
|
||||
if api_key and secret:
|
||||
self.client = Client(api_key, secret)
|
||||
else:
|
||||
self.client = Client() # 公共客户端,只能访问市场数据
|
||||
|
||||
# 测试连接
|
||||
self.client.ping()
|
||||
print("Binance API连接成功")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Binance API初始化失败: {e}")
|
||||
print(f"Binance API初始化失败: {e}")
|
||||
raise
|
||||
|
||||
def get_top_usdt_pairs(self, limit=100) -> List[str]:
|
||||
"""获取交易量最大的USDT交易对,排除稳定币"""
|
||||
try:
|
||||
print(f"正在获取前{limit}个USDT交易对...")
|
||||
|
||||
# 定义需要排除的稳定币
|
||||
stable_coins = {
|
||||
'USDCUSDT', 'BUSDUSDT', 'TUSDUSDT', 'PAXUSDT', 'DAIUSDT',
|
||||
'FDUSDUSDT', 'USTCUSDT', 'SUSDUSDT', 'GUSDUSDT', 'USDPUSDT'
|
||||
}
|
||||
|
||||
# 获取24小时ticker统计
|
||||
tickers = self.client.get_ticker()
|
||||
print(f"获取到{len(tickers)}个交易对")
|
||||
usdt_pairs = []
|
||||
|
||||
for ticker in tickers:
|
||||
symbol = ticker['symbol']
|
||||
if (symbol.endswith('USDT') and
|
||||
float(ticker['quoteVolume']) > 2000 * 10000 and
|
||||
symbol not in stable_coins):
|
||||
usdt_pairs.append({
|
||||
'symbol': symbol,
|
||||
'volume': float(ticker['quoteVolume']),
|
||||
'price': float(ticker['lastPrice'])
|
||||
})
|
||||
|
||||
# 按交易量排序
|
||||
usdt_pairs.sort(key=lambda x: x['volume'], reverse=True)
|
||||
top_pairs = [pair['symbol'] for pair in usdt_pairs[:limit]]
|
||||
|
||||
logging.info(f"获取到{len(top_pairs)}个USDT交易对(已排除稳定币)")
|
||||
print(f"成功获取{len(top_pairs)}个USDT交易对(已排除稳定币)")
|
||||
return top_pairs
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"获取交易对失败: {e}")
|
||||
print(f"获取交易对失败: {e}")
|
||||
return []
|
||||
|
||||
def _convert_timeframe(self, timeframe: str) -> str:
|
||||
"""转换时间周期格式"""
|
||||
timeframe_mapping = {
|
||||
'1m': Client.KLINE_INTERVAL_1MINUTE,
|
||||
'5m': Client.KLINE_INTERVAL_5MINUTE,
|
||||
'15m': Client.KLINE_INTERVAL_15MINUTE,
|
||||
'30m': Client.KLINE_INTERVAL_30MINUTE,
|
||||
'1h': Client.KLINE_INTERVAL_1HOUR,
|
||||
'4h': Client.KLINE_INTERVAL_4HOUR,
|
||||
'1d': Client.KLINE_INTERVAL_1DAY,
|
||||
'3d': Client.KLINE_INTERVAL_3DAY,
|
||||
'1w': Client.KLINE_INTERVAL_1WEEK
|
||||
}
|
||||
return timeframe_mapping.get(timeframe, Client.KLINE_INTERVAL_4HOUR)
|
||||
|
||||
def fetch_ohlcv_data(self, symbol: str, timeframe: str, limit: int = 500) -> pd.DataFrame:
|
||||
"""获取K线数据"""
|
||||
try:
|
||||
interval = self._convert_timeframe(timeframe)
|
||||
klines = self.client.get_klines(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# 转换为DataFrame
|
||||
df = pd.DataFrame(klines, columns=[
|
||||
'timestamp', 'open', 'high', 'low', 'close', 'volume',
|
||||
'close_time', 'quote_asset_volume', 'number_of_trades',
|
||||
'taker_buy_base_asset_volume', 'taker_buy_quote_asset_volume', 'ignore'
|
||||
])
|
||||
|
||||
# 数据类型转换
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||||
for col in ['open', 'high', 'low', 'close', 'volume']:
|
||||
df[col] = pd.to_numeric(df[col])
|
||||
|
||||
# 设置索引
|
||||
df.set_index('timestamp', inplace=True)
|
||||
|
||||
# 只保留需要的列
|
||||
df = df[['open', 'high', 'low', 'close', 'volume']]
|
||||
|
||||
return df
|
||||
|
||||
except BinanceAPIException as e:
|
||||
logging.error(f"获取{symbol} {timeframe}数据失败: {e}")
|
||||
print(f"获取{symbol} {timeframe}数据失败: {e}")
|
||||
return pd.DataFrame()
|
||||
except Exception as e:
|
||||
logging.error(f"获取{symbol} {timeframe}数据时发生未知错误: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def get_multi_timeframe_data(self, symbol: str, timeframes: List[str] = None) -> Dict[str, pd.DataFrame]:
|
||||
"""获取多时间周期数据"""
|
||||
if timeframes is None:
|
||||
timeframes = ['4h', '1h', '15m']
|
||||
|
||||
data = {}
|
||||
for tf in timeframes:
|
||||
df = self.fetch_ohlcv_data(symbol, tf)
|
||||
if not df.empty:
|
||||
data[tf] = df
|
||||
time.sleep(0.1) # 避免频率限制
|
||||
|
||||
return data
|
||||
|
||||
def get_current_price(self, symbol: str) -> Optional[float]:
|
||||
"""获取当前价格"""
|
||||
try:
|
||||
ticker = self.client.get_symbol_ticker(symbol=symbol)
|
||||
return float(ticker['price'])
|
||||
except Exception as e:
|
||||
logging.error(f"获取{symbol}当前价格失败: {e}")
|
||||
return None
|
||||
|
||||
def get_24h_stats(self, symbol: str) -> Dict:
|
||||
"""获取24小时统计数据"""
|
||||
try:
|
||||
ticker = self.client.get_ticker(symbol=symbol)
|
||||
return {
|
||||
'price_change_percent': float(ticker['priceChangePercent']),
|
||||
'volume': float(ticker['volume']),
|
||||
'quote_volume': float(ticker['quoteVolume']),
|
||||
'high_24h': float(ticker['highPrice']),
|
||||
'low_24h': float(ticker['lowPrice'])
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"获取{symbol} 24h统计数据失败: {e}")
|
||||
return {}
|
||||
|
||||
def batch_fetch_data(self, symbols: List[str], timeframes: List[str] = None) -> Dict[str, Dict]:
|
||||
"""批量获取多个币种的多时间周期数据"""
|
||||
if timeframes is None:
|
||||
timeframes = ['4h', '1h', '15m']
|
||||
|
||||
all_data = {}
|
||||
total = len(symbols)
|
||||
|
||||
for i, symbol in enumerate(symbols):
|
||||
try:
|
||||
logging.info(f"正在获取 {symbol} 数据 ({i+1}/{total})")
|
||||
print(f"正在获取 {symbol} 数据 ({i+1}/{total})")
|
||||
|
||||
# 获取多时间周期K线数据
|
||||
timeframe_data = self.get_multi_timeframe_data(symbol, timeframes)
|
||||
|
||||
if timeframe_data:
|
||||
# 获取24小时统计数据
|
||||
stats = self.get_24h_stats(symbol)
|
||||
|
||||
# 组织数据结构以匹配分析器期望
|
||||
all_data[symbol] = {
|
||||
'timeframes': timeframe_data,
|
||||
'volume_24h_usd': stats.get('quote_volume', 0), # 这是24小时USDT交易量
|
||||
'stats': stats
|
||||
}
|
||||
|
||||
logging.info(f"{symbol} 24小时交易量: ${stats.get('quote_volume', 0):,.0f}")
|
||||
|
||||
# 控制请求频率,避免被限制
|
||||
time.sleep(0.2)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"获取{symbol}数据时出错: {e}")
|
||||
print(f"获取{symbol}数据时出错: {e}")
|
||||
continue
|
||||
|
||||
logging.info(f"成功获取{len(all_data)}个币种的数据")
|
||||
print(f"成功获取{len(all_data)}个币种的数据")
|
||||
return all_data
|
||||
|
||||
def get_top_market_cap_usdt_pairs(self, limit=100) -> List[str]:
|
||||
"""获取市值排名前N的USDT交易对"""
|
||||
try:
|
||||
print(f"正在获取市值排名前{limit}个币种...")
|
||||
|
||||
# 从CoinGecko获取市值排名数据
|
||||
url = "https://api.coingecko.com/api/v3/coins/markets"
|
||||
params = {
|
||||
'vs_currency': 'usd',
|
||||
'order': 'market_cap_desc',
|
||||
'per_page': min(limit * 2, 250), # 获取更多数据以确保有足够的匹配
|
||||
'page': 1,
|
||||
'sparkline': 'false'
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
if response.status_code != 200:
|
||||
print(f"CoinGecko API请求失败: {response.status_code}")
|
||||
# 回退到按交易量获取
|
||||
return self.get_top_usdt_pairs(limit)
|
||||
|
||||
market_data = response.json()
|
||||
print(f"从CoinGecko获取到{len(market_data)}个币种的市值数据")
|
||||
|
||||
# 获取Binance支持的所有USDT交易对
|
||||
binance_tickers = self.client.get_ticker()
|
||||
binance_usdt_symbols = set()
|
||||
|
||||
# 排除稳定币
|
||||
stable_coins = {
|
||||
'USDCUSDT', 'BUSDUSDT', 'TUSDUSDT', 'PAXUSDT', 'DAIUSDT',
|
||||
'FDUSDUSDT', 'USTCUSDT', 'SUSDUSDT', 'GUSDUSDT', 'USDPUSDT'
|
||||
}
|
||||
|
||||
for ticker in binance_tickers:
|
||||
symbol = ticker['symbol']
|
||||
if (symbol.endswith('USDT') and
|
||||
float(ticker['quoteVolume']) > 1000000 and # 最低交易量要求
|
||||
symbol not in stable_coins):
|
||||
binance_usdt_symbols.add(symbol)
|
||||
|
||||
print(f"Binance支持的USDT交易对: {len(binance_usdt_symbols)}个")
|
||||
|
||||
# 匹配市值排名和Binance交易对
|
||||
matched_pairs = []
|
||||
for coin in market_data:
|
||||
# 尝试不同的符号格式匹配
|
||||
symbol_variants = [
|
||||
f"{coin['symbol'].upper()}USDT",
|
||||
f"{coin['id'].upper().replace('-', '')}USDT"
|
||||
]
|
||||
|
||||
for variant in symbol_variants:
|
||||
if variant in binance_usdt_symbols:
|
||||
matched_pairs.append({
|
||||
'symbol': variant,
|
||||
'market_cap_rank': coin['market_cap_rank'],
|
||||
'market_cap': coin['market_cap'],
|
||||
'name': coin['name']
|
||||
})
|
||||
print(f"匹配成功: {coin['name']}({coin['symbol']}) -> {variant} (排名#{coin['market_cap_rank']})")
|
||||
break
|
||||
|
||||
if len(matched_pairs) >= limit:
|
||||
break
|
||||
|
||||
# 按市值排名排序
|
||||
matched_pairs.sort(key=lambda x: x['market_cap_rank'] if x['market_cap_rank'] else 999999)
|
||||
top_pairs = [pair['symbol'] for pair in matched_pairs[:limit]]
|
||||
|
||||
print(f"成功匹配{len(top_pairs)}个市值排名前{limit}的USDT交易对")
|
||||
|
||||
# 如果匹配数量不足,用交易量排序的方式补充
|
||||
if len(top_pairs) < limit:
|
||||
print(f"市值匹配数量不足({len(top_pairs)}/{limit}),用交易量排序补充...")
|
||||
volume_pairs = self.get_top_usdt_pairs(limit)
|
||||
|
||||
# 补充未包含的交易对
|
||||
for pair in volume_pairs:
|
||||
if pair not in top_pairs and len(top_pairs) < limit:
|
||||
top_pairs.append(pair)
|
||||
|
||||
return top_pairs[:limit]
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"获取市值排名失败: {e}")
|
||||
print(f"获取市值排名失败: {e},回退到按交易量获取")
|
||||
# 出错时回退到原有的交易量排序方式
|
||||
return self.get_top_usdt_pairs(limit)
|
||||
|
||||
def get_exchange_info(self):
|
||||
"""获取交易所信息"""
|
||||
try:
|
||||
return self.client.get_exchange_info()
|
||||
except Exception as e:
|
||||
logging.error(f"获取交易所信息失败: {e}")
|
||||
return None
|
||||
266
database.py
Normal file
266
database.py
Normal file
@ -0,0 +1,266 @@
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import logging
|
||||
|
||||
# 设置东八区时区
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
def utc_to_beijing(utc_time_str):
|
||||
"""将UTC时间字符串转换为东八区时间字符串"""
|
||||
try:
|
||||
# 解析UTC时间字符串
|
||||
utc_dt = datetime.fromisoformat(utc_time_str.replace('Z', '+00:00'))
|
||||
if utc_dt.tzinfo is None:
|
||||
utc_dt = utc_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
# 转换为东八区时间
|
||||
beijing_dt = utc_dt.astimezone(BEIJING_TZ)
|
||||
return beijing_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except:
|
||||
return utc_time_str
|
||||
|
||||
def get_utc_time():
|
||||
"""获取UTC时间"""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
class DatabaseManager:
|
||||
def __init__(self, db_path="trading.db"):
|
||||
self.db_path = db_path
|
||||
self.init_database()
|
||||
|
||||
def init_database(self):
|
||||
"""初始化数据库表结构"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 币种基础信息表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS coins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT UNIQUE NOT NULL,
|
||||
base_asset TEXT NOT NULL,
|
||||
quote_asset TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# K线数据表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS klines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
open_time TIMESTAMP NOT NULL,
|
||||
close_time TIMESTAMP NOT NULL,
|
||||
open_price REAL NOT NULL,
|
||||
high_price REAL NOT NULL,
|
||||
low_price REAL NOT NULL,
|
||||
close_price REAL NOT NULL,
|
||||
volume REAL NOT NULL,
|
||||
quote_volume REAL NOT NULL,
|
||||
trades_count INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(symbol, timeframe, open_time)
|
||||
)
|
||||
''')
|
||||
|
||||
# 选币结果表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS coin_selections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
stop_loss REAL NOT NULL,
|
||||
take_profit REAL NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
selection_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'active',
|
||||
actual_entry_price REAL,
|
||||
exit_price REAL,
|
||||
exit_time TIMESTAMP,
|
||||
pnl_percentage REAL,
|
||||
notes TEXT,
|
||||
strategy_type TEXT NOT NULL DEFAULT '中线',
|
||||
holding_period INTEGER NOT NULL DEFAULT 7,
|
||||
risk_reward_ratio REAL NOT NULL DEFAULT 2.0,
|
||||
expiry_time TIMESTAMP,
|
||||
is_expired BOOLEAN DEFAULT FALSE,
|
||||
action_suggestion TEXT DEFAULT '等待回调买入'
|
||||
)
|
||||
''')
|
||||
|
||||
# 检查并添加新列(如果表已存在)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN strategy_type TEXT NOT NULL DEFAULT '中线'")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN holding_period INTEGER NOT NULL DEFAULT 7")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN risk_reward_ratio REAL NOT NULL DEFAULT 2.0")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN expiry_time TIMESTAMP")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN is_expired BOOLEAN DEFAULT FALSE")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN action_suggestion TEXT DEFAULT '等待回调买入'")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 添加多空支持字段
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN signal_type TEXT DEFAULT 'LONG'")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE coin_selections ADD COLUMN direction TEXT DEFAULT 'BUY'")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 技术指标表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS technical_indicators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
indicator_time TIMESTAMP NOT NULL,
|
||||
ma20 REAL,
|
||||
ma50 REAL,
|
||||
ma200 REAL,
|
||||
rsi REAL,
|
||||
macd REAL,
|
||||
macd_signal REAL,
|
||||
macd_hist REAL,
|
||||
bb_upper REAL,
|
||||
bb_middle REAL,
|
||||
bb_lower REAL,
|
||||
volume_ma REAL,
|
||||
fib_618 REAL,
|
||||
fib_382 REAL,
|
||||
support_level REAL,
|
||||
resistance_level REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(symbol, timeframe, indicator_time)
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_klines_symbol_time ON klines(symbol, timeframe, open_time)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_selections_symbol_time ON coin_selections(symbol, selection_time)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_indicators_symbol_time ON technical_indicators(symbol, timeframe, indicator_time)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logging.info("数据库初始化完成")
|
||||
|
||||
def get_connection(self):
|
||||
"""获取数据库连接"""
|
||||
return sqlite3.connect(self.db_path)
|
||||
|
||||
def insert_coin_selection(self, symbol, score, reason, entry_price, stop_loss, take_profit,
|
||||
timeframe, strategy_type, holding_period, risk_reward_ratio, expiry_hours, action_suggestion,
|
||||
signal_type="LONG", direction="BUY"):
|
||||
"""插入选币结果 - 支持多空方向"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 计算过期时间
|
||||
expiry_time = datetime.now(timezone.utc) + timedelta(hours=expiry_hours)
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO coin_selections
|
||||
(symbol, score, reason, entry_price, stop_loss, take_profit, timeframe,
|
||||
strategy_type, holding_period, risk_reward_ratio, expiry_time, action_suggestion,
|
||||
signal_type, direction)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (symbol, score, reason, entry_price, stop_loss, take_profit, timeframe,
|
||||
strategy_type, holding_period, risk_reward_ratio, expiry_time, action_suggestion,
|
||||
signal_type, direction))
|
||||
|
||||
selection_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return selection_id
|
||||
|
||||
def get_active_selections(self, limit=20, offset=0):
|
||||
"""获取活跃的选币结果,自动标记过期的 - 支持分页"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 首先标记过期的选币
|
||||
cursor.execute('''
|
||||
UPDATE coin_selections
|
||||
SET is_expired = TRUE, status = 'expired'
|
||||
WHERE expiry_time < datetime('now') AND status = 'active'
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM coin_selections
|
||||
WHERE status = 'active' AND is_expired = FALSE
|
||||
ORDER BY selection_time DESC, score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
''', (limit, offset))
|
||||
|
||||
results = cursor.fetchall()
|
||||
conn.commit() # 提交过期状态更新
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
def check_and_expire_selections(self):
|
||||
"""检查并标记过期的选币"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE coin_selections
|
||||
SET is_expired = TRUE, status = 'expired'
|
||||
WHERE expiry_time < datetime('now') AND status = 'active'
|
||||
''')
|
||||
|
||||
expired_count = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if expired_count > 0:
|
||||
logging.info(f"标记了{expired_count}个过期的选币结果")
|
||||
|
||||
return expired_count
|
||||
|
||||
def update_selection_status(self, selection_id, status, exit_price=None, pnl_percentage=None):
|
||||
"""更新选币结果状态"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if exit_price and pnl_percentage:
|
||||
cursor.execute('''
|
||||
UPDATE coin_selections
|
||||
SET status = ?, exit_price = ?, exit_time = CURRENT_TIMESTAMP, pnl_percentage = ?
|
||||
WHERE id = ?
|
||||
''', (status, exit_price, pnl_percentage, selection_id))
|
||||
else:
|
||||
cursor.execute('''
|
||||
UPDATE coin_selections
|
||||
SET status = ?
|
||||
WHERE id = ?
|
||||
''', (status, selection_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
244
dingtalk_notifier.py
Normal file
244
dingtalk_notifier.py
Normal file
@ -0,0 +1,244 @@
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import urllib.parse
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from technical_analyzer import CoinSignal
|
||||
|
||||
class DingTalkNotifier:
|
||||
def __init__(self, webhook_url: str = None, secret: str = None):
|
||||
"""初始化钉钉通知器
|
||||
|
||||
Args:
|
||||
webhook_url: 钉钉机器人的webhook URL
|
||||
secret: 钉钉机器人的加签密钥(可选)
|
||||
"""
|
||||
self.webhook_url = webhook_url
|
||||
self.secret = secret
|
||||
self.enabled = webhook_url is not None and webhook_url.strip() != ""
|
||||
|
||||
if not self.enabled:
|
||||
logging.warning("钉钉webhook URL未配置,通知功能已禁用")
|
||||
elif self.secret:
|
||||
logging.info("钉钉通知已启用(加签模式)")
|
||||
else:
|
||||
logging.info("钉钉通知已启用(普通模式)")
|
||||
|
||||
def send_coin_selection_notification(self, signals: List[CoinSignal]) -> bool:
|
||||
"""发送选币结果通知
|
||||
|
||||
Args:
|
||||
signals: 选币信号列表
|
||||
|
||||
Returns:
|
||||
bool: 发送是否成功
|
||||
"""
|
||||
if not self.enabled:
|
||||
logging.info("钉钉通知未启用,跳过发送")
|
||||
return False
|
||||
|
||||
if not signals:
|
||||
return self._send_no_signals_notification()
|
||||
|
||||
try:
|
||||
# 按多头和空头分组
|
||||
long_signals = [s for s in signals if s.signal_type == "LONG"]
|
||||
short_signals = [s for s in signals if s.signal_type == "SHORT"]
|
||||
|
||||
# 构建消息内容
|
||||
message = self._build_selection_message(long_signals, short_signals)
|
||||
|
||||
# 发送消息
|
||||
return self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"发送钉钉通知失败: {e}")
|
||||
return False
|
||||
|
||||
def _build_selection_message(self, long_signals: List[CoinSignal], short_signals: List[CoinSignal]) -> dict:
|
||||
"""构建选币结果消息"""
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 构建markdown格式消息
|
||||
markdown_text = f"# 🎯 加密货币选币结果\n\n"
|
||||
markdown_text += f"**时间**: {current_time}\n"
|
||||
markdown_text += f"**总计**: {len(long_signals + short_signals)}个信号 (多头{len(long_signals)}个, 空头{len(short_signals)}个)\n\n"
|
||||
|
||||
# 多头信号
|
||||
if long_signals:
|
||||
markdown_text += "## 📈 多头信号\n\n"
|
||||
for i, signal in enumerate(long_signals, 1):
|
||||
confidence_emoji = "🔥" if signal.confidence == "高" else "⚡" if signal.confidence == "中" else "💡"
|
||||
strategy_emoji = self._get_strategy_emoji(signal.strategy_type)
|
||||
|
||||
markdown_text += f"### {i}. {signal.symbol} {confidence_emoji}\n"
|
||||
markdown_text += f"- **策略**: {strategy_emoji} {signal.strategy_type}\n"
|
||||
markdown_text += f"- **评分**: {signal.score:.1f}分 ({signal.confidence}信心)\n"
|
||||
markdown_text += f"- **建议**: {signal.action_suggestion}\n"
|
||||
markdown_text += f"- **入场**: ${signal.entry_price:.4f}\n"
|
||||
markdown_text += f"- **止损**: ${signal.stop_loss:.4f} ({self._get_percentage_change(signal.entry_price, signal.stop_loss):.1f}%)\n"
|
||||
markdown_text += f"- **止盈**: ${signal.take_profit:.4f} ({self._get_percentage_change(signal.entry_price, signal.take_profit):.1f}%)\n"
|
||||
markdown_text += f"- **风险回报比**: 1:{signal.risk_reward_ratio:.2f}\n"
|
||||
markdown_text += f"- **持仓周期**: {signal.holding_period}天\n\n"
|
||||
|
||||
# 空头信号
|
||||
if short_signals:
|
||||
markdown_text += "## 📉 空头信号\n\n"
|
||||
for i, signal in enumerate(short_signals, 1):
|
||||
confidence_emoji = "🔥" if signal.confidence == "高" else "⚡" if signal.confidence == "中" else "💡"
|
||||
strategy_emoji = self._get_strategy_emoji(signal.strategy_type)
|
||||
|
||||
markdown_text += f"### {i}. {signal.symbol} {confidence_emoji}\n"
|
||||
markdown_text += f"- **策略**: {strategy_emoji} {signal.strategy_type}\n"
|
||||
markdown_text += f"- **评分**: {signal.score:.1f}分 ({signal.confidence}信心)\n"
|
||||
markdown_text += f"- **建议**: {signal.action_suggestion}\n"
|
||||
markdown_text += f"- **入场**: ${signal.entry_price:.4f}\n"
|
||||
markdown_text += f"- **止损**: ${signal.stop_loss:.4f} ({self._get_percentage_change(signal.entry_price, signal.stop_loss):.1f}%)\n"
|
||||
markdown_text += f"- **止盈**: ${signal.take_profit:.4f} ({self._get_percentage_change(signal.entry_price, signal.take_profit):.1f}%)\n"
|
||||
markdown_text += f"- **风险回报比**: 1:{signal.risk_reward_ratio:.2f}\n"
|
||||
markdown_text += f"- **持仓周期**: {signal.holding_period}天\n\n"
|
||||
|
||||
# 添加风险提示
|
||||
markdown_text += "---\n"
|
||||
markdown_text += "⚠️ **风险提示**: 投资有风险,决策需谨慎。本信号仅供参考,不构成投资建议。\n"
|
||||
markdown_text += "🤖 *由AI选币系统自动生成*"
|
||||
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": f"选币结果 ({len(long_signals + short_signals)}个信号)",
|
||||
"text": markdown_text
|
||||
}
|
||||
}
|
||||
|
||||
def _send_no_signals_notification(self) -> bool:
|
||||
"""发送无信号通知"""
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
message = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": f"📊 加密货币选币结果\n\n"
|
||||
f"时间: {current_time}\n"
|
||||
f"结果: 暂无符合条件的投资机会\n"
|
||||
f"建议: 继续观察市场动态\n\n"
|
||||
f"🤖 由AI选币系统自动生成"
|
||||
}
|
||||
}
|
||||
|
||||
return self._send_message(message)
|
||||
|
||||
def _generate_sign_url(self) -> str:
|
||||
"""生成带加签的webhook URL"""
|
||||
if not self.secret:
|
||||
return self.webhook_url
|
||||
|
||||
# 当前时间戳(毫秒)
|
||||
timestamp = str(round(time.time() * 1000))
|
||||
|
||||
# 拼接字符串
|
||||
string_to_sign = f"{timestamp}\n{self.secret}"
|
||||
string_to_sign_enc = string_to_sign.encode('utf-8')
|
||||
secret_enc = self.secret.encode('utf-8')
|
||||
|
||||
# HMAC-SHA256加密
|
||||
hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest()
|
||||
|
||||
# Base64编码
|
||||
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
|
||||
|
||||
# 拼接最终URL
|
||||
if '?' in self.webhook_url:
|
||||
signed_url = f"{self.webhook_url}×tamp={timestamp}&sign={sign}"
|
||||
else:
|
||||
signed_url = f"{self.webhook_url}?timestamp={timestamp}&sign={sign}"
|
||||
|
||||
return signed_url
|
||||
|
||||
def _send_message(self, message: dict) -> bool:
|
||||
"""发送消息到钉钉"""
|
||||
try:
|
||||
# 生成签名URL(如果配置了密钥)
|
||||
url = self._generate_sign_url()
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
response = requests.post(
|
||||
url,
|
||||
data=json.dumps(message),
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('errcode') == 0:
|
||||
logging.info("钉钉通知发送成功")
|
||||
return True
|
||||
else:
|
||||
logging.error(f"钉钉通知发送失败: {result.get('errmsg', '未知错误')}")
|
||||
return False
|
||||
else:
|
||||
logging.error(f"钉钉通知HTTP请求失败: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"发送钉钉消息异常: {e}")
|
||||
return False
|
||||
|
||||
def _get_strategy_emoji(self, strategy: str) -> str:
|
||||
"""获取策略对应的emoji"""
|
||||
strategy_emojis = {
|
||||
"超短线": "⚡",
|
||||
"短线": "🏃",
|
||||
"中线": "🚶",
|
||||
"波段": "🌊",
|
||||
"长线": "🐢",
|
||||
"趋势": "📈"
|
||||
}
|
||||
return strategy_emojis.get(strategy, "📊")
|
||||
|
||||
def _get_percentage_change(self, entry_price: float, target_price: float) -> float:
|
||||
"""计算价格变化百分比"""
|
||||
if entry_price == 0:
|
||||
return 0
|
||||
return ((target_price - entry_price) / entry_price) * 100
|
||||
|
||||
def send_test_message(self) -> bool:
|
||||
"""发送测试消息"""
|
||||
if not self.enabled:
|
||||
print("钉钉webhook URL未配置,无法发送测试消息")
|
||||
return False
|
||||
|
||||
test_message = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": f"🤖 加密货币选币系统测试消息\n\n"
|
||||
f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"状态: 系统运行正常\n"
|
||||
f"功能: 钉钉通知已配置成功"
|
||||
}
|
||||
}
|
||||
|
||||
success = self._send_message(test_message)
|
||||
if success:
|
||||
print("✅ 钉钉测试消息发送成功")
|
||||
else:
|
||||
print("❌ 钉钉测试消息发送失败")
|
||||
|
||||
return success
|
||||
|
||||
# 测试脚本
|
||||
if __name__ == "__main__":
|
||||
# 测试用例
|
||||
webhook_url = input("请输入钉钉机器人webhook URL: ").strip()
|
||||
|
||||
if webhook_url:
|
||||
notifier = DingTalkNotifier(webhook_url)
|
||||
notifier.send_test_message()
|
||||
else:
|
||||
print("未提供webhook URL,退出测试")
|
||||
104
docker-compose.yml
Normal file
104
docker-compose.yml
Normal file
@ -0,0 +1,104 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 选币引擎服务
|
||||
coin-selector:
|
||||
build: .
|
||||
container_name: trading-ai-selector
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config.py:/app/config.py:ro # 只读挂载配置文件
|
||||
- trading-db:/app/trading.db
|
||||
environment:
|
||||
- PYTHONPATH=/app
|
||||
- TZ=Asia/Shanghai
|
||||
# 钉钉配置(可选,也可以通过config.py配置)
|
||||
- DINGTALK_WEBHOOK_URL=${DINGTALK_WEBHOOK_URL:-}
|
||||
- DINGTALK_WEBHOOK_SECRET=${DINGTALK_WEBHOOK_SECRET:-}
|
||||
# 选币配置
|
||||
- USE_MARKET_CAP_RANKING=${USE_MARKET_CAP_RANKING:-true}
|
||||
- COIN_LIMIT=${COIN_LIMIT:-50}
|
||||
command: python coin_selection_engine.py
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import os; exit(0 if os.path.exists('/app/trading.db') else 1)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- trading-network
|
||||
|
||||
# Web仪表板服务
|
||||
web-dashboard:
|
||||
build: .
|
||||
container_name: trading-ai-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./static:/app/static
|
||||
- ./templates:/app/templates
|
||||
- trading-db:/app/trading.db
|
||||
environment:
|
||||
- PYTHONPATH=/app
|
||||
- TZ=Asia/Shanghai
|
||||
- FLASK_ENV=production
|
||||
command: python web_app.py
|
||||
depends_on:
|
||||
- coin-selector
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- trading-network
|
||||
|
||||
# 定时调度服务
|
||||
scheduler:
|
||||
build: .
|
||||
container_name: trading-ai-scheduler
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config.py:/app/config.py:ro
|
||||
- trading-db:/app/trading.db
|
||||
environment:
|
||||
- PYTHONPATH=/app
|
||||
- TZ=Asia/Shanghai
|
||||
- DINGTALK_WEBHOOK_URL=${DINGTALK_WEBHOOK_URL:-}
|
||||
- DINGTALK_WEBHOOK_SECRET=${DINGTALK_WEBHOOK_SECRET:-}
|
||||
command: python scheduler.py
|
||||
depends_on:
|
||||
- coin-selector
|
||||
networks:
|
||||
- trading-network
|
||||
|
||||
# 网络配置
|
||||
networks:
|
||||
trading-network:
|
||||
driver: bridge
|
||||
|
||||
# 数据卷
|
||||
volumes:
|
||||
trading-db:
|
||||
driver: local
|
||||
|
||||
# 环境变量文件示例
|
||||
# 创建 .env 文件并设置以下变量:
|
||||
#
|
||||
# # 钉钉机器人配置
|
||||
# DINGTALK_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN
|
||||
# DINGTALK_WEBHOOK_SECRET=SEC_YOUR_SECRET
|
||||
#
|
||||
# # 选币配置
|
||||
# USE_MARKET_CAP_RANKING=true
|
||||
# COIN_LIMIT=50
|
||||
#
|
||||
# # Binance API(可选)
|
||||
# BINANCE_API_KEY=your_api_key
|
||||
# BINANCE_SECRET=your_secret
|
||||
56
install_dependencies.sh
Executable file
56
install_dependencies.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "正在安装加密货币选币引擎依赖..."
|
||||
|
||||
# 检查Python版本
|
||||
python_version=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
|
||||
echo "Python版本: $python_version"
|
||||
|
||||
# 安装基础依赖
|
||||
echo "安装基础Python包..."
|
||||
pip3 install requests pandas numpy python-binance ccxt fastapi uvicorn jinja2 aiofiles schedule
|
||||
|
||||
# 安装TA-Lib
|
||||
echo "安装TA-Lib..."
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
echo "检测到macOS系统"
|
||||
if command -v brew &> /dev/null; then
|
||||
echo "使用Homebrew安装TA-Lib..."
|
||||
brew install ta-lib
|
||||
pip3 install TA-Lib
|
||||
else
|
||||
echo "请先安装Homebrew,或手动安装TA-Lib"
|
||||
echo "访问: https://github.com/mrjbq7/ta-lib#installation"
|
||||
fi
|
||||
elif [[ "$OSTYPE" == "linux"* ]]; then
|
||||
# Linux
|
||||
echo "检测到Linux系统"
|
||||
# 尝试使用包管理器安装
|
||||
if command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential wget
|
||||
|
||||
# 下载并编译TA-Lib
|
||||
cd /tmp
|
||||
wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz
|
||||
cd ta-lib/
|
||||
./configure --prefix=/usr/local
|
||||
make
|
||||
sudo make install
|
||||
cd ~
|
||||
|
||||
pip3 install TA-Lib
|
||||
else
|
||||
echo "请手动安装TA-Lib依赖"
|
||||
fi
|
||||
else
|
||||
# Windows或其他系统
|
||||
echo "其他系统,请手动安装TA-Lib"
|
||||
echo "Windows用户可以下载预编译包:"
|
||||
echo "https://github.com/cgohlke/talib-builds/releases"
|
||||
fi
|
||||
|
||||
echo "依赖安装完成!"
|
||||
echo "运行测试: python3 -c 'import talib; print(\"TA-Lib安装成功\")'"
|
||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@ -0,0 +1,23 @@
|
||||
# 核心依赖
|
||||
requests>=2.25.0
|
||||
pandas>=1.3.0
|
||||
numpy>=1.20.0
|
||||
python-binance>=1.0.15
|
||||
TA-Lib>=0.4.24
|
||||
urllib3<2.0.0
|
||||
|
||||
# Web框架
|
||||
flask>=2.3.0
|
||||
flask-cors>=4.0.0
|
||||
jinja2>=3.0.0
|
||||
|
||||
# 调度任务
|
||||
schedule>=1.1.0
|
||||
APScheduler>=3.10.0
|
||||
|
||||
# 日志和工具
|
||||
python-dateutil>=2.8.0
|
||||
pytz>=2023.3
|
||||
|
||||
# 开发工具(可选)
|
||||
pytest>=7.4.0
|
||||
166
scheduler.py
Normal file
166
scheduler.py
Normal file
@ -0,0 +1,166 @@
|
||||
import schedule
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from coin_selection_engine import CoinSelectionEngine
|
||||
import threading
|
||||
|
||||
# 东八区时区
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
def get_beijing_time():
|
||||
"""获取当前东八区时间用于显示"""
|
||||
return datetime.now(BEIJING_TZ)
|
||||
|
||||
class ScheduledCoinSelector:
|
||||
def __init__(self, api_key=None, secret=None, db_path="trading.db"):
|
||||
"""初始化定时选币器"""
|
||||
self.engine = CoinSelectionEngine(api_key, secret, db_path)
|
||||
self.is_running = False
|
||||
self.scheduler_thread = None
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('coin_selection.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def scheduled_selection_job(self):
|
||||
"""定时选币任务"""
|
||||
self.logger.info("开始执行定时选币任务...")
|
||||
|
||||
try:
|
||||
# 执行选币
|
||||
selected_coins = self.engine.run_coin_selection()
|
||||
|
||||
if selected_coins:
|
||||
self.logger.info(f"定时选币完成,共选出{len(selected_coins)}个币种")
|
||||
|
||||
# 打印选币结果
|
||||
print("\n" + "="*60)
|
||||
print(f"定时选币结果 - {get_beijing_time().strftime('%Y-%m-%d %H:%M:%S CST')}")
|
||||
print("="*60)
|
||||
|
||||
for i, signal in enumerate(selected_coins, 1):
|
||||
print(f"{i}. {signal.symbol} - 评分: {signal.score:.1f}")
|
||||
print(f" 入场: ${signal.entry_price:.4f}")
|
||||
print(f" 止损: ${signal.stop_loss:.4f}")
|
||||
print(f" 止盈: ${signal.take_profit:.4f}")
|
||||
print(f" 理由: {signal.reason}")
|
||||
print("-" * 40)
|
||||
|
||||
else:
|
||||
self.logger.warning("本次定时选币未找到符合条件的币种")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"定时选币任务执行失败: {e}")
|
||||
|
||||
def start_scheduler(self):
|
||||
"""启动定时器"""
|
||||
if self.is_running:
|
||||
self.logger.warning("定时器已经在运行中")
|
||||
return
|
||||
|
||||
self.logger.info("启动定时选币器...")
|
||||
|
||||
# 设置定时任务
|
||||
# 每天8点和20点执行选币
|
||||
schedule.every().day.at("08:00").do(self.scheduled_selection_job)
|
||||
schedule.every().day.at("20:00").do(self.scheduled_selection_job)
|
||||
|
||||
# 也可以设置每隔4小时执行一次
|
||||
# schedule.every(4).hours.do(self.scheduled_selection_job)
|
||||
|
||||
self.is_running = True
|
||||
|
||||
# 在单独的线程中运行调度器
|
||||
def run_scheduler():
|
||||
while self.is_running:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # 每分钟检查一次
|
||||
|
||||
self.scheduler_thread = threading.Thread(target=run_scheduler, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
|
||||
self.logger.info("定时选币器已启动,计划每日8:00和20:00执行")
|
||||
|
||||
def stop_scheduler(self):
|
||||
"""停止定时器"""
|
||||
if not self.is_running:
|
||||
self.logger.warning("定时器未在运行")
|
||||
return
|
||||
|
||||
self.logger.info("停止定时选币器...")
|
||||
self.is_running = False
|
||||
schedule.clear()
|
||||
|
||||
if self.scheduler_thread and self.scheduler_thread.is_alive():
|
||||
self.scheduler_thread.join(timeout=5)
|
||||
|
||||
self.logger.info("定时选币器已停止")
|
||||
|
||||
def run_once(self):
|
||||
"""立即执行一次选币"""
|
||||
self.logger.info("立即执行选币...")
|
||||
self.scheduled_selection_job()
|
||||
|
||||
def get_next_run_time(self):
|
||||
"""获取下次执行时间"""
|
||||
jobs = schedule.get_jobs()
|
||||
if jobs:
|
||||
next_run = min(job.next_run for job in jobs)
|
||||
# 转换为东八区时间显示
|
||||
beijing_time = next_run.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
|
||||
return beijing_time.strftime('%Y-%m-%d %H:%M:%S CST')
|
||||
return "无计划任务"
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='加密货币定时选币器')
|
||||
parser.add_argument('--api-key', help='Binance API Key')
|
||||
parser.add_argument('--secret', help='Binance Secret Key')
|
||||
parser.add_argument('--db-path', default='trading.db', help='数据库路径')
|
||||
parser.add_argument('--run-once', action='store_true', help='立即执行一次选币')
|
||||
parser.add_argument('--daemon', action='store_true', help='以守护进程方式运行')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 创建定时选币器
|
||||
selector = ScheduledCoinSelector(args.api_key, args.secret, args.db_path)
|
||||
|
||||
if args.run_once:
|
||||
# 立即执行一次
|
||||
selector.run_once()
|
||||
elif args.daemon:
|
||||
# 启动定时器并保持运行
|
||||
selector.start_scheduler()
|
||||
|
||||
try:
|
||||
print("定时选币器正在运行...")
|
||||
print(f"下次执行时间: {selector.get_next_run_time()}")
|
||||
print("按 Ctrl+C 停止")
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n收到停止信号...")
|
||||
selector.stop_scheduler()
|
||||
print("定时选币器已停止")
|
||||
else:
|
||||
print("请使用 --run-once 立即执行或 --daemon 启动定时器")
|
||||
print("示例:")
|
||||
print(" python scheduler.py --run-once")
|
||||
print(" python scheduler.py --daemon")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
184
setup_dingtalk.py
Normal file
184
setup_dingtalk.py
Normal file
@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
钉钉通知快速设置和测试脚本
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from coin_selection_engine import CoinSelectionEngine
|
||||
|
||||
def setup_dingtalk():
|
||||
"""设置钉钉webhook"""
|
||||
print("🤖 加密货币选币系统 - 钉钉通知设置")
|
||||
print("=" * 50)
|
||||
|
||||
# 获取当前配置
|
||||
current_webhook = os.getenv('DINGTALK_WEBHOOK_URL')
|
||||
current_secret = os.getenv('DINGTALK_WEBHOOK_SECRET')
|
||||
|
||||
if current_webhook:
|
||||
print(f"当前配置的webhook: {current_webhook[:50]}...")
|
||||
if current_secret:
|
||||
print("已配置加签密钥(安全模式)")
|
||||
use_current = input("是否使用当前配置? (y/n): ").lower().strip()
|
||||
if use_current == 'y':
|
||||
return current_webhook, current_secret
|
||||
|
||||
# 输入新的webhook
|
||||
print("\n📱 请按以下步骤获取钉钉机器人webhook URL:")
|
||||
print("1. 在钉钉群中添加机器人")
|
||||
print("2. 选择'自定义机器人'")
|
||||
print("3. 复制webhook URL")
|
||||
print("4. 如果启用了加签安全,也复制密钥")
|
||||
print()
|
||||
|
||||
webhook_url = input("请输入钉钉机器人webhook URL: ").strip()
|
||||
|
||||
if not webhook_url:
|
||||
print("❌ 未输入webhook URL")
|
||||
return None, None
|
||||
|
||||
if not webhook_url.startswith('https://oapi.dingtalk.com/robot/send'):
|
||||
print("❌ webhook URL格式不正确")
|
||||
return None, None
|
||||
|
||||
# 询问是否使用加签
|
||||
use_sign = input("是否启用了加签安全设置? (y/n): ").lower().strip()
|
||||
webhook_secret = None
|
||||
|
||||
if use_sign == 'y':
|
||||
webhook_secret = input("请输入加签密钥: ").strip()
|
||||
if webhook_secret and not webhook_secret.startswith('SEC'):
|
||||
print("⚠️ 加签密钥通常以'SEC'开头,请确认输入正确")
|
||||
|
||||
# 保存到环境变量 (临时)
|
||||
os.environ['DINGTALK_WEBHOOK_URL'] = webhook_url
|
||||
if webhook_secret:
|
||||
os.environ['DINGTALK_WEBHOOK_SECRET'] = webhook_secret
|
||||
|
||||
security_mode = "加签模式" if webhook_secret else "普通模式"
|
||||
print(f"✅ 钉钉通知配置完成 ({security_mode})")
|
||||
|
||||
return webhook_url, webhook_secret
|
||||
|
||||
def test_notification():
|
||||
"""测试钉钉通知"""
|
||||
print("\n🧪 测试钉钉通知...")
|
||||
|
||||
try:
|
||||
# 创建选币引擎实例
|
||||
engine = CoinSelectionEngine()
|
||||
|
||||
# 发送测试消息
|
||||
success = engine.test_dingtalk_notification()
|
||||
|
||||
if success:
|
||||
print("✅ 钉钉通知测试成功!")
|
||||
return True
|
||||
else:
|
||||
print("❌ 钉钉通知测试失败")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试过程中出错: {e}")
|
||||
return False
|
||||
|
||||
def run_selection_with_notification():
|
||||
"""运行选币并发送通知"""
|
||||
print("\n🎯 开始执行选币...")
|
||||
|
||||
try:
|
||||
engine = CoinSelectionEngine()
|
||||
selected_coins = engine.run_coin_selection()
|
||||
|
||||
print(f"\n📊 选币完成,共发现 {len(selected_coins)} 个投资机会")
|
||||
|
||||
# 打印简要结果
|
||||
if selected_coins:
|
||||
long_signals = [s for s in selected_coins if s.signal_type == "LONG"]
|
||||
short_signals = [s for s in selected_coins if s.signal_type == "SHORT"]
|
||||
print(f"多头信号: {len(long_signals)}个")
|
||||
print(f"空头信号: {len(short_signals)}个")
|
||||
|
||||
print("\n前5个信号:")
|
||||
for i, signal in enumerate(selected_coins[:5], 1):
|
||||
direction = "📈多头" if signal.signal_type == "LONG" else "📉空头"
|
||||
print(f"{i}. {signal.symbol} {direction} - {signal.score:.1f}分 ({signal.confidence}信心)")
|
||||
|
||||
return selected_coins
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 选币过程中出错: {e}")
|
||||
return []
|
||||
|
||||
def create_env_file(webhook_url, webhook_secret=None):
|
||||
"""创建 .env 文件保存配置"""
|
||||
env_content = f"""# 钉钉机器人配置
|
||||
DINGTALK_WEBHOOK_URL={webhook_url}
|
||||
"""
|
||||
|
||||
if webhook_secret:
|
||||
env_content += f"DINGTALK_WEBHOOK_SECRET={webhook_secret}\n"
|
||||
|
||||
env_content += """
|
||||
# 选币配置
|
||||
USE_MARKET_CAP_RANKING=True
|
||||
"""
|
||||
|
||||
try:
|
||||
with open('.env', 'w', encoding='utf-8') as f:
|
||||
f.write(env_content)
|
||||
print("✅ 配置已保存到 .env 文件")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 保存配置文件失败: {e}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1].lower()
|
||||
|
||||
if command == 'test':
|
||||
test_notification()
|
||||
return
|
||||
elif command == 'run':
|
||||
run_selection_with_notification()
|
||||
return
|
||||
elif command == 'setup':
|
||||
webhook, secret = setup_dingtalk()
|
||||
if webhook:
|
||||
create_env_file(webhook, secret)
|
||||
test_notification()
|
||||
return
|
||||
|
||||
# 交互式菜单
|
||||
while True:
|
||||
print("\n🤖 加密货币选币系统 - 钉钉通知")
|
||||
print("=" * 40)
|
||||
print("1. 设置钉钉webhook")
|
||||
print("2. 测试钉钉通知")
|
||||
print("3. 执行选币并发送通知")
|
||||
print("4. 退出")
|
||||
print()
|
||||
|
||||
choice = input("请选择操作 (1-4): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
webhook, secret = setup_dingtalk()
|
||||
if webhook:
|
||||
create_env_file(webhook, secret)
|
||||
|
||||
elif choice == '2':
|
||||
test_notification()
|
||||
|
||||
elif choice == '3':
|
||||
run_selection_with_notification()
|
||||
|
||||
elif choice == '4':
|
||||
print("👋 再见!")
|
||||
break
|
||||
|
||||
else:
|
||||
print("❌ 无效选择,请重试")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2350
technical_analyzer.py
Normal file
2350
technical_analyzer.py
Normal file
File diff suppressed because it is too large
Load Diff
775
templates/dashboard.html
Normal file
775
templates/dashboard.html
Normal file
@ -0,0 +1,775 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI选币系统</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 主容器 */
|
||||
.main-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 选币结果区域 */
|
||||
.results-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.last-update {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 时间分组 */
|
||||
.time-group {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.time-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
background: #f8fafc;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.group-coins {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 币种网格 */
|
||||
.coins-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.coin-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* 多空信号样式 */
|
||||
.long-signal {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.short-signal {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.coin-symbol-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.signal-type-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.long-badge {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border: 1px solid #16a34a;
|
||||
}
|
||||
|
||||
.short-badge {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #dc2626;
|
||||
}
|
||||
|
||||
.coin-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.coin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.coin-symbol {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.coin-score {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.coin-reason {
|
||||
color: #475569;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
background: #f8fafc;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.price-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.price-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.price-positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.price-negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.coin-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.strategy-short {
|
||||
background: linear-gradient(45deg, #ff6b6b, #ee5a24);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.strategy-medium {
|
||||
background: linear-gradient(45deg, #4834d4, #686de0);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.strategy-long {
|
||||
background: linear-gradient(45deg, #00d2d3, #01a3a4);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expiry-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
color: #856404;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.time-remaining {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-active { background: #10b981; color: white; }
|
||||
.status-expired { background: #ef4444; color: white; }
|
||||
.status-completed { background: #6366f1; color: white; }
|
||||
|
||||
/* 操作建议样式 */
|
||||
.action-suggestion {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 根据操作建议类型设置不同颜色 */
|
||||
.action-buy-now {
|
||||
background: #dcfce7;
|
||||
border-color: #16a34a;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.action-buy-current {
|
||||
background: #ddd6fe;
|
||||
border-color: #7c3aed;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.action-buy-batch {
|
||||
background: #e0f2fe;
|
||||
border-color: #0284c7;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.action-try-small {
|
||||
background: #fef3c7;
|
||||
border-color: #d97706;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.action-wait-pullback {
|
||||
background: #fef9c3;
|
||||
border-color: #ca8a04;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.action-wait-big-pullback {
|
||||
background: #ffedd5;
|
||||
border-color: #ea580c;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.action-wait {
|
||||
background: #f1f5f9;
|
||||
border-color: #64748b;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.action-careful {
|
||||
background: #fee2e2;
|
||||
border-color: #dc2626;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.loading i {
|
||||
font-size: 2rem;
|
||||
color: #3b82f6;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
color: #475569;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.main-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.coins-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-content">
|
||||
<div class="logo">
|
||||
<i class="fas fa-coins"></i>
|
||||
AI选币系统
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="runSelection()">
|
||||
<i class="fas fa-sync-alt" id="refresh-icon"></i>
|
||||
执行选币
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</div>
|
||||
<div class="stat-value" id="total-selections">-</div>
|
||||
<div class="stat-label">总选币数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</div>
|
||||
<div class="stat-value" id="active-selections">-</div>
|
||||
<div class="stat-label">活跃选币</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="stat-value" id="completed-selections">-</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-percentage"></i>
|
||||
</div>
|
||||
<div class="stat-value" id="avg-pnl">-%</div>
|
||||
<div class="stat-label">平均收益</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选币结果 -->
|
||||
<div class="results-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<i class="fas fa-list"></i>
|
||||
选币结果
|
||||
</div>
|
||||
<div class="last-update">
|
||||
最后更新: {{ last_update }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading">
|
||||
<i class="fas fa-spinner"></i>
|
||||
<div class="loading-text">正在执行选币分析...</div>
|
||||
</div>
|
||||
|
||||
<div id="selections-container">
|
||||
{% if grouped_selections %}
|
||||
{% for group_time, selections in grouped_selections.items() %}
|
||||
<div class="time-group">
|
||||
<div class="group-header">
|
||||
{{ group_time }} ({{ selections|length }}个币种)
|
||||
</div>
|
||||
<div class="group-coins">
|
||||
<div class="coins-grid" id="coins-grid-{{ loop.index }}">
|
||||
{% for selection in selections %}
|
||||
<div class="coin-card {{ 'short-signal' if selection.signal_type == 'SHORT' else 'long-signal' }}">
|
||||
<div class="coin-header">
|
||||
<div class="coin-symbol-container">
|
||||
<div class="coin-symbol">{{ selection.symbol.replace('USDT', '') }}</div>
|
||||
<div class="signal-type-badge {{ 'short-badge' if selection.signal_type == 'SHORT' else 'long-badge' }}">
|
||||
{{ '做空' if selection.signal_type == 'SHORT' else '做多' }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<div class="coin-score">{{ "%.1f"|format(selection.score) }}</div>
|
||||
<div class="strategy-badge strategy-{{ selection.strategy_type|replace('短线', 'short')|replace('中线', 'medium')|replace('长线', 'long') }}">
|
||||
{{ selection.strategy_type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作建议 -->
|
||||
<div class="action-suggestion" style="text-align: center; margin-bottom: 1rem; padding: 0.75rem; border-radius: 6px; font-weight: 600; font-size: 0.9rem;">
|
||||
{{ selection.action_suggestion }}
|
||||
</div>
|
||||
|
||||
<div class="coin-reason">
|
||||
<strong>选择理由:</strong> {{ selection.reason }}
|
||||
</div>
|
||||
|
||||
<div class="strategy-info" style="background: #f8fafc; padding: 0.5rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.8rem;">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>持仓周期: {{ selection.holding_period }}天</span>
|
||||
<span>风险回报: 1:{{ "%.1f"|format(selection.risk_reward_ratio) }}</span>
|
||||
</div>
|
||||
{% if selection.expiry_time %}
|
||||
<div class="time-remaining">
|
||||
有效期至: {{ selection.expiry_time[:16] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="price-grid">
|
||||
<div class="price-item">
|
||||
<div class="price-label">
|
||||
{% if selection.signal_type == 'SHORT' %}做空入场价{% else %}做多入场价{% endif %}
|
||||
</div>
|
||||
<div class="price-value">${{ "%.4f"|format(selection.entry_price) }}</div>
|
||||
</div>
|
||||
<div class="price-item">
|
||||
<div class="price-label">当前市价</div>
|
||||
{% if selection.current_price %}
|
||||
<div class="price-value {{ 'price-positive' if (selection.signal_type == 'LONG' and selection.current_price < selection.entry_price) or (selection.signal_type == 'SHORT' and selection.current_price > selection.entry_price) else 'price-negative' }}">
|
||||
${{ "%.4f"|format(selection.current_price) }}
|
||||
{% if (selection.signal_type == 'LONG' and selection.current_price < selection.entry_price) or (selection.signal_type == 'SHORT' and selection.current_price > selection.entry_price) %}
|
||||
<br><small>✓ 可入场</small>
|
||||
{% else %}
|
||||
<br><small>⚠ 价格不利</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="price-value">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="price-item">
|
||||
<div class="price-label">止损位</div>
|
||||
<div class="price-value price-negative">${{ "%.4f"|format(selection.stop_loss) }}</div>
|
||||
</div>
|
||||
<div class="price-item">
|
||||
<div class="price-label">止盈位</div>
|
||||
<div class="price-value price-positive">${{ "%.4f"|format(selection.take_profit) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="coin-footer">
|
||||
<span>{{ selection.selection_time.split(' ')[1] }}</span>
|
||||
<span class="status-badge status-{{ selection.status }}">{{ selection.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- 加载更多按钮 -->
|
||||
{% if total_count >= current_limit %}
|
||||
<div class="load-more-container" style="text-align: center; padding: 2rem;">
|
||||
<button class="load-more-btn" onclick="loadMoreData()" style="background: #3b82f6; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-weight: 500; cursor: pointer;">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-search"></i>
|
||||
<h3>暂无选币结果</h3>
|
||||
<p>点击"执行选币"开始分析市场</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 当前分页状态
|
||||
let currentOffset = {{ current_offset }};
|
||||
let currentLimit = {{ current_limit }};
|
||||
let isLoading = false;
|
||||
|
||||
// 加载更多数据
|
||||
async function loadMoreData() {
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
const loadMoreBtn = document.querySelector('.load-more-btn');
|
||||
const originalText = loadMoreBtn.innerHTML;
|
||||
|
||||
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 加载中...';
|
||||
loadMoreBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/selections?limit=${currentLimit}&offset=${currentOffset + currentLimit}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.length > 0) {
|
||||
// 这里应该追加新数据到现有列表
|
||||
// 由于需要重新分组,我们简化为重新加载页面
|
||||
const newUrl = new URL(window.location);
|
||||
newUrl.searchParams.set('limit', currentLimit + data.data.length);
|
||||
window.location.href = newUrl.toString();
|
||||
} else {
|
||||
loadMoreBtn.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载更多数据失败:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
loadMoreBtn.innerHTML = originalText;
|
||||
loadMoreBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
document.getElementById('total-selections').textContent = data.data.total_selections;
|
||||
document.getElementById('active-selections').textContent = data.data.active_selections;
|
||||
document.getElementById('completed-selections').textContent = data.data.completed_selections;
|
||||
document.getElementById('avg-pnl').textContent = data.data.avg_pnl + '%';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行选币
|
||||
async function runSelection() {
|
||||
const button = document.querySelector('.refresh-btn');
|
||||
const icon = document.getElementById('refresh-icon');
|
||||
const loading = document.querySelector('.loading');
|
||||
const container = document.getElementById('selections-container');
|
||||
|
||||
button.disabled = true;
|
||||
icon.classList.add('fa-spin');
|
||||
loading.style.display = 'block';
|
||||
container.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/run_selection', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('选币执行失败: ' + data.message);
|
||||
loading.style.display = 'none';
|
||||
container.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
alert('选币执行失败: ' + error.message);
|
||||
loading.style.display = 'none';
|
||||
container.style.display = 'block';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
icon.classList.remove('fa-spin');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadStats();
|
||||
styleActionSuggestions();
|
||||
});
|
||||
|
||||
// 设置操作建议的样式
|
||||
function styleActionSuggestions() {
|
||||
const suggestions = document.querySelectorAll('.action-suggestion');
|
||||
suggestions.forEach(function(element) {
|
||||
const text = element.textContent.trim();
|
||||
|
||||
// 移除现有的样式类
|
||||
element.classList.remove('action-buy-now', 'action-buy-current', 'action-buy-batch',
|
||||
'action-try-small', 'action-wait-pullback', 'action-wait-big-pullback',
|
||||
'action-wait', 'action-careful');
|
||||
|
||||
// 根据文本内容添加对应的样式类
|
||||
if (text.includes('立即买入')) {
|
||||
element.classList.add('action-buy-now');
|
||||
} else if (text.includes('现价买入')) {
|
||||
element.classList.add('action-buy-current');
|
||||
} else if (text.includes('分批买入')) {
|
||||
element.classList.add('action-buy-batch');
|
||||
} else if (text.includes('小仓位试探')) {
|
||||
element.classList.add('action-try-small');
|
||||
} else if (text.includes('等待大幅回调')) {
|
||||
element.classList.add('action-wait-big-pullback');
|
||||
} else if (text.includes('等待回调买入')) {
|
||||
element.classList.add('action-wait-pullback');
|
||||
} else if (text.includes('谨慎观望')) {
|
||||
element.classList.add('action-careful');
|
||||
} else if (text.includes('暂时观望')) {
|
||||
element.classList.add('action-wait');
|
||||
} else {
|
||||
element.classList.add('action-wait'); // 默认样式
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 定期刷新统计数据
|
||||
setInterval(loadStats, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
218
web_app.py
Normal file
218
web_app.py
Normal file
@ -0,0 +1,218 @@
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
import uvicorn
|
||||
import os
|
||||
from coin_selection_engine import CoinSelectionEngine
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from database import utc_to_beijing
|
||||
import asyncio
|
||||
from functools import lru_cache
|
||||
import time
|
||||
|
||||
# 东八区时区
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
def get_beijing_time():
|
||||
"""获取当前东八区时间用于显示"""
|
||||
return datetime.now(BEIJING_TZ)
|
||||
|
||||
app = FastAPI(title="加密货币选币系统", version="1.0.0")
|
||||
|
||||
# 设置模板和静态文件目录
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# 创建静态文件目录
|
||||
os.makedirs("static", exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# 全局变量
|
||||
engine = CoinSelectionEngine()
|
||||
|
||||
# 缓存配置
|
||||
CACHE_DURATION = 60 # 缓存60秒
|
||||
cache_data = {
|
||||
'selections': {'data': None, 'timestamp': 0},
|
||||
'stats': {'data': None, 'timestamp': 0}
|
||||
}
|
||||
|
||||
def get_cached_data(cache_key, fetch_func, *args, **kwargs):
|
||||
"""获取缓存数据或重新获取"""
|
||||
current_time = time.time()
|
||||
cache_entry = cache_data.get(cache_key, {'data': None, 'timestamp': 0})
|
||||
|
||||
# 检查缓存是否过期
|
||||
if current_time - cache_entry['timestamp'] > CACHE_DURATION or cache_entry['data'] is None:
|
||||
cache_entry['data'] = fetch_func(*args, **kwargs)
|
||||
cache_entry['timestamp'] = current_time
|
||||
cache_data[cache_key] = cache_entry
|
||||
|
||||
return cache_entry['data']
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, limit: int = 20, offset: int = 0):
|
||||
"""主页面 - 支持分页"""
|
||||
try:
|
||||
# 使用缓存获取数据
|
||||
selections = get_cached_data(
|
||||
f'selections_{limit}_{offset}',
|
||||
lambda: engine.get_latest_selections(limit + 5, offset)
|
||||
)
|
||||
|
||||
# 按年月日时分分组选币结果,转换时间为东八区显示
|
||||
grouped_selections = {}
|
||||
latest_update_time = None
|
||||
|
||||
for selection in selections:
|
||||
# 将UTC时间转换为东八区时间显示
|
||||
utc_time = selection['selection_time']
|
||||
beijing_time = utc_to_beijing(utc_time)
|
||||
|
||||
# 跟踪最新的更新时间
|
||||
if latest_update_time is None or utc_time > latest_update_time:
|
||||
latest_update_time = beijing_time
|
||||
|
||||
# 提取年月日时分部分 (YYYY-MM-DD HH:MM)
|
||||
time_key = beijing_time[:16] # "YYYY-MM-DD HH:MM"
|
||||
|
||||
if time_key not in grouped_selections:
|
||||
grouped_selections[time_key] = []
|
||||
|
||||
# 更新选币记录的显示时间,但不修改原始时间
|
||||
selection_copy = selection.copy()
|
||||
selection_copy['selection_time'] = beijing_time
|
||||
grouped_selections[time_key].append(selection_copy)
|
||||
|
||||
# 按时间降序排序(最新的在前面)
|
||||
sorted_grouped_selections = dict(sorted(
|
||||
grouped_selections.items(),
|
||||
key=lambda x: x[0],
|
||||
reverse=True
|
||||
))
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"grouped_selections": sorted_grouped_selections,
|
||||
"last_update": latest_update_time + " CST" if latest_update_time else "暂无数据",
|
||||
"total_count": len(selections),
|
||||
"current_limit": limit,
|
||||
"current_offset": offset
|
||||
})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/selections")
|
||||
async def get_selections(limit: int = 20, offset: int = 0):
|
||||
"""获取选币结果API - 支持分页"""
|
||||
try:
|
||||
# 限制每页数据量
|
||||
limit = min(limit, 100)
|
||||
# 使用缓存
|
||||
selections = get_cached_data(
|
||||
f'selections_{limit}_{offset}',
|
||||
lambda: engine.get_latest_selections(limit, offset)
|
||||
)
|
||||
return JSONResponse({
|
||||
"status": "success",
|
||||
"data": selections,
|
||||
"count": len(selections),
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
@app.post("/api/run_selection")
|
||||
async def run_selection():
|
||||
"""执行选币API"""
|
||||
try:
|
||||
# 清除相关缓存
|
||||
cache_data.clear()
|
||||
|
||||
# 异步执行选币
|
||||
selected_coins = engine.run_coin_selection()
|
||||
|
||||
return JSONResponse({
|
||||
"status": "success",
|
||||
"message": f"选币完成,共选出{len(selected_coins)}个币种",
|
||||
"count": len(selected_coins)
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
@app.put("/api/selections/{selection_id}/status")
|
||||
async def update_selection_status(selection_id: int, status: str, exit_price: float = None):
|
||||
"""更新选币状态API"""
|
||||
try:
|
||||
# 清除相关缓存
|
||||
for key in list(cache_data.keys()):
|
||||
if 'selections' in key or 'stats' in key:
|
||||
del cache_data[key]
|
||||
|
||||
engine.update_selection_status(selection_id, status, exit_price)
|
||||
return JSONResponse({
|
||||
"status": "success",
|
||||
"message": f"更新选币{selection_id}状态为{status}"
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_stats():
|
||||
"""获取统计信息API"""
|
||||
try:
|
||||
# 使用缓存获取统计数据
|
||||
stats = get_cached_data('stats', lambda: _get_fresh_stats())
|
||||
|
||||
return JSONResponse({
|
||||
"status": "success",
|
||||
"data": stats
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
def _get_fresh_stats():
|
||||
"""获取新鲜的统计数据"""
|
||||
conn = engine.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 总选币数
|
||||
cursor.execute("SELECT COUNT(*) FROM coin_selections")
|
||||
total_selections = cursor.fetchone()[0]
|
||||
|
||||
# 活跃选币数
|
||||
cursor.execute("SELECT COUNT(*) FROM coin_selections WHERE status = 'active'")
|
||||
active_selections = cursor.fetchone()[0]
|
||||
|
||||
# 完成选币数
|
||||
cursor.execute("SELECT COUNT(*) FROM coin_selections WHERE status = 'completed'")
|
||||
completed_selections = cursor.fetchone()[0]
|
||||
|
||||
# 平均收益率
|
||||
cursor.execute("SELECT AVG(pnl_percentage) FROM coin_selections WHERE pnl_percentage IS NOT NULL")
|
||||
avg_pnl = cursor.fetchone()[0] or 0
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total_selections": total_selections,
|
||||
"active_selections": active_selections,
|
||||
"completed_selections": completed_selections,
|
||||
"avg_pnl": round(avg_pnl, 2)
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
Loading…
Reference in New Issue
Block a user