feat: Add MySQL support and migration script

This commit is contained in:
crypto_predictor committed 2026-02-06 16:35:08 +08:00
1 parent 006ccb2a1d
commit 8de6c54887
4 files changed
+427 -93

No files matched your search

+106 -44
View File
@@ -1,22 +1,44 @@
import sqlite3
import pymysql
import pandas as pd
from pathlib import Path
from typing import Optional, List, Tuple
from contextlib import contextmanager
import os
from dotenv import load_dotenv
load_dotenv()
class DatabaseManager:
"""Manages SQLite database for storing OHLCV data."""
"""Manages database for storing OHLCV data.
Supports SQLite (local) and MySQL (remote).
"""
def __init__(self, db_path: str = "./data/market_data.db"):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.use_mysql = bool(os.getenv('MYSQL_HOST'))
if not self.use_mysql:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.initialize_db()
@contextmanager
def get_connection(self):
"""Context manager for database connection."""
conn = sqlite3.connect(self.db_path)
if self.use_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',
# pandas requires standard cursor or handling
)
else:
conn = sqlite3.connect(self.db_path)
try:
yield conn
finally:
@@ -27,27 +49,41 @@ class DatabaseManager:
with self.get_connection() as conn:
cursor = conn.cursor()
# Create OHLCV table
# Composite primary key ensures uniqueness for a specific candle
cursor.execute("""
CREATE TABLE IF NOT EXISTS ohlcv (
timestamp DATETIME,
symbol TEXT,
timeframe TEXT,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
PRIMARY KEY (symbol, timeframe, timestamp)
)
""")
# Create index for faster querying by time range
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_ohlcv_time
ON ohlcv (symbol, timeframe, timestamp)
""")
if self.use_mysql:
# MySQL Schema
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_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()
@@ -61,7 +97,7 @@ class DatabaseManager:
if not pd.api.types.is_datetime64_any_dtype(df['timestamp']):
df['timestamp'] = pd.to_datetime(df['timestamp'])
# Convert to string for SQLite compatibility (ISO 8601)
# Convert to string for SQLite compatibility (ISO 8601) or MySQL
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
# Add metadata columns if they don't exist
@@ -71,38 +107,62 @@ class DatabaseManager:
df['timeframe'] = timeframe
# Prepare data for insertion (ensure order)
records = df[['timestamp', 'symbol', 'timeframe', 'open', 'high', 'low', 'close', 'volume']].to_records(index=False)
# 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']
data = [tuple(row) for row in df[cols].values]
with self.get_connection() as conn:
cursor = conn.cursor()
# efficiently insert or replace
# sqlite3 adapter handles datetime objects correctly
cursor.executemany("""
INSERT OR REPLACE INTO ohlcv
(timestamp, symbol, timeframe, open, high, low, close, volume)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", records)
if self.use_mysql:
# MySQL uses %s and REPLACE INTO
sql = """
REPLACE INTO ohlcv
(timestamp, symbol, timeframe, open, high, low, close, volume)
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()
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."""
query = """
placeholder = '%s' if self.use_mysql else '?'
query = f"""
SELECT timestamp, open, high, low, close, volume
FROM ohlcv
WHERE symbol = ? AND timeframe = ?
WHERE symbol = {placeholder} AND timeframe = {placeholder}
"""
params = [symbol, timeframe]
if start_date:
query += " AND timestamp >= ?"
query += f" AND timestamp >= {placeholder}"
params.append(start_date)
if end_date:
# Handle 'YYYY-MM-DD' vs full timestamp
if len(str(end_date)) == 10: # YYYY-MM-DD
query += " AND timestamp < date(?, '+1 day')"
# SQLite has date(), MySQL uses DATE() or just string comparison
if self.use_mysql:
query += f" AND timestamp < DATE_ADD({placeholder}, INTERVAL 1 DAY)"
else:
query += f" AND timestamp < date({placeholder}, '+1 day')"
else:
query += " AND timestamp <= ?"
query += f" AND timestamp <= {placeholder}"
params.append(end_date)
query += " ORDER BY timestamp ASC"
@@ -114,26 +174,28 @@ class DatabaseManager:
def get_latest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
"""Get the latest timestamp for a symbol/timeframe."""
placeholder = '%s' if self.use_mysql else '?'
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT MAX(timestamp)
FROM ohlcv
WHERE symbol = ? AND timeframe = ?
WHERE symbol = {placeholder} AND timeframe = {placeholder}
""", (symbol, timeframe))
result = cursor.fetchone()
return pd.to_datetime(result[0]) if result[0] else None
return pd.to_datetime(result[0]) if result and result[0] else None
def get_earliest_timestamp(self, symbol: str, timeframe: str) -> Optional[pd.Timestamp]:
"""Get the earliest timestamp for a symbol/timeframe."""
placeholder = '%s' if self.use_mysql else '?'
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
SELECT MIN(timestamp)
FROM ohlcv
WHERE symbol = ? AND timeframe = ?
WHERE symbol = {placeholder} AND timeframe = {placeholder}
""", (symbol, timeframe))
result = cursor.fetchone()
return pd.to_datetime(result[0]) if result[0] else None
return pd.to_datetime(result[0]) if result and result[0] else None
+119 -49
View File
@@ -1,20 +1,50 @@
import sqlite3
import pymysql
from datetime import datetime
import json
import os
from contextlib import contextmanager
from dotenv import load_dotenv
load_dotenv()
class TradingDatabase:
"""交易数据库管理类 - 用于记录交易历史和预测结果"""
"""交易数据库管理类 - 用于记录交易历史和预测结果
支持 SQLite (本地) 和 MySQL (远程)。
如果 .env 中配置了 MYSQL_HOST,则优先使用 MySQL。
"""
def __init__(self, db_path="trading_history.db"):
self.db_path = db_path
self.use_mysql = bool(os.getenv('MYSQL_HOST'))
self._init_db()
@contextmanager
def get_connection(self):
"""数据库连接上下文管理器"""
conn = sqlite3.connect(self.db_path)
if self.use_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
)
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:
yield conn
finally:
@@ -24,49 +54,84 @@ class TradingDatabase:
"""初始化数据库表结构"""
with self.get_connection() as conn:
cursor = conn.cursor()
# 创建交易记录表
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
)
''')
if self.use_mysql:
# MySQL Schema
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;
''')
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()
def log_trade(self, trade_data: dict):
"""
记录交易信息
Args:
trade_data: 包含交易信息的字典,支持字段:
timestamp, market_title, market_slug, model_name,
prediction, probability, action, price, amount, size,
status, error_message, raw_data
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO trades (
timestamp, market_title, market_slug, model_name,
prediction, probability, action, price, amount, size,
status, error_message, raw_data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
trade_data.get('timestamp', datetime.now().isoformat()),
# Prepare data
timestamp = trade_data.get('timestamp', datetime.now().isoformat())
raw_data_json = json.dumps(trade_data.get('raw_data', {}), ensure_ascii=False)
if self.use_mysql:
sql = '''
INSERT INTO trades (
timestamp, market_title, market_slug, model_name,
prediction, probability, action, price, amount, size,
status, error_message, raw_data
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
'''
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, (
timestamp,
trade_data.get('market_title'),
trade_data.get('market_slug'),
trade_data.get('model_name'),
@@ -78,7 +143,7 @@ class TradingDatabase:
trade_data.get('size'),
trade_data.get('status'),
trade_data.get('error_message'),
json.dumps(trade_data.get('raw_data', {}), ensure_ascii=False)
raw_data_json
))
conn.commit()
except Exception as e:
@@ -87,22 +152,27 @@ class TradingDatabase:
def check_existing_trade(self, market_slug: str, status: str = 'success') -> bool:
"""
检查指定市场是否已有成功交易记录
Args:
market_slug: 市场slug
status: 交易状态 (默认 'success', 也可以是 'test')
Returns:
True 如果已有成功交易, 否则 False
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT COUNT(*) FROM trades
WHERE market_slug = ? AND status = ?
''', (market_slug, status))
count = cursor.fetchone()[0]
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))
# Fetch result compatible with both
result = cursor.fetchone()
if isinstance(result, dict): # If DictCursor was used (not used here but for safety)
count = list(result.values())[0]
elif isinstance(result, (list, tuple)):
count = result[0]
else:
count = 0
return count > 0
except Exception as e:
print(f"检查已存在交易失败: {e}")