Files

715 lines
26 KiB
Python
Raw Permalink Normal View History

"""
NexQuant Full Data Factor Evaluator - Evaluate factors with FULL 1min data.
Evaluates factors using the complete intraday_pv.h5 dataset (2022-2026, ~2.26M rows)
instead of the debug dataset (2024 only, ~371K rows).
Usage:
python nexquant_full_eval.py --top 100 # Evaluate top 100 factors with full data
python nexquant_full_eval.py --all # Evaluate all factors
python nexquant_full_eval.py --parallel 4 # 4 parallel workers
"""
import json
import os
import sys
import warnings
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import numpy as np
import pandas as pd
from rich.console import Console
from rich.progress import (
Progress,
SpinnerColumn,
TextColumn,
BarColumn,
TaskProgressColumn,
TimeElapsedColumn,
)
from rich.table import Table
from rich.panel import Panel
console = Console()
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).resolve().parent
WORKSPACE_DIR = PROJECT_ROOT / "git_ignore_folder" / "RD-Agent_workspace"
# FULL data file (2022-2026, ~72MB)
FULL_DATA_FILE = PROJECT_ROOT / "git_ignore_folder" / "factor_implementation_source_data" / "intraday_pv.h5"
RESULTS_DIR = PROJECT_ROOT / "results"
BACKTESTS_DIR = RESULTS_DIR / "backtests"
DB_DIR = RESULTS_DIR / "db"
DB_PATH = DB_DIR / "backtest_results.db"
EVAL_SUMMARY_PATH = RESULTS_DIR / "eval_summary.json"
# Ensure directories exist
BACKTESTS_DIR.mkdir(parents=True, exist_ok=True)
DB_DIR.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class FactorInfo:
"""Factor information."""
workspace_hash: str
factor_name: str
factor_code: str
@dataclass
class EvalResult:
"""Evaluation result for a single factor."""
factor_name: str
workspace_hash: str
factor_code: str = ""
factor_description: str = ""
status: str = "" # success, failed
ic: Optional[float] = None
rank_ic: Optional[float] = None
sharpe: Optional[float] = None
annualized_return: Optional[float] = None
max_drawdown: Optional[float] = None
win_rate: Optional[float] = None
non_null_count: int = 0
total_count: int = 0
error_message: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items()}
# ---------------------------------------------------------------------------
# Factor description extractor
# ---------------------------------------------------------------------------
def _extract_factor_description(code: str) -> str:
"""Extract docstring or description from factor code."""
import re
# Try to extract docstring
match = re.search(r'"""(.*?)"""', code, re.DOTALL)
if match:
return match.group(1).strip()[:500]
# Try to extract from comments
lines = code.split('\n')
desc_lines = []
for line in lines[:20]:
if line.strip().startswith('#') and not line.strip().startswith('#!'):
desc_lines.append(line.strip()[1:].strip())
if desc_lines:
return ' '.join(desc_lines)[:500]
return "No description available"
# ---------------------------------------------------------------------------
# Factor scanner
# ---------------------------------------------------------------------------
def scan_factors(workspace_dir: Path, skip_evaluated: bool = True) -> List[FactorInfo]:
"""Scan workspace directories for unique factor codes.
Parameters
----------
workspace_dir : Path
Path to workspace directory
skip_evaluated : bool
If True, skip factors that already have valid results in results/factors/
Returns
-------
List[FactorInfo]
List of factor information
"""
factors = []
seen_names = set()
# Load already evaluated factors (if skip_evaluated is True)
evaluated_factors = set()
if skip_evaluated:
project_root = Path(__file__).parent
factors_dir = project_root / "results" / "factors"
if factors_dir.exists():
import json
import glob
for f in glob.glob(str(factors_dir / "*.json")):
try:
with open(f) as fh:
data = json.load(fh)
if data.get("status") == "success" and data.get("ic") is not None:
evaluated_factors.add(data.get("factor_name"))
except Exception:
pass
print(f" Found {len(evaluated_factors)} already evaluated factors - skipping")
for ws in workspace_dir.iterdir():
if not ws.is_dir():
continue
factor_file = ws / "factor.py"
result_file = ws / "result.h5"
if not factor_file.exists():
continue
# Read factor name from result.h5
factor_name = None
if result_file.exists():
try:
result = pd.read_hdf(str(result_file), key="data")
if result is not None and len(result.columns) > 0:
factor_name = result.columns[0]
except Exception:
pass
if factor_name is None:
# Try to extract from code
code = factor_file.read_text()
import re
match = re.search(r'def calculate_(\w+)', code)
if match:
factor_name = match.group(1)
else:
factor_name = f"factor_{ws.name}"
# Skip duplicates
if factor_name in seen_names:
continue
# Skip already evaluated factors
if skip_evaluated and factor_name in evaluated_factors:
continue
seen_names.add(factor_name)
factors.append(FactorInfo(
workspace_hash=ws.name,
factor_name=factor_name,
factor_code=factor_file.read_text(),
))
return factors
# ---------------------------------------------------------------------------
# Look-ahead bias detection for daily-constant factors
# ---------------------------------------------------------------------------
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
"""Detect daily-constant factors (look-ahead bias) and shift by 1 trading day."""
sample_days = factor_col.index.get_level_values("datetime").normalize().unique()
if len(sample_days) < 10:
return factor_col
rng = np.random.default_rng(42)
days_to_check = rng.choice(sample_days, size=min(50, len(sample_days)), replace=False)
constant_count = 0
for day in days_to_check:
day_mask = factor_col.index.get_level_values("datetime").normalize() == day
day_vals = factor_col[day_mask].dropna()
if len(day_vals) == 0:
continue
if day_vals.nunique() == 1:
constant_count += 1
fraction_constant = constant_count / len(days_to_check)
if fraction_constant < 0.90:
return factor_col
# Shift by 1 trading day per instrument
import logging
logging.getLogger(__name__).info(
"Factor '%s' is %.0f%% daily-constant — shifting 1 trading day to fix look-ahead bias",
factor_name, fraction_constant * 100,
)
instruments = factor_col.index.get_level_values("instrument").unique() if "instrument" in factor_col.index.names else [None]
shifted_parts = []
for instr in instruments:
if instr is not None:
mask = factor_col.index.get_level_values("instrument") == instr
col_instr = factor_col[mask]
else:
col_instr = factor_col
dates = col_instr.index.get_level_values("datetime").normalize()
trading_days = dates.unique().sort_values()
day_first = col_instr.groupby(dates).first()
day_first_shifted = day_first.shift(1)
day_first_shifted.index = pd.to_datetime(day_first_shifted.index)
day_map = day_first_shifted.reindex(pd.to_datetime(trading_days)).values
new_vals = pd.Series(
day_map[np.searchsorted(trading_days.values, dates.values)],
index=col_instr.index,
)
shifted_parts.append(new_vals)
return pd.concat(shifted_parts).sort_index()
# ---------------------------------------------------------------------------
# Factor evaluator
# ---------------------------------------------------------------------------
def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
forward_return_bars: int = 96) -> EvalResult:
"""
Evaluate a factor using the FULL dataset.
Parameters
----------
factor : FactorInfo
Factor information with code
full_data : pd.DataFrame
Full intraday_pv.h5 data
forward_return_bars : int
Number of bars for forward return calculation
Returns
-------
EvalResult
"""
import tempfile
import subprocess
with tempfile.TemporaryDirectory(prefix="nexquant_full_") as tmp_dir:
ws = Path(tmp_dir)
try:
# Copy full data to temp workspace
import shutil
shutil.copy(str(FULL_DATA_FILE), str(ws / "intraday_pv.h5"))
# Write factor code
(ws / "factor.py").write_text(factor.factor_code, encoding="utf-8")
# Execute factor code
proc = subprocess.run(
[sys.executable, str(ws / "factor.py")],
cwd=str(ws),
capture_output=True,
text=True,
timeout=120,
)
if proc.returncode != 0:
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
error_message=f"Execution failed: {proc.stderr[:300]}",
)
# Read result
result_file = ws / "result.h5"
if not result_file.exists():
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
error_message="No result.h5 generated",
)
result = pd.read_hdf(str(result_file), key="data")
total_count = len(result)
factor_val = result.iloc[:, 0]
factor_val = _shift_daily_constant_factor_if_needed(factor_val, factor.factor_name)
non_null_count = factor_val.notna().sum()
if non_null_count < 1000:
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
non_null_count=non_null_count,
total_count=total_count,
error_message=f"Too few valid values: {non_null_count}",
)
# Compute forward returns
col_close = "$close"
if col_close not in full_data.columns:
col_close = next((c for c in full_data.columns if "close" in c.lower()), None)
if col_close is None:
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
error_message=f"No close column found",
)
close = full_data[col_close]
forward_ret = close.groupby(level="instrument").shift(-forward_return_bars) / close - 1
# Compute IC
valid_idx = factor_val.dropna().index.intersection(forward_ret.dropna().index)
if len(valid_idx) < 1000:
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
non_null_count=non_null_count,
total_count=total_count,
error_message=f"Too little overlap: {len(valid_idx)}",
)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
ic = factor_val.loc[valid_idx].corr(forward_ret.loc[valid_idx])
rank_ic = factor_val.loc[valid_idx].corr(forward_ret.loc[valid_idx], method="spearman")
# Compute strategy returns from factor signal
signal = np.where(factor_val.loc[valid_idx] > 0, 1.0, -1.0)
strategy_ret = signal * forward_ret.loc[valid_idx]
bars_per_year = 252 * 1440
ann_factor = np.sqrt(bars_per_year / forward_return_bars)
# Sharpe: annualized mean/vol of strategy returns
ret_mean = strategy_ret.mean()
ret_std = strategy_ret.std()
sharpe = float(ret_mean / ret_std * ann_factor) if ret_std > 0 else 0.0
# Annualized return
annualized_return = float(ret_mean * bars_per_year / forward_return_bars * 100)
# Max drawdown on equity curve
equity = (1.0 + strategy_ret).cumprod()
running_max = equity.expanding().max()
drawdown = (equity - running_max) / running_max.replace(0, np.nan)
max_drawdown = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# Win rate: fraction of positive strategy returns
win_rate = float((strategy_ret > 0).sum()) / len(strategy_ret) if len(strategy_ret) > 0 else 0.0
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
factor_code=factor.factor_code,
factor_description=_extract_factor_description(factor.factor_code),
status="success",
ic=float(ic) if ic is not None and not np.isnan(ic) else None,
rank_ic=float(rank_ic) if rank_ic is not None and not np.isnan(rank_ic) else None,
sharpe=float(sharpe) if sharpe is not None and not np.isnan(sharpe) else None,
annualized_return=annualized_return,
max_drawdown=max_drawdown,
win_rate=win_rate,
non_null_count=non_null_count,
total_count=total_count,
)
except subprocess.TimeoutExpired:
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
error_message="Execution timeout (120s)",
)
except Exception as e:
return EvalResult(
factor_name=factor.factor_name,
workspace_hash=factor.workspace_hash,
status="failed",
error_message=str(e)[:500],
)
# ---------------------------------------------------------------------------
# Parallel evaluation
# ---------------------------------------------------------------------------
def run_evaluation(
factors: List[FactorInfo],
full_data: pd.DataFrame,
n_workers: int = 4,
) -> List[EvalResult]:
"""Run factor evaluation in parallel using threads."""
results = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
console=console,
) as progress:
task = progress.add_task(f"Evaluating {len(factors)} factors with FULL data...", total=len(factors))
with ThreadPoolExecutor(max_workers=n_workers) as executor:
futures = {executor.submit(evaluate_factor_full, f, full_data): f for f in factors}
for future in as_completed(futures):
factor = futures[future]
result = None
try:
result = future.result(timeout=300)
results.append(result)
except Exception as e:
# Handle timeout and other exceptions
fname = str(getattr(factor, 'factor_name', 'unknown'))[:40]
results.append(EvalResult(
factor_name=fname,
workspace_hash=getattr(factor, 'workspace_hash', 'unknown'),
status="failed",
error_message=f"Exception: {str(e)[:300]}",
))
result = results[-1]
n_success = sum(1 for r in results if r.status == "success")
n_fail = sum(1 for r in results if r.status == "failed")
# Save immediately after each factor
if result is not None:
save_single_result(result)
# Update progress with safe string handling
fname = str(getattr(factor, 'factor_name', 'unknown'))[:40]
progress.update(
task,
advance=1,
description=f"Evaluating: {n_success}{n_fail}❌ | {fname}",
)
return results
# ---------------------------------------------------------------------------
# Results storage
# ---------------------------------------------------------------------------
FACTORS_DIR = RESULTS_DIR / "factors"
FACTORS_DIR.mkdir(parents=True, exist_ok=True)
def save_single_result(r: EvalResult) -> None:
"""Save a single factor result to results/factors/."""
if r.status != "success":
return
safe_name = r.factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
json_path = FACTORS_DIR / f"{safe_name}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(r.to_dict(), f, indent=2, default=str)
def save_results(results: List[EvalResult]) -> None:
"""Save evaluation results to JSON and SQLite."""
successful = [r for r in results if r.status == "success"]
failed = [r for r in results if r.status == "failed"]
# Sort by IC
successful.sort(key=lambda r: abs(r.ic) if r.ic is not None else 0, reverse=True)
# Save ALL successful results to results/factors/
for r in successful:
# Safe filename (remove special chars)
safe_name = r.factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
json_path = FACTORS_DIR / f"{safe_name}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(r.to_dict(), f, indent=2, default=str)
# Save summary
valid_ic = [r.ic for r in results if r.ic is not None]
valid_sharpe = [r.sharpe for r in results if r.sharpe is not None]
summary = {
"generated_at": datetime.now().isoformat(),
"total_evaluated": len(results),
"successful": len(successful),
"failed": len(failed),
"success_rate": len(successful) / len(results) if results else 0,
"avg_ic": float(np.mean(valid_ic)) if valid_ic else 0,
"best_ic": float(max(valid_ic, key=abs, default=0)),
"avg_sharpe": float(np.mean(valid_sharpe)) if valid_sharpe else 0,
"best_sharpe": float(max(valid_sharpe, default=0)),
"top_20_by_ic": [r.to_dict() for r in successful[:20]],
"all_results": [r.to_dict() for r in results],
}
with open(EVAL_SUMMARY_PATH, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, default=str)
# Save to SQLite
try:
import sqlite3
conn = sqlite3.connect(str(DB_PATH))
c = conn.cursor()
c.execute("""CREATE TABLE IF NOT EXISTS factor_evaluations (
id INTEGER PRIMARY KEY,
factor_name TEXT,
workspace_hash TEXT,
ic REAL,
rank_ic REAL,
sharpe REAL,
annualized_return REAL,
max_drawdown REAL,
win_rate REAL,
non_null_count INTEGER,
total_count INTEGER,
status TEXT,
timestamp TEXT
)""")
for r in results:
c.execute("""INSERT INTO factor_evaluations
(factor_name, workspace_hash, ic, rank_ic, sharpe,
annualized_return, max_drawdown, win_rate,
non_null_count, total_count, status, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(r.factor_name, r.workspace_hash, r.ic, r.rank_ic, r.sharpe,
r.annualized_return, r.max_drawdown, r.win_rate,
r.non_null_count, r.total_count, r.status, r.timestamp))
conn.commit()
conn.close()
except Exception as e:
console.print(f"[yellow]SQLite save warning: {e}[/yellow]")
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
def display_results(results: List[EvalResult]) -> None:
"""Display evaluation results as a table."""
successful = [r for r in results if r.status == "success"]
successful.sort(key=lambda r: abs(r.ic) if r.ic is not None else 0, reverse=True)
table = Table(
title="Factor Evaluation Results (FULL DATA)",
show_header=True,
header_style="bold cyan",
)
table.add_column("#", justify="center", width=4)
table.add_column("Factor", width=40)
table.add_column("IC", justify="right", width=10)
table.add_column("Rank IC", justify="right", width=10)
table.add_column("Sharpe", justify="right", width=10)
table.add_column("Ann. Ret %", justify="right", width=10)
table.add_column("Max DD", justify="right", width=10)
table.add_column("Win Rate", justify="right", width=10)
for i, r in enumerate(successful[:20], 1):
table.add_row(
str(i),
r.factor_name[:38],
f"{r.ic:.6f}" if r.ic is not None else "N/A",
f"{r.rank_ic:.6f}" if r.rank_ic is not None else "N/A",
f"{r.sharpe:.4f}" if r.sharpe is not None else "N/A",
f"{r.annualized_return:.4f}" if r.annualized_return is not None else "N/A",
f"{r.max_drawdown:.4f}" if r.max_drawdown is not None else "N/A",
f"{r.win_rate:.2%}" if r.win_rate is not None else "N/A",
)
console.print()
console.print(table)
# Summary
valid_ic = [r.ic for r in results if r.ic is not None]
valid_sharpe = [r.sharpe for r in results if r.sharpe is not None]
console.print(Panel(
f"[bold]Evaluation Summary (FULL DATA)[/bold]\n"
f"Total evaluated: {len(results)}\n"
f"Successful: {len(successful)}\n"
f"Failed: {len(results) - len(successful)}\n"
f"Avg IC: {np.mean(valid_ic):.6f} (n={len(valid_ic)})\n"
f"Best IC: {max(valid_ic, key=abs, default=0):.6f}\n"
f"Avg Sharpe: {np.mean(valid_sharpe):.4f} (n={len(valid_sharpe)})\n"
f"Best Sharpe: {max(valid_sharpe, default=0):.4f}\n"
f"Saved to: {EVAL_SUMMARY_PATH}\n"
f"Database: {DB_PATH}",
border_style="green",
))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(
top: int = 100,
all_factors: bool = False,
parallel: int = 4,
force: bool = False,
) -> None:
"""Main entry point."""
console.print(Panel(
"[bold cyan]NexQuant Full Data Factor Evaluator[/bold cyan]\n"
f"Using FULL 1min data: {FULL_DATA_FILE}",
border_style="cyan",
))
# Load full data
if not FULL_DATA_FILE.exists():
console.print(f"[red]Full data file not found: {FULL_DATA_FILE}[/red]")
return
console.print(f"\n[dim]Loading full data...[/dim]")
full_data = pd.read_hdf(str(FULL_DATA_FILE), key="data")
console.print(f"[bold green]✓ Loaded {len(full_data):,} rows ({full_data.index.get_level_values('datetime').min()} to {full_data.index.get_level_values('datetime').max()})[/bold green]")
# Scan factors (skip already evaluated by default)
console.print(f"\n[dim]Scanning workspaces...[/dim]")
factors = scan_factors(WORKSPACE_DIR, skip_evaluated=not force)
console.print(f"[bold]Total unique factors found: {len(factors)}[/bold]")
if force:
console.print("[yellow]⚠️ Force mode: Re-evaluating ALL factors[/yellow]")
else:
console.print("[dim]Skipping already evaluated factors[/dim]")
if not factors:
console.print("[red]No factors found![/red]")
return
# Select factors to evaluate
if all_factors:
to_evaluate = factors
else:
to_evaluate = factors[:top]
console.print(f"\n[bold green]Selected {len(to_evaluate)} factors for evaluation[/bold green]")
console.print(f" Using {parallel} parallel workers")
# Run evaluation
results = run_evaluation(to_evaluate, full_data, n_workers=parallel)
# Save results
console.print(f"\n[bold cyan]Saving results...[/bold cyan]")
save_results(results)
# Display
display_results(results)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="NexQuant Full Data Factor Evaluator"
)
parser.add_argument(
"--top", "-n",
type=int,
default=100,
help="Number of factors to evaluate (default: 100)",
)
parser.add_argument(
"--all", "-a",
action="store_true",
help="Evaluate all discovered factors",
)
parser.add_argument(
"--parallel", "-p",
type=int,
default=4,
help="Number of parallel workers (default: 4)",
)
parser.add_argument(
"--force", "-f",
action="store_true",
help="Force re-evaluation of ALL factors (even already evaluated)",
)
args = parser.parse_args()
main(
top=args.top,
all_factors=args.all,
parallel=args.parallel,
force=args.force,
)