17 changed files with 2498 additions and 230 deletions

No files matched your search

-32
View File
@@ -1,32 +0,0 @@
# Binance API (public data - no keys needed)
BINANCE_API_URL=https://api.binance.com
# 代理配置 (SOCKS5)
SOCKS5_PROXY=socks5://127.0.0.1:7890
USE_PROXY=true
# Data Configuration
DATA_CACHE_DIR=./data/cache
LOOKBACK_1H=168 # 1 week for 1h data
LOOKBACK_15M=672 # 1 week for 15m data
FORECAST_HORIZON=24 # Predict 24 periods ahead
# Training Configuration
BATCH_SIZE=64
LEARNING_RATE=0.001
MAX_EPOCHS=100
EARLY_STOPPING_PATIENCE=10
TRAIN_SPLIT=0.7
VAL_SPLIT=0.15
TEST_SPLIT=0.15
# Model Paths
MODEL_DIR=./models/checkpoints
ONNX_DIR=./models/onnx
# API Configuration
API_PORT=5000
API_HOST=0.0.0.0
# Classification Threshold
PRICE_CHANGE_THRESHOLD=0.015 # 1.5% threshold for binary labels
+7
View File
@@ -30,3 +30,10 @@ API_HOST=0.0.0.0
# Classification Threshold
PRICE_CHANGE_THRESHOLD=0.015 # 1.5% threshold for binary labels
# MySQL Configuration
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_DB=crypto_predictor
+4 -1
View File
@@ -9,9 +9,11 @@ venv/
env/
ENV/
# Sensitive
.env
# 数据和缓存
data/cache/*.csv
*.db
# 模型检查点(保留最佳模型,忽略训练过程文件)
models/checkpoints/*/epoch_*.pt
@@ -38,3 +40,4 @@ logs/
# 临时文件
*.tmp
.cache/
test_*.py
+111
View File
@@ -0,0 +1,111 @@
# AGENTS.md - 智能代理开发指南
本文件定义了在该项目中进行开发的 AI 代理(Agent)的行为准则、技术规范和操作流程。请严格遵守。
## 1. 核心行为准则 (Core Mandates)
**最高优先级原则:**
* **语言要求**:所有回答必须使用**中文**。
* **称呼要求**:每次回答必须加上**“主人”**。
* **最小改动 (Minimal Change)**:只修改完成任务所必须的代码。**禁止**为了“最佳实践”或“代码美观”而进行未被请求的重构。
* **环境约束**:本项目严格使用 **Conda** 环境。禁止使用 venv/poetry/pipenv。
* **复用优先**:在编写新功能前,必须检查 `data/`, `models/`, `utils/` 下是否已有类似实现。
## 2. 环境与构建 (Environment & Build)
本项目依赖管理基于 `requirements.txt`, conda环境名称**crypto_pred_312**
* **安装依赖**:
```bash
pip install -r requirements.txt
```
## 3. 测试与质量保证 (Testing & QA)
使用 `pytest` 作为测试框架。在提交修改前,必须确保相关测试通过。
* **运行所有测试**:
```bash
pytest
```
* **运行单个测试文件 (常用)**:
```bash
pytest tests/test_example.py
```
* **运行单个测试用例 (推荐)**:
用于专注调试某一个功能:
```bash
pytest tests/test_example.py::test_specific_function
```
* **显示打印输出 (Debug模式)**:
```bash
pytest -s tests/test_example.py
```
* **代码格式化与检查**:
```bash
black . # 格式化代码
flake8 . # 代码风格检查
```
## 4. 代码风格规范 (Code Style)
* **注释 (Comments)**:
* **必须使用中文**编写所有注释和文档字符串 (Docstrings)。
* Docstring 风格推荐 Google Style 或简单的多行注释。
* 注释应解释“为什么这样做” (Why),而不是“做了什么” (What)。
* **命名约定 (Naming)**:
* 变量/函数: `snake_case` (如 `add_technical_features`)
* 类名: `PascalCase` (如 `BinanceDataFetcher`)
* 常量: `UPPER_CASE` (如 `MAX_RETRIES`)
* 私有成员: `_snake_case` (前缀下划线)
* **导入 (Imports)**:
* 顺序:标准库 -> 第三方库 -> 本地模块。
* 尽量使用绝对导入。
* 注意:项目中部分脚本使用了 `sys.path.insert` hack,修改时需保持兼容,不要随意删除。
* **类型提示 (Type Hints)**:
* 推荐在函数签名中添加类型提示,但不强制要求全覆盖。
* 示例: `def process_data(df: pd.DataFrame) -> np.ndarray:`
* **错误处理 (Error Handling)**:
* 使用具体的异常类型 (如 `ValueError`, `FileNotFoundError`),避免裸 `except:`。
* 在 API 调用或网络请求处必须添加重试或错误捕获逻辑。
## 5. 项目结构说明 (Project Structure)
在修改代码前,请参考以下结构定位文件:
* **`api/`**: FastAPI 接口服务代码。
* **`data/`**: 数据获取 (BinanceFetcher等) 和预处理逻辑。
* **`models/`**: 模型定义 (N-BEATS, DLinear, CatBoost 等) 和 训练好的模型文件。
* **`tests/`**: 单元测试和集成测试。
* **`scripts/`**: 辅助脚本。
* **`training/`** & **`logs/`**: 训练日志和过程文件。
* **根目录脚本**: 包含多个入口脚本 (如 `ensemble_predict.py`, `train_catboost.py`),修改时需格外小心,避免破坏生产流程。
## 6. 开发流程 (Workflow)
1. **理解需求**: 先阅读代码,确认需求范围。如果有歧义,**先询问主人**。
2. **定位代码**: 使用 `grep` 或 `glob` 查找相关文件。
3. **制定计划**: 思考修改方案,优先考虑不破坏现有逻辑的“加法”修改。
4. **实施修改**: 编辑代码,保持风格一致。
5. **验证**:
* 运行 `flake8` 检查语法错误。
* 运行 `pytest` 验证逻辑。
* 如果是脚本,尝试带 `--help` 或测试参数运行以确保无运行时错误。
## 7. 禁止事项 (Constraints)
* ❌ **禁止**猜测业务逻辑。
* ❌ **禁止**删除看似无用但可能被外部脚本调用的代码。
* ❌ **禁止**在未确认的情况下升级核心库版本 (如 torch, pandas)。
* ❌ **禁止**输出英语回复 (代码变量名除外)。
---
*遵循此指南,确保高效、安全地协助主人完成任务。*
+191
View File
@@ -0,0 +1,191 @@
"""
FastAPI 主应用 - 交易看板 API
"""
import os
import sys
from datetime import datetime
from typing import Optional
from zoneinfo import ZoneInfo
from fastapi import FastAPI, Query, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.models import TradeModel, PredictionModel
from api.prediction_service import get_prediction_service
# 创建 FastAPI 应用
app = FastAPI(
title="Crypto Predictor Dashboard API",
description="BTC 预测交易看板 API",
version="1.0.0"
)
# 启用 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件目录
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
# 挂载静态文件
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ==================== 页面路由 ====================
@app.get("/")
async def index():
"""返回主页"""
return FileResponse(os.path.join(STATIC_DIR, 'index.html'))
# ==================== 交易 API ====================
@app.get("/api/trades/current")
async def get_current_trade():
"""获取当前/最新交易"""
try:
trade = TradeModel.get_current_trade()
if trade:
return {"success": True, "data": trade}
return {"success": True, "data": None, "message": "暂无交易记录"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/trades/history")
async def get_trade_history(
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(10, ge=1, le=100, description="每页条数"),
month: Optional[str] = Query(None, description="月份筛选 (YYYY-MM)"),
day: Optional[str] = Query(None, description="日期筛选 (YYYY-MM-DD)")
):
"""获取历史交易记录"""
try:
result = TradeModel.get_history(page=page, limit=limit, month=month, day=day)
return {"success": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== 准确率 API ====================
@app.get("/api/accuracy/current")
async def get_current_prediction():
"""获取当前小时的预测"""
try:
service = get_prediction_service()
prediction = service.predict_current_hour()
return {"success": True, "data": prediction}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/accuracy/monthly")
async def get_monthly_accuracy(
month: Optional[str] = Query(None, description="月份 (YYYY-MM),默认当月")
):
"""获取月度准确率统计"""
try:
# 默认当月
if not month:
et_tz = ZoneInfo('America/New_York')
month = datetime.now(et_tz).strftime('%Y-%m')
# 确保有数据
service = get_prediction_service()
service.ensure_predictions_for_month(month)
# 获取统计
stats = PredictionModel.get_monthly_stats(month)
return {
"success": True,
"month": month,
"data": stats
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/accuracy/daily")
async def get_daily_accuracy(
month: Optional[str] = Query(None, description="月份 (YYYY-MM),默认当月"),
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(10, ge=1, le=100, description="每页条数")
):
"""获取每日准确率统计"""
try:
# 默认当月
if not month:
et_tz = ZoneInfo('America/New_York')
month = datetime.now(et_tz).strftime('%Y-%m')
# 确保有数据
service = get_prediction_service()
service.ensure_predictions_for_month(month)
# 获取统计
result = PredictionModel.get_daily_stats(month=month, page=page, limit=limit)
return {
"success": True,
"month": month,
**result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/accuracy/hourly/{date}")
async def get_hourly_accuracy(date: str):
"""获取指定日期的每小时准确率详情"""
try:
# 确保有数据
service = get_prediction_service()
service.ensure_predictions_for_date(date)
# 获取详情
details = PredictionModel.get_hourly_details(date)
return {
"success": True,
"date": date,
"data": details
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== 状态 API ====================
@app.get("/api/status")
async def get_status():
"""获取服务状态"""
et_tz = ZoneInfo('America/New_York')
now_et = datetime.now(et_tz)
return {
"success": True,
"status": "running",
"server_time": datetime.now().isoformat(),
"server_time_et": now_et.isoformat(),
"version": "1.0.0"
}
# ==================== 启动入口 ====================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+457
View File
@@ -0,0 +1,457 @@
"""
数据库模型和操作类 - MySQL
"""
import pymysql
import os
from typing import Optional, List, Dict
from datetime import datetime
from contextlib import contextmanager
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
load_dotenv()
# 时区定义
TZ_UTC = ZoneInfo('UTC')
TZ_ET = ZoneInfo('America/New_York') # 美东时间 (Polymarket)
TZ_CST = ZoneInfo('Asia/Shanghai') # 中国标准时间 (前端显示)
def convert_to_cst(timestamp_val) -> str:
"""
将时间戳转换为 CST 时间字符串
Args:
timestamp_val: datetime 对象或 ISO 格式时间戳字符串 (假设是 UTC)
Returns:
CST 时间字符串 (YYYY-MM-DD HH:MM:SS)
"""
if not timestamp_val:
return ""
try:
# 如果是 datetime 对象 (MySQL 返回)
if isinstance(timestamp_val, datetime):
dt = timestamp_val
else:
# 字符串格式
dt = datetime.fromisoformat(str(timestamp_val).replace('Z', '+00:00'))
# 如果没有时区信息,假设是 UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=TZ_UTC)
# 转换为 CST
dt_cst = dt.astimezone(TZ_CST)
return dt_cst.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
return str(timestamp_val)
@contextmanager
def get_db_connection():
"""获取 MySQL 数据库连接"""
conn = pymysql.connect(
host=os.getenv('MYSQL_HOST'),
port=int(os.getenv('MYSQL_PORT', 3306)),
user=os.getenv('MYSQL_USER'),
password=os.getenv('MYSQL_PASSWORD'),
database=os.getenv('MYSQL_DB'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
try:
yield conn
finally:
conn.close()
def _dict_from_row(row):
"""将数据库行转换为字典"""
if row is None:
return None
if isinstance(row, dict):
return row
return dict(row)
def init_model_predictions_table():
"""初始化 model_predictions 表"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS model_predictions (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp DATETIME NOT NULL,
timestamp_et VARCHAR(50),
date VARCHAR(10),
hour INT,
model_name VARCHAR(50) NOT NULL,
prediction INT,
probability DOUBLE,
actual_result INT,
is_correct INT,
open_price DOUBLE,
close_price DOUBLE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_hour_model (date, hour, model_name),
INDEX idx_date (date),
INDEX idx_model (model_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
''')
conn.commit()
# 初始化表
init_model_predictions_table()
class TradeModel:
"""交易记录模型"""
@staticmethod
def get_current_trade() -> Optional[Dict]:
"""获取最新的交易记录"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM trades
ORDER BY timestamp DESC
LIMIT 1
""")
row = cursor.fetchone()
result = _dict_from_row(row)
if result and result.get('timestamp'):
result['timestamp_cst'] = convert_to_cst(result['timestamp'])
return result
@staticmethod
def get_history(page: int = 1, limit: int = 10,
month: Optional[str] = None,
day: Optional[str] = None) -> Dict:
"""
获取历史交易记录
Args:
page: 页码
limit: 每页条数
month: 月份筛选 (YYYY-MM)
day: 日期筛选 (YYYY-MM-DD)
"""
offset = (page - 1) * limit
with get_db_connection() as conn:
cursor = conn.cursor()
# 构建查询条件
where_clauses = []
params = []
if month:
where_clauses.append("DATE_FORMAT(timestamp, '%%Y-%%m') = %s")
params.append(month)
if day:
where_clauses.append("DATE(timestamp) = %s")
params.append(day)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# 查询数据
sql = f"SELECT * FROM trades WHERE {where_sql} ORDER BY timestamp DESC LIMIT %s OFFSET %s"
cursor.execute(sql, params + [limit, offset])
rows = cursor.fetchall()
# 查询总数
count_sql = f"SELECT COUNT(*) as cnt FROM trades WHERE {where_sql}"
cursor.execute(count_sql, params)
result = cursor.fetchone()
total = result['cnt'] if result else 0
# 转换每条记录的时间戳
data_list = []
for row in rows:
item = _dict_from_row(row)
if item and item.get('timestamp'):
item['timestamp_cst'] = convert_to_cst(item['timestamp'])
data_list.append(item)
return {
"data": data_list,
"page": page,
"limit": limit,
"total": total,
"total_pages": (total + limit - 1) // limit if limit > 0 else 0
}
class PredictionModel:
"""预测记录模型"""
@staticmethod
def get_prediction(date: str, hour: int, model_name: str) -> Optional[Dict]:
"""获取指定时间和模型的预测记录"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM model_predictions
WHERE date = %s AND hour = %s AND model_name = %s
""", (date, hour, model_name))
row = cursor.fetchone()
return _dict_from_row(row)
@staticmethod
def save_prediction(data: Dict) -> int:
"""
保存预测记录
Args:
data: 预测数据字典
Returns:
插入的记录ID
"""
with get_db_connection() as conn:
cursor = conn.cursor()
sql = """
INSERT INTO model_predictions
(timestamp, timestamp_et, date, hour, model_name,
prediction, probability, actual_result, is_correct,
open_price, close_price)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
prediction = VALUES(prediction),
probability = VALUES(probability),
actual_result = VALUES(actual_result),
is_correct = VALUES(is_correct),
open_price = VALUES(open_price),
close_price = VALUES(close_price)
"""
cursor.execute(sql, (
data.get('timestamp'),
data.get('timestamp_et'),
data.get('date'),
data.get('hour'),
data.get('model_name'),
data.get('prediction'),
data.get('probability'),
data.get('actual_result'),
data.get('is_correct'),
data.get('open_price'),
data.get('close_price')
))
conn.commit()
return cursor.lastrowid or 0
@staticmethod
def update_actual_result(date: str, hour: int, model_name: str,
actual_result: int, close_price: float) -> bool:
"""更新实际结果"""
with get_db_connection() as conn:
cursor = conn.cursor()
# 先获取预测值
cursor.execute("""
SELECT prediction FROM model_predictions
WHERE date = %s AND hour = %s AND model_name = %s
""", (date, hour, model_name))
row = cursor.fetchone()
if not row:
return False
prediction = row['prediction']
is_correct = 1 if prediction == actual_result else 0
cursor.execute("""
UPDATE model_predictions
SET actual_result = %s, is_correct = %s, close_price = %s
WHERE date = %s AND hour = %s AND model_name = %s
""", (actual_result, is_correct, close_price, date, hour, model_name))
conn.commit()
return True
@staticmethod
def get_monthly_stats(month: str) -> Dict:
"""
获取月度统计
Args:
month: 月份 (YYYY-MM)
Returns:
各模型的统计数据
"""
with get_db_connection() as conn:
cursor = conn.cursor()
sql = """
SELECT
model_name,
COUNT(*) as total,
SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct,
SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as incorrect
FROM model_predictions
WHERE DATE_FORMAT(date, '%%Y-%%m') = %s
AND actual_result IS NOT NULL
GROUP BY model_name
"""
cursor.execute(sql, (month,))
rows = cursor.fetchall()
result = {}
for row in rows:
row_dict = _dict_from_row(row)
model = row_dict['model_name']
total = row_dict['total']
correct = row_dict['correct'] or 0
incorrect = row_dict['incorrect'] or 0
accuracy = (correct / total * 100) if total > 0 else 0
result[model] = {
"total": total,
"correct": correct,
"incorrect": incorrect,
"accuracy": round(accuracy, 2)
}
return result
@staticmethod
def get_daily_stats(month: str, page: int = 1, limit: int = 10) -> Dict:
"""
获取每日统计
Args:
month: 月份 (YYYY-MM)
page: 页码
limit: 每页条数
"""
with get_db_connection() as conn:
cursor = conn.cursor()
sql = """
SELECT
date,
model_name,
COUNT(*) as total,
SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct,
SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as incorrect
FROM model_predictions
WHERE DATE_FORMAT(date, '%%Y-%%m') = %s
AND actual_result IS NOT NULL
GROUP BY date, model_name
ORDER BY date DESC
"""
cursor.execute(sql, (month,))
rows = cursor.fetchall()
# 重组数据: 按日期合并不同模型的统计
daily_data = {}
for row in rows:
row_dict = _dict_from_row(row)
date = row_dict['date']
if date not in daily_data:
daily_data[date] = {"date": date}
model = row_dict['model_name']
total = row_dict['total']
correct = row_dict['correct'] or 0
incorrect = row_dict['incorrect'] or 0
accuracy = (correct / total * 100) if total > 0 else 0
daily_data[date][model] = {
"total": total,
"correct": correct,
"incorrect": incorrect,
"accuracy": round(accuracy, 2)
}
# 排序并分页
sorted_dates = sorted(daily_data.keys(), reverse=True)
total_count = len(sorted_dates)
offset = (page - 1) * limit
paginated_dates = sorted_dates[offset:offset + limit]
data = [daily_data[d] for d in paginated_dates]
return {
"data": data,
"page": page,
"limit": limit,
"total": total_count,
"total_pages": (total_count + limit - 1) // limit if limit > 0 else 0
}
@staticmethod
def get_hourly_details(date: str) -> List[Dict]:
"""
获取指定日期的每小时详情
Args:
date: 日期 (YYYY-MM-DD)
"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM model_predictions
WHERE date = %s
ORDER BY hour, model_name
""", (date,))
rows = cursor.fetchall()
# 按小时重组数据
hourly_data = {}
for row in rows:
row_dict = _dict_from_row(row)
hour = row_dict['hour']
if hour not in hourly_data:
# 将 timestamp 转换为 CST 时间
timestamp_cst = convert_to_cst(row_dict.get('timestamp', ''))
hourly_data[hour] = {
"hour": hour,
"timestamp_et": row_dict['timestamp_et'],
"timestamp_cst": timestamp_cst,
"open_price": row_dict['open_price'],
"close_price": row_dict['close_price'],
"actual_result": row_dict['actual_result'],
"models": {}
}
model = row_dict['model_name']
hourly_data[hour]["models"][model] = {
"prediction": row_dict['prediction'],
"probability": row_dict['probability'],
"is_correct": row_dict['is_correct']
}
# 按小时排序返回
return [hourly_data[h] for h in sorted(hourly_data.keys())]
@staticmethod
def get_missing_hours(date: str, model_name: str) -> List[int]:
"""
获取指定日期缺少预测的小时列表
Args:
date: 日期 (YYYY-MM-DD)
model_name: 模型名称
Returns:
缺少预测的小时列表
"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT hour FROM model_predictions
WHERE date = %s AND model_name = %s
""", (date, model_name))
rows = cursor.fetchall()
existing_hours = {row['hour'] for row in rows}
all_hours = set(range(24))
return sorted(list(all_hours - existing_hours))
+477
View File
@@ -0,0 +1,477 @@
"""
预测服务 - DLinear 和 TTM-R2 模型预测
"""
import os
import sys
import pickle
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Tuple
from zoneinfo import ZoneInfo
import torch
import numpy as np
import pandas as pd
import pandas_ta as ta
from sklearn.preprocessing import StandardScaler
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data.fetcher import BinanceDataFetcher
from api.models import PredictionModel
class ClassificationPreprocessor:
"""DLinear分类预处理器"""
def __init__(self, lookback: int = 168, forecast_horizon: int = 1):
self.lookback = lookback
self.forecast_horizon = forecast_horizon
self.scaler = StandardScaler()
self.is_fitted = False
def add_features(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['rsi'] = ta.rsi(df['close'], length=14)
macd = ta.macd(df['close'])
if macd is not None and not macd.empty:
df['macd'] = macd.iloc[:, 0]
df['macd_hist'] = macd.iloc[:, 1]
df['macd_signal'] = macd.iloc[:, 2]
return df
def create_labels(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['future_price'] = df['close'].shift(-self.forecast_horizon)
df['future_return'] = (df['future_price'] - df['close']) / df['close']
df['label'] = (df['future_return'] > 0).astype(int)
return df[:-self.forecast_horizon]
def preprocess(self, df: pd.DataFrame, fit: bool = True):
df = self.add_features(df)
df = self.create_labels(df)
df = df.dropna().reset_index(drop=True)
feature_cols = ['open', 'high', 'low', 'close', 'volume',
'rsi', 'macd', 'macd_hist', 'macd_signal']
if fit:
scaled_features = self.scaler.fit_transform(df[feature_cols])
self.is_fitted = True
self.feature_columns = feature_cols
else:
scaled_features = self.scaler.transform(df[feature_cols])
X, y = [], []
for i in range(len(scaled_features) - self.lookback):
X.append(scaled_features[i:i + self.lookback])
y.append(df['label'].iloc[i + self.lookback])
return np.array(X), np.array(y), df
class CustomUnpickler(pickle.Unpickler):
"""自定义反序列化器,用于处理 __main__ 模块的类"""
def find_class(self, module, name):
if module == "__main__":
if name == "ClassificationPreprocessor":
return ClassificationPreprocessor
return super().find_class(module, name)
class PredictionService:
"""预测服务 - 管理 DLinear 和 TTM-R2 模型"""
# 类级别的锁,确保多个请求不会同时生成预测
_prediction_lock = None
def __init__(self):
import threading
# 初始化锁
if PredictionService._prediction_lock is None:
PredictionService._prediction_lock = threading.Lock()
self.device = torch.device('mps' if torch.backends.mps.is_available() else
'cuda' if torch.cuda.is_available() else 'cpu')
self.et_tz = ZoneInfo('America/New_York')
self.fetcher = BinanceDataFetcher()
# 模型和预处理器
self.dlinear_model = None
self.dlinear_preprocessor = None
self.ttm_model = None
# 记录正在处理的日期和月份,避免重复处理
self._processing_dates = set()
self._processing_months = set()
self._load_models()
def _load_models(self):
"""加载模型"""
print("正在加载预测模型...")
# 加载 DLinear
from models.dlinear.model import DLinear
dlinear_path = './models/checkpoints/dlinear_classification_2026/best_model.pt'
preprocessor_path = './models/checkpoints/dlinear_classification_2026/preprocessor.pkl'
checkpoint = torch.load(dlinear_path, map_location=self.device, weights_only=False)
self.dlinear_model = DLinear(checkpoint['config'])
self.dlinear_model.load_state_dict(checkpoint['model_state_dict'])
self.dlinear_model = self.dlinear_model.to(self.device).eval()
with open(preprocessor_path, 'rb') as f:
try:
self.dlinear_preprocessor = pickle.load(f)
except AttributeError:
f.seek(0)
self.dlinear_preprocessor = CustomUnpickler(f).load()
print(f" ✓ DLinear 已加载")
# 加载 TTM-R2
from tsfm_public.models.tinytimemixer import TinyTimeMixerForPrediction
self.ttm_model = TinyTimeMixerForPrediction.from_pretrained(
"ibm-granite/granite-timeseries-ttm-r2",
trust_remote_code=True
)
self.ttm_model = self.ttm_model.to(self.device).eval()
print(f" ✓ TTM-R2 已加载")
print(f" 设备: {self.device}")
def _get_btc_data(self, days: int = 30) -> pd.DataFrame:
"""获取BTC历史数据"""
end_date = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
df = self.fetcher.fetch_ohlcv(
symbol='BTC',
timeframe='1h',
start_date=start_date,
end_date=end_date,
use_cache=False
)
return df
def predict_dlinear(self, df: pd.DataFrame) -> Tuple[int, float]:
"""
使用 DLinear 预测下一小时的涨跌
输入: df 包含历史K线数据 (最后一行是最新的已完成K线)
输出: 预测下一小时相对于 df 最后一行收盘价的涨跌
Returns:
(prediction, probability): 预测结果(0=跌, 1=涨)和概率
"""
# 预处理: 使用全部数据,不去掉最后一行
X, _, _ = self.dlinear_preprocessor.preprocess(df, fit=False)
if len(X) == 0:
return 0, 0.5
# 取最后一个窗口进行预测
X_last = torch.FloatTensor(X[-1:]).to(self.device)
with torch.no_grad():
logits = self.dlinear_model(X_last).cpu().numpy()
prob = 1 / (1 + np.exp(-logits))
prediction = int(prob.item() > 0.5)
probability = float(prob.item())
return prediction, probability
def predict_ttm(self, df: pd.DataFrame) -> Tuple[int, float]:
"""
使用 TTM-R2 预测下一小时的涨跌
输入: df 包含历史K线数据 (最后一行是最新的已完成K线)
输出: 预测下一小时相对于 df 最后一行收盘价的涨跌
Returns:
(prediction, probability): 预测结果(0=跌, 1=涨)和概率
"""
context_length = 512
all_prices = df['close'].values
# 使用最后 context_length 个价格
if len(all_prices) >= context_length:
context_data = all_prices[-context_length:].reshape(1, -1, 1)
else:
# 数据不足,用最后一个价格填充
padded = np.full(context_length, all_prices[-1])
padded[-len(all_prices):] = all_prices
context_data = padded.reshape(1, -1, 1)
# 基准价格: 最后一行的收盘价 (即预测目标小时的前一小时收盘价)
base_price = float(df['close'].iloc[-1])
context_tensor = torch.FloatTensor(context_data).to(self.device)
with torch.no_grad():
output = self.ttm_model(context_tensor)
pred_price = output.prediction_outputs.cpu().numpy()[0, 0, 0]
# 判断涨跌: 预测价格 vs 基准价格
prediction = 1 if pred_price > base_price else 0
# 计算伪概率: 基于价格变化百分比
price_pct = (pred_price - base_price) / base_price
# 将价格变化百分比映射到概率 (sigmoid)
probability = 1 / (1 + np.exp(-price_pct * 100))
return prediction, float(probability)
def predict_current_hour(self) -> Dict:
"""
预测当前小时的涨跌
逻辑:
- 获取最新的K线数据 (最后一行是最新已完成的K线)
- 预测下一小时相对于最后一行收盘价的涨跌
Returns:
预测结果字典
"""
df = self._get_btc_data()
# 获取当前时间 (美东)
now_et = datetime.now(self.et_tz)
current_date = now_et.strftime('%Y-%m-%d')
current_hour = now_et.hour
# 最后一个已完成的K线信息 (作为预测基准)
last_timestamp = df['timestamp'].iloc[-1]
last_close = float(df['close'].iloc[-1])
# 当前正在形成的K线信息
current_open = float(df['open'].iloc[-1])
current_price = float(df['close'].iloc[-1]) # 当前价格
results = {
"date": current_date,
"hour": current_hour,
"timestamp": str(last_timestamp),
"base_price": last_close, # 预测基准价格
"current_price": current_price,
"models": {}
}
# DLinear 预测 (预测下一小时相对于 last_close 的涨跌)
dlinear_pred, dlinear_prob = self.predict_dlinear(df)
results["models"]["DLinear"] = {
"prediction": dlinear_pred,
"probability": round(dlinear_prob, 4),
"direction": "UP" if dlinear_pred == 1 else "DOWN"
}
# TTM-R2 预测
ttm_pred, ttm_prob = self.predict_ttm(df)
results["models"]["TTM-R2"] = {
"prediction": ttm_pred,
"probability": round(ttm_prob, 4),
"direction": "UP" if ttm_pred == 1 else "DOWN"
}
return results
def ensure_predictions_for_date(self, date: str) -> None:
"""
确保指定日期有预测数据,如果没有则生成
Args:
date: 日期 (YYYY-MM-DD)
"""
# 使用锁避免并发冲突
with PredictionService._prediction_lock:
# 检查是否正在处理该日期
if date in self._processing_dates:
return
# 标记正在处理
self._processing_dates.add(date)
try:
# 检查是否有缺失的小时
for model_name in ["DLinear", "TTM-R2"]:
missing_hours = PredictionModel.get_missing_hours(date, model_name)
if not missing_hours:
continue
print(f"为 {date} 生成 {model_name} 预测 (缺失 {len(missing_hours)} 小时)...")
# 获取足够的历史数据
target_date = datetime.strptime(date, '%Y-%m-%d')
df = self._get_historical_data(target_date)
if df is None or len(df) < 200:
print(f" 警告: 数据不足,无法为 {date} 生成预测")
continue
# 为每个缺失的小时生成预测
for hour in missing_hours:
self._generate_prediction_for_hour(df, date, hour, model_name)
finally:
# 移除处理标记
with PredictionService._prediction_lock:
self._processing_dates.discard(date)
def _get_historical_data(self, target_date: datetime) -> Optional[pd.DataFrame]:
"""获取指定日期的历史数据"""
try:
start_date = (target_date - timedelta(days=30)).strftime('%Y-%m-%d')
end_date = (target_date + timedelta(days=2)).strftime('%Y-%m-%d')
df = self.fetcher.fetch_ohlcv(
symbol='BTC',
timeframe='1h',
start_date=start_date,
end_date=end_date,
use_cache=True
)
return df
except Exception as e:
print(f"获取历史数据失败: {e}")
return None
def _generate_prediction_for_hour(self, df: pd.DataFrame, date: str,
hour: int, model_name: str) -> None:
"""
为指定小时生成预测
预测逻辑:
- 预测目标: {date} {hour}:00 的K线收盘价涨跌
- 输入数据: {hour-1}:00 及以前的K线数据 (不包含目标小时)
- 涨跌判断: 预测价格 vs 前一小时收盘价 (即 {hour-1}:00 的 close)
- 正确率验证: 目标小时的 close vs 前一小时的 close
Args:
df: 历史K线数据 (UTC时间戳)
date: 目标日期 (美东时间 YYYY-MM-DD)
hour: 目标小时 (美东时间 0-23)
model_name: 模型名称
"""
try:
# 1. 构建目标时间 (美东时间 -> UTC)
target_time_et = pd.Timestamp(f"{date} {hour:02d}:00:00", tz='America/New_York')
target_time_utc = target_time_et.tz_convert('UTC').tz_localize(None)
# 2. 确保 timestamp 列是 datetime 类型
if 'timestamp_dt' not in df.columns:
df = df.copy()
df['timestamp_dt'] = pd.to_datetime(df['timestamp'])
# 3. 找到目标小时的K线 (用于验证)
target_mask = df['timestamp_dt'] == target_time_utc
if not target_mask.any():
# 目标小时数据不存在,跳过
return
target_row = df[target_mask].iloc[0]
target_open = float(target_row['open'])
target_close = float(target_row['close'])
# 4. 找到前一小时的K线 (用于预测输入和基准价格)
prev_time_utc = target_time_utc - pd.Timedelta(hours=1)
prev_mask = df['timestamp_dt'] == prev_time_utc
if not prev_mask.any():
# 前一小时数据不存在,跳过
return
prev_row = df[prev_mask].iloc[0]
prev_close = float(prev_row['close'])
# 5. 获取预测输入数据: 前一小时及以前的所有数据
input_mask = df['timestamp_dt'] <= prev_time_utc
df_input = df[input_mask].copy()
if len(df_input) < 200:
# 数据不足,跳过
return
# 6. 调用模型预测
if model_name == "DLinear":
prediction, probability = self.predict_dlinear(df_input)
else: # TTM-R2
prediction, probability = self.predict_ttm(df_input)
# 7. 判断实际涨跌 (目标小时收盘价 vs 前一小时收盘价)
actual_result = 1 if target_close > prev_close else 0
# 8. 判断预测是否正确
is_correct = 1 if prediction == actual_result else 0
# 9. 保存预测结果
prediction_data = {
"timestamp": target_time_utc.isoformat(),
"timestamp_et": target_time_et.isoformat(),
"date": date,
"hour": hour,
"model_name": model_name,
"prediction": prediction,
"probability": probability,
"actual_result": actual_result,
"is_correct": is_correct,
"open_price": target_open,
"close_price": target_close
}
PredictionModel.save_prediction(prediction_data)
except Exception as e:
print(f"生成预测失败 ({date} {hour}:00 {model_name}): {e}")
def ensure_predictions_for_month(self, month: str) -> None:
"""
确保指定月份有预测数据
Args:
month: 月份 (YYYY-MM)
"""
# 使用锁避免并发冲突
with PredictionService._prediction_lock:
# 检查是否正在处理该月份
if month in self._processing_months:
return
# 标记正在处理
self._processing_months.add(month)
try:
# 解析月份
year, mon = map(int, month.split('-'))
# 计算该月的日期范围
from calendar import monthrange
_, last_day = monthrange(year, mon)
# 对于过去的日期,确保有预测数据
today = datetime.now(self.et_tz).date()
for day in range(1, last_day + 1):
date = f"{year}-{mon:02d}-{day:02d}"
date_obj = datetime.strptime(date, '%Y-%m-%d').date()
# 只处理过去和今天的日期
if date_obj <= today:
self.ensure_predictions_for_date(date)
finally:
# 移除处理标记
with PredictionService._prediction_lock:
self._processing_months.discard(month)
# 全局单例
_prediction_service: Optional[PredictionService] = None
def get_prediction_service() -> PredictionService:
"""获取预测服务单例"""
global _prediction_service
if _prediction_service is None:
_prediction_service = PredictionService()
return _prediction_service
+252
View File
@@ -0,0 +1,252 @@
/**
* BTC 预测交易看板 - Vue 3 应用
*/
const { createApp } = Vue;
const app = createApp({
data() {
return {
// 状态
loading: false,
activeTab: 'trades',
serverTime: '',
// 当前交易
currentTrade: null,
// 交易历史
tradeHistory: {
data: [],
page: 1,
limit: 10,
total: 0,
total_pages: 0
},
tradeFilter: {
month: '',
day: ''
},
// 准确率
accuracyFilter: {
month: this.getCurrentMonth()
},
monthlyStats: {},
dailyStats: {
data: [],
page: 1,
limit: 10,
total: 0,
total_pages: 0
},
// 弹窗数据
hourlyModal: {
date: '',
data: []
}
};
},
mounted() {
// 初始化月份筛选
this.accuracyFilter.month = this.getCurrentMonth();
// 加载数据
this.loadCurrentTrade();
this.loadTradeHistory(1);
// 每分钟更新时间
this.updateServerTime();
setInterval(() => this.updateServerTime(), 60000);
},
methods: {
// ==================== 工具方法 ====================
getCurrentMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
},
updateServerTime() {
const now = new Date();
this.serverTime = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
formatTime(timestamp, timestamp_cst) {
// 优先使用后端返回的 CST 时间
if (timestamp_cst) {
return timestamp_cst;
}
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
formatPercent(value) {
if (value === null || value === undefined) return '-';
return `${(value * 100).toFixed(1)}%`;
},
getPredictionClass(prediction) {
if (prediction === 'UP') return 'text-success fw-bold';
if (prediction === 'DOWN') return 'text-danger fw-bold';
return '';
},
getStatusClass(status) {
switch (status) {
case 'success': return 'text-success';
case 'test': return 'text-info';
case 'failure': return 'text-danger';
default: return 'text-muted';
}
},
getStatusText(status) {
switch (status) {
case 'success': return '成功';
case 'test': return '测试';
case 'failure': return '失败';
case 'in_progress': return '进行中';
default: return status || '-';
}
},
getAccuracyClass(accuracy) {
if (accuracy >= 60) return 'text-success fw-bold';
if (accuracy >= 50) return 'text-warning';
return 'text-danger';
},
// ==================== API 调用 ====================
async fetchAPI(url) {
try {
const response = await fetch(url);
const data = await response.json();
if (!data.success) {
console.error('API Error:', data);
}
return data;
} catch (error) {
console.error('Fetch Error:', error);
return { success: false, error: error.message };
}
},
async loadCurrentTrade() {
const data = await this.fetchAPI('/api/trades/current');
if (data.success) {
this.currentTrade = data.data;
}
},
async loadTradeHistory(page) {
if (page < 1) return;
this.loading = true;
let url = `/api/trades/history?page=${page}&limit=${this.tradeHistory.limit}`;
if (this.tradeFilter.month) {
url += `&month=${this.tradeFilter.month}`;
}
if (this.tradeFilter.day) {
url += `&day=${this.tradeFilter.day}`;
}
const data = await this.fetchAPI(url);
if (data.success) {
this.tradeHistory = {
data: data.data,
page: data.page,
limit: data.limit,
total: data.total,
total_pages: data.total_pages
};
}
this.loading = false;
},
clearTradeFilter() {
this.tradeFilter.month = '';
this.tradeFilter.day = '';
this.loadTradeHistory(1);
},
async loadAccuracyData() {
this.loading = true;
// 加载月度统计
const monthlyData = await this.fetchAPI(
`/api/accuracy/monthly?month=${this.accuracyFilter.month}`
);
if (monthlyData.success) {
this.monthlyStats = monthlyData.data || {};
}
// 加载每日统计
await this.loadDailyStats(1);
this.loading = false;
},
async loadDailyStats(page) {
if (page < 1) return;
const data = await this.fetchAPI(
`/api/accuracy/daily?month=${this.accuracyFilter.month}&page=${page}&limit=10`
);
if (data.success) {
this.dailyStats = {
data: data.data,
page: data.page,
limit: data.limit,
total: data.total,
total_pages: data.total_pages
};
}
},
async showHourlyDetails(date) {
this.loading = true;
this.hourlyModal.date = date;
const data = await this.fetchAPI(`/api/accuracy/hourly/${date}`);
if (data.success) {
this.hourlyModal.data = data.data || [];
}
this.loading = false;
// 显示弹窗
const modal = new bootstrap.Modal(document.getElementById('hourlyModal'));
modal.show();
}
},
watch: {
activeTab(newTab) {
if (newTab === 'accuracy' && !this.dailyStats.data.length) {
this.loadAccuracyData();
}
}
}
});
app.mount('#app');
+414
View File
@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BTC 预测交易看板</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
<div id="app">
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="#">
<i class="bi bi-currency-bitcoin"></i> BTC 预测交易看板
</a>
<span class="navbar-text text-light">
<i class="bi bi-clock"></i> {{ serverTime }}
</span>
</div>
</nav>
<!-- 主容器 -->
<div class="container mt-4">
<!-- Tab 导航 -->
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'trades' }"
@click="activeTab = 'trades'">
<i class="bi bi-graph-up"></i> 交易详情
</button>
</li>
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'accuracy' }"
@click="activeTab = 'accuracy'">
<i class="bi bi-bullseye"></i> 模型准确率
</button>
</li>
</ul>
<!-- 交易详情 Tab -->
<div v-show="activeTab === 'trades'">
<!-- 当前交易卡片 -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<i class="bi bi-lightning-charge"></i> 当前小时交易
</div>
<div class="card-body">
<div v-if="currentTrade" class="row">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label">市场</div>
<div class="stat-value small">{{ currentTrade.market_title || '-' }}</div>
</div>
</div>
<div class="col-md-2">
<div class="stat-card">
<div class="stat-label">预测方向</div>
<div class="stat-value" :class="getPredictionClass(currentTrade.prediction)">
{{ currentTrade.prediction || '-' }}
</div>
</div>
</div>
<div class="col-md-2">
<div class="stat-card">
<div class="stat-label">概率</div>
<div class="stat-value">{{ formatPercent(currentTrade.probability) }}</div>
</div>
</div>
<div class="col-md-2">
<div class="stat-card">
<div class="stat-label">价格</div>
<div class="stat-value">${{ currentTrade.price?.toFixed(3) || '-' }}</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label">状态</div>
<div class="stat-value" :class="getStatusClass(currentTrade.status)">
{{ getStatusText(currentTrade.status) }}
</div>
</div>
</div>
</div>
<div v-else class="text-center text-muted py-4">
<i class="bi bi-inbox"></i> 暂无交易记录
</div>
</div>
</div>
<!-- 历史交易 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history"></i> 历史交易记录</span>
<div class="d-flex gap-2">
<input type="month" class="form-control form-control-sm"
v-model="tradeFilter.month" @change="loadTradeHistory(1)"
style="width: 150px;">
<input type="date" class="form-control form-control-sm"
v-model="tradeFilter.day" @change="loadTradeHistory(1)"
style="width: 150px;">
<button class="btn btn-sm btn-outline-secondary" @click="clearTradeFilter">
<i class="bi bi-x-circle"></i> 清除
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0">
<thead class="table-light">
<tr>
<th>时间 (北京)</th>
<th>市场</th>
<th>预测</th>
<th>概率</th>
<th>价格</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="trade in tradeHistory.data" :key="trade.id">
<td>{{ formatTime(trade.timestamp, trade.timestamp_cst) }}</td>
<td class="text-truncate" style="max-width: 200px;">
{{ trade.market_title }}
</td>
<td :class="getPredictionClass(trade.prediction)">
<i :class="trade.prediction === 'UP' ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ trade.prediction }}
</td>
<td>{{ formatPercent(trade.probability) }}</td>
<td>${{ trade.price?.toFixed(3) }}</td>
<td :class="getStatusClass(trade.status)">
{{ getStatusText(trade.status) }}
</td>
</tr>
<tr v-if="!tradeHistory.data?.length">
<td colspan="6" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="text-muted">共 {{ tradeHistory.total }} 条</span>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item" :class="{ disabled: tradeHistory.page <= 1 }">
<a class="page-link" href="#" @click.prevent="loadTradeHistory(tradeHistory.page - 1)">
上一页
</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ tradeHistory.page }} / {{ tradeHistory.total_pages }}</span>
</li>
<li class="page-item" :class="{ disabled: tradeHistory.page >= tradeHistory.total_pages }">
<a class="page-link" href="#" @click.prevent="loadTradeHistory(tradeHistory.page + 1)">
下一页
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<!-- 模型准确率 Tab -->
<div v-show="activeTab === 'accuracy'">
<!-- 月份筛选 -->
<div class="mb-4">
<div class="d-flex gap-2 align-items-center">
<label class="form-label mb-0">选择月份:</label>
<input type="month" class="form-control" v-model="accuracyFilter.month"
@change="loadAccuracyData" style="width: 180px;">
</div>
</div>
<!-- 当月汇总卡片 -->
<div class="row mb-4">
<!-- DLinear 卡片 -->
<div class="col-md-6">
<div class="card h-100 border-primary">
<div class="card-header bg-primary text-white">
<i class="bi bi-graph-up-arrow"></i> DLinear 模型
</div>
<div class="card-body">
<div class="row text-center" v-if="monthlyStats.DLinear">
<div class="col-3">
<div class="stat-number">{{ monthlyStats.DLinear.total }}</div>
<div class="stat-label">预测次数</div>
</div>
<div class="col-3">
<div class="stat-number text-success">{{ monthlyStats.DLinear.correct }}</div>
<div class="stat-label">正确</div>
</div>
<div class="col-3">
<div class="stat-number text-danger">{{ monthlyStats.DLinear.incorrect }}</div>
<div class="stat-label">错误</div>
</div>
<div class="col-3">
<div class="stat-number text-primary">{{ monthlyStats.DLinear.accuracy }}%</div>
<div class="stat-label">准确率</div>
</div>
</div>
<div v-else class="text-center text-muted py-3">
暂无数据
</div>
</div>
</div>
</div>
<!-- TTM-R2 卡片 -->
<div class="col-md-6">
<div class="card h-100 border-success">
<div class="card-header bg-success text-white">
<i class="bi bi-cpu"></i> TTM-R2 模型
</div>
<div class="card-body">
<div class="row text-center" v-if="monthlyStats['TTM-R2']">
<div class="col-3">
<div class="stat-number">{{ monthlyStats['TTM-R2'].total }}</div>
<div class="stat-label">预测次数</div>
</div>
<div class="col-3">
<div class="stat-number text-success">{{ monthlyStats['TTM-R2'].correct }}</div>
<div class="stat-label">正确</div>
</div>
<div class="col-3">
<div class="stat-number text-danger">{{ monthlyStats['TTM-R2'].incorrect }}</div>
<div class="stat-label">错误</div>
</div>
<div class="col-3">
<div class="stat-number text-primary">{{ monthlyStats['TTM-R2'].accuracy }}%</div>
<div class="stat-label">准确率</div>
</div>
</div>
<div v-else class="text-center text-muted py-3">
暂无数据
</div>
</div>
</div>
</div>
</div>
<!-- 每日准确率表格 -->
<div class="card">
<div class="card-header">
<i class="bi bi-calendar3"></i> 每日准确率
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>日期</th>
<th>DLinear 预测</th>
<th>DLinear 准确率</th>
<th>TTM-R2 预测</th>
<th>TTM-R2 准确率</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="day in dailyStats.data" :key="day.date">
<td>{{ day.date }}</td>
<td>
<span v-if="day.DLinear">
{{ day.DLinear.correct }}/{{ day.DLinear.total }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="day.DLinear" :class="getAccuracyClass(day.DLinear.accuracy)">
{{ day.DLinear.accuracy }}%
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="day['TTM-R2']">
{{ day['TTM-R2'].correct }}/{{ day['TTM-R2'].total }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="day['TTM-R2']" :class="getAccuracyClass(day['TTM-R2'].accuracy)">
{{ day['TTM-R2'].accuracy }}%
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<button class="btn btn-sm btn-outline-primary"
@click="showHourlyDetails(day.date)">
<i class="bi bi-eye"></i> 查看
</button>
</td>
</tr>
<tr v-if="!dailyStats.data?.length">
<td colspan="6" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="text-muted">共 {{ dailyStats.total }} 天</span>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item" :class="{ disabled: dailyStats.page <= 1 }">
<a class="page-link" href="#" @click.prevent="loadDailyStats(dailyStats.page - 1)">
上一页
</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ dailyStats.page }} / {{ dailyStats.total_pages }}</span>
</li>
<li class="page-item" :class="{ disabled: dailyStats.page >= dailyStats.total_pages }">
<a class="page-link" href="#" @click.prevent="loadDailyStats(dailyStats.page + 1)">
下一页
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
<!-- 每小时详情弹窗 -->
<div class="modal fade" id="hourlyModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clock"></i> {{ hourlyModal.date }} 每小时预测详情
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th>时间 (北京)</th>
<th>实际涨跌</th>
<th>DLinear</th>
<th>TTM-R2</th>
</tr>
</thead>
<tbody>
<tr v-for="hour in hourlyModal.data" :key="hour.hour">
<td>{{ hour.timestamp_cst || (hour.hour + ':00') }}</td>
<td :class="hour.actual_result === 1 ? 'text-success' : 'text-danger'">
<i :class="hour.actual_result === 1 ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ hour.actual_result === 1 ? 'UP' : 'DOWN' }}
</td>
<td>
<span v-if="hour.models?.DLinear"
:class="hour.models.DLinear.is_correct ? 'text-success' : 'text-danger'">
<i :class="hour.models.DLinear.prediction === 1 ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ hour.models.DLinear.prediction === 1 ? 'UP' : 'DOWN' }}
<i :class="hour.models.DLinear.is_correct ? 'bi-check-circle' : 'bi-x-circle'"></i>
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="hour.models?.['TTM-R2']"
:class="hour.models['TTM-R2'].is_correct ? 'text-success' : 'text-danger'">
<i :class="hour.models['TTM-R2'].prediction === 1 ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ hour.models['TTM-R2'].prediction === 1 ? 'UP' : 'DOWN' }}
<i :class="hour.models['TTM-R2'].is_correct ? 'bi-check-circle' : 'bi-x-circle'"></i>
</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
<tr v-if="!hourlyModal.data?.length">
<td colspan="4" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- Loading 遮罩 -->
<div v-if="loading" class="loading-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
</div>
</div>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- Vue 3 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.15/dist/vue.global.prod.js"></script>
<!-- App JS -->
<script src="/static/app.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
/**
* BTC 预测交易看板 - 自定义样式
*/
/* 全局样式 */
body {
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 导航栏 */
.navbar-brand {
font-weight: 600;
}
.navbar-brand i {
color: #f7931a;
}
/* Tab 样式 */
.nav-tabs .nav-link {
color: #6c757d;
border: none;
padding: 0.75rem 1.5rem;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
border-bottom: 3px solid #0d6efd;
background: transparent;
}
.nav-tabs .nav-link:hover:not(.active) {
border-bottom: 3px solid #dee2e6;
}
/* 卡片样式 */
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
}
.card-header {
font-weight: 600;
border-radius: 0.5rem 0.5rem 0 0 !important;
}
/* 统计卡片 */
.stat-card {
padding: 1rem;
text-align: center;
background: #f8f9fa;
border-radius: 0.5rem;
height: 100%;
}
.stat-label {
font-size: 0.75rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
}
.stat-number {
font-size: 1.75rem;
font-weight: 700;
}
/* 表格样式 */
.table {
margin-bottom: 0;
}
.table th {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #6c757d;
border-bottom: 2px solid #dee2e6;
}
.table td {
vertical-align: middle;
}
.table-hover tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
/* 涨跌颜色 */
.text-success {
color: #198754 !important;
}
.text-danger {
color: #dc3545 !important;
}
/* 分页 */
.pagination {
margin-bottom: 0;
}
.page-link {
color: #0d6efd;
}
.page-item.disabled .page-link {
color: #6c757d;
}
/* Loading 遮罩 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
/* 弹窗 */
.modal-content {
border-radius: 0.5rem;
}
.modal-header {
background: #f8f9fa;
border-radius: 0.5rem 0.5rem 0 0;
}
/* 响应式调整 */
@media (max-width: 768px) {
.stat-card {
margin-bottom: 1rem;
}
.card-header .d-flex {
flex-direction: column;
gap: 0.5rem !important;
}
.nav-tabs .nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
/* 准确率颜色 */
.accuracy-high {
color: #198754;
font-weight: 600;
}
.accuracy-medium {
color: #fd7e14;
font-weight: 600;
}
.accuracy-low {
color: #dc3545;
font-weight: 600;
}
/* 边框颜色增强 */
.border-primary {
border-color: #0d6efd !important;
border-width: 2px !important;
}
.border-success {
border-color: #198754 !important;
border-width: 2px !important;
}
/* 按钮样式 */
.btn-outline-primary:hover {
color: #fff;
}
/* 截断文本 */
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 间距调整 */
.gap-2 {
gap: 0.5rem !important;
}
+104 -8
View File
@@ -34,6 +34,7 @@ import httpx
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from data.fetcher import BinanceDataFetcher
from data.trading_db import TradingDatabase
load_dotenv()
@@ -186,6 +187,9 @@ class PolymarketBTCTrader:
# 数据获取器
self.fetcher = BinanceDataFetcher()
# 数据库
self.db = TradingDatabase()
# CLOB客户端(用于下单)
self.clob_client = None
if self.private_key and not self.dry_run:
@@ -230,6 +234,42 @@ class PolymarketBTCTrader:
logger.warning(f"⚠️ CLOB客户端初始化失败: {e}")
self.clob_client = None
def _log_to_db(self, result: Dict, market: Optional[Dict]):
"""记录交易结果到数据库"""
try:
# 确定状态
status = 'failure'
if self.dry_run:
status = 'test'
elif result.get('success'):
status = 'success'
elif result.get('error'):
status = 'failure'
elif result.get('prediction'): # 有预测但未交易(如市场未找到)
status = 'in_progress' # 或者 failure? 未找到市场算 failure
if result.get('error'):
status = 'failure'
log_data = {
'timestamp': result.get('timestamp'),
'market_title': result.get('market'),
'market_slug': market.get('slug') if market else result.get('market_slug'),
'model_name': 'DLinear',
'prediction': result.get('prediction'),
'probability': result.get('probability'),
'action': result.get('side'),
'price': result.get('price'),
'amount': result.get('amount'),
'size': result.get('size'),
'status': status,
'error_message': result.get('error'),
'raw_data': result
}
self.db.log_trade(log_data)
logger.info(f"💾 交易记录已保存到数据库")
except Exception as e:
logger.error(f"❌ 保存数据库失败: {e}")
def generate_market_slug(self, target_time: Optional[datetime] = None) -> str:
"""
生成市场slug
@@ -598,7 +638,12 @@ class PolymarketBTCTrader:
return prediction, probability
def run_at_hour_start(self, delay_seconds: float = 2.0) -> Dict:
def run_once(self) -> Dict:
"""运行一次交易流程(不等待整点)"""
logger.info("运行单次交易...")
return self.run_at_hour_start(delay_seconds=0)
def run_at_hour_start(self, delay_seconds: float = 2.0, timeout_minutes: int = 50) -> Dict:
"""
整点时执行的交易流程
@@ -606,11 +651,13 @@ class PolymarketBTCTrader:
2. 获取最新K线
3. DLinear预测当前整点的收盘涨跌
4. 获取对应的Polymarket市场
5. 检查价格是否≤max_price
6. 下单
5. 循环检查价格:
- 如果价格 <= max_price: 下单并结束
- 如果价格 > max_price: 等待并重试(直到超时)
Args:
delay_seconds: 整点后延迟秒数
timeout_minutes: 价格等待超时时间(分钟)
Returns:
执行结果
@@ -621,6 +668,7 @@ class PolymarketBTCTrader:
logger.info(f" 模式: {'🔸 模拟' if self.dry_run else '💰 真实交易'}")
logger.info(f" 最大下注: ${self.max_bet:.2f} USDC")
logger.info(f" 最高价格: ${self.max_price:.2f}")
logger.info(f" 等待超时: {timeout_minutes}分钟")
result = {
'timestamp': datetime.now().isoformat(),
@@ -633,24 +681,75 @@ class PolymarketBTCTrader:
result['prediction'] = 'UP' if prediction == 1 else 'DOWN'
result['probability'] = probability if prediction == 1 else 1 - probability
# 2. 获取当前整点的市场(不是下一个整点)
# 2. 获取初始市场
market = self.get_current_hourly_market()
if not market:
result['error'] = '未找到市场'
logger.error(f"❌ {result['error']}")
self._log_to_db(result, None)
return result
result['market'] = market['title']
result['market_slug'] = market['slug']
if market['closed']:
result['error'] = '市场已关闭'
logger.error(f"❌ {result['error']}")
self._log_to_db(result, market)
return result
# 3. 执行交易
# 检查是否已交易过该市场
check_status = 'test' if self.dry_run else 'success'
if self.db.check_existing_trade(market['slug'], status=check_status):
result['error'] = '该市场已存在成功交易记录'
logger.warning(f"⚠️ {result['error']}, 跳过交易")
return result
# 3. 循环检查价格并交易
start_time = datetime.now()
end_time = start_time + timedelta(minutes=timeout_minutes)
logger.info(f"🔄 开始价格监控 (预测: {'UP' if prediction == 1 else 'DOWN'})...")
while datetime.now() < end_time:
# 刷新价格
market = self.refresh_prices()
if not market:
logger.warning("无法获取市场价格,重试中...")
time.sleep(10)
continue
# 检查价格
price_idx = 0 if prediction == 1 else 1
current_price = market['outcome_prices'][price_idx]
logger.info(f" 当前价格: ${current_price:.3f} (目标: <= ${self.max_price:.2f})")
if current_price <= self.max_price:
# 价格符合,执行交易
logger.info("✅ 价格满足条件,正在下单...")
trade_result = self.execute_trade(prediction, market)
result.update(trade_result)
# 记录数据库
self._log_to_db(result, market)
logger.info("=" * 60)
return result
else:
# 价格过高,等待
wait_time = 30 # 等待30秒
elapsed = int((datetime.now() - start_time).total_seconds())
logger.info(f"⏳ 价格过高,继续等待... (已耗时: {elapsed}s)")
time.sleep(wait_time)
# 超时
result['error'] = f'等待价格超时 ({timeout_minutes}分钟)'
result['price'] = market['outcome_prices'][0] if prediction == 1 else market['outcome_prices'][1]
result['side'] = 'UP' if prediction == 1 else 'DOWN'
logger.error(f"❌ {result['error']}")
self._log_to_db(result, market)
logger.info("=" * 60)
return result
@@ -758,9 +857,6 @@ class PolymarketBTCTrader:
# 启动时进行一次预测(不下单,仅显示)
self._show_initial_prediction()
# 定时刷新价格
schedule.every(interval_minutes).minutes.do(self.refresh_prices)
# 每小时整点后执行交易
def hourly_trade():
self.run_at_hour_start(delay_seconds=delay_seconds)
+77 -59
View File
@@ -1,139 +1,157 @@
import sqlite3
import pymysql
import pandas as pd
from pathlib import Path
from typing import Optional, List, Tuple
from typing import Optional
from contextlib import contextmanager
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, text
load_dotenv()
class DatabaseManager:
"""Manages SQLite database for storing OHLCV data."""
"""管理 OHLCV 数据的 MySQL 数据库"""
def __init__(self, db_path: str = "./data/market_data.db"):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def __init__(self, db_path: str = None):
"""初始化数据库管理器 (db_path 参数已废弃,仅为兼容性保留)"""
self._engine = None
self.initialize_db()
def _get_sqlalchemy_engine(self):
"""获取 SQLAlchemy 引擎(用于 pandas read_sql)"""
if self._engine is None:
host = os.getenv('MYSQL_HOST')
port = os.getenv('MYSQL_PORT', 3306)
user = os.getenv('MYSQL_USER')
password = os.getenv('MYSQL_PASSWORD')
database = os.getenv('MYSQL_DB')
self._engine = create_engine(
f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset=utf8mb4"
)
return self._engine
@contextmanager
def get_connection(self):
"""Context manager for database connection."""
conn = sqlite3.connect(self.db_path)
"""获取 MySQL 数据库连接"""
conn = pymysql.connect(
host=os.getenv('MYSQL_HOST'),
port=int(os.getenv('MYSQL_PORT', 3306)),
user=os.getenv('MYSQL_USER'),
password=os.getenv('MYSQL_PASSWORD'),
database=os.getenv('MYSQL_DB'),
charset='utf8mb4',
)
try:
yield conn
finally:
conn.close()
def initialize_db(self):
"""Create tables if they don't exist."""
"""创建表(如果不存在)"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Create OHLCV table
# Composite primary key ensures uniqueness for a specific candle
cursor.execute("""
CREATE TABLE IF NOT EXISTS ohlcv (
timestamp DATETIME,
symbol TEXT,
timeframe TEXT,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
PRIMARY KEY (symbol, timeframe, timestamp)
)
symbol VARCHAR(20),
timeframe VARCHAR(10),
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume DOUBLE,
PRIMARY KEY (symbol, timeframe, timestamp),
INDEX idx_ohlcv_time (symbol, timeframe, timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
# Create index for faster querying by time range
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_ohlcv_time
ON ohlcv (symbol, timeframe, timestamp)
""")
conn.commit()
def save_ohlcv(self, df: pd.DataFrame, symbol: str, timeframe: str):
"""Save OHLCV data to database (Upsert)."""
"""保存 OHLCV 数据到数据库 (Upsert)"""
if df.empty:
return
# Ensure timestamp is datetime
# 确保 timestamp 是 datetime 类型
df = df.copy()
if not pd.api.types.is_datetime64_any_dtype(df['timestamp']):
df['timestamp'] = pd.to_datetime(df['timestamp'])
# Convert to string for SQLite compatibility (ISO 8601)
# 转换为字符串格式
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
# Add metadata columns if they don't exist
# 添加元数据列
if 'symbol' not in df.columns:
df['symbol'] = symbol
if 'timeframe' not in df.columns:
df['timeframe'] = timeframe
# Prepare data for insertion (ensure order)
records = df[['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']].to_records(index=False)
cols = ['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']
data = [tuple(row) for row in df[cols].values]
with self.get_connection() as conn:
cursor = conn.cursor()
# efficiently insert or replace
# sqlite3 adapter handles datetime objects correctly
cursor.executemany("""
INSERT OR REPLACE INTO ohlcv
sql = """
REPLACE INTO ohlcv
(timestamp, symbol, timeframe, open, high, low, close, volume)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", records)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.executemany(sql, data)
conn.commit()
def get_ohlcv(self, symbol: str, timeframe: str, start_date: Optional[str] = None, end_date: Optional[str] = None) -> pd.DataFrame:
"""Retrieve OHLCV data from database."""
"""从数据库获取 OHLCV 数据"""
query = """
SELECT timestamp, open, high, low, close, volume
FROM ohlcv
WHERE symbol = ? AND timeframe = ?
WHERE symbol = :p0 AND timeframe = :p1
"""
params = [symbol, timeframe]
params = {'p0': symbol, 'p1': timeframe}
param_idx = 2
if start_date:
query += " AND timestamp >= ?"
params.append(start_date)
query += f" AND timestamp >= :p{param_idx}"
params[f'p{param_idx}'] = start_date
param_idx += 1
if end_date:
# Handle 'YYYY-MM-DD' vs full timestamp
if len(str(end_date)) == 10: # YYYY-MM-DD
query += " AND timestamp < date(?, '+1 day')"
# 处理 'YYYY-MM-DD' 格式
if len(str(end_date)) == 10:
query += f" AND timestamp < DATE_ADD(:p{param_idx}, INTERVAL 1 DAY)"
else:
query += " AND timestamp <= ?"
params.append(end_date)
query += f" AND timestamp <= :p{param_idx}"
params[f'p{param_idx}'] = end_date
query += " ORDER BY timestamp ASC"
with self.get_connection() as conn:
df = pd.read_sql_query(query, conn, params=params, parse_dates=['timestamp'])
engine = self._get_sqlalchemy_engine()
with engine.connect() as conn:
df = pd.read_sql_query(text(query), conn, params=params, parse_dates=['timestamp'])
return df
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
"""Get the latest timestamp for a symbol/timeframe."""
"""获取指定 symbol/timeframe 的最新时间戳"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT MAX(timestamp)
FROM ohlcv
WHERE symbol = ? AND timeframe = ?
WHERE symbol = %s AND timeframe = %s
""", (symbol, timeframe))
result = cursor.fetchone()
return pd.to_datetime(result[0]) if result[0] else None
return pd.to_datetime(result[0]) if result and result[0] else None
def get_earliest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
"""Get the earliest timestamp for a symbol/timeframe."""
"""获取指定 symbol/timeframe 的最早时间戳"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT MIN(timestamp)
FROM ohlcv
WHERE symbol = ? AND timeframe = ?
WHERE symbol = %s AND timeframe = %s
""", (symbol, timeframe))
result = cursor.fetchone()
return pd.to_datetime(result[0]) if result[0] else None
return pd.to_datetime(result[0]) if result and result[0] else None
+110
View File
@@ -0,0 +1,110 @@
import pymysql
from datetime import datetime
import json
import os
from contextlib import contextmanager
from dotenv import load_dotenv
load_dotenv()
class TradingDatabase:
"""交易数据库管理类 - 用于记录交易历史和预测结果 (MySQL)"""
def __init__(self, db_path=None):
"""初始化 (db_path 参数已废弃,仅为兼容性保留)"""
self._init_db()
@contextmanager
def get_connection(self):
"""获取 MySQL 数据库连接"""
conn = pymysql.connect(
host=os.getenv('MYSQL_HOST'),
port=int(os.getenv('MYSQL_PORT', 3306)),
user=os.getenv('MYSQL_USER'),
password=os.getenv('MYSQL_PASSWORD'),
database=os.getenv('MYSQL_DB'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
try:
yield conn
finally:
conn.close()
def _init_db(self):
"""初始化数据库表结构"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS trades (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp DATETIME,
market_title VARCHAR(255),
market_slug VARCHAR(255),
model_name VARCHAR(50),
prediction VARCHAR(10),
probability DOUBLE,
action VARCHAR(10),
price DOUBLE,
amount DOUBLE,
size DOUBLE,
status VARCHAR(20),
error_message TEXT,
raw_data JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_market_slug (market_slug),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
''')
conn.commit()
def log_trade(self, trade_data: dict):
"""记录交易信息"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = trade_data.get('timestamp', datetime.now().isoformat())
raw_data_json = json.dumps(trade_data.get('raw_data', {}), ensure_ascii=False)
sql = '''
INSERT INTO trades (
timestamp, market_title, market_slug, model_name,
prediction, probability, action, price, amount, size,
status, error_message, raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
'''
cursor.execute(sql, (
timestamp,
trade_data.get('market_title'),
trade_data.get('market_slug'),
trade_data.get('model_name'),
trade_data.get('prediction'),
trade_data.get('probability'),
trade_data.get('action'),
trade_data.get('price'),
trade_data.get('amount'),
trade_data.get('size'),
trade_data.get('status'),
trade_data.get('error_message'),
raw_data_json
))
conn.commit()
except Exception as e:
print(f"写入数据库失败: {e}")
def check_existing_trade(self, market_slug: str, status: str = 'success') -> bool:
"""检查指定市场是否已有成功交易记录"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
sql = 'SELECT COUNT(*) as cnt FROM trades WHERE market_slug = %s AND status = %s'
cursor.execute(sql, (market_slug, status))
result = cursor.fetchone()
count = result['cnt'] if result else 0
return count > 0
except Exception as e:
print(f"检查已存在交易失败: {e}")
return False
-63
View File
@@ -1,63 +0,0 @@
name: crypto_pred
channels:
- pytorch
- conda-forge
- defaults
dependencies:
# Python
- python=3.10
# PyTorch (Mac M1 支持)
- pytorch
- torchvision
- torchaudio
# 数据处理
- pandas>=2.0.0
- numpy>=1.24.0
- scikit-learn>=1.3.0
# 开发工具
- jupyter
- ipython
- tqdm
# 可视化
- matplotlib
- seaborn
# 通过pip安装的包
- pip
- pip:
# Binance API
- python-binance>=1.0.19
# 环境变量
- python-dotenv>=1.0.0
# 时区处理
- pytz
# ONNX
- onnx>=1.14.0
- onnxruntime>=1.15.0
# 技术指标
- pandas-ta>=0.3.14b
# 模型库
- darts>=0.27.0
- u8darts[torch]>=0.27.0
- tsfm_public>=0.2.0
- gluonts[torch]>=0.14.0
# FastAPI
- fastapi>=0.104.0
- uvicorn[standard]>=0.24.0
- pydantic>=2.0.0
- python-multipart>=0.0.6
# 测试
- pytest>=7.4.0
- black>=23.0.0
- flake8>=6.0.0
+5
View File
@@ -38,3 +38,8 @@ flake8>=6.0.0
# Utilities
tqdm>=4.66.0
# Database
pymysql>=1.1.0
sqlalchemy>=2.0.0
cryptography>=41.0.0
-67
View File
@@ -1,67 +0,0 @@
"""Migrate existing CSV cache data to SQLite database."""
import sys
import os
from pathlib import Path
import re
# Add project root to path
sys.path.append(str(Path(__file__).resolve().parent.parent))
import pandas as pd
from data.database import DatabaseManager
def migrate():
cache_dir = Path("./data/cache")
db_path = cache_dir / "market_data.db"
print(f"Initializing database at {db_path}...")
db_manager = DatabaseManager(str(db_path))
# Find all CSV files
csv_files = list(cache_dir.glob("*.csv"))
print(f"Found {len(csv_files)} CSV files to migrate.")
success_count = 0
fail_count = 0
for csv_file in csv_files:
try:
filename = csv_file.name
print(f"Processing {filename}...")
# Parse filename
parts = filename.split('_')
if len(parts) < 2:
print(f" ⚠ Skipping {filename}: Invalid naming format")
continue
symbol = parts[0]
timeframe = parts[1]
# Validate symbol and timeframe (basic check)
if timeframe not in ['1h', '15m']:
# Relaxed check, just warning
print(f" ℹ Note: Unfamiliar timeframe {timeframe} in {filename}")
# Read CSV
df = pd.read_csv(csv_file)
if 'timestamp' not in df.columns:
print(f" ⚠ Skipping {filename}: No timestamp column")
continue
# Save to DB
db_manager.save_ohlcv(df, symbol, timeframe)
print(f" ✓ Imported {len(df)} records for {symbol} {timeframe}")
success_count += 1
except Exception as e:
print(f" ❌ Error processing {csv_file.name}: {e}")
fail_count += 1
print("\nMigration complete!")
print(f"Successfully migrated: {success_count} files")
print(f"Failed: {fail_count} files")
if __name__ == "__main__":
migrate()
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
系统启动脚本
同时启动:
1. FastAPI 后端服务 (提供前端看板 API)
2. Polymarket 自动交易程序
"""
import subprocess
import sys
import time
import signal
import os
from datetime import datetime
# 颜色代码
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
RESET = '\033[0m'
def log(message, color=RESET):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"{color}[{timestamp}] {message}{RESET}")
def start_process(command, name):
"""启动子进程"""
log(f"正在启动 {name}...", YELLOW)
try:
process = subprocess.Popen(
command,
shell=True,
stdout=sys.stdout,
stderr=sys.stderr,
preexec_fn=os.setsid # 创建新的进程组,方便统一杀掉
)
log(f"{name} 已启动 (PID: {process.pid})", GREEN)
return process
except Exception as e:
log(f"启动 {name} 失败: {e}", RED)
return None
def main():
# 激活 Conda 环境
# 注意:在子进程中直接使用当前已激活的 python 解释器路径,避免 conda activate 问题
python_exec = sys.executable
# 1. 启动 API 服务 (Uvicorn)
# 使用 --host 0.0.0.0 允许局域网访问
api_cmd = f"{python_exec} -m uvicorn api.main:app --host 0.0.0.0 --port 8000"
api_process = start_process(api_cmd, "API 服务 & 前端看板")
# 等待几秒确保 API 启动
time.sleep(3)
# 2. 启动自动交易程序
# 使用 -u 参数禁用输出缓冲
trade_cmd = f"{python_exec} -u auto_trade_polymarket.py"
trade_process = start_process(trade_cmd, "Polymarket 自动交易程序")
log("系统启动完成!", GREEN)
log("前端访问地址: http://localhost:8000", GREEN)
log("按 Ctrl+C 停止所有服务...", YELLOW)
try:
# 监控子进程状态
while True:
if api_process.poll() is not None:
log("API 服务意外退出", RED)
break
if trade_process.poll() is not None:
log("自动交易程序意外退出", RED)
break
time.sleep(1)
except KeyboardInterrupt:
log("\n正在停止服务...", YELLOW)
finally:
# 优雅退出
if api_process:
os.killpg(os.getpgid(api_process.pid), signal.SIGTERM)
if trade_process:
os.killpg(os.getpgid(trade_process.pid), signal.SIGTERM)
log("所有服务已停止", GREEN)
if __name__ == "__main__":
main()