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