Files
NexQuant/rdagent/components/backtesting/results_db.py
T
TPTBusiness 633b5639de fix: Add nosec comments for schema migration SQL in results_db.py
Bandit false positive B608: Schema migration uses controlled column names,
not user input. Add nosec comments to suppress warning.
2026-04-03 14:37:22 +02:00

292 lines
10 KiB
Python

"""
Predix Results Database - SQLite für Backtest-Ergebnisse
Stores backtest metrics from Qlib/MLflow runs for querying and dashboard display.
"""
import sqlite3
import json
import pandas as pd
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional
class ResultsDatabase:
def __init__(self, db_path: Optional[str] = None):
if db_path is None:
db_path = Path(__file__).parent.parent.parent / "results" / "db" / "backtest_results.db"
self.db_path = db_path
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(db_path)
self._create_tables()
def _create_tables(self):
"""Create database tables if they don't exist and migrate schema if needed."""
c = self.conn.cursor()
# Factors table - stores factor metadata
c.execute("""CREATE TABLE IF NOT EXISTS factors (
id INTEGER PRIMARY KEY,
factor_name TEXT UNIQUE,
factor_type TEXT,
created_at TIMESTAMP
)""")
# Backtest runs table - stores individual backtest metrics
c.execute("""CREATE TABLE IF NOT EXISTS backtest_runs (
id INTEGER PRIMARY KEY,
factor_id INTEGER,
run_name TEXT,
run_date TIMESTAMP,
ic REAL,
sharpe REAL,
annual_return REAL,
max_drawdown REAL,
win_rate REAL,
FOREIGN KEY (factor_id) REFERENCES factors(id)
)""")
# Loop results table - stores overall loop statistics
c.execute("""CREATE TABLE IF NOT EXISTS loop_results (
id INTEGER PRIMARY KEY,
loop_index INTEGER,
factors_success INTEGER,
factors_fail INTEGER,
success_rate REAL,
best_ic REAL,
status TEXT
)""")
# Migrate schema: add new columns if they don't exist
self._add_column_if_not_exists('backtest_runs', 'information_ratio', 'REAL')
self._add_column_if_not_exists('backtest_runs', 'volatility', 'REAL')
self._add_column_if_not_exists('backtest_runs', 'raw_metrics', 'TEXT')
# Create indexes for common queries
c.execute("""CREATE INDEX IF NOT EXISTS idx_backtest_ic ON backtest_runs(ic)""")
c.execute("""CREATE INDEX IF NOT EXISTS idx_backtest_sharpe ON backtest_runs(sharpe)""")
c.execute("""CREATE INDEX IF NOT EXISTS idx_backtest_date ON backtest_runs(run_date)""")
self.conn.commit()
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
"""
Add a column to a table if it doesn't already exist.
Parameters
----------
table : str
Table name
column : str
Column name to add
col_type : str
SQL column type (e.g., 'REAL', 'TEXT')
"""
c = self.conn.cursor()
try:
# Try to query the column - if it fails, it doesn't exist
# nosec B608: Internal schema migration, column names are controlled
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608
except sqlite3.OperationalError:
# Column doesn't exist, add it
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
def add_factor(self, name: str, type: str = "unknown") -> int:
c = self.conn.cursor()
c.execute("INSERT OR IGNORE INTO factors (factor_name, factor_type, created_at) VALUES (?, ?, ?)",
(name, type, datetime.now()))
c.execute("SELECT id FROM factors WHERE factor_name = ?", (name,))
self.conn.commit()
result = c.fetchone()
return result[0] if result else -1
def add_backtest(self, factor_name: str, metrics: Dict) -> int:
"""
Add a backtest result to the database.
Parameters
----------
factor_name : str
Name of the factor (max 100 chars)
metrics : Dict
Dictionary containing metrics like ic, sharpe_ratio, etc.
Returns
-------
int
The ID of the inserted backtest run
"""
factor_id = self.add_factor(factor_name)
if factor_id <= 0:
return -1
c = self.conn.cursor()
# Serialize raw_metrics if present
raw_metrics_json = None
if "raw_metrics" in metrics:
try:
# Convert all values to native Python types for JSON serialization
raw = metrics["raw_metrics"]
raw_metrics_json = json.dumps({
k: (float(v) if v is not None else None)
for k, v in raw.items()
})
except Exception:
raw_metrics_json = None
c.execute(
"""INSERT INTO backtest_runs
(factor_id, run_name, run_date, ic, sharpe, annual_return, max_drawdown,
win_rate, information_ratio, volatility, raw_metrics)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
factor_id,
f"{factor_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
datetime.now(),
metrics.get('ic'),
metrics.get('sharpe_ratio'),
metrics.get('annualized_return'),
metrics.get('max_drawdown'),
metrics.get('win_rate'),
metrics.get('information_ratio'),
metrics.get('volatility'),
raw_metrics_json,
)
)
self.conn.commit()
return c.lastrowid
def add_loop(self, loop_idx: int, success: int, fail: int, best_ic: float = None, status: str = "completed") -> int:
c = self.conn.cursor()
rate = success / (success + fail) if (success + fail) > 0 else 0
c.execute("""INSERT INTO loop_results (loop_index, factors_success, factors_fail, success_rate, best_ic, status)
VALUES (?, ?, ?, ?, ?, ?)""", (loop_idx, success, fail, rate, best_ic, status))
self.conn.commit()
return c.lastrowid
def get_top_factors(self, metric: str = 'sharpe', limit: int = 20) -> pd.DataFrame:
"""
Get top performing factors sorted by specified metric.
Parameters
----------
metric : str
Metric to sort by ('sharpe', 'ic', 'annual_return', 'max_drawdown')
limit : int
Number of top factors to return
Returns
-------
pd.DataFrame
DataFrame with factor names and metrics
"""
# Map shorthand to full column name
metric_map = {
'sharpe': 'sharpe',
'ic': 'ic',
'return': 'annual_return',
'drawdown': 'max_drawdown',
'win_rate': 'win_rate',
'information_ratio': 'information_ratio',
}
col = metric_map.get(metric, metric)
return pd.read_sql_query(
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
win_rate, information_ratio, volatility, run_date
FROM backtest_runs
JOIN factors ON factor_id = factors.id
WHERE {col} IS NOT NULL
ORDER BY {col} DESC
LIMIT ?""",
self.conn,
params=[limit]
)
def get_factor_history(self, factor_name: str) -> pd.DataFrame:
"""
Get all backtest runs for a specific factor.
Parameters
----------
factor_name : str
Name of the factor
Returns
-------
pd.DataFrame
DataFrame with all runs for the factor
"""
return pd.read_sql_query(
"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
win_rate, information_ratio, run_date, run_name
FROM backtest_runs
JOIN factors ON factor_id = factors.id
WHERE factor_name = ?
ORDER BY run_date DESC""",
self.conn,
params=[factor_name]
)
def get_aggregate_stats(self) -> Dict:
"""
Get aggregate statistics across all factors.
Returns
-------
Dict
Dictionary with total_factors, avg_ic, max_sharpe, avg_return
"""
c = self.conn.cursor()
c.execute(
"""SELECT COUNT(DISTINCT factor_name), AVG(ic), MAX(sharpe), AVG(annual_return)
FROM backtest_runs
JOIN factors ON factor_id = factors.id"""
)
r = c.fetchone()
return {
'total_factors': r[0],
'avg_ic': r[1],
'max_sharpe': r[2],
'avg_return': r[3]
}
def get_all_results(self) -> pd.DataFrame:
"""
Get all backtest results with factor details.
Returns
-------
pd.DataFrame
DataFrame with all backtest results
"""
return pd.read_sql_query(
"""SELECT f.id as factor_id, f.factor_name, f.factor_type, f.created_at,
b.id as run_id, b.run_name, b.run_date,
b.ic, b.sharpe, b.annual_return, b.max_drawdown,
b.win_rate, b.information_ratio, b.volatility,
b.raw_metrics
FROM factors f
LEFT JOIN backtest_runs b ON f.id = b.factor_id
ORDER BY b.run_date DESC""",
self.conn
)
def close(self):
self.conn.close()
if __name__ == "__main__":
print("=== DB Test ===")
db = ResultsDatabase()
db.add_factor("TestFactor", "Momentum")
db.add_backtest("TestFactor", {'ic': 0.05, 'sharpe_ratio': 1.5, 'annualized_return': 0.15, 'max_drawdown': -0.08, 'win_rate': 0.55})
db.add_loop(1, 4, 6, 0.05, "completed")
print("Top Faktoren:")
print(db.get_top_factors())
print("\nAggregate Stats:")
print(db.get_aggregate_stats())
db.close()
print("✅ Test bestanden!")