quantforge-miner / core /evaluator.py
DevWizard-Vandan
Improve alpha research selection and submission verification
a98e19d
Raw
History Blame Contribute Delete
13.2 kB
import json
import logging
from typing import Dict, Any, Tuple, Optional
import config
from database.db import AsyncSessionLocal
from database.models import Alpha
logger = logging.getLogger("AlphaEvaluator")
def serialize_raw_payload(raw_data: Any) -> str:
"""Preserve completed API payloads for parser and research forensics."""
payload = json.dumps(raw_data or {}, default=str, separators=(",", ":"))
return payload[:250_000]
# Trash reason categories for analytics
TRASH_REASON_NO_TRADES = "No Trades / Null Metrics"
TRASH_REASON_LOW_PERFORMANCE = "Low Sharpe / Fitness"
TRASH_REASON_HIGH_CORRELATION = "High Correlation"
TRASH_REASON_SYNTAX_ERROR = "Syntax / Schema Error"
def classify_trash_reason(reason: str) -> str:
"""
Classifies a failure reason string into one of 4 canonical trash categories
for structured analytics on the dashboard.
"""
r = reason.lower()
if "no trades" in r or "null" in r or "invalid stats" in r or "missing critical" in r:
return TRASH_REASON_NO_TRADES
if "sharpe" in r or "fitness" in r or "turnover" in r or "performance" in r:
return TRASH_REASON_LOW_PERFORMANCE
if "correlation" in r or "corr" in r:
return TRASH_REASON_HIGH_CORRELATION
# Simulation failures, schema errors, platform rejects, syntax errors
return TRASH_REASON_SYNTAX_ERROR
class AlphaEvaluator:
"""
Evaluation Engine for QuantForge v2.0.
Applies strict Consultant-tier acceptance thresholds on simulated Alphas.
"""
def __init__(self) -> None:
# Ordinary consultant submission thresholds. Pure Power Pool uses a
# distinct campaign and is disabled until that engine exists.
self.min_fitness_d1 = 1.0
self.min_fitness_d0 = 1.5
self.min_sharpe_d1 = 1.58
self.min_sharpe_d0 = 2.69
self.min_turnover = 0.01
self.max_turnover = 0.70
self.min_htvr_ratio = 0.75 # High Turnover Returns Ratio for x2 multiplier eligibility
self.max_self_correlation = 0.70
self.max_prod_correlation = 0.70
def evaluate_metrics(self, sim_results: Dict[str, Any], delay: int, expression: Optional[str] = None) -> Tuple[bool, str]:
"""
Evaluates parsed simulation results against thresholds.
Returns:
(is_passing, failure_reason)
"""
# 1. Check for system or platform errors reported during simulation/polling
if sim_results.get("status") == "FAILED":
err_msg = sim_results.get("error_message") or "Simulation status failed."
return False, f"Simulation failure: {err_msg}"
# 2. Check for missing essential metrics
sharpe = sim_results.get("sharpe")
fitness = sim_results.get("fitness")
turnover = sim_results.get("turnover")
if sharpe is None or fitness is None or turnover is None:
msg = "Simulation produced no trades / Invalid Stats"
logger.warning(msg)
return False, msg
# 3. Sharpe cutoff — match the ordinary consultant IS thresholds.
# D0: 2.69 | D1: 1.58. Do not spend submit quota on predictable rejects.
min_sharpe = self.min_sharpe_d0 if delay == 0 else self.min_sharpe_d1
# Pure Power Pool is opt-in only; it has its own themed campaign rules.
if expression:
try:
import re
# Calculate complexity
operators = re.findall(r'\b[a-zA-Z_][a-zA-Z0-9_]*\b(?=\s*\()', expression)
filtered_ops = [op for op in operators if op.lower() not in {"ts_backfill", "group_backfill"}]
num_ops = len(filtered_ops)
words = re.findall(r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', expression)
grouping_fields = {"country", "industry", "subindustry", "currency", "market", "sector", "exchange"}
reserved = {"ts_corr", "ts_covariance", "ts_mean", "ts_std_dev", "ts_decay_linear", "ts_delta", "ts_rank", "ts_zscore",
"rank", "zscore", "group_rank", "group_zscore", "group_neutralize", "signed_power", "if_else", "log",
"abs", "sqrt", "power", "min", "max", "sign", "exp", "ts_backfill", "group_backfill"}
fields = set()
for w in words:
w_lower = w.lower()
if w_lower in grouping_fields or w_lower in reserved:
continue
if w_lower in {"nan", "nil", "null", "true", "false"}:
continue
fields.add(w_lower)
num_fields = len(fields)
if config.ENABLE_PURE_POWER_POOL_SUBMISSIONS and num_fields <= 3 and num_ops <= 8:
logger.info(f"Alpha complexity qualifies for Power Pool (fields={num_fields}, ops={num_ops}). Setting min_sharpe = 1.0.")
min_sharpe = 1.0
except Exception as e:
logger.error(f"Error checking Power Pool complexity: {e}")
if sharpe <= min_sharpe:
return False, f"Sharpe {sharpe:.4f} below pre-filter threshold of {min_sharpe} (Delay {delay})"
# 4. Fitness Cutoff
min_fitness = self.min_fitness_d0 if delay == 0 else self.min_fitness_d1
if fitness <= min_fitness:
return False, f"Fitness {fitness:.4f} below threshold of {min_fitness}"
# 5. Turnover band for regular consultant alphas.
if turnover <= self.min_turnover:
return False, f"Turnover {turnover:.4f} below floor of {self.min_turnover}"
if turnover >= self.max_turnover:
return False, f"Turnover {turnover:.4f} above cap of {self.max_turnover}"
# 5b. HTVR ratio check — log eligibility for x2/x3 multiplier campaign
# HTVR ratio = returns / turnover (platform definition)
returns_val = sim_results.get("returns") or sim_results.get("ann_ret")
if returns_val is not None and turnover and turnover > 0:
htvr_ratio = abs(returns_val) / turnover
if turnover > 0.20 and htvr_ratio >= self.min_htvr_ratio:
logger.info(
f"HTVR ELIGIBLE: Turnover={turnover:.2%}, Returns={returns_val:.2%}, "
f"HTVR_ratio={htvr_ratio:.2f} — qualifies for x2 High Turnover Multiplier"
)
# 6. Parse and check checks list for Correlation and Sub-Universe NaN failures
raw_data = sim_results.get("raw_data") or {}
# Extract checks from raw JSON payload
checks = []
alpha_data = raw_data.get("alpha", {})
if isinstance(alpha_data, dict):
checks = alpha_data.get("is", {}).get("checks", [])
if not checks and isinstance(raw_data, dict):
checks = raw_data.get("is", {}).get("checks", []) or raw_data.get("checks", [])
if not isinstance(checks, list):
checks = []
self_corr = None
prod_corr = None
sub_universe_nan_fail = False
for c in checks:
if not isinstance(c, dict):
continue
name = str(c.get("name", "")).lower()
val = c.get("value")
passed = c.get("passed")
result = str(c.get("result", "")).upper()
msg = str(c.get("message", "")).lower()
# Self-correlation check
if "selfcorrelation" in name or "self_correlation" in name:
try:
self_corr = float(val) if val is not None else None
except (ValueError, TypeError):
pass
if passed is False or result == "FAIL":
return False, f"Self-correlation check failed: {msg or 'Exceeded platform limits'}"
# Product-correlation check
elif "prodcorrelation" in name or "prod_correlation" in name or "productioncorrelation" in name:
try:
prod_corr = float(val) if val is not None else None
except (ValueError, TypeError):
pass
if passed is False or result == "FAIL":
return False, f"Production-correlation check failed: {msg or 'Exceeded platform limits'}"
# Sub-universe Sharpe NaN is not above cutoff check
elif "sub-universe" in name or "subuniverse" in name or "sub-universe" in msg or "subuniverse" in msg:
if passed is False or result == "FAIL" or "nan" in msg:
sub_universe_nan_fail = True
# General catch-all for any failed check in the simulation logs
elif passed is False or result == "FAIL":
return False, f"Platform check failed: {c.get('name')} - {msg}"
# Apply custom correlation thresholds if values are parsed
if self_corr is not None and self_corr >= self.max_self_correlation:
return False, f"Self-correlation {self_corr:.4f} exceeds limit of {self.max_self_correlation}"
if prod_corr is not None and prod_corr >= self.max_prod_correlation:
return False, f"Production-correlation {prod_corr:.4f} exceeds limit of {self.max_prod_correlation}"
if sub_universe_nan_fail:
return False, "Sub-universe check failed: Sharpe is NaN or below cutoff in sub-universe"
# 7. Strict Power Pool Theme Verification (only applies to Pure Power Pool candidates: Sharpe < 1.58)
if config.ENABLE_PURE_POWER_POOL_SUBMISSIONS and config.STRICT_POWER_POOL_THEME and sharpe < 1.58:
from datetime import datetime, date
current_date = datetime.now().date()
# Dataset restrictions: drop if contains pvl or pv1
expr_lower = expression.lower() if expression else ""
if "pvl" in expr_lower or "pv1" in expr_lower:
return False, "Failed theme check: Pure Power Pool candidate contains restricted dataset 'pvl' or 'pv1'."
# Late July blocks 'model110'
if current_date >= date(2026, 7, 27) and "model110" in expr_lower:
return False, "Failed theme check: Pure Power Pool candidate contains restricted dataset 'model110'."
# July 13 to July 26 requires High Turnover returns ratio >= 0.75
if date(2026, 7, 13) <= current_date <= date(2026, 7, 26):
htvr_value = None
for c in checks:
if not isinstance(c, dict):
continue
c_name = str(c.get("name", "")).lower()
if c_name == "ht_high_turnover_returns_ratio":
try:
htvr_value = float(c.get("value"))
except (ValueError, TypeError):
pass
# Alternate calculation fallback (returns / turnover)
if htvr_value is None:
returns_val = sim_results.get("returns") or sim_results.get("ann_ret")
if returns_val is not None and turnover and turnover > 0:
htvr_value = abs(returns_val) / turnover
if htvr_value is None or htvr_value < 0.75:
return False, f"Failed theme check: Simulated HTVR ratio ({htvr_value if htvr_value is not None else 'N/A'}) is below 0.75 (required for Pure Power Pool July 13-26)."
return True, "Passed all acceptance thresholds"
async def evaluate_and_update(self, alpha_id: int, sim_results: Dict[str, Any], delay: int) -> str:
"""
Runs evaluation on the simulation results and updates the record in SQLite database.
Stores categorized trash_reason for analytics observability.
Returns the new status string.
"""
async with AsyncSessionLocal() as session:
# Fetch the alpha record
alpha = await session.get(Alpha, alpha_id)
if not alpha:
logger.error(f"Alpha record {alpha_id} not found in database for evaluation.")
return "NOT_FOUND"
expr = alpha.expression
is_passing, reason = self.evaluate_metrics(sim_results, delay, expr)
# Parse results and save metrics
alpha.sharpe = sim_results.get("sharpe")
alpha.fitness = sim_results.get("fitness")
alpha.turnover = sim_results.get("turnover")
alpha.raw_result_json = serialize_raw_payload(sim_results.get("raw_data"))
if is_passing:
alpha.status = "PENDING_SUBMIT"
alpha.error_log = reason
alpha.trash_reason = None
logger.info(f"Alpha {alpha_id} PASSED evaluation. Queued for submission.")
else:
alpha.status = "TRASHED"
alpha.error_log = reason
alpha.trash_reason = classify_trash_reason(reason)
logger.info(f"Alpha {alpha_id} TRASHED during evaluation: {reason}")
await session.commit()
return alpha.status