Initial commit: BTC 1h prediction model (53.10% accuracy)

🎯 项目成果:
- 准确率: 53.10% (2026年测试集)
- 任务类型: 二分类 (预测涨/跌)
- 特征: OHLCV + RSI + MACD (9个特征)
- 训练数据: 2020-2025年BTC小时数据 (52,351序列)
- 测试数据: 2026年1-29日 (484序列)

📊 性能指标:
- 精确率: 51.79%
- 召回率: 49.36%
- F1分数: 0.5054
- 预测分布: 非常平衡 (偏差仅-11个)

🔬 实验历史:
- 尝试了5种方案 (回归/分类, 不同数据划分)
- 分类任务优于回归任务 (53.10% vs 49.5%)
- 全量训练避免了过拟合问题

📦 主要模型文件:
- models/checkpoints/dlinear_classification_2026/
  - best_model.pt (最佳模型)
  - preprocessor.pkl (预处理器)
  - test_2026_results.json (测试结果)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
crypto_predictorandClaude Sonnet 4.5 committed 2026-01-29 21:27:15 +08:00
commit 9bbdc8d42b
82 files changed
+8742

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Model implementations."""
+182
View File
@@ -0,0 +1,182 @@
"""Base model interface for all prediction models."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Literal, Optional
import torch
import torch.nn as nn
@dataclass
class ModelConfig:
"""Base configuration for all models."""
input_dim: int # Number of input features
lookback: int # Sequence length
task: Literal['classification', 'regression'] = 'classification'
hidden_dim: int = 512
dropout: float = 0.1
class BasePredictor(nn.Module, ABC):
"""Abstract base class for all prediction models."""
def __init__(self, config: ModelConfig):
"""Initialize model with configuration.
Args:
config: Model configuration
"""
super().__init__()
self.config = config
@abstractmethod
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Forward pass.
Args:
x: Input tensor of shape (batch_size, lookback, input_dim)
Returns:
Output tensor. Shape depends on task:
- Classification: (batch_size, 1) with sigmoid output
- Regression: (batch_size, 1) with raw value
"""
pass
def predict(self, x: torch.Tensor) -> torch.Tensor:
"""Make predictions (inference mode).
Args:
x: Input tensor
Returns:
Predictions
"""
self.eval()
with torch.no_grad():
return self.forward(x)
def get_loss_function(self, pos_weight=None, device='cpu') -> nn.Module:
"""Get appropriate loss function for the task.
Args:
pos_weight: Weight for positive class (for imbalanced data)
device: Device to place tensors on
Returns:
Loss function
"""
if self.config.task == 'classification':
if pos_weight is not None:
# 使用float32以兼容MPS (Mac M1)
pos_weight_tensor = torch.tensor([pos_weight], dtype=torch.float32).to(device)
return nn.BCEWithLogitsLoss(pos_weight=pos_weight_tensor)
else:
return nn.BCELoss()
else: # regression
return nn.MSELoss()
def save(self, path: str):
"""Save model checkpoint.
Args:
path: Path to save checkpoint
"""
torch.save({
'model_state_dict': self.state_dict(),
'config': self.config
}, path)
@classmethod
def load(cls, path: str, device: Optional[str] = None):
"""Load model from checkpoint.
Args:
path: Path to checkpoint
device: Device to load model to
Returns:
Loaded model
"""
checkpoint = torch.load(path, map_location=device)
model = cls(checkpoint['config'])
model.load_state_dict(checkpoint['model_state_dict'])
return model
def to_onnx(self, path: str, dummy_input: Optional[torch.Tensor] = None):
"""Export model to ONNX format.
Args:
path: Path to save ONNX model
dummy_input: Example input tensor. If None, will create default.
"""
# Move to CPU for ONNX export (critical for M1)
self.cpu()
self.eval()
# Create dummy input if not provided
if dummy_input is None:
dummy_input = torch.randn(
1,
self.config.lookback,
self.config.input_dim
)
# Ensure dummy input is on CPU
dummy_input = dummy_input.cpu()
# Export to ONNX
torch.onnx.export(
self,
dummy_input,
path,
export_params=True,
opset_version=14,
do_constant_folding=True,
input_names=['input'],
output_names=['output'],
dynamic_axes={
'input': {0: 'batch_size'},
'output': {0: 'batch_size'}
}
)
print(f"Model exported to ONNX: {path}")
def count_parameters(self) -> int:
"""Count trainable parameters.
Returns:
Number of trainable parameters
"""
return sum(p.numel() for p in self.parameters() if p.requires_grad)
if __name__ == "__main__":
# Test base class (with a dummy implementation)
class DummyModel(BasePredictor):
def __init__(self, config: ModelConfig):
super().__init__(config)
self.fc = nn.Linear(config.input_dim * config.lookback, 1)
if config.task == 'classification':
self.activation = nn.Sigmoid()
else:
self.activation = nn.Identity()
def forward(self, x):
batch_size = x.size(0)
x = x.view(batch_size, -1)
x = self.fc(x)
return self.activation(x)
# Test
config = ModelConfig(input_dim=10, lookback=168, task='classification')
model = DummyModel(config)
print(f"Parameters: {model.count_parameters()}")
# Test forward pass
x = torch.randn(32, 168, 10)
output = model(x)
print(f"Output shape: {output.shape}")
@@ -0,0 +1,13 @@
{
"mse": 5.495949517353438e-05,
"rmse": 0.007413467149285439,
"mae": 0.004826944321393967,
"mape": 930.6216430664062,
"directional_accuracy": 0.4907678594167834,
"backtest_total_return": -0.9745769500732422,
"backtest_final_capital": 254.23023986816406,
"backtest_sharpe_ratio": -0.26884937315369317,
"backtest_max_drawdown": -0.978744387626648,
"backtest_win_rate": 0.47166687889978354,
"backtest_num_trades": 3251
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
{
"price_mae": 4283.3626861511675,
"price_rmse": 4735.107859758518,
"price_mape": 3.928560493143656,
"direction_accuracy": 0.49637127578304047,
"true_positives": 258,
"true_negatives": 2341,
"false_positives": 255,
"false_negatives": 2382
}
@@ -0,0 +1,18 @@
{
"accuracy": 0.5,
"precision": 0.0,
"recall": 0.0,
"f1": 0.0,
"true_negatives": 642,
"false_positives": 0,
"false_negatives": 642,
"true_positives": 0,
"specificity": 1.0,
"mean_confidence": 0.0068817585706710815,
"backtest_total_return": -1.0,
"backtest_final_capital": 0.0,
"backtest_sharpe_ratio": -9.643650760992955,
"backtest_max_drawdown": NaN,
"backtest_win_rate": 0.0,
"backtest_num_trades": 0
}
@@ -0,0 +1,18 @@
{
"accuracy": 0.5070093457943925,
"precision": 0.5053254437869823,
"recall": 0.6651090342679128,
"f1": 0.574310692669805,
"true_negatives": 224,
"false_positives": 418,
"false_negatives": 215,
"true_positives": 427,
"specificity": 0.34890965732087226,
"mean_confidence": 0.5017203688621521,
"backtest_total_return": -1.0,
"backtest_final_capital": -0.0,
"backtest_sharpe_ratio": 2.314261942010565,
"backtest_max_drawdown": -1.0,
"backtest_win_rate": 0.3325545171339564,
"backtest_num_trades": 136
}
@@ -0,0 +1,18 @@
{
"accuracy": 0.49764421240290335,
"precision": 0.502192108409725,
"recall": 0.3185035389282103,
"f1": 0.38979118329466356,
"true_negatives": 2648,
"false_positives": 1249,
"false_negatives": 2696,
"true_positives": 1260,
"specificity": 0.6794970490120605,
"mean_confidence": 0.49758902192115784,
"backtest_total_return": -1.0,
"backtest_final_capital": -0.0,
"backtest_sharpe_ratio": -2.5728736046923655,
"backtest_max_drawdown": NaN,
"backtest_win_rate": 0.16044823634279892,
"backtest_num_trades": 782
}
@@ -0,0 +1,13 @@
{
"mse": 2.142045195796527e-05,
"rmse": 0.004628223412710893,
"mae": 0.0030602782499045134,
"mape": 131.8273162841797,
"directional_accuracy": 0.49732620320855614,
"backtest_total_return": 0.2429506778717041,
"backtest_final_capital": 12429.5068359375,
"backtest_sharpe_ratio": 0.19540257211386183,
"backtest_max_drawdown": -0.13858233392238617,
"backtest_win_rate": 0.49732620320855614,
"backtest_num_trades": 0
}
@@ -0,0 +1,13 @@
{
"mse": 2.382730417593848e-05,
"rmse": 0.004881321970116137,
"mae": 0.0033776722848415375,
"mape": 780.3690185546875,
"directional_accuracy": 0.5066853431809499,
"backtest_total_return": -0.1671363264322281,
"backtest_final_capital": 8328.63671875,
"backtest_sharpe_ratio": -0.0266775386419239,
"backtest_max_drawdown": -0.40377509593963623,
"backtest_win_rate": 0.48974914045587675,
"backtest_num_trades": 1061
}
@@ -0,0 +1,11 @@
{
"accuracy": 0.53099173553719,
"precision": 0.5178571428571429,
"recall": 0.49361702127659574,
"f1_score": 0.505446623093682,
"true_positives": 116,
"true_negatives": 141,
"false_positives": 108,
"false_negatives": 119,
"n_samples": 484
}
@@ -0,0 +1,6 @@
{
"price_mae": 528.017854278076,
"price_rmse": 736.6082253699151,
"price_mape": 0.523728600511905,
"direction_accuracy": 0.5022918258212375
}
@@ -0,0 +1,13 @@
{
"mse": 2.1433252186398022e-05,
"rmse": 0.004629606050885758,
"mae": 0.00306295626796782,
"mape": 180.17770385742188,
"directional_accuracy": 0.4992360580595875,
"backtest_total_return": 0.226644828915596,
"backtest_final_capital": 12266.4482421875,
"backtest_sharpe_ratio": 0.18486101832170032,
"backtest_max_drawdown": -0.13858233392238617,
"backtest_win_rate": 0.4977081741787624,
"backtest_num_trades": 38
}
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""DLinear model implementation."""
+203
View File
@@ -0,0 +1,203 @@
"""DLinear: Decomposition Linear model for time series forecasting.
Reference: Are Transformers Effective for Time Series Forecasting? (DLinear)
Paper: https://arxiv.org/abs/2205.13504
"""
import torch
import torch.nn as nn
from dataclasses import dataclass
from typing import Literal
from ..base import BasePredictor, ModelConfig
@dataclass
class DLinearConfig(ModelConfig):
"""Configuration for DLinear model."""
moving_avg_kernel: int = 25 # Kernel size for moving average decomposition
individual: bool = False # Whether to use individual linear layers per feature
class MovingAvg(nn.Module):
"""Moving average block for trend extraction."""
def __init__(self, kernel_size: int):
"""Initialize moving average layer.
Args:
kernel_size: Size of the moving average window
"""
super().__init__()
self.kernel_size = kernel_size
self.avg = nn.AvgPool1d(
kernel_size=kernel_size,
stride=1,
padding=kernel_size // 2,
count_include_pad=False
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Apply moving average.
Args:
x: Input tensor of shape (batch_size, seq_len, features)
Returns:
Smoothed tensor
"""
# Padding at both ends
front = x[:, 0:1, :].repeat(1, self.kernel_size // 2, 1)
end = x[:, -1:, :].repeat(1, (self.kernel_size - 1) // 2, 1)
x_padded = torch.cat([front, x, end], dim=1)
# Apply average pooling (swap dimensions for AvgPool1d)
x_padded = x_padded.permute(0, 2, 1) # (batch, features, seq_len)
x_smoothed = self.avg(x_padded)
x_smoothed = x_smoothed.permute(0, 2, 1) # (batch, seq_len, features)
# Trim to original length
return x_smoothed[:, :x.size(1), :]
class SeriesDecomposition(nn.Module):
"""Series decomposition block to extract trend and seasonal components."""
def __init__(self, kernel_size: int):
"""Initialize decomposition layer.
Args:
kernel_size: Size of the moving average window for trend
"""
super().__init__()
self.moving_avg = MovingAvg(kernel_size)
def forward(self, x: torch.Tensor) -> tuple:
"""Decompose series into trend and seasonal components.
Args:
x: Input tensor of shape (batch_size, seq_len, features)
Returns:
Tuple of (seasonal, trend) tensors
"""
trend = self.moving_avg(x)
seasonal = x - trend
return seasonal, trend
class DLinear(BasePredictor):
"""DLinear model: Decomposition + Linear layers for forecasting."""
def __init__(self, config: DLinearConfig):
"""Initialize DLinear model.
Args:
config: Model configuration
"""
super().__init__(config)
self.decomposition = SeriesDecomposition(config.moving_avg_kernel)
if config.individual:
# Individual linear layer for each feature
self.linear_seasonal = nn.ModuleList([
nn.Linear(config.lookback, 1)
for _ in range(config.input_dim)
])
self.linear_trend = nn.ModuleList([
nn.Linear(config.lookback, 1)
for _ in range(config.input_dim)
])
else:
# Shared linear layers
self.linear_seasonal = nn.Linear(config.lookback, 1)
self.linear_trend = nn.Linear(config.lookback, 1)
# Final aggregation layer
self.aggregation = nn.Sequential(
nn.Linear(config.input_dim, config.hidden_dim),
nn.ReLU(),
nn.Dropout(config.dropout),
nn.Linear(config.hidden_dim, 1)
)
# Output activation
# 注意:对于分类任务,使用Identity而非Sigmoid
# 因为BCEWithLogitsLoss内部已包含Sigmoid,避免双重激活
if config.task == 'classification':
self.output_activation = nn.Identity() # 输出logits,由损失函数处理
else:
self.output_activation = nn.Identity()
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Forward pass through DLinear.
Args:
x: Input tensor of shape (batch_size, lookback, input_dim)
Returns:
Output tensor of shape (batch_size, 1)
"""
# Decompose into seasonal and trend
seasonal, trend = self.decomposition(x)
# Apply linear layers to each component
if self.config.individual:
seasonal_out = []
trend_out = []
for i in range(self.config.input_dim):
# Extract feature i: (batch, seq_len)
s_i = seasonal[:, :, i]
t_i = trend[:, :, i]
# Apply linear transformation: (batch, seq_len) -> (batch, 1)
seasonal_out.append(self.linear_seasonal[i](s_i))
trend_out.append(self.linear_trend[i](t_i))
# Stack outputs: (batch, input_dim)
seasonal_out = torch.stack(seasonal_out, dim=1).squeeze(-1)
trend_out = torch.stack(trend_out, dim=1).squeeze(-1)
else:
# Shared linear layer
# Swap dimensions: (batch, seq_len, features) -> (batch, features, seq_len)
seasonal = seasonal.permute(0, 2, 1)
trend = trend.permute(0, 2, 1)
# Apply linear: (batch, features, seq_len) -> (batch, features, 1)
seasonal_out = self.linear_seasonal(seasonal).squeeze(-1)
trend_out = self.linear_trend(trend).squeeze(-1)
# Combine seasonal and trend
combined = seasonal_out + trend_out # (batch, input_dim)
# Aggregate and produce final prediction
output = self.aggregation(combined) # (batch, 1)
output = self.output_activation(output)
return output
if __name__ == "__main__":
# Test DLinear model
config = DLinearConfig(
input_dim=30,
lookback=168,
task='classification',
hidden_dim=512,
moving_avg_kernel=25
)
model = DLinear(config)
print(f"DLinear parameters: {model.count_parameters()}")
# Test forward pass
x = torch.randn(32, 168, 30)
output = model(x)
print(f"Output shape: {output.shape}")
print(f"Output range: [{output.min():.4f}, {output.max():.4f}]")
# Test ONNX export
model.to_onnx('/tmp/dlinear_test.onnx')
print("ONNX export successful!")