feat(frontend): 添加交易看板,统一时区为CST

refactor(db): 移除所有SQLite相关代码,迁移至MySQL
This commit is contained in:
crypto_predictor committed 2026-02-07 16:48:14 +08:00
1 parent 5e0d6b18ef
commit 55453d3739
12 files changed
+2116 -501

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
"""
FastAPI 主应用 - 交易看板 API
"""
import os
import sys
from datetime import datetime
from typing import Optional
from zoneinfo import ZoneInfo
from fastapi import FastAPI, Query, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.models import TradeModel, PredictionModel
from api.prediction_service import get_prediction_service
# 创建 FastAPI 应用
app = FastAPI(
title="Crypto Predictor Dashboard API",
description="BTC 预测交易看板 API",
version="1.0.0"
)
# 启用 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件目录
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
# 挂载静态文件
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ==================== 页面路由 ====================
@app.get("/")
async def index():
"""返回主页"""
return FileResponse(os.path.join(STATIC_DIR, 'index.html'))
# ==================== 交易 API ====================
@app.get("/api/trades/current")
async def get_current_trade():
"""获取当前/最新交易"""
try:
trade = TradeModel.get_current_trade()
if trade:
return {"success": True, "data": trade}
return {"success": True, "data": None, "message": "暂无交易记录"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/trades/history")
async def get_trade_history(
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(10, ge=1, le=100, description="每页条数"),
month: Optional[str] = Query(None, description="月份筛选 (YYYY-MM)"),
day: Optional[str] = Query(None, description="日期筛选 (YYYY-MM-DD)")
):
"""获取历史交易记录"""
try:
result = TradeModel.get_history(page=page, limit=limit, month=month, day=day)
return {"success": True, **result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== 准确率 API ====================
@app.get("/api/accuracy/current")
async def get_current_prediction():
"""获取当前小时的预测"""
try:
service = get_prediction_service()
prediction = service.predict_current_hour()
return {"success": True, "data": prediction}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/accuracy/monthly")
async def get_monthly_accuracy(
month: Optional[str] = Query(None, description="月份 (YYYY-MM),默认当月")
):
"""获取月度准确率统计"""
try:
# 默认当月
if not month:
et_tz = ZoneInfo('America/New_York')
month = datetime.now(et_tz).strftime('%Y-%m')
# 确保有数据
service = get_prediction_service()
service.ensure_predictions_for_month(month)
# 获取统计
stats = PredictionModel.get_monthly_stats(month)
return {
"success": True,
"month": month,
"data": stats
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/accuracy/daily")
async def get_daily_accuracy(
month: Optional[str] = Query(None, description="月份 (YYYY-MM),默认当月"),
page: int = Query(1, ge=1, description="页码"),
limit: int = Query(10, ge=1, le=100, description="每页条数")
):
"""获取每日准确率统计"""
try:
# 默认当月
if not month:
et_tz = ZoneInfo('America/New_York')
month = datetime.now(et_tz).strftime('%Y-%m')
# 确保有数据
service = get_prediction_service()
service.ensure_predictions_for_month(month)
# 获取统计
result = PredictionModel.get_daily_stats(month=month, page=page, limit=limit)
return {
"success": True,
"month": month,
**result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/accuracy/hourly/{date}")
async def get_hourly_accuracy(date: str):
"""获取指定日期的每小时准确率详情"""
try:
# 确保有数据
service = get_prediction_service()
service.ensure_predictions_for_date(date)
# 获取详情
details = PredictionModel.get_hourly_details(date)
return {
"success": True,
"date": date,
"data": details
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== 状态 API ====================
@app.get("/api/status")
async def get_status():
"""获取服务状态"""
et_tz = ZoneInfo('America/New_York')
now_et = datetime.now(et_tz)
return {
"success": True,
"status": "running",
"server_time": datetime.now().isoformat(),
"server_time_et": now_et.isoformat(),
"version": "1.0.0"
}
# ==================== 启动入口 ====================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+457
View File
@@ -0,0 +1,457 @@
"""
数据库模型和操作类 - MySQL
"""
import pymysql
import os
from typing import Optional, List, Dict
from datetime import datetime
from contextlib import contextmanager
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
load_dotenv()
# 时区定义
TZ_UTC = ZoneInfo('UTC')
TZ_ET = ZoneInfo('America/New_York') # 美东时间 (Polymarket)
TZ_CST = ZoneInfo('Asia/Shanghai') # 中国标准时间 (前端显示)
def convert_to_cst(timestamp_val) -> str:
"""
将时间戳转换为 CST 时间字符串
Args:
timestamp_val: datetime 对象或 ISO 格式时间戳字符串 (假设是 UTC)
Returns:
CST 时间字符串 (YYYY-MM-DD HH:MM:SS)
"""
if not timestamp_val:
return ""
try:
# 如果是 datetime 对象 (MySQL 返回)
if isinstance(timestamp_val, datetime):
dt = timestamp_val
else:
# 字符串格式
dt = datetime.fromisoformat(str(timestamp_val).replace('Z', '+00:00'))
# 如果没有时区信息,假设是 UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=TZ_UTC)
# 转换为 CST
dt_cst = dt.astimezone(TZ_CST)
return dt_cst.strftime('%Y-%m-%d %H:%M:%S')
except Exception:
return str(timestamp_val)
@contextmanager
def get_db_connection():
"""获取 MySQL 数据库连接"""
conn = pymysql.connect(
host=os.getenv('MYSQL_HOST'),
port=int(os.getenv('MYSQL_PORT', 3306)),
user=os.getenv('MYSQL_USER'),
password=os.getenv('MYSQL_PASSWORD'),
database=os.getenv('MYSQL_DB'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
try:
yield conn
finally:
conn.close()
def _dict_from_row(row):
"""将数据库行转换为字典"""
if row is None:
return None
if isinstance(row, dict):
return row
return dict(row)
def init_model_predictions_table():
"""初始化 model_predictions 表"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS model_predictions (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp DATETIME NOT NULL,
timestamp_et VARCHAR(50),
date VARCHAR(10),
hour INT,
model_name VARCHAR(50) NOT NULL,
prediction INT,
probability DOUBLE,
actual_result INT,
is_correct INT,
open_price DOUBLE,
close_price DOUBLE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_date_hour_model (date, hour, model_name),
INDEX idx_date (date),
INDEX idx_model (model_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
''')
conn.commit()
# 初始化表
init_model_predictions_table()
class TradeModel:
"""交易记录模型"""
@staticmethod
def get_current_trade() -> Optional[Dict]:
"""获取最新的交易记录"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM trades
ORDER BY timestamp DESC
LIMIT 1
""")
row = cursor.fetchone()
result = _dict_from_row(row)
if result and result.get('timestamp'):
result['timestamp_cst'] = convert_to_cst(result['timestamp'])
return result
@staticmethod
def get_history(page: int = 1, limit: int = 10,
month: Optional[str] = None,
day: Optional[str] = None) -> Dict:
"""
获取历史交易记录
Args:
page: 页码
limit: 每页条数
month: 月份筛选 (YYYY-MM)
day: 日期筛选 (YYYY-MM-DD)
"""
offset = (page - 1) * limit
with get_db_connection() as conn:
cursor = conn.cursor()
# 构建查询条件
where_clauses = []
params = []
if month:
where_clauses.append("DATE_FORMAT(timestamp, '%%Y-%%m') = %s")
params.append(month)
if day:
where_clauses.append("DATE(timestamp) = %s")
params.append(day)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# 查询数据
sql = f"SELECT * FROM trades WHERE {where_sql} ORDER BY timestamp DESC LIMIT %s OFFSET %s"
cursor.execute(sql, params + [limit, offset])
rows = cursor.fetchall()
# 查询总数
count_sql = f"SELECT COUNT(*) as cnt FROM trades WHERE {where_sql}"
cursor.execute(count_sql, params)
result = cursor.fetchone()
total = result['cnt'] if result else 0
# 转换每条记录的时间戳
data_list = []
for row in rows:
item = _dict_from_row(row)
if item and item.get('timestamp'):
item['timestamp_cst'] = convert_to_cst(item['timestamp'])
data_list.append(item)
return {
"data": data_list,
"page": page,
"limit": limit,
"total": total,
"total_pages": (total + limit - 1) // limit if limit > 0 else 0
}
class PredictionModel:
"""预测记录模型"""
@staticmethod
def get_prediction(date: str, hour: int, model_name: str) -> Optional[Dict]:
"""获取指定时间和模型的预测记录"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM model_predictions
WHERE date = %s AND hour = %s AND model_name = %s
""", (date, hour, model_name))
row = cursor.fetchone()
return _dict_from_row(row)
@staticmethod
def save_prediction(data: Dict) -> int:
"""
保存预测记录
Args:
data: 预测数据字典
Returns:
插入的记录ID
"""
with get_db_connection() as conn:
cursor = conn.cursor()
sql = """
INSERT INTO model_predictions
(timestamp, timestamp_et, date, hour, model_name,
prediction, probability, actual_result, is_correct,
open_price, close_price)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
prediction = VALUES(prediction),
probability = VALUES(probability),
actual_result = VALUES(actual_result),
is_correct = VALUES(is_correct),
open_price = VALUES(open_price),
close_price = VALUES(close_price)
"""
cursor.execute(sql, (
data.get('timestamp'),
data.get('timestamp_et'),
data.get('date'),
data.get('hour'),
data.get('model_name'),
data.get('prediction'),
data.get('probability'),
data.get('actual_result'),
data.get('is_correct'),
data.get('open_price'),
data.get('close_price')
))
conn.commit()
return cursor.lastrowid or 0
@staticmethod
def update_actual_result(date: str, hour: int, model_name: str,
actual_result: int, close_price: float) -> bool:
"""更新实际结果"""
with get_db_connection() as conn:
cursor = conn.cursor()
# 先获取预测值
cursor.execute("""
SELECT prediction FROM model_predictions
WHERE date = %s AND hour = %s AND model_name = %s
""", (date, hour, model_name))
row = cursor.fetchone()
if not row:
return False
prediction = row['prediction']
is_correct = 1 if prediction == actual_result else 0
cursor.execute("""
UPDATE model_predictions
SET actual_result = %s, is_correct = %s, close_price = %s
WHERE date = %s AND hour = %s AND model_name = %s
""", (actual_result, is_correct, close_price, date, hour, model_name))
conn.commit()
return True
@staticmethod
def get_monthly_stats(month: str) -> Dict:
"""
获取月度统计
Args:
month: 月份 (YYYY-MM)
Returns:
各模型的统计数据
"""
with get_db_connection() as conn:
cursor = conn.cursor()
sql = """
SELECT
model_name,
COUNT(*) as total,
SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct,
SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as incorrect
FROM model_predictions
WHERE DATE_FORMAT(date, '%%Y-%%m') = %s
AND actual_result IS NOT NULL
GROUP BY model_name
"""
cursor.execute(sql, (month,))
rows = cursor.fetchall()
result = {}
for row in rows:
row_dict = _dict_from_row(row)
model = row_dict['model_name']
total = row_dict['total']
correct = row_dict['correct'] or 0
incorrect = row_dict['incorrect'] or 0
accuracy = (correct / total * 100) if total > 0 else 0
result[model] = {
"total": total,
"correct": correct,
"incorrect": incorrect,
"accuracy": round(accuracy, 2)
}
return result
@staticmethod
def get_daily_stats(month: str, page: int = 1, limit: int = 10) -> Dict:
"""
获取每日统计
Args:
month: 月份 (YYYY-MM)
page: 页码
limit: 每页条数
"""
with get_db_connection() as conn:
cursor = conn.cursor()
sql = """
SELECT
date,
model_name,
COUNT(*) as total,
SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct,
SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as incorrect
FROM model_predictions
WHERE DATE_FORMAT(date, '%%Y-%%m') = %s
AND actual_result IS NOT NULL
GROUP BY date, model_name
ORDER BY date DESC
"""
cursor.execute(sql, (month,))
rows = cursor.fetchall()
# 重组数据: 按日期合并不同模型的统计
daily_data = {}
for row in rows:
row_dict = _dict_from_row(row)
date = row_dict['date']
if date not in daily_data:
daily_data[date] = {"date": date}
model = row_dict['model_name']
total = row_dict['total']
correct = row_dict['correct'] or 0
incorrect = row_dict['incorrect'] or 0
accuracy = (correct / total * 100) if total > 0 else 0
daily_data[date][model] = {
"total": total,
"correct": correct,
"incorrect": incorrect,
"accuracy": round(accuracy, 2)
}
# 排序并分页
sorted_dates = sorted(daily_data.keys(), reverse=True)
total_count = len(sorted_dates)
offset = (page - 1) * limit
paginated_dates = sorted_dates[offset:offset + limit]
data = [daily_data[d] for d in paginated_dates]
return {
"data": data,
"page": page,
"limit": limit,
"total": total_count,
"total_pages": (total_count + limit - 1) // limit if limit > 0 else 0
}
@staticmethod
def get_hourly_details(date: str) -> List[Dict]:
"""
获取指定日期的每小时详情
Args:
date: 日期 (YYYY-MM-DD)
"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM model_predictions
WHERE date = %s
ORDER BY hour, model_name
""", (date,))
rows = cursor.fetchall()
# 按小时重组数据
hourly_data = {}
for row in rows:
row_dict = _dict_from_row(row)
hour = row_dict['hour']
if hour not in hourly_data:
# 将 timestamp 转换为 CST 时间
timestamp_cst = convert_to_cst(row_dict.get('timestamp', ''))
hourly_data[hour] = {
"hour": hour,
"timestamp_et": row_dict['timestamp_et'],
"timestamp_cst": timestamp_cst,
"open_price": row_dict['open_price'],
"close_price": row_dict['close_price'],
"actual_result": row_dict['actual_result'],
"models": {}
}
model = row_dict['model_name']
hourly_data[hour]["models"][model] = {
"prediction": row_dict['prediction'],
"probability": row_dict['probability'],
"is_correct": row_dict['is_correct']
}
# 按小时排序返回
return [hourly_data[h] for h in sorted(hourly_data.keys())]
@staticmethod
def get_missing_hours(date: str, model_name: str) -> List[int]:
"""
获取指定日期缺少预测的小时列表
Args:
date: 日期 (YYYY-MM-DD)
model_name: 模型名称
Returns:
缺少预测的小时列表
"""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT hour FROM model_predictions
WHERE date = %s AND model_name = %s
""", (date, model_name))
rows = cursor.fetchall()
existing_hours = {row['hour'] for row in rows}
all_hours = set(range(24))
return sorted(list(all_hours - existing_hours))
+477
View File
@@ -0,0 +1,477 @@
"""
预测服务 - DLinear 和 TTM-R2 模型预测
"""
import os
import sys
import pickle
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Tuple
from zoneinfo import ZoneInfo
import torch
import numpy as np
import pandas as pd
import pandas_ta as ta
from sklearn.preprocessing import StandardScaler
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from data.fetcher import BinanceDataFetcher
from api.models import PredictionModel
class ClassificationPreprocessor:
"""DLinear分类预处理器"""
def __init__(self, lookback: int = 168, forecast_horizon: int = 1):
self.lookback = lookback
self.forecast_horizon = forecast_horizon
self.scaler = StandardScaler()
self.is_fitted = False
def add_features(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['rsi'] = ta.rsi(df['close'], length=14)
macd = ta.macd(df['close'])
if macd is not None and not macd.empty:
df['macd'] = macd.iloc[:, 0]
df['macd_hist'] = macd.iloc[:, 1]
df['macd_signal'] = macd.iloc[:, 2]
return df
def create_labels(self, df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df['future_price'] = df['close'].shift(-self.forecast_horizon)
df['future_return'] = (df['future_price'] - df['close']) / df['close']
df['label'] = (df['future_return'] > 0).astype(int)
return df[:-self.forecast_horizon]
def preprocess(self, df: pd.DataFrame, fit: bool = True):
df = self.add_features(df)
df = self.create_labels(df)
df = df.dropna().reset_index(drop=True)
feature_cols = ['open', 'high', 'low', 'close', 'volume',
'rsi', 'macd', 'macd_hist', 'macd_signal']
if fit:
scaled_features = self.scaler.fit_transform(df[feature_cols])
self.is_fitted = True
self.feature_columns = feature_cols
else:
scaled_features = self.scaler.transform(df[feature_cols])
X, y = [], []
for i in range(len(scaled_features) - self.lookback):
X.append(scaled_features[i:i + self.lookback])
y.append(df['label'].iloc[i + self.lookback])
return np.array(X), np.array(y), df
class CustomUnpickler(pickle.Unpickler):
"""自定义反序列化器,用于处理 __main__ 模块的类"""
def find_class(self, module, name):
if module == "__main__":
if name == "ClassificationPreprocessor":
return ClassificationPreprocessor
return super().find_class(module, name)
class PredictionService:
"""预测服务 - 管理 DLinear 和 TTM-R2 模型"""
# 类级别的锁,确保多个请求不会同时生成预测
_prediction_lock = None
def __init__(self):
import threading
# 初始化锁
if PredictionService._prediction_lock is None:
PredictionService._prediction_lock = threading.Lock()
self.device = torch.device('mps' if torch.backends.mps.is_available() else
'cuda' if torch.cuda.is_available() else 'cpu')
self.et_tz = ZoneInfo('America/New_York')
self.fetcher = BinanceDataFetcher()
# 模型和预处理器
self.dlinear_model = None
self.dlinear_preprocessor = None
self.ttm_model = None
# 记录正在处理的日期和月份,避免重复处理
self._processing_dates = set()
self._processing_months = set()
self._load_models()
def _load_models(self):
"""加载模型"""
print("正在加载预测模型...")
# 加载 DLinear
from models.dlinear.model import DLinear
dlinear_path = './models/checkpoints/dlinear_classification_2026/best_model.pt'
preprocessor_path = './models/checkpoints/dlinear_classification_2026/preprocessor.pkl'
checkpoint = torch.load(dlinear_path, map_location=self.device, weights_only=False)
self.dlinear_model = DLinear(checkpoint['config'])
self.dlinear_model.load_state_dict(checkpoint['model_state_dict'])
self.dlinear_model = self.dlinear_model.to(self.device).eval()
with open(preprocessor_path, 'rb') as f:
try:
self.dlinear_preprocessor = pickle.load(f)
except AttributeError:
f.seek(0)
self.dlinear_preprocessor = CustomUnpickler(f).load()
print(f" ✓ DLinear 已加载")
# 加载 TTM-R2
from tsfm_public.models.tinytimemixer import TinyTimeMixerForPrediction
self.ttm_model = TinyTimeMixerForPrediction.from_pretrained(
"ibm-granite/granite-timeseries-ttm-r2",
trust_remote_code=True
)
self.ttm_model = self.ttm_model.to(self.device).eval()
print(f" ✓ TTM-R2 已加载")
print(f" 设备: {self.device}")
def _get_btc_data(self, days: int = 30) -> pd.DataFrame:
"""获取BTC历史数据"""
end_date = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
df = self.fetcher.fetch_ohlcv(
symbol='BTC',
timeframe='1h',
start_date=start_date,
end_date=end_date,
use_cache=False
)
return df
def predict_dlinear(self, df: pd.DataFrame) -> Tuple[int, float]:
"""
使用 DLinear 预测下一小时的涨跌
输入: df 包含历史K线数据 (最后一行是最新的已完成K线)
输出: 预测下一小时相对于 df 最后一行收盘价的涨跌
Returns:
(prediction, probability): 预测结果(0=跌, 1=涨)和概率
"""
# 预处理: 使用全部数据,不去掉最后一行
X, _, _ = self.dlinear_preprocessor.preprocess(df, fit=False)
if len(X) == 0:
return 0, 0.5
# 取最后一个窗口进行预测
X_last = torch.FloatTensor(X[-1:]).to(self.device)
with torch.no_grad():
logits = self.dlinear_model(X_last).cpu().numpy()
prob = 1 / (1 + np.exp(-logits))
prediction = int(prob.item() > 0.5)
probability = float(prob.item())
return prediction, probability
def predict_ttm(self, df: pd.DataFrame) -> Tuple[int, float]:
"""
使用 TTM-R2 预测下一小时的涨跌
输入: df 包含历史K线数据 (最后一行是最新的已完成K线)
输出: 预测下一小时相对于 df 最后一行收盘价的涨跌
Returns:
(prediction, probability): 预测结果(0=跌, 1=涨)和概率
"""
context_length = 512
all_prices = df['close'].values
# 使用最后 context_length 个价格
if len(all_prices) >= context_length:
context_data = all_prices[-context_length:].reshape(1, -1, 1)
else:
# 数据不足,用最后一个价格填充
padded = np.full(context_length, all_prices[-1])
padded[-len(all_prices):] = all_prices
context_data = padded.reshape(1, -1, 1)
# 基准价格: 最后一行的收盘价 (即预测目标小时的前一小时收盘价)
base_price = float(df['close'].iloc[-1])
context_tensor = torch.FloatTensor(context_data).to(self.device)
with torch.no_grad():
output = self.ttm_model(context_tensor)
pred_price = output.prediction_outputs.cpu().numpy()[0, 0, 0]
# 判断涨跌: 预测价格 vs 基准价格
prediction = 1 if pred_price > base_price else 0
# 计算伪概率: 基于价格变化百分比
price_pct = (pred_price - base_price) / base_price
# 将价格变化百分比映射到概率 (sigmoid)
probability = 1 / (1 + np.exp(-price_pct * 100))
return prediction, float(probability)
def predict_current_hour(self) -> Dict:
"""
预测当前小时的涨跌
逻辑:
- 获取最新的K线数据 (最后一行是最新已完成的K线)
- 预测下一小时相对于最后一行收盘价的涨跌
Returns:
预测结果字典
"""
df = self._get_btc_data()
# 获取当前时间 (美东)
now_et = datetime.now(self.et_tz)
current_date = now_et.strftime('%Y-%m-%d')
current_hour = now_et.hour
# 最后一个已完成的K线信息 (作为预测基准)
last_timestamp = df['timestamp'].iloc[-1]
last_close = float(df['close'].iloc[-1])
# 当前正在形成的K线信息
current_open = float(df['open'].iloc[-1])
current_price = float(df['close'].iloc[-1]) # 当前价格
results = {
"date": current_date,
"hour": current_hour,
"timestamp": str(last_timestamp),
"base_price": last_close, # 预测基准价格
"current_price": current_price,
"models": {}
}
# DLinear 预测 (预测下一小时相对于 last_close 的涨跌)
dlinear_pred, dlinear_prob = self.predict_dlinear(df)
results["models"]["DLinear"] = {
"prediction": dlinear_pred,
"probability": round(dlinear_prob, 4),
"direction": "UP" if dlinear_pred == 1 else "DOWN"
}
# TTM-R2 预测
ttm_pred, ttm_prob = self.predict_ttm(df)
results["models"]["TTM-R2"] = {
"prediction": ttm_pred,
"probability": round(ttm_prob, 4),
"direction": "UP" if ttm_pred == 1 else "DOWN"
}
return results
def ensure_predictions_for_date(self, date: str) -> None:
"""
确保指定日期有预测数据,如果没有则生成
Args:
date: 日期 (YYYY-MM-DD)
"""
# 使用锁避免并发冲突
with PredictionService._prediction_lock:
# 检查是否正在处理该日期
if date in self._processing_dates:
return
# 标记正在处理
self._processing_dates.add(date)
try:
# 检查是否有缺失的小时
for model_name in ["DLinear", "TTM-R2"]:
missing_hours = PredictionModel.get_missing_hours(date, model_name)
if not missing_hours:
continue
print(f"为 {date} 生成 {model_name} 预测 (缺失 {len(missing_hours)} 小时)...")
# 获取足够的历史数据
target_date = datetime.strptime(date, '%Y-%m-%d')
df = self._get_historical_data(target_date)
if df is None or len(df) < 200:
print(f" 警告: 数据不足,无法为 {date} 生成预测")
continue
# 为每个缺失的小时生成预测
for hour in missing_hours:
self._generate_prediction_for_hour(df, date, hour, model_name)
finally:
# 移除处理标记
with PredictionService._prediction_lock:
self._processing_dates.discard(date)
def _get_historical_data(self, target_date: datetime) -> Optional[pd.DataFrame]:
"""获取指定日期的历史数据"""
try:
start_date = (target_date - timedelta(days=30)).strftime('%Y-%m-%d')
end_date = (target_date + timedelta(days=2)).strftime('%Y-%m-%d')
df = self.fetcher.fetch_ohlcv(
symbol='BTC',
timeframe='1h',
start_date=start_date,
end_date=end_date,
use_cache=True
)
return df
except Exception as e:
print(f"获取历史数据失败: {e}")
return None
def _generate_prediction_for_hour(self, df: pd.DataFrame, date: str,
hour: int, model_name: str) -> None:
"""
为指定小时生成预测
预测逻辑:
- 预测目标: {date} {hour}:00 的K线收盘价涨跌
- 输入数据: {hour-1}:00 及以前的K线数据 (不包含目标小时)
- 涨跌判断: 预测价格 vs 前一小时收盘价 (即 {hour-1}:00 的 close)
- 正确率验证: 目标小时的 close vs 前一小时的 close
Args:
df: 历史K线数据 (UTC时间戳)
date: 目标日期 (美东时间 YYYY-MM-DD)
hour: 目标小时 (美东时间 0-23)
model_name: 模型名称
"""
try:
# 1. 构建目标时间 (美东时间 -> UTC)
target_time_et = pd.Timestamp(f"{date} {hour:02d}:00:00", tz='America/New_York')
target_time_utc = target_time_et.tz_convert('UTC').tz_localize(None)
# 2. 确保 timestamp 列是 datetime 类型
if 'timestamp_dt' not in df.columns:
df = df.copy()
df['timestamp_dt'] = pd.to_datetime(df['timestamp'])
# 3. 找到目标小时的K线 (用于验证)
target_mask = df['timestamp_dt'] == target_time_utc
if not target_mask.any():
# 目标小时数据不存在,跳过
return
target_row = df[target_mask].iloc[0]
target_open = float(target_row['open'])
target_close = float(target_row['close'])
# 4. 找到前一小时的K线 (用于预测输入和基准价格)
prev_time_utc = target_time_utc - pd.Timedelta(hours=1)
prev_mask = df['timestamp_dt'] == prev_time_utc
if not prev_mask.any():
# 前一小时数据不存在,跳过
return
prev_row = df[prev_mask].iloc[0]
prev_close = float(prev_row['close'])
# 5. 获取预测输入数据: 前一小时及以前的所有数据
input_mask = df['timestamp_dt'] <= prev_time_utc
df_input = df[input_mask].copy()
if len(df_input) < 200:
# 数据不足,跳过
return
# 6. 调用模型预测
if model_name == "DLinear":
prediction, probability = self.predict_dlinear(df_input)
else: # TTM-R2
prediction, probability = self.predict_ttm(df_input)
# 7. 判断实际涨跌 (目标小时收盘价 vs 前一小时收盘价)
actual_result = 1 if target_close > prev_close else 0
# 8. 判断预测是否正确
is_correct = 1 if prediction == actual_result else 0
# 9. 保存预测结果
prediction_data = {
"timestamp": target_time_utc.isoformat(),
"timestamp_et": target_time_et.isoformat(),
"date": date,
"hour": hour,
"model_name": model_name,
"prediction": prediction,
"probability": probability,
"actual_result": actual_result,
"is_correct": is_correct,
"open_price": target_open,
"close_price": target_close
}
PredictionModel.save_prediction(prediction_data)
except Exception as e:
print(f"生成预测失败 ({date} {hour}:00 {model_name}): {e}")
def ensure_predictions_for_month(self, month: str) -> None:
"""
确保指定月份有预测数据
Args:
month: 月份 (YYYY-MM)
"""
# 使用锁避免并发冲突
with PredictionService._prediction_lock:
# 检查是否正在处理该月份
if month in self._processing_months:
return
# 标记正在处理
self._processing_months.add(month)
try:
# 解析月份
year, mon = map(int, month.split('-'))
# 计算该月的日期范围
from calendar import monthrange
_, last_day = monthrange(year, mon)
# 对于过去的日期,确保有预测数据
today = datetime.now(self.et_tz).date()
for day in range(1, last_day + 1):
date = f"{year}-{mon:02d}-{day:02d}"
date_obj = datetime.strptime(date, '%Y-%m-%d').date()
# 只处理过去和今天的日期
if date_obj <= today:
self.ensure_predictions_for_date(date)
finally:
# 移除处理标记
with PredictionService._prediction_lock:
self._processing_months.discard(month)
# 全局单例
_prediction_service: Optional[PredictionService] = None
def get_prediction_service() -> PredictionService:
"""获取预测服务单例"""
global _prediction_service
if _prediction_service is None:
_prediction_service = PredictionService()
return _prediction_service
+252
View File
@@ -0,0 +1,252 @@
/**
* BTC 预测交易看板 - Vue 3 应用
*/
const { createApp } = Vue;
const app = createApp({
data() {
return {
// 状态
loading: false,
activeTab: 'trades',
serverTime: '',
// 当前交易
currentTrade: null,
// 交易历史
tradeHistory: {
data: [],
page: 1,
limit: 10,
total: 0,
total_pages: 0
},
tradeFilter: {
month: '',
day: ''
},
// 准确率
accuracyFilter: {
month: this.getCurrentMonth()
},
monthlyStats: {},
dailyStats: {
data: [],
page: 1,
limit: 10,
total: 0,
total_pages: 0
},
// 弹窗数据
hourlyModal: {
date: '',
data: []
}
};
},
mounted() {
// 初始化月份筛选
this.accuracyFilter.month = this.getCurrentMonth();
// 加载数据
this.loadCurrentTrade();
this.loadTradeHistory(1);
// 每分钟更新时间
this.updateServerTime();
setInterval(() => this.updateServerTime(), 60000);
},
methods: {
// ==================== 工具方法 ====================
getCurrentMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
},
updateServerTime() {
const now = new Date();
this.serverTime = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
formatTime(timestamp, timestamp_cst) {
// 优先使用后端返回的 CST 时间
if (timestamp_cst) {
return timestamp_cst;
}
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
formatPercent(value) {
if (value === null || value === undefined) return '-';
return `${(value * 100).toFixed(1)}%`;
},
getPredictionClass(prediction) {
if (prediction === 'UP') return 'text-success fw-bold';
if (prediction === 'DOWN') return 'text-danger fw-bold';
return '';
},
getStatusClass(status) {
switch (status) {
case 'success': return 'text-success';
case 'test': return 'text-info';
case 'failure': return 'text-danger';
default: return 'text-muted';
}
},
getStatusText(status) {
switch (status) {
case 'success': return '成功';
case 'test': return '测试';
case 'failure': return '失败';
case 'in_progress': return '进行中';
default: return status || '-';
}
},
getAccuracyClass(accuracy) {
if (accuracy >= 60) return 'text-success fw-bold';
if (accuracy >= 50) return 'text-warning';
return 'text-danger';
},
// ==================== API 调用 ====================
async fetchAPI(url) {
try {
const response = await fetch(url);
const data = await response.json();
if (!data.success) {
console.error('API Error:', data);
}
return data;
} catch (error) {
console.error('Fetch Error:', error);
return { success: false, error: error.message };
}
},
async loadCurrentTrade() {
const data = await this.fetchAPI('/api/trades/current');
if (data.success) {
this.currentTrade = data.data;
}
},
async loadTradeHistory(page) {
if (page < 1) return;
this.loading = true;
let url = `/api/trades/history?page=${page}&limit=${this.tradeHistory.limit}`;
if (this.tradeFilter.month) {
url += `&month=${this.tradeFilter.month}`;
}
if (this.tradeFilter.day) {
url += `&day=${this.tradeFilter.day}`;
}
const data = await this.fetchAPI(url);
if (data.success) {
this.tradeHistory = {
data: data.data,
page: data.page,
limit: data.limit,
total: data.total,
total_pages: data.total_pages
};
}
this.loading = false;
},
clearTradeFilter() {
this.tradeFilter.month = '';
this.tradeFilter.day = '';
this.loadTradeHistory(1);
},
async loadAccuracyData() {
this.loading = true;
// 加载月度统计
const monthlyData = await this.fetchAPI(
`/api/accuracy/monthly?month=${this.accuracyFilter.month}`
);
if (monthlyData.success) {
this.monthlyStats = monthlyData.data || {};
}
// 加载每日统计
await this.loadDailyStats(1);
this.loading = false;
},
async loadDailyStats(page) {
if (page < 1) return;
const data = await this.fetchAPI(
`/api/accuracy/daily?month=${this.accuracyFilter.month}&page=${page}&limit=10`
);
if (data.success) {
this.dailyStats = {
data: data.data,
page: data.page,
limit: data.limit,
total: data.total,
total_pages: data.total_pages
};
}
},
async showHourlyDetails(date) {
this.loading = true;
this.hourlyModal.date = date;
const data = await this.fetchAPI(`/api/accuracy/hourly/${date}`);
if (data.success) {
this.hourlyModal.data = data.data || [];
}
this.loading = false;
// 显示弹窗
const modal = new bootstrap.Modal(document.getElementById('hourlyModal'));
modal.show();
}
},
watch: {
activeTab(newTab) {
if (newTab === 'accuracy' && !this.dailyStats.data.length) {
this.loadAccuracyData();
}
}
}
});
app.mount('#app');
+414
View File
@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BTC 预测交易看板</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
<div id="app">
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="#">
<i class="bi bi-currency-bitcoin"></i> BTC 预测交易看板
</a>
<span class="navbar-text text-light">
<i class="bi bi-clock"></i> {{ serverTime }}
</span>
</div>
</nav>
<!-- 主容器 -->
<div class="container mt-4">
<!-- Tab 导航 -->
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'trades' }"
@click="activeTab = 'trades'">
<i class="bi bi-graph-up"></i> 交易详情
</button>
</li>
<li class="nav-item">
<button class="nav-link" :class="{ active: activeTab === 'accuracy' }"
@click="activeTab = 'accuracy'">
<i class="bi bi-bullseye"></i> 模型准确率
</button>
</li>
</ul>
<!-- 交易详情 Tab -->
<div v-show="activeTab === 'trades'">
<!-- 当前交易卡片 -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<i class="bi bi-lightning-charge"></i> 当前小时交易
</div>
<div class="card-body">
<div v-if="currentTrade" class="row">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label">市场</div>
<div class="stat-value small">{{ currentTrade.market_title || '-' }}</div>
</div>
</div>
<div class="col-md-2">
<div class="stat-card">
<div class="stat-label">预测方向</div>
<div class="stat-value" :class="getPredictionClass(currentTrade.prediction)">
{{ currentTrade.prediction || '-' }}
</div>
</div>
</div>
<div class="col-md-2">
<div class="stat-card">
<div class="stat-label">概率</div>
<div class="stat-value">{{ formatPercent(currentTrade.probability) }}</div>
</div>
</div>
<div class="col-md-2">
<div class="stat-card">
<div class="stat-label">价格</div>
<div class="stat-value">${{ currentTrade.price?.toFixed(3) || '-' }}</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-label">状态</div>
<div class="stat-value" :class="getStatusClass(currentTrade.status)">
{{ getStatusText(currentTrade.status) }}
</div>
</div>
</div>
</div>
<div v-else class="text-center text-muted py-4">
<i class="bi bi-inbox"></i> 暂无交易记录
</div>
</div>
</div>
<!-- 历史交易 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history"></i> 历史交易记录</span>
<div class="d-flex gap-2">
<input type="month" class="form-control form-control-sm"
v-model="tradeFilter.month" @change="loadTradeHistory(1)"
style="width: 150px;">
<input type="date" class="form-control form-control-sm"
v-model="tradeFilter.day" @change="loadTradeHistory(1)"
style="width: 150px;">
<button class="btn btn-sm btn-outline-secondary" @click="clearTradeFilter">
<i class="bi bi-x-circle"></i> 清除
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0">
<thead class="table-light">
<tr>
<th>时间 (北京)</th>
<th>市场</th>
<th>预测</th>
<th>概率</th>
<th>价格</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="trade in tradeHistory.data" :key="trade.id">
<td>{{ formatTime(trade.timestamp, trade.timestamp_cst) }}</td>
<td class="text-truncate" style="max-width: 200px;">
{{ trade.market_title }}
</td>
<td :class="getPredictionClass(trade.prediction)">
<i :class="trade.prediction === 'UP' ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ trade.prediction }}
</td>
<td>{{ formatPercent(trade.probability) }}</td>
<td>${{ trade.price?.toFixed(3) }}</td>
<td :class="getStatusClass(trade.status)">
{{ getStatusText(trade.status) }}
</td>
</tr>
<tr v-if="!tradeHistory.data?.length">
<td colspan="6" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="text-muted">共 {{ tradeHistory.total }} 条</span>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item" :class="{ disabled: tradeHistory.page <= 1 }">
<a class="page-link" href="#" @click.prevent="loadTradeHistory(tradeHistory.page - 1)">
上一页
</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ tradeHistory.page }} / {{ tradeHistory.total_pages }}</span>
</li>
<li class="page-item" :class="{ disabled: tradeHistory.page >= tradeHistory.total_pages }">
<a class="page-link" href="#" @click.prevent="loadTradeHistory(tradeHistory.page + 1)">
下一页
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<!-- 模型准确率 Tab -->
<div v-show="activeTab === 'accuracy'">
<!-- 月份筛选 -->
<div class="mb-4">
<div class="d-flex gap-2 align-items-center">
<label class="form-label mb-0">选择月份:</label>
<input type="month" class="form-control" v-model="accuracyFilter.month"
@change="loadAccuracyData" style="width: 180px;">
</div>
</div>
<!-- 当月汇总卡片 -->
<div class="row mb-4">
<!-- DLinear 卡片 -->
<div class="col-md-6">
<div class="card h-100 border-primary">
<div class="card-header bg-primary text-white">
<i class="bi bi-graph-up-arrow"></i> DLinear 模型
</div>
<div class="card-body">
<div class="row text-center" v-if="monthlyStats.DLinear">
<div class="col-3">
<div class="stat-number">{{ monthlyStats.DLinear.total }}</div>
<div class="stat-label">预测次数</div>
</div>
<div class="col-3">
<div class="stat-number text-success">{{ monthlyStats.DLinear.correct }}</div>
<div class="stat-label">正确</div>
</div>
<div class="col-3">
<div class="stat-number text-danger">{{ monthlyStats.DLinear.incorrect }}</div>
<div class="stat-label">错误</div>
</div>
<div class="col-3">
<div class="stat-number text-primary">{{ monthlyStats.DLinear.accuracy }}%</div>
<div class="stat-label">准确率</div>
</div>
</div>
<div v-else class="text-center text-muted py-3">
暂无数据
</div>
</div>
</div>
</div>
<!-- TTM-R2 卡片 -->
<div class="col-md-6">
<div class="card h-100 border-success">
<div class="card-header bg-success text-white">
<i class="bi bi-cpu"></i> TTM-R2 模型
</div>
<div class="card-body">
<div class="row text-center" v-if="monthlyStats['TTM-R2']">
<div class="col-3">
<div class="stat-number">{{ monthlyStats['TTM-R2'].total }}</div>
<div class="stat-label">预测次数</div>
</div>
<div class="col-3">
<div class="stat-number text-success">{{ monthlyStats['TTM-R2'].correct }}</div>
<div class="stat-label">正确</div>
</div>
<div class="col-3">
<div class="stat-number text-danger">{{ monthlyStats['TTM-R2'].incorrect }}</div>
<div class="stat-label">错误</div>
</div>
<div class="col-3">
<div class="stat-number text-primary">{{ monthlyStats['TTM-R2'].accuracy }}%</div>
<div class="stat-label">准确率</div>
</div>
</div>
<div v-else class="text-center text-muted py-3">
暂无数据
</div>
</div>
</div>
</div>
</div>
<!-- 每日准确率表格 -->
<div class="card">
<div class="card-header">
<i class="bi bi-calendar3"></i> 每日准确率
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>日期</th>
<th>DLinear 预测</th>
<th>DLinear 准确率</th>
<th>TTM-R2 预测</th>
<th>TTM-R2 准确率</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="day in dailyStats.data" :key="day.date">
<td>{{ day.date }}</td>
<td>
<span v-if="day.DLinear">
{{ day.DLinear.correct }}/{{ day.DLinear.total }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="day.DLinear" :class="getAccuracyClass(day.DLinear.accuracy)">
{{ day.DLinear.accuracy }}%
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="day['TTM-R2']">
{{ day['TTM-R2'].correct }}/{{ day['TTM-R2'].total }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="day['TTM-R2']" :class="getAccuracyClass(day['TTM-R2'].accuracy)">
{{ day['TTM-R2'].accuracy }}%
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<button class="btn btn-sm btn-outline-primary"
@click="showHourlyDetails(day.date)">
<i class="bi bi-eye"></i> 查看
</button>
</td>
</tr>
<tr v-if="!dailyStats.data?.length">
<td colspan="6" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="text-muted">共 {{ dailyStats.total }} 天</span>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item" :class="{ disabled: dailyStats.page <= 1 }">
<a class="page-link" href="#" @click.prevent="loadDailyStats(dailyStats.page - 1)">
上一页
</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ dailyStats.page }} / {{ dailyStats.total_pages }}</span>
</li>
<li class="page-item" :class="{ disabled: dailyStats.page >= dailyStats.total_pages }">
<a class="page-link" href="#" @click.prevent="loadDailyStats(dailyStats.page + 1)">
下一页
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
<!-- 每小时详情弹窗 -->
<div class="modal fade" id="hourlyModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clock"></i> {{ hourlyModal.date }} 每小时预测详情
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th>时间 (北京)</th>
<th>实际涨跌</th>
<th>DLinear</th>
<th>TTM-R2</th>
</tr>
</thead>
<tbody>
<tr v-for="hour in hourlyModal.data" :key="hour.hour">
<td>{{ hour.timestamp_cst || (hour.hour + ':00') }}</td>
<td :class="hour.actual_result === 1 ? 'text-success' : 'text-danger'">
<i :class="hour.actual_result === 1 ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ hour.actual_result === 1 ? 'UP' : 'DOWN' }}
</td>
<td>
<span v-if="hour.models?.DLinear"
:class="hour.models.DLinear.is_correct ? 'text-success' : 'text-danger'">
<i :class="hour.models.DLinear.prediction === 1 ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ hour.models.DLinear.prediction === 1 ? 'UP' : 'DOWN' }}
<i :class="hour.models.DLinear.is_correct ? 'bi-check-circle' : 'bi-x-circle'"></i>
</span>
<span v-else class="text-muted">-</span>
</td>
<td>
<span v-if="hour.models?.['TTM-R2']"
:class="hour.models['TTM-R2'].is_correct ? 'text-success' : 'text-danger'">
<i :class="hour.models['TTM-R2'].prediction === 1 ? 'bi-arrow-up' : 'bi-arrow-down'"></i>
{{ hour.models['TTM-R2'].prediction === 1 ? 'UP' : 'DOWN' }}
<i :class="hour.models['TTM-R2'].is_correct ? 'bi-check-circle' : 'bi-x-circle'"></i>
</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
<tr v-if="!hourlyModal.data?.length">
<td colspan="4" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- Loading 遮罩 -->
<div v-if="loading" class="loading-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
</div>
</div>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- Vue 3 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.15/dist/vue.global.prod.js"></script>
<!-- App JS -->
<script src="/static/app.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
/**
* BTC 预测交易看板 - 自定义样式
*/
/* 全局样式 */
body {
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 导航栏 */
.navbar-brand {
font-weight: 600;
}
.navbar-brand i {
color: #f7931a;
}
/* Tab 样式 */
.nav-tabs .nav-link {
color: #6c757d;
border: none;
padding: 0.75rem 1.5rem;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
border-bottom: 3px solid #0d6efd;
background: transparent;
}
.nav-tabs .nav-link:hover:not(.active) {
border-bottom: 3px solid #dee2e6;
}
/* 卡片样式 */
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 0.5rem;
}
.card-header {
font-weight: 600;
border-radius: 0.5rem 0.5rem 0 0 !important;
}
/* 统计卡片 */
.stat-card {
padding: 1rem;
text-align: center;
background: #f8f9fa;
border-radius: 0.5rem;
height: 100%;
}
.stat-label {
font-size: 0.75rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
}
.stat-number {
font-size: 1.75rem;
font-weight: 700;
}
/* 表格样式 */
.table {
margin-bottom: 0;
}
.table th {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #6c757d;
border-bottom: 2px solid #dee2e6;
}
.table td {
vertical-align: middle;
}
.table-hover tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
/* 涨跌颜色 */
.text-success {
color: #198754 !important;
}
.text-danger {
color: #dc3545 !important;
}
/* 分页 */
.pagination {
margin-bottom: 0;
}
.page-link {
color: #0d6efd;
}
.page-item.disabled .page-link {
color: #6c757d;
}
/* Loading 遮罩 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
/* 弹窗 */
.modal-content {
border-radius: 0.5rem;
}
.modal-header {
background: #f8f9fa;
border-radius: 0.5rem 0.5rem 0 0;
}
/* 响应式调整 */
@media (max-width: 768px) {
.stat-card {
margin-bottom: 1rem;
}
.card-header .d-flex {
flex-direction: column;
gap: 0.5rem !important;
}
.nav-tabs .nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
/* 准确率颜色 */
.accuracy-high {
color: #198754;
font-weight: 600;
}
.accuracy-medium {
color: #fd7e14;
font-weight: 600;
}
.accuracy-low {
color: #dc3545;
font-weight: 600;
}
/* 边框颜色增强 */
.border-primary {
border-color: #0d6efd !important;
border-width: 2px !important;
}
.border-success {
border-color: #198754 !important;
border-width: 2px !important;
}
/* 按钮样式 */
.btn-outline-primary:hover {
color: #fff;
}
/* 截断文本 */
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 间距调整 */
.gap-2 {
gap: 0.5rem !important;
}