This commit is contained in:
aaron 2025-08-14 10:06:19 +08:00
commit 65276c5beb
16 changed files with 5384 additions and 0 deletions

123
.gitignore vendored Normal file
View 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
View 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
View 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"]

BIN
README.md Normal file

Binary file not shown.

357
coin_selection_engine.py Normal file
View 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
View 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
View 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
View 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}&timestamp={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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

775
templates/dashboard.html Normal file
View 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
View 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)