feat(frontend): 添加交易看板,统一时区为CST
refactor(db): 移除所有SQLite相关代码,迁移至MySQL
This commit is contained in:
1 parent
5e0d6b18ef
commit
55453d3739
12 files changed
+2115
-500
No files matched your search
+191
@@ -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
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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');
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+76
-120
@@ -1,200 +1,156 @@
|
|||||||
import sqlite3
|
|
||||||
import pymysql
|
import pymysql
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pathlib import Path
|
from typing import Optional
|
||||||
from typing import Optional, List, Tuple
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
class DatabaseManager:
|
class DatabaseManager:
|
||||||
"""Manages database for storing OHLCV data.
|
"""管理 OHLCV 数据的 MySQL 数据库"""
|
||||||
Supports SQLite (local) and MySQL (remote).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: str = "./data/market_data.db"):
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
self.use_mysql = bool(os.getenv('MYSQL_HOST'))
|
|
||||||
|
|
||||||
if not self.use_mysql:
|
|
||||||
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()
|
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
|
@contextmanager
|
||||||
def get_connection(self):
|
def get_connection(self):
|
||||||
"""Context manager for database connection."""
|
"""获取 MySQL 数据库连接"""
|
||||||
if self.use_mysql:
|
conn = pymysql.connect(
|
||||||
conn = pymysql.connect(
|
host=os.getenv('MYSQL_HOST'),
|
||||||
host=os.getenv('MYSQL_HOST'),
|
port=int(os.getenv('MYSQL_PORT', 3306)),
|
||||||
port=int(os.getenv('MYSQL_PORT', 3306)),
|
user=os.getenv('MYSQL_USER'),
|
||||||
user=os.getenv('MYSQL_USER'),
|
password=os.getenv('MYSQL_PASSWORD'),
|
||||||
password=os.getenv('MYSQL_PASSWORD'),
|
database=os.getenv('MYSQL_DB'),
|
||||||
database=os.getenv('MYSQL_DB'),
|
charset='utf8mb4',
|
||||||
charset='utf8mb4',
|
)
|
||||||
# pandas requires standard cursor or handling
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def initialize_db(self):
|
def initialize_db(self):
|
||||||
"""Create tables if they don't exist."""
|
"""创建表(如果不存在)"""
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
if self.use_mysql:
|
CREATE TABLE IF NOT EXISTS ohlcv (
|
||||||
# MySQL Schema
|
timestamp DATETIME,
|
||||||
cursor.execute("""
|
symbol VARCHAR(20),
|
||||||
CREATE TABLE IF NOT EXISTS ohlcv (
|
timeframe VARCHAR(10),
|
||||||
timestamp DATETIME,
|
open DOUBLE,
|
||||||
symbol VARCHAR(20),
|
high DOUBLE,
|
||||||
timeframe VARCHAR(10),
|
low DOUBLE,
|
||||||
open DOUBLE,
|
close DOUBLE,
|
||||||
high DOUBLE,
|
volume DOUBLE,
|
||||||
low DOUBLE,
|
PRIMARY KEY (symbol, timeframe, timestamp),
|
||||||
close DOUBLE,
|
INDEX idx_ohlcv_time (symbol, timeframe, timestamp)
|
||||||
volume DOUBLE,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
PRIMARY KEY (symbol, timeframe, timestamp),
|
""")
|
||||||
INDEX idx_ohlcv_time (symbol, timeframe, timestamp)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
""")
|
|
||||||
else:
|
|
||||||
# SQLite Schema
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ohlcv_time
|
|
||||||
ON ohlcv (symbol, timeframe, timestamp)
|
|
||||||
""")
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def save_ohlcv(self, df: pd.DataFrame, symbol: str, timeframe: str):
|
def save_ohlcv(self, df: pd.DataFrame, symbol: str, timeframe: str):
|
||||||
"""Save OHLCV data to database (Upsert)."""
|
"""保存 OHLCV 数据到数据库 (Upsert)"""
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure timestamp is datetime
|
# 确保 timestamp 是 datetime 类型
|
||||||
df = df.copy()
|
df = df.copy()
|
||||||
if not pd.api.types.is_datetime64_any_dtype(df['timestamp']):
|
if not pd.api.types.is_datetime64_any_dtype(df['timestamp']):
|
||||||
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
||||||
|
|
||||||
# Convert to string for SQLite compatibility (ISO 8601) or MySQL
|
# 转换为字符串格式
|
||||||
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
|
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:
|
if 'symbol' not in df.columns:
|
||||||
df['symbol'] = symbol
|
df['symbol'] = symbol
|
||||||
if 'timeframe' not in df.columns:
|
if 'timeframe' not in df.columns:
|
||||||
df['timeframe'] = timeframe
|
df['timeframe'] = timeframe
|
||||||
|
|
||||||
# Prepare data for insertion (ensure order)
|
|
||||||
# records = df[['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']].to_records(index=False)
|
|
||||||
# to_records returns numpy record array, which sqlite3 adapts.
|
|
||||||
# For MySQL/pymysql, we need list of tuples.
|
|
||||||
|
|
||||||
cols = ['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']
|
cols = ['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']
|
||||||
data = [tuple(row) for row in df[cols].values]
|
data = [tuple(row) for row in df[cols].values]
|
||||||
|
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
sql = """
|
||||||
if self.use_mysql:
|
REPLACE INTO ohlcv
|
||||||
# MySQL uses %s and REPLACE INTO
|
(timestamp, symbol, timeframe, open, high, low, close, volume)
|
||||||
sql = """
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
REPLACE INTO ohlcv
|
"""
|
||||||
(timestamp, symbol, timeframe, open, high, low, close, volume)
|
cursor.executemany(sql, data)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
"""
|
|
||||||
cursor.executemany(sql, data)
|
|
||||||
else:
|
|
||||||
# SQLite uses ? and INSERT OR REPLACE
|
|
||||||
sql = """
|
|
||||||
INSERT OR REPLACE INTO ohlcv
|
|
||||||
(timestamp, symbol, timeframe, open, high, low, close, volume)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"""
|
|
||||||
# sqlite3 accepts both list of tuples and numpy records, but list of tuples is safer for compatibility
|
|
||||||
cursor.executemany(sql, data)
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def get_ohlcv(self, symbol: str, timeframe: str, start_date: Optional[str] = None, end_date: Optional[str] = None) -> pd.DataFrame:
|
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 数据"""
|
||||||
|
|
||||||
placeholder = '%s' if self.use_mysql else '?'
|
query = """
|
||||||
|
|
||||||
query = f"""
|
|
||||||
SELECT timestamp, open, high, low, close, volume
|
SELECT timestamp, open, high, low, close, volume
|
||||||
FROM ohlcv
|
FROM ohlcv
|
||||||
WHERE symbol = {placeholder} AND timeframe = {placeholder}
|
WHERE symbol = :p0 AND timeframe = :p1
|
||||||
"""
|
"""
|
||||||
params = [symbol, timeframe]
|
params = {'p0': symbol, 'p1': timeframe}
|
||||||
|
param_idx = 2
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
query += f" AND timestamp >= {placeholder}"
|
query += f" AND timestamp >= :p{param_idx}"
|
||||||
params.append(start_date)
|
params[f'p{param_idx}'] = start_date
|
||||||
|
param_idx += 1
|
||||||
|
|
||||||
if end_date:
|
if end_date:
|
||||||
# Handle 'YYYY-MM-DD' vs full timestamp
|
# 处理 'YYYY-MM-DD' 格式
|
||||||
if len(str(end_date)) == 10: # YYYY-MM-DD
|
if len(str(end_date)) == 10:
|
||||||
# SQLite has date(), MySQL uses DATE() or just string comparison
|
query += f" AND timestamp < DATE_ADD(:p{param_idx}, INTERVAL 1 DAY)"
|
||||||
if self.use_mysql:
|
|
||||||
query += f" AND timestamp < DATE_ADD({placeholder}, INTERVAL 1 DAY)"
|
|
||||||
else:
|
|
||||||
query += f" AND timestamp < date({placeholder}, '+1 day')"
|
|
||||||
else:
|
else:
|
||||||
query += f" AND timestamp <= {placeholder}"
|
query += f" AND timestamp <= :p{param_idx}"
|
||||||
params.append(end_date)
|
params[f'p{param_idx}'] = end_date
|
||||||
|
|
||||||
query += " ORDER BY timestamp ASC"
|
query += " ORDER BY timestamp ASC"
|
||||||
|
|
||||||
with self.get_connection() as conn:
|
engine = self._get_sqlalchemy_engine()
|
||||||
df = pd.read_sql_query(query, conn, params=params, parse_dates=['timestamp'])
|
with engine.connect() as conn:
|
||||||
|
df = pd.read_sql_query(text(query), conn, params=params, parse_dates=['timestamp'])
|
||||||
|
|
||||||
return df
|
return df
|
||||||
|
|
||||||
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
|
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
|
||||||
"""Get the latest timestamp for a symbol/timeframe."""
|
"""获取指定 symbol/timeframe 的最新时间戳"""
|
||||||
placeholder = '%s' if self.use_mysql else '?'
|
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(f"""
|
cursor.execute("""
|
||||||
SELECT MAX(timestamp)
|
SELECT MAX(timestamp)
|
||||||
FROM ohlcv
|
FROM ohlcv
|
||||||
WHERE symbol = {placeholder} AND timeframe = {placeholder}
|
WHERE symbol = %s AND timeframe = %s
|
||||||
""", (symbol, timeframe))
|
""", (symbol, timeframe))
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
|
|
||||||
return pd.to_datetime(result[0]) if result and 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]:
|
def get_earliest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
|
||||||
"""Get the earliest timestamp for a symbol/timeframe."""
|
"""获取指定 symbol/timeframe 的最早时间戳"""
|
||||||
placeholder = '%s' if self.use_mysql else '?'
|
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(f"""
|
cursor.execute("""
|
||||||
SELECT MIN(timestamp)
|
SELECT MIN(timestamp)
|
||||||
FROM ohlcv
|
FROM ohlcv
|
||||||
WHERE symbol = {placeholder} AND timeframe = {placeholder}
|
WHERE symbol = %s AND timeframe = %s
|
||||||
""", (symbol, timeframe))
|
""", (symbol, timeframe))
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+46
-115
@@ -1,4 +1,3 @@
|
|||||||
import sqlite3
|
|
||||||
import pymysql
|
import pymysql
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
@@ -8,43 +7,26 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
class TradingDatabase:
|
class TradingDatabase:
|
||||||
"""交易数据库管理类 - 用于记录交易历史和预测结果
|
"""交易数据库管理类 - 用于记录交易历史和预测结果 (MySQL)"""
|
||||||
|
|
||||||
支持 SQLite (本地) 和 MySQL (远程)。
|
def __init__(self, db_path=None):
|
||||||
如果 .env 中配置了 MYSQL_HOST,则优先使用 MySQL。
|
"""初始化 (db_path 参数已废弃,仅为兼容性保留)"""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path="trading_history.db"):
|
|
||||||
self.db_path = db_path
|
|
||||||
self.use_mysql = bool(os.getenv('MYSQL_HOST'))
|
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_connection(self):
|
def get_connection(self):
|
||||||
"""数据库连接上下文管理器"""
|
"""获取 MySQL 数据库连接"""
|
||||||
if self.use_mysql:
|
conn = pymysql.connect(
|
||||||
conn = pymysql.connect(
|
host=os.getenv('MYSQL_HOST'),
|
||||||
host=os.getenv('MYSQL_HOST'),
|
port=int(os.getenv('MYSQL_PORT', 3306)),
|
||||||
port=int(os.getenv('MYSQL_PORT', 3306)),
|
user=os.getenv('MYSQL_USER'),
|
||||||
user=os.getenv('MYSQL_USER'),
|
password=os.getenv('MYSQL_PASSWORD'),
|
||||||
password=os.getenv('MYSQL_PASSWORD'),
|
database=os.getenv('MYSQL_DB'),
|
||||||
database=os.getenv('MYSQL_DB'),
|
charset='utf8mb4',
|
||||||
charset='utf8mb4',
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
cursorclass=pymysql.cursors.DictCursor
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
# Make sqlite behave like dict cursor for consistency if needed,
|
|
||||||
# but existing code might expect tuple.
|
|
||||||
# Actually existing code uses cursor.execute directly.
|
|
||||||
# SQLite cursor returns tuples by default.
|
|
||||||
# PyMySQL DictCursor returns dicts.
|
|
||||||
# We need to be careful about compatibility.
|
|
||||||
# The existing log_trade uses INSERT which is fine.
|
|
||||||
# check_existing_trade uses SELECT count(*), fetchone()[0], which works for both if we don't use DictCursor for MySQL or handle it.
|
|
||||||
# Let's use standard cursor for MySQL for compatibility with existing fetchone()[0] usage.
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
@@ -54,81 +36,45 @@ class TradingDatabase:
|
|||||||
"""初始化数据库表结构"""
|
"""初始化数据库表结构"""
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
if self.use_mysql:
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
# MySQL Schema
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
cursor.execute('''
|
timestamp DATETIME,
|
||||||
CREATE TABLE IF NOT EXISTS trades (
|
market_title VARCHAR(255),
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
market_slug VARCHAR(255),
|
||||||
timestamp DATETIME,
|
model_name VARCHAR(50),
|
||||||
market_title VARCHAR(255),
|
prediction VARCHAR(10),
|
||||||
market_slug VARCHAR(255),
|
probability DOUBLE,
|
||||||
model_name VARCHAR(50),
|
action VARCHAR(10),
|
||||||
prediction VARCHAR(10),
|
price DOUBLE,
|
||||||
probability DOUBLE,
|
amount DOUBLE,
|
||||||
action VARCHAR(10),
|
size DOUBLE,
|
||||||
price DOUBLE,
|
status VARCHAR(20),
|
||||||
amount DOUBLE,
|
error_message TEXT,
|
||||||
size DOUBLE,
|
raw_data JSON,
|
||||||
status VARCHAR(20),
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
error_message TEXT,
|
INDEX idx_market_slug (market_slug),
|
||||||
raw_data JSON,
|
INDEX idx_status (status)
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
INDEX idx_market_slug (market_slug),
|
''')
|
||||||
INDEX idx_status (status)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
''')
|
|
||||||
else:
|
|
||||||
# SQLite Schema
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS trades (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
timestamp TEXT,
|
|
||||||
market_title TEXT,
|
|
||||||
market_slug TEXT,
|
|
||||||
model_name TEXT,
|
|
||||||
prediction TEXT,
|
|
||||||
probability REAL,
|
|
||||||
action TEXT,
|
|
||||||
price REAL,
|
|
||||||
amount REAL,
|
|
||||||
size REAL,
|
|
||||||
status TEXT,
|
|
||||||
error_message TEXT,
|
|
||||||
raw_data TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def log_trade(self, trade_data: dict):
|
def log_trade(self, trade_data: dict):
|
||||||
"""
|
"""记录交易信息"""
|
||||||
记录交易信息
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Prepare data
|
|
||||||
timestamp = trade_data.get('timestamp', datetime.now().isoformat())
|
timestamp = trade_data.get('timestamp', datetime.now().isoformat())
|
||||||
raw_data_json = json.dumps(trade_data.get('raw_data', {}), ensure_ascii=False)
|
raw_data_json = json.dumps(trade_data.get('raw_data', {}), ensure_ascii=False)
|
||||||
|
|
||||||
if self.use_mysql:
|
sql = '''
|
||||||
sql = '''
|
INSERT INTO trades (
|
||||||
INSERT INTO trades (
|
timestamp, market_title, market_slug, model_name,
|
||||||
timestamp, market_title, market_slug, model_name,
|
prediction, probability, action, price, amount, size,
|
||||||
prediction, probability, action, price, amount, size,
|
status, error_message, raw_data
|
||||||
status, error_message, raw_data
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
'''
|
||||||
'''
|
|
||||||
else:
|
|
||||||
sql = '''
|
|
||||||
INSERT INTO trades (
|
|
||||||
timestamp, market_title, market_slug, model_name,
|
|
||||||
prediction, probability, action, price, amount, size,
|
|
||||||
status, error_message, raw_data
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
|
|
||||||
cursor.execute(sql, (
|
cursor.execute(sql, (
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -150,29 +96,14 @@ class TradingDatabase:
|
|||||||
print(f"写入数据库失败: {e}")
|
print(f"写入数据库失败: {e}")
|
||||||
|
|
||||||
def check_existing_trade(self, market_slug: str, status: str = 'success') -> bool:
|
def check_existing_trade(self, market_slug: str, status: str = 'success') -> bool:
|
||||||
"""
|
"""检查指定市场是否已有成功交易记录"""
|
||||||
检查指定市场是否已有成功交易记录
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
sql = 'SELECT COUNT(*) as cnt FROM trades WHERE market_slug = %s AND status = %s'
|
||||||
if self.use_mysql:
|
|
||||||
sql = 'SELECT COUNT(*) FROM trades WHERE market_slug = %s AND status = %s'
|
|
||||||
else:
|
|
||||||
sql = 'SELECT COUNT(*) FROM trades WHERE market_slug = ? AND status = ?'
|
|
||||||
|
|
||||||
cursor.execute(sql, (market_slug, status))
|
cursor.execute(sql, (market_slug, status))
|
||||||
|
|
||||||
# Fetch result compatible with both
|
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
if isinstance(result, dict): # If DictCursor was used (not used here but for safety)
|
count = result['cnt'] if result else 0
|
||||||
count = list(result.values())[0]
|
|
||||||
elif isinstance(result, (list, tuple)):
|
|
||||||
count = result[0]
|
|
||||||
else:
|
|
||||||
count = 0
|
|
||||||
|
|
||||||
return count > 0
|
return count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"检查已存在交易失败: {e}")
|
print(f"检查已存在交易失败: {e}")
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""
|
|
||||||
SQLite to MySQL Migration Script
|
|
||||||
|
|
||||||
This script migrates data from local SQLite databases (`trading_history.db` and `data/market_data.db`)
|
|
||||||
to a remote MySQL database defined in environment variables.
|
|
||||||
|
|
||||||
Environment Variables required in .env:
|
|
||||||
- MYSQL_HOST
|
|
||||||
- MYSQL_PORT (default: 3306)
|
|
||||||
- MYSQL_USER
|
|
||||||
- MYSQL_PASSWORD
|
|
||||||
- MYSQL_DB
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/migrate_sqlite_to_mysql.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import pymysql
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from tqdm import tqdm
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
# Add project root to sys.path
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
def get_mysql_connection():
|
|
||||||
return 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
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_mysql_tables(conn):
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
# Create trades table
|
|
||||||
print("Creating 'trades' table in MySQL...")
|
|
||||||
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;
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Create ohlcv table
|
|
||||||
print("Creating 'ohlcv' table in MySQL...")
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS ohlcv (
|
|
||||||
timestamp DATETIME,
|
|
||||||
symbol VARCHAR(20),
|
|
||||||
timeframe VARCHAR(10),
|
|
||||||
open DOUBLE,
|
|
||||||
high DOUBLE,
|
|
||||||
low DOUBLE,
|
|
||||||
close DOUBLE,
|
|
||||||
volume DOUBLE,
|
|
||||||
PRIMARY KEY (symbol, timeframe, timestamp),
|
|
||||||
INDEX idx_time (timestamp)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def migrate_trades(mysql_conn):
|
|
||||||
sqlite_db_path = "trading_history.db"
|
|
||||||
if not os.path.exists(sqlite_db_path):
|
|
||||||
print(f"Skipping trades migration: {sqlite_db_path} not found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Migrating trades from {sqlite_db_path}...")
|
|
||||||
|
|
||||||
with sqlite3.connect(sqlite_db_path) as sqlite_conn:
|
|
||||||
sqlite_cursor = sqlite_conn.cursor()
|
|
||||||
|
|
||||||
# Check if table exists
|
|
||||||
sqlite_cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='trades';")
|
|
||||||
if not sqlite_cursor.fetchone():
|
|
||||||
print("Table 'trades' not found in SQLite.")
|
|
||||||
return
|
|
||||||
|
|
||||||
sqlite_cursor.execute("SELECT * FROM trades")
|
|
||||||
rows = sqlite_cursor.fetchall()
|
|
||||||
columns = [description[0] for description in sqlite_cursor.description]
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
print("No trades data to migrate.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Found {len(rows)} trade records.")
|
|
||||||
|
|
||||||
# Insert into MySQL
|
|
||||||
with mysql_conn.cursor() as mysql_cursor:
|
|
||||||
# Prepare SQL statement
|
|
||||||
# Map columns. SQLite 'timestamp' is text, MySQL needs valid datetime format or string.
|
|
||||||
# We skip 'id' to let MySQL handle auto-increment or we can preserve it.
|
|
||||||
# Let's preserve ID if possible, but usually better to let destination handle it unless we need consistency.
|
|
||||||
# User wants migration, preserving ID is safer for history.
|
|
||||||
|
|
||||||
placeholders = ', '.join(['%s'] * len(columns))
|
|
||||||
cols_str = ', '.join(columns)
|
|
||||||
|
|
||||||
sql = f"INSERT IGNORE INTO trades ({cols_str}) VALUES ({placeholders})"
|
|
||||||
|
|
||||||
mysql_cursor.executemany(sql, rows)
|
|
||||||
mysql_conn.commit()
|
|
||||||
print(f"Migrated {mysql_cursor.rowcount} trade records.")
|
|
||||||
|
|
||||||
def migrate_ohlcv(mysql_conn):
|
|
||||||
sqlite_db_path = "data/market_data.db"
|
|
||||||
if not os.path.exists(sqlite_db_path):
|
|
||||||
print(f"Skipping OHLCV migration: {sqlite_db_path} not found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Migrating OHLCV from {sqlite_db_path}...")
|
|
||||||
|
|
||||||
with sqlite3.connect(sqlite_db_path) as sqlite_conn:
|
|
||||||
# Use pandas for easier chunk reading
|
|
||||||
try:
|
|
||||||
# Check total count
|
|
||||||
total_rows = pd.read_sql_query("SELECT COUNT(*) FROM ohlcv", sqlite_conn).iloc[0, 0]
|
|
||||||
print(f"Found {total_rows} OHLCV records.")
|
|
||||||
|
|
||||||
chunk_size = 10000
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
with tqdm(total=total_rows, desc="Migrating OHLCV") as pbar:
|
|
||||||
while True:
|
|
||||||
df = pd.read_sql_query(f"SELECT * FROM ohlcv LIMIT {chunk_size} OFFSET {offset}", sqlite_conn)
|
|
||||||
if df.empty:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Convert DataFrame to list of tuples
|
|
||||||
data = [tuple(x) for x in df.to_numpy()]
|
|
||||||
|
|
||||||
with mysql_conn.cursor() as mysql_cursor:
|
|
||||||
sql = """
|
|
||||||
INSERT IGNORE INTO ohlcv
|
|
||||||
(timestamp, symbol, timeframe, open, high, low, close, volume)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
"""
|
|
||||||
# Select columns explicitly to match SQL
|
|
||||||
cols = ['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']
|
|
||||||
batch_data = [tuple(row[col] for col in cols) for _, row in df.iterrows()]
|
|
||||||
|
|
||||||
mysql_cursor.executemany(sql, batch_data)
|
|
||||||
mysql_conn.commit()
|
|
||||||
|
|
||||||
offset += chunk_size
|
|
||||||
pbar.update(len(df))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading/migrating OHLCV: {e}")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not os.getenv('MYSQL_HOST'):
|
|
||||||
print("Error: MYSQL_HOST not set in .env")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
print("Connecting to MySQL...")
|
|
||||||
mysql_conn = get_mysql_connection()
|
|
||||||
print("Connected.")
|
|
||||||
|
|
||||||
create_mysql_tables(mysql_conn)
|
|
||||||
|
|
||||||
migrate_trades(mysql_conn)
|
|
||||||
migrate_ohlcv(mysql_conn)
|
|
||||||
|
|
||||||
mysql_conn.close()
|
|
||||||
print("\nMigration completed successfully.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\nMigration failed: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Binary file not shown.
Reference in new issue
Block a user