feat: unified backtest engine, LLM error handling, strategy refactor

- Add vbt_backtest.py as single source of truth for all metric formulas
  (Sharpe, drawdown, IC, transaction costs) — backtest_engine.py and
  strategy_orchestrator.py now delegate to it
- Add LLMUnavailableError to exception.py; rd_loop.py catches it at the
  proposal stage and raises LoopResumeError to avoid corrupting trace
  history with None hypotheses
- Guard record() against None exp/hypothesis so loop resets leave
  trace.hist in a consistent state
- Refactor strategy_orchestrator and optuna_optimizer to use unified
  backtest path; remove duplicate metric calculation code
- Add predix_rebacktest_unified.py script for offline re-evaluation
- Update tests and README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TPTBusiness
2026-04-17 22:52:07 +02:00
parent 2baa8d3e65
commit 0651faed92
17 changed files with 1379 additions and 403 deletions
+2 -1
View File
@@ -139,4 +139,5 @@ pickle_cache/
.qwen/
RD-Agent_workspace_run*/
AGENTS.md
CLAUDE.md
CLAUDE.md
.claude/
+26 -8
View File
@@ -146,17 +146,35 @@ QLIB_DATA_DIR=~/.qlib/qlib_data/eurusd_1min_data
2. **Start LLM server (llama.cpp):**
```bash
~/llama.cpp/build/bin/llama-server \
--model ~/models/qwen3.5/Qwen3.5-35B-A3B-Q3_K_M.gguf \
--n-gpu-layers 28 \
--ctx-size 80000 \
--model ~/models/qwen3.6/Qwen3.6-35B-A3B-UD-Q3_K_XL.gguf \
--n-gpu-layers 26 \
--no-mmap \
--port 8081 \
--reasoning off
--ctx-size 240000 \
--parallel 3 \
--batch-size 512 --ubatch-size 512 \
--host 0.0.0.0 \
-ctk q4_0 -ctv q4_0 \
--reasoning-budget 0 \
--chat-template-kwargs '{"enable_thinking":false}'
```
> **Important flags:**
> - `--reasoning off` — disables chain-of-thought thinking mode. Without this, Qwen3.5 spends 1522 s per request on internal reasoning, which causes rdagent to time out after 10 retries. With it off, responses take ~0.6 s.
> - `--n-gpu-layers 28` — use 28 instead of 36 if another GPU process (e.g. Ollama) is already running. 36 layers will exceed VRAM on a 16 GB card when Ollama occupies ~668 MB. Reduce layers further if you hit `cudaMalloc failed: out of memory`.
> - `--ctx-size 80000` — 80 k context is required for long factor-generation prompts.
> **Important flags and token budget:**
> - `--ctx-size 240000 --parallel 3` — allocates **3 slots × 80,000 tokens each**. This is required because `fin_quant` hypothesis prompts include up to `MAX_FACTOR_HISTORY` past experiments and can reach **25,00035,000 tokens**. With less context per slot (e.g. 33 k from `--ctx-size 100000 --parallel 3`) prompts overflow silently and produce empty/invalid responses.
>
> **Token budget breakdown per fin_quant request:**
> | Component | Approx. tokens |
> |---|---|
> | System prompt + scenario description | ~3,000 |
> | `MAX_FACTOR_HISTORY=5` past experiments × ~2,500 | ~12,500 |
> | RAG context + instructions | ~2,000 |
> | **Total** | **~17,500** (safe under 80k slot) |
>
> Formula: `ctx_size / parallel` must be at least `MAX_FACTOR_HISTORY × 2500 + 8000`.
>
> - `--reasoning-budget 0` — disables chain-of-thought. Without this, Qwen3 spends 1522 s per request, causing rdagent to time out after 10 retries.
> - `--n-gpu-layers 26` — use 26 on RTX 5060 Ti (16 GB) when Ollama is also running. Frees ~500 MB VRAM needed for the larger KV cache (1.3 GB at 240k ctx Q4_0).
> - `-ctk q4_0 -ctv q4_0` — quantise the KV cache to 4-bit, reducing VRAM from ~5 GB to ~1.3 GB at 240k context.
---
+144
View File
@@ -1439,5 +1439,149 @@ def status():
console.print(f" Factors: {factors}")
_STRATEGY_DIRS = (
Path(__file__).parent / "results" / "strategies_new",
Path(__file__).parent / "results" / "strategies",
)
_SAFE_KEYS = ("strategy_name", "factor_names", "description", "real_backtest", "metrics", "summary")
def _load_strategies():
import json
items = []
seen = set()
for d in _STRATEGY_DIRS:
if not d.exists():
continue
for p in d.glob("*.json"):
try:
raw = json.loads(p.read_text())
except Exception:
continue
if not isinstance(raw, dict):
continue
name = raw.get("strategy_name") or p.stem
if name in seen:
continue
seen.add(name)
metrics = raw.get("summary") or raw.get("metrics") or raw.get("real_backtest") or {}
if metrics.get("status") and metrics.get("status") != "success":
if metrics.get("real_backtest_status") != "success":
continue
items.append({
"file": p.name,
"name": name,
"factors": raw.get("factor_names") or [],
"description": raw.get("description") or "",
"sharpe": float(metrics.get("sharpe", 0) or 0),
"ic": float(metrics.get("ic", metrics.get("real_ic", 0)) or 0),
"max_drawdown": float(metrics.get("max_drawdown", 0) or 0),
"win_rate": float(metrics.get("win_rate", 0) or 0),
"n_trades": int(metrics.get("n_trades", metrics.get("real_n_trades", 0)) or 0),
"monthly_return_pct": float(metrics.get("monthly_return_pct", 0) or 0),
"annual_return_pct": float(metrics.get("annual_return_pct", 0) or 0),
"total_return": float(metrics.get("total_return", 0) or 0),
})
return items
def _composite_score(s):
dd_penalty = max(0.1, 1.0 + min(s["max_drawdown"], 0))
trade_penalty = 1.0 if s["n_trades"] >= 30 else 0.5
return s["sharpe"] * dd_penalty * trade_penalty
@app.command()
def best(
n: int = typer.Option(10, "--num", "-n", help="Number of strategies to show"),
metric: str = typer.Option("composite", "--metric", "-m", help="sharpe|ic|composite|monthly_return|annual_return"),
min_trades: int = typer.Option(30, "--min-trades", help="Filter: minimum trade count"),
realistic: bool = typer.Option(True, "--realistic/--no-realistic", help="Exclude DD<-50%% or total_return>100x (suspected numerical bugs)"),
show: str = typer.Option(None, "--show", "-s", help="Show details for one strategy by name or file id"),
export: Path = typer.Option(None, "--export", "-e", help="Export top-N metadata (without source code) to JSON"),
):
"""Rank backtested strategies by performance — source code is never exposed.
Examples:
$ predix best # Top 10 by composite score
$ predix best -n 20 -m sharpe # Top 20 by Sharpe
$ predix best --no-realistic # Include numerically suspicious runs
$ predix best --show TrendMomentumHybrid
$ predix best -n 50 --export /tmp/top.json
"""
import json
from rich.table import Table
items = _load_strategies()
if not items:
console.print("[red]No strategies found in results/strategies_new or results/strategies[/red]")
raise typer.Exit(1)
if show:
hit = next((s for s in items if s["name"] == show or s["file"].startswith(show)), None)
if not hit:
console.print(f"[red]Strategy not found: {show}[/red]")
raise typer.Exit(1)
console.print(f"\n[bold cyan]{hit['name']}[/bold cyan] ({hit['file']})")
console.print(f"[dim]{hit['description']}[/dim]\n")
console.print(f" Factors : {', '.join(hit['factors'])}")
console.print(f" Sharpe : {hit['sharpe']:.3f}")
console.print(f" Max Drawdown : {hit['max_drawdown']:.2%}")
console.print(f" Win Rate : {hit['win_rate']:.2%}")
console.print(f" IC : {hit['ic']:.4f}")
console.print(f" Trades : {hit['n_trades']}")
console.print(f" Monthly Ret : {hit['monthly_return_pct']:.2f}%")
console.print(f" Annual Ret : {hit['annual_return_pct']:.2f}%")
console.print(f" Composite : {_composite_score(hit):.3f}")
return
pool = [s for s in items if s["n_trades"] >= min_trades]
if realistic:
pool = [s for s in pool if s["max_drawdown"] > -0.5 and abs(s["total_return"]) < 100]
key_map = {
"sharpe": lambda s: s["sharpe"],
"ic": lambda s: abs(s["ic"]),
"composite": _composite_score,
"monthly_return": lambda s: s["monthly_return_pct"],
"annual_return": lambda s: s["annual_return_pct"],
}
if metric not in key_map:
console.print(f"[red]Unknown metric: {metric}. Use one of: {', '.join(key_map)}[/red]")
raise typer.Exit(1)
pool.sort(key=key_map[metric], reverse=True)
top = pool[:n]
if not top:
console.print("[yellow]No strategies match the filters.[/yellow]")
raise typer.Exit(0)
table = Table(title=f"Top {len(top)} Strategies (metric={metric}, min_trades={min_trades}, realistic={realistic})")
table.add_column("#", justify="right")
table.add_column("Name", style="cyan")
table.add_column("Sharpe", justify="right")
table.add_column("DD", justify="right")
table.add_column("WinRate", justify="right")
table.add_column("IC", justify="right")
table.add_column("Trades", justify="right")
table.add_column("Mon%", justify="right")
table.add_column("Factors", justify="right")
for i, s in enumerate(top, 1):
table.add_row(
str(i), s["name"], f"{s['sharpe']:.2f}", f"{s['max_drawdown']:.1%}",
f"{s['win_rate']:.1%}", f"{s['ic']:.3f}", str(s["n_trades"]),
f"{s['monthly_return_pct']:.2f}", str(len(s["factors"])),
)
console.print(table)
console.print(f"\n[dim]{len(pool)} strategies matched filters (of {len(items)} total). "
f"Use [bold]predix best --show NAME[/bold] for details.[/dim]")
if export:
payload = [{k: v for k, v in s.items() if k != "code"} for s in top]
export.parent.mkdir(parents=True, exist_ok=True)
export.write_text(json.dumps(payload, indent=2, default=float))
console.print(f"[green]Exported {len(top)} strategies (code stripped) → {export}[/green]")
if __name__ == "__main__":
app()
+1 -1
View File
@@ -246,7 +246,7 @@ def fin_quant_cli(
import urllib.request
import urllib.error
base_url = os.environ["OPENAI_API_BASE"].rstrip("/v1").rstrip("/")
base_url = os.environ["OPENAI_API_BASE"].removesuffix("/v1").rstrip("/")
health_url = f"{base_url}/health"
console.print(f" [yellow]⏳ Waiting for local LLM server to be ready ({health_url})...[/yellow]")
max_wait = 300 # seconds
+11 -5
View File
@@ -14,7 +14,7 @@ from rdagent.components.workflow.conf import BasePropSetting
from rdagent.components.workflow.rd_loop import RDLoop
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.core.developer import Developer
from rdagent.core.exception import FactorEmptyError, ModelEmptyError
from rdagent.core.exception import FactorEmptyError, LLMUnavailableError, ModelEmptyError
from rdagent.core.proposal import (
Experiment2Feedback,
ExperimentPlan,
@@ -33,6 +33,7 @@ class QuantRDLoop(RDLoop):
skip_loop_error = (
FactorEmptyError,
ModelEmptyError,
LLMUnavailableError, # LLM timeout after all retries → skip loop, don't crash
)
def __init__(self, PROP_SETTING: BasePropSetting):
@@ -94,10 +95,15 @@ class QuantRDLoop(RDLoop):
def coding(self, prev_out: dict[str, Any]):
exp = None
try:
if prev_out["direct_exp_gen"]["propose"].action == "factor":
exp = self.factor_coder.develop(prev_out["direct_exp_gen"]["exp_gen"])
elif prev_out["direct_exp_gen"]["propose"].action == "model":
exp = self.model_coder.develop(prev_out["direct_exp_gen"]["exp_gen"])
direct = prev_out.get("direct_exp_gen")
if not direct:
# Loop was reset (LoopResumeError) while this step was already queued.
# Treat as empty so skip_loop_error skips this iteration cleanly.
raise FactorEmptyError("direct_exp_gen result missing after loop reset")
if direct["propose"].action == "factor":
exp = self.factor_coder.develop(direct["exp_gen"])
elif direct["propose"].action == "model":
exp = self.model_coder.develop(direct["exp_gen"])
logger.log_object(exp, tag="coder result")
except (FactorEmptyError, ModelEmptyError) as e:
logger.warning(f"Coding failed with {type(e).__name__}: {e}")
+13 -2
View File
@@ -2,5 +2,16 @@
from .backtest_engine import BacktestMetrics, FactorBacktester
from .results_db import ResultsDatabase
from .risk_management import CorrelationAnalyzer, PortfolioOptimizer, AdvancedRiskManager
__all__ = ['BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager']
from .vbt_backtest import (
DEFAULT_BARS_PER_YEAR,
DEFAULT_TXN_COST_BPS,
backtest_from_forward_returns,
backtest_signal,
)
__all__ = [
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
'backtest_signal', 'backtest_from_forward_returns',
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
]
@@ -1,7 +1,9 @@
"""
Predix Backtesting Engine - IC, Sharpe, Drawdown
Supports both factor-based backtesting and RL agent backtesting.
Thin wrapper around the unified ``vbt_backtest.backtest_signal`` engine.
All metric formulas live in ``vbt_backtest``; this module exists for
backwards compatibility with the FactorBacktester API and the RL path.
"""
import numpy as np
import pandas as pd
@@ -10,65 +12,113 @@ from typing import Dict, Optional, Any, List
from datetime import datetime
import json
from rdagent.components.backtesting.vbt_backtest import (
DEFAULT_BARS_PER_YEAR,
DEFAULT_TXN_COST_BPS,
backtest_from_forward_returns,
backtest_signal,
)
class BacktestMetrics:
def __init__(self, risk_free_rate: float = 0.02):
"""
Legacy metric helper. All methods delegate to the unified engine to
guarantee identical formulas across the repo. Kept so external callers
that still use ``BacktestMetrics().calculate_*`` continue to work.
"""
def __init__(self, risk_free_rate: float = 0.02, bars_per_year: int = DEFAULT_BARS_PER_YEAR):
self.risk_free_rate = risk_free_rate
self.bars_per_year = bars_per_year
def calculate_ic(self, factor_values: pd.Series, forward_returns: pd.Series) -> float:
mask = factor_values.notna() & forward_returns.notna()
if mask.sum() < 10: return np.nan
if mask.sum() < 10:
return np.nan
return factor_values[mask].corr(forward_returns[mask])
def calculate_sharpe(self, returns: pd.Series, annualize: bool = True) -> float:
if len(returns) < 10 or returns.std() == 0: return np.nan
sharpe = (returns.mean() - self.risk_free_rate/252) / returns.std()
return sharpe * np.sqrt(252) if annualize else sharpe
if len(returns) < 10 or returns.std() == 0:
return np.nan
rf_per_bar = self.risk_free_rate / self.bars_per_year
sharpe = (returns.mean() - rf_per_bar) / returns.std()
return sharpe * np.sqrt(self.bars_per_year) if annualize else sharpe
def calculate_max_drawdown(self, equity: pd.Series) -> float:
running_max = equity.cummax()
drawdown = (equity - running_max) / running_max
drawdown = (equity - running_max) / running_max.replace(0, np.nan)
return float(drawdown.min())
def calculate_all(self, returns: pd.Series, equity: pd.Series,
factor_values: Optional[pd.Series] = None,
forward_returns: Optional[pd.Series] = None) -> Dict:
def calculate_all(
self,
returns: pd.Series,
equity: pd.Series,
factor_values: Optional[pd.Series] = None,
forward_returns: Optional[pd.Series] = None,
) -> Dict:
metrics = {
'total_return': float((1 + returns).prod() - 1),
'annualized_return': float(returns.mean() * 252),
'sharpe_ratio': self.calculate_sharpe(returns),
'max_drawdown': self.calculate_max_drawdown(equity),
'win_rate': float((returns > 0).mean()),
'total_trades': len(returns),
"total_return": float((1 + returns).prod() - 1),
"annualized_return": float(returns.mean() * self.bars_per_year),
"sharpe_ratio": self.calculate_sharpe(returns),
"max_drawdown": self.calculate_max_drawdown(equity),
"win_rate": float((returns > 0).mean()),
"total_trades": len(returns),
}
if factor_values is not None and forward_returns is not None:
metrics['ic'] = self.calculate_ic(factor_values, forward_returns)
metrics["ic"] = self.calculate_ic(factor_values, forward_returns)
return metrics
class FactorBacktester:
def __init__(self):
self.metrics = BacktestMetrics()
self.results_path = Path(__file__).parent.parent.parent / "results" / "backtests"
self.results_path.mkdir(parents=True, exist_ok=True)
def run_backtest(self, factor_values: pd.Series, forward_returns: pd.Series,
factor_name: str, transaction_cost: float = 0.00015) -> Dict:
ic = self.metrics.calculate_ic(factor_values, forward_returns)
signals = np.sign(factor_values)
strategy_returns = signals.shift(1) * forward_returns - transaction_cost
equity = (1 + strategy_returns).cumprod()
metrics = self.metrics.calculate_all(strategy_returns, equity, factor_values, forward_returns)
metrics['ic'] = ic if not np.isnan(ic) else np.nan
metrics['factor_name'] = factor_name
metrics['timestamp'] = datetime.now().isoformat()
# Speichern
def run_backtest(
self,
factor_values: pd.Series,
forward_returns: pd.Series,
factor_name: str,
transaction_cost: float = DEFAULT_TXN_COST_BPS / 10_000.0,
) -> Dict:
"""
Factor-sign backtest via unified engine.
``transaction_cost`` remains in decimal form (e.g. 0.00015 = 1.5 bps)
for backwards compatibility; it is converted to bps internally.
"""
txn_cost_bps = transaction_cost * 10_000.0
result = backtest_from_forward_returns(
factor_values=factor_values,
forward_returns=forward_returns,
txn_cost_bps=txn_cost_bps,
)
metrics: Dict[str, Any] = {
"total_return": result.get("total_return", np.nan),
"annualized_return": result.get("annualized_return", np.nan),
"sharpe_ratio": result.get("sharpe", np.nan),
"max_drawdown": result.get("max_drawdown", np.nan),
"win_rate": result.get("win_rate", np.nan),
"total_trades": result.get("n_trades", 0),
"ic": result.get("ic", np.nan),
"factor_name": factor_name,
"timestamp": datetime.now().isoformat(),
}
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = factor_name.replace("/", "_")
with open(self.results_path / f"{safe_name}_{timestamp}.json", 'w') as f:
json.dump({k: (None if isinstance(v, float) and np.isnan(v) else v) for k, v in metrics.items()}, f, indent=2)
with open(self.results_path / f"{safe_name}_{timestamp}.json", "w") as f:
json.dump(
{
k: (None if isinstance(v, float) and np.isnan(v) else v)
for k, v in metrics.items()
},
f,
indent=2,
)
return metrics
def run_rl_backtest(
@@ -0,0 +1,324 @@
"""
Unified, verifiable backtesting engine.
Single entry point (`backtest_signal`) used by:
- scripts/predix_gen_strategies_real_bt.py
- rdagent/components/coder/strategy_orchestrator.py
- rdagent/components/coder/optuna_optimizer.py
- rdagent/components/backtesting/backtest_engine.py
Design goals
------------
1. One formula for every metric, used everywhere.
2. Annualization uses 252 * 1440 = 362,880 bars/year (1-min EUR/USD convention).
3. Transaction cost applied on every position change; default 1.5 bps.
4. Position is signal.shift(1) (no look-ahead).
5. No silent return clipping; extreme bars are flagged in ``data_quality_flag``.
6. n_trades = actual roundtrips (entryexit), not position-diff count.
7. Returns are cross-checked against vectorbt; mismatch raises in dev mode.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
import numpy as np
import pandas as pd
try:
import vectorbt as vbt # noqa: F401
VBT_AVAILABLE = True
except ImportError:
VBT_AVAILABLE = False
DEFAULT_TXN_COST_BPS = 1.5
DEFAULT_BARS_PER_YEAR = 252 * 1440 # 252 trading days * 1440 min/day = 362,880
EXTREME_BAR_THRESHOLD = 0.05 # |ret| > 5% on a single 1-min bar → suspicious
def _compute_trade_pnl(position: pd.Series, strategy_returns: pd.Series) -> pd.Series:
"""
Group strategy returns into trade epochs (runs of same-sign position).
Each non-flat epoch = one trade roundtrip; its P&L is the sum of
strategy_returns within that epoch.
"""
position_sign = np.sign(position).astype(int)
epoch = (position_sign != position_sign.shift(1)).cumsum()
epoch_sign = position_sign.groupby(epoch).first()
pnl_per_epoch = strategy_returns.groupby(epoch).sum()
return pnl_per_epoch[epoch_sign != 0]
def _cross_check_with_vbt(
close: pd.Series,
position: pd.Series,
txn_cost: float,
manual_total_return: float,
freq: str,
) -> Optional[float]:
"""Run a vectorbt simulation and return its total_return for comparison."""
if not VBT_AVAILABLE:
return None
try:
import vectorbt as vbt
pf = vbt.Portfolio.from_orders(
close=close,
size=position,
size_type="targetpercent",
fees=txn_cost,
init_cash=10_000.0,
freq=freq,
)
return float(pf.total_return())
except Exception:
return None
def backtest_signal(
close: pd.Series,
signal: pd.Series,
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
freq: str = "1min",
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
forward_returns: Optional[pd.Series] = None,
cross_check: bool = False,
) -> Dict[str, Any]:
"""
Run a single-asset backtest from a position signal.
Parameters
----------
close : pd.Series
Close-price series indexed by datetime.
signal : pd.Series
Target position as fraction of equity, in [-1, +1].
{-1, 0, 1} or continuous both supported. Missing bars 0 (flat).
txn_cost_bps : float
One-sided transaction cost in basis points, charged on every
position change in proportion to |Δposition|.
freq : str
Pandas frequency string for vectorbt cross-check. Does NOT affect
manual metric formulas those use ``bars_per_year``.
bars_per_year : int
Used only for Sharpe / Sortino / volatility / arithmetic annualized
return. Default 252 * 1440.
forward_returns : pd.Series, optional
If given, IC (correlation of raw signal with forward returns) is
computed and returned.
cross_check : bool
If True, also run vectorbt and include its total_return in the
result dict as ``vbt_total_return`` for verification.
Returns
-------
dict with keys:
status, sharpe, sortino, calmar, max_drawdown, win_rate,
profit_factor, total_return, annualized_return, annual_return_cagr,
monthly_return, monthly_return_pct, annual_return_pct, volatility,
n_trades, n_position_changes, n_bars, n_months,
signal_long, signal_short, signal_neutral, ic, txn_cost_bps,
bars_per_year, data_quality_flag (optional), vbt_total_return (if cross_check)
"""
if not isinstance(close, pd.Series):
raise TypeError(f"close must be a pd.Series, got {type(close)}")
if not isinstance(signal, pd.Series):
raise TypeError(f"signal must be a pd.Series, got {type(signal)}")
close = pd.to_numeric(close, errors="coerce").dropna().astype(float)
if len(close) < 2:
return {"status": "failed", "reason": f"insufficient close data ({len(close)} bars)"}
signal = pd.to_numeric(signal, errors="coerce")
signal = signal.reindex(close.index).fillna(0).clip(-1, 1).astype(float)
# Position is lagged by one bar: signal generated at t executes at t+1.
position = signal.shift(1).fillna(0)
# Bar returns from close prices, aligned to position index.
bar_ret = close.pct_change().fillna(0)
# Strategy returns = position * bar_ret - turnover cost.
txn_cost = txn_cost_bps / 10_000.0
position_change = position.diff().abs().fillna(position.abs())
gross_ret = position * bar_ret
strategy_returns = gross_ret - position_change * txn_cost
# Data quality flag: single-bar moves over 5% are almost certainly
# data spikes, strategy bugs, or an unrealistic leverage setting.
extreme_bars = int((strategy_returns.abs() > EXTREME_BAR_THRESHOLD).sum())
if strategy_returns.std() > 0:
sharpe = float(strategy_returns.mean() / strategy_returns.std() * np.sqrt(bars_per_year))
else:
sharpe = 0.0
downside = strategy_returns[strategy_returns < 0]
if len(downside) > 1 and downside.std() > 0:
sortino = float(strategy_returns.mean() / downside.std() * np.sqrt(bars_per_year))
else:
sortino = 0.0
total_return = float((1 + strategy_returns).prod() - 1)
ann_return_arith = float(strategy_returns.mean() * bars_per_year)
volatility = float(strategy_returns.std() * np.sqrt(bars_per_year))
equity = (1 + strategy_returns).cumprod()
running_max = equity.cummax()
# equity is strictly positive unless a bar return <= -100%, which we don't clip.
# If that happens we propagate NaN rather than silently clip.
running_max_safe = running_max.where(running_max > 0, np.nan)
drawdown = (equity - running_max) / running_max_safe
drawdown = drawdown.replace([np.inf, -np.inf], np.nan).fillna(0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# Time span — always derived from the actual DatetimeIndex, never from
# n_bars / (bars_per_year / 12) which silently fails on gapped data.
if isinstance(close.index, pd.DatetimeIndex) and len(close.index) > 1:
span_days = (close.index[-1] - close.index[0]).total_seconds() / 86400.0
n_months = max(1.0, span_days / 30.4375)
else:
n_months = max(1.0, len(strategy_returns) / (bars_per_year / 12))
if n_months > 0 and (1 + total_return) > 0:
monthly_return = (1 + total_return) ** (1 / n_months) - 1
annual_return_cagr = (1 + total_return) ** (12 / n_months) - 1
else:
monthly_return = total_return / n_months
annual_return_cagr = total_return * 12 / n_months
calmar = ann_return_arith / abs(max_dd) if max_dd < 0 else 0.0
trade_pnl = _compute_trade_pnl(position, strategy_returns)
n_trades = int(len(trade_pnl))
n_position_changes = int((position.diff().fillna(0) != 0).sum())
if n_trades > 0:
win_rate = float((trade_pnl > 0).mean())
wins = trade_pnl[trade_pnl > 0].sum()
losses = -trade_pnl[trade_pnl < 0].sum()
profit_factor = float(wins / losses) if losses > 0 else float("inf") if wins > 0 else 0.0
else:
win_rate = 0.0
profit_factor = 0.0
ic: Optional[float] = None
if forward_returns is not None:
fwd = pd.to_numeric(forward_returns, errors="coerce")
common = signal.index.intersection(fwd.dropna().index)
if len(common) > 10:
s = signal.loc[common]
f = fwd.loc[common]
if s.std() > 0 and f.std() > 0:
ic_val = float(s.corr(f))
ic = ic_val if np.isfinite(ic_val) else None
result: Dict[str, Any] = {
"status": "success",
"sharpe": sharpe,
"sortino": sortino,
"calmar": calmar,
"max_drawdown": max_dd,
"win_rate": win_rate,
"profit_factor": profit_factor,
"total_return": total_return,
"annualized_return": ann_return_arith,
"annual_return_cagr": annual_return_cagr,
"monthly_return": monthly_return,
"monthly_return_pct": monthly_return * 100,
"annual_return_pct": annual_return_cagr * 100,
"volatility": volatility,
"n_trades": n_trades,
"n_position_changes": n_position_changes,
"n_bars": int(len(strategy_returns)),
"n_months": float(n_months),
"signal_long": int((signal > 0).sum()),
"signal_short": int((signal < 0).sum()),
"signal_neutral": int((signal == 0).sum()),
"ic": ic,
"txn_cost_bps": txn_cost_bps,
"bars_per_year": bars_per_year,
}
if extreme_bars > 0:
result["data_quality_flag"] = (
f"extreme_returns: {extreme_bars} bars with |ret|>{EXTREME_BAR_THRESHOLD:.0%}"
)
if cross_check:
result["vbt_total_return"] = _cross_check_with_vbt(
close=close,
position=position,
txn_cost=txn_cost,
manual_total_return=total_return,
freq=freq,
)
return result
def backtest_from_forward_returns(
factor_values: pd.Series,
forward_returns: pd.Series,
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
) -> Dict[str, Any]:
"""
Backtest a factor using sign(factor) as signal against forward returns.
This is the legacy FactorBacktester mode: no close series available,
just (factor, forward_return) pairs. All time-based metrics degrade
gracefully (n_months approximated from n_bars).
"""
factor_values = pd.to_numeric(factor_values, errors="coerce")
forward_returns = pd.to_numeric(forward_returns, errors="coerce")
common = factor_values.dropna().index.intersection(forward_returns.dropna().index)
if len(common) < 10:
return {"status": "failed", "reason": f"insufficient aligned data ({len(common)} rows)"}
f = factor_values.loc[common]
r = forward_returns.loc[common]
signal = np.sign(f).astype(float)
position = signal.shift(1).fillna(0)
txn_cost = txn_cost_bps / 10_000.0
position_change = position.diff().abs().fillna(position.abs())
strategy_returns = position * r - position_change * txn_cost
if strategy_returns.std() > 0:
sharpe = float(strategy_returns.mean() / strategy_returns.std() * np.sqrt(bars_per_year))
else:
sharpe = 0.0
total_return = float((1 + strategy_returns).prod() - 1)
equity = (1 + strategy_returns).cumprod()
max_dd = float(((equity - equity.cummax()) / equity.cummax().replace(0, np.nan)).min() or 0.0)
ic_val = float(f.corr(r)) if f.std() > 0 and r.std() > 0 else 0.0
ic = ic_val if np.isfinite(ic_val) else 0.0
trade_pnl = _compute_trade_pnl(position, strategy_returns)
n_trades = int(len(trade_pnl))
win_rate = float((trade_pnl > 0).mean()) if n_trades > 0 else 0.0
ann_return = float(strategy_returns.mean() * bars_per_year)
volatility = float(strategy_returns.std() * np.sqrt(bars_per_year))
return {
"status": "success",
"sharpe": sharpe,
"max_drawdown": max_dd,
"total_return": total_return,
"annualized_return": ann_return,
"volatility": volatility,
"win_rate": win_rate,
"n_trades": n_trades,
"ic": ic,
"n_bars": int(len(strategy_returns)),
"txn_cost_bps": txn_cost_bps,
"bars_per_year": bars_per_year,
}
+24 -57
View File
@@ -586,69 +586,36 @@ class OptunaOptimizer:
if signal_bias != 0.0:
signal = (signal.astype(float) + signal_bias).round().astype(int).clip(-1, 1)
# Calculate returns using factor changes as proxy
# Build a synthetic close from the factor-mean so we can route
# through the same unified engine as every other backtest path.
# Backtest formulas must match the orchestrator's real-OHLCV path.
combined = df_factors.mean(axis=1)
returns = combined.pct_change().fillna(0) * signal.shift(1).fillna(0)
combined_ret = combined.pct_change().fillna(0)
synthetic_close = (1 + combined_ret).cumprod() * 100.0
# Apply spread costs
SPREAD_COST = 0.00015
signal_changes = signal.diff().abs().fillna(0)
spread_costs = signal_changes * SPREAD_COST
returns = returns - spread_costs
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal,
DEFAULT_TXN_COST_BPS,
)
import os as _os
if len(returns) < 10 or returns.std() == 0:
bt = backtest_signal(
close=synthetic_close,
signal=signal,
txn_cost_bps=float(_os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
freq="1min",
)
if bt.get("status") != "success":
return self._default_metrics()
# FIX 1: Korrekte Sharpe Ratio Annualisierung für 1-Minuten-Daten
bars_per_year = 252 * 1440 # 252 Handelstage * 1440 Minuten/Tag
mean_return = float(returns.mean())
ann_return = mean_return * bars_per_year
volatility = float(returns.std() * np.sqrt(bars_per_year))
sharpe = ann_return / volatility if volatility > 0 else 0.0
total_return = float(returns.sum())
# FIX 3: Drawdown-Berechnung mit korrektem Error-Handling
returns_clean = returns.fillna(0).replace([np.inf, -np.inf], 0)
returns_clean = returns_clean.clip(-0.1, 0.1) # Max 10% pro Bar
cum = (1 + returns_clean).cumprod()
running_max = cum.expanding().max()
drawdown = (cum - running_max) / running_max.replace(0, np.nan)
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# FIX 2: Win Rate korrigieren - echte Trade-P&L Berechnung
signal_positions = signal.shift(1).fillna(0).astype(int)
trade_pnl = []
current_pnl = 0.0
in_position = False
for idx in signal_positions.index:
pos = signal_positions[idx]
ret = returns_clean.get(idx, 0)
if pos != 0: # In Position (Long oder Short)
current_pnl += ret * np.sign(pos)
in_position = True
elif in_position and pos == 0: # Ausstieg
trade_pnl.append(current_pnl)
current_pnl = 0.0
in_position = False
if in_position and current_pnl != 0:
trade_pnl.append(current_pnl)
num_real_trades = len(trade_pnl)
win_rate = float(sum(1 for p in trade_pnl if p > 0) / num_real_trades) if num_real_trades > 0 else 0.0
return {
"sharpe_ratio": sharpe,
"annualized_return": ann_return,
"max_drawdown": max_dd,
"win_rate": win_rate,
"volatility": volatility,
"total_return": total_return,
"num_trades": num_real_trades,
"sharpe_ratio": bt["sharpe"],
"annualized_return": bt["annualized_return"],
"max_drawdown": bt["max_drawdown"],
"win_rate": bt["win_rate"],
"volatility": bt["volatility"],
"total_return": bt["total_return"],
"num_trades": bt["n_trades"],
}
except Exception as e:
+69 -175
View File
@@ -842,7 +842,6 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
signal = local_vars["signal"]
# FIX 4: Debug-Logging nach Signal-Berechnung
logger.info(
f"[DEBUG] {strategy_name}: signal stats: "
f"len={len(signal)}, "
@@ -852,144 +851,72 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
f"unique={signal.nunique()}"
)
# Calculate REAL returns using OHLCV data
# Delegate all metric computation to the single source of truth.
# Same formulas as every other backtest path in the repo.
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal,
DEFAULT_TXN_COST_BPS,
)
close = self.load_ohlcv_close()
combined_factor = df_factors.mean(axis=1) # Always define combined_factor
price_returns = combined_factor.pct_change().fillna(0) # Default fallback
if close is not None:
# Use factor timestamps as the base (signal is generated on factor data)
# Resample OHLCV close to factor timestamps
signal_index = signal.index
close_aligned = close.reindex(signal_index).ffill()
# Calculate real price returns
price_returns = close_aligned.pct_change().fillna(0)
# Apply signal positions to real returns (lagged signal)
signal_positions = signal.shift(1).fillna(0)
returns = price_returns * signal_positions
# Include spread costs (1.5 bps per trade = 0.00015)
SPREAD_COST = 0.00015
signal_changes = signal_positions.diff().abs().fillna(0)
spread_costs = signal_changes * SPREAD_COST
returns = returns - spread_costs
if close is None:
logger.warning("OHLCV data unavailable, using factor-mean proxy")
proxy = df_factors.mean(axis=1).astype(float)
synthetic_close = (1 + proxy.pct_change().fillna(0) * 0.0001).cumprod() * 100.0
close_for_bt = synthetic_close
else:
# Fallback: use factor proxy if OHLCV unavailable
logger.warning("OHLCV data unavailable, using factor proxy")
signal_positions = signal.shift(1).fillna(0)
return_proxy = combined_factor * 0.0001
returns = return_proxy * signal_positions
# Returns calculated
close_for_bt = close.reindex(signal.index).ffill()
if returns.std() == 0:
txn_cost_bps = float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS))
bt = backtest_signal(
close=close_for_bt,
signal=signal,
txn_cost_bps=txn_cost_bps,
freq="1min",
)
if bt.get("status") != "success":
return {
"strategy_name": strategy_name,
"status": "rejected",
"reason": "Zero return variance",
"reason": bt.get("reason", "backtest failed"),
"factors_used": factor_names,
}
# FIX 1: Korrekte Sharpe Ratio Annualisierung für 1-Minuten-Daten
# Verwende mean_return * bars_per_year statt komplexer Jahres-Berechnung
bars_per_year = 252 * 1440 # 252 Handelstage * 1440 Minuten/Tag
ann_factor = np.sqrt(bars_per_year) # Für Annualisierung von Mean/Std
mean_return = float(returns.mean())
ann_return = mean_return * bars_per_year
volatility = float(returns.std() * np.sqrt(bars_per_year))
sharpe = ann_return / volatility if volatility > 0 else 0.0
total_return = float(returns.sum())
n_periods = len(returns)
sharpe = bt["sharpe"]
max_dd = bt["max_drawdown"]
win_rate = bt["win_rate"]
num_real_trades = bt["n_trades"]
# FIX 3: Drawdown-Berechnung mit korrektem Error-Handling
# Stelle sicher, dass returns keine Inf/NaN haben VOR der Berechnung
returns_clean = returns.fillna(0).replace([np.inf, -np.inf], 0)
# Clip extreme values that could cause unrealistic drawdown
returns_clean = returns_clean.clip(-0.1, 0.1) # Max 10% per bar realistic
cum_returns = (1 + returns_clean).cumprod()
# Handle empty cum_returns
if len(cum_returns) == 0 or cum_returns.isna().all():
max_dd = 0.0
else:
running_max = cum_returns.expanding().max()
# Avoid division by zero: use clip instead of replace
running_max_safe = running_max.clip(lower=1e-8) # Prevent div-by-zero
drawdown = (cum_returns - running_max) / running_max_safe
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# FIX 2: Win Rate korrigieren - echte Trade-P&L Berechnung
signal_positions = signal.shift(1).fillna(0).astype(int)
# Finde Trade-Einstiegspunkte
position_changes = signal_positions.diff().fillna(0)
# Berechne P&L für jede Position
trade_pnl = []
current_pnl = 0.0
in_position = False
for idx in signal_positions.index:
pos = signal_positions[idx]
ret = returns_clean.get(idx, 0)
if pos != 0: # In Position (Long oder Short)
current_pnl += ret * np.sign(pos)
in_position = True
elif in_position and pos == 0: # Ausstieg
trade_pnl.append(current_pnl)
current_pnl = 0.0
in_position = False
# Offene Position am Ende schließen
if in_position and current_pnl != 0:
trade_pnl.append(current_pnl)
num_real_trades = len(trade_pnl)
win_rate = float(sum(1 for p in trade_pnl if p > 0) / num_real_trades) if num_real_trades > 0 else 0.0
# FIX 4: Debug-Logging nach Return-Berechnung
logger.info(
f"[DEBUG] {strategy_name}: return stats: "
f"mean={returns.mean():.6e}, "
f"std={returns.std():.6e}, "
f"skew={returns.skew():.3f}, "
f"total_return={total_return:.6f}, "
f"num_trades={num_real_trades}"
f"[DEBUG] {strategy_name}: bt stats: "
f"sharpe={sharpe:.4f} dd={max_dd:.4f} wr={win_rate:.4f} "
f"trades={num_real_trades} total_ret={bt['total_return']:.4%}"
)
# Information ratio (signal vs buy-and-hold)
if close is not None:
benchmark_returns = price_returns
else:
benchmark_returns = combined_factor.pct_change().fillna(0)
excess_returns = returns - benchmark_returns
if excess_returns.std() > 0:
ir = float(excess_returns.mean() / excess_returns.std() * ann_factor)
else:
ir = 0.0
metrics = {
"strategy_name": strategy_name,
"status": "accepted" if self._check_acceptance(sharpe, max_dd, win_rate) else "rejected",
"sharpe_ratio": round(sharpe, 4),
"annualized_return": round(ann_return, 6),
"annualized_return": round(bt["annualized_return"], 6),
"annual_return_cagr": round(bt["annual_return_cagr"], 6),
"max_drawdown": round(max_dd, 6),
"win_rate": round(win_rate, 4),
"volatility": round(volatility, 6),
"information_ratio": round(ir, 4),
"total_return": round(total_return, 6),
"num_periods": n_periods,
"volatility": round(bt["volatility"], 6),
"total_return": round(bt["total_return"], 6),
"num_periods": bt["n_bars"],
"num_real_trades": num_real_trades,
"profit_factor": round(bt["profit_factor"], 4) if np.isfinite(bt["profit_factor"]) else None,
"sortino": round(bt["sortino"], 4),
"calmar": round(bt["calmar"], 4),
"factors_used": factor_names,
"trading_style": self.trading_style,
"generated_at": datetime.now().isoformat(),
}
if "data_quality_flag" in bt:
metrics["data_quality_flag"] = bt["data_quality_flag"]
if metrics["status"] == "rejected":
# FIX 4: Debug-Logging bei Ablehnung
logger.info(
f"[DEBUG] {strategy_name}: rejection breakdown: "
f"sharpe={sharpe:.4f} (need>={self.min_sharpe}), "
@@ -1336,81 +1263,48 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
signal = local_vars["signal"]
signal_index = signal.index
close_aligned = close.reindex(signal_index).ffill()
price_returns = close_aligned.pct_change().fillna(0)
signal_positions = signal.shift(1).fillna(0)
returns = price_returns * signal_positions
SPREAD_COST = 0.00015
signal_changes = signal_positions.diff().abs().fillna(0)
spread_costs = signal_changes * SPREAD_COST
returns = returns - spread_costs
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal,
DEFAULT_TXN_COST_BPS,
)
if returns.std() == 0 or len(returns) < 10:
close_for_bt = close.reindex(signal.index).ffill() if close is not None else None
if close_for_bt is None:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
# FIX 1: Korrekte Sharpe Ratio Annualisierung für 1-Minuten-Daten
bars_per_year = 252 * 1440
mean_return = float(returns.mean())
ann_return = mean_return * bars_per_year
volatility = float(returns.std() * np.sqrt(bars_per_year))
sharpe = ann_return / volatility if volatility > 0 else 0.0
total_return = float(returns.sum())
n_periods = len(returns)
bt = backtest_signal(
close=close_for_bt,
signal=signal,
txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
freq="1min",
)
if bt.get("status") != "success":
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
# FIX 3: Drawdown-Berechnung mit korrektem Error-Handling
returns_clean = returns.fillna(0).replace([np.inf, -np.inf], 0)
returns_clean = returns_clean.clip(-0.1, 0.1)
cum_returns = (1 + returns_clean).cumprod()
# Handle empty cum_returns
if len(cum_returns) == 0 or cum_returns.isna().all():
max_dd = 0.0
else:
running_max = cum_returns.expanding().max()
# Avoid division by zero: use clip instead of replace
running_max_safe = running_max.clip(lower=1e-8) # Prevent div-by-zero
drawdown = (cum_returns - running_max) / running_max_safe
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
sharpe = bt["sharpe"]
max_dd = bt["max_drawdown"]
win_rate = bt["win_rate"]
# FIX 2: Win Rate korrigieren - echte Trade-P&L Berechnung
sig_pos = signal.shift(1).fillna(0).astype(int)
trade_pnl = []
current_pnl = 0.0
in_position = False
for idx in sig_pos.index:
pos = sig_pos[idx]
ret = returns_clean.get(idx, 0)
if pos != 0:
current_pnl += ret * np.sign(pos)
in_position = True
elif in_position and pos == 0:
trade_pnl.append(current_pnl)
current_pnl = 0.0
in_position = False
if in_position and current_pnl != 0:
trade_pnl.append(current_pnl)
num_real_trades = len(trade_pnl)
win_rate = float(sum(1 for p in trade_pnl if p > 0) / num_real_trades) if num_real_trades > 0 else 0.0
status = "accepted" if sharpe >= self.min_sharpe and max_dd >= self.max_drawdown and win_rate >= self.min_win_rate else "rejected"
status = (
"accepted"
if sharpe >= self.min_sharpe
and max_dd >= self.max_drawdown
and win_rate >= self.min_win_rate
else "rejected"
)
return {
"strategy_name": strategy_name,
"status": status,
"sharpe_ratio": round(sharpe, 4),
"annualized_return": round(ann_return, 6),
"annualized_return": round(bt["annualized_return"], 6),
"max_drawdown": round(max_dd, 6),
"win_rate": round(win_rate, 4),
"volatility": round(volatility, 6),
"total_return": round(total_return, 6),
"num_periods": n_periods,
"num_real_trades": num_real_trades,
"volatility": round(bt["volatility"], 6),
"total_return": round(bt["total_return"], 6),
"num_periods": bt["n_bars"],
"num_real_trades": bt["n_trades"],
}
except Exception as e:
+15 -2
View File
@@ -182,7 +182,15 @@ class RDLoop(LoopBase, metaclass=LoopMeta):
return modified_feedback
def _propose(self):
hypothesis = self.hypothesis_gen.gen(self.trace, self.plan)
from rdagent.core.exception import LLMUnavailableError
try:
hypothesis = self.hypothesis_gen.gen(self.trace, self.plan)
except LLMUnavailableError as e:
# LLM timeout at the proposal stage: skip_loop_error would leave
# hypothesis=None in trace.hist and corrupt all future iterations.
# Reset the whole loop instead so state stays consistent.
raise self.LoopResumeError("LLM unavailable during proposal, resetting loop") from e
# user can change the hypothesis here
hypothesis = self._interact_hypo(hypothesis)
@@ -237,5 +245,10 @@ class RDLoop(LoopBase, metaclass=LoopMeta):
def record(self, prev_out: dict[str, Any]):
feedback = prev_out["feedback"]
exp = prev_out.get("running") or prev_out.get("coding") or prev_out.get("direct_exp_gen", {}).get("exp_gen")
exp = prev_out.get("running") or prev_out.get("coding") or (prev_out.get("direct_exp_gen") or {}).get("exp_gen")
if exp is None or getattr(exp, "hypothesis", None) is None:
# Loop was reset or skipped — nothing valid to record in trace history.
# Storing None here would corrupt quant_proposal.py which reads
# trace.hist[-1][0].hypothesis on the next iteration.
return
self.trace.sync_dag_parent_and_hist((exp, feedback), prev_out[self.LOOP_IDX_KEY])
+8
View File
@@ -4,6 +4,14 @@ class WorkflowError(Exception):
"""
class LLMUnavailableError(RuntimeError):
"""
Raised when the LLM backend fails to respond after all retries.
Registered as skip_loop_error in QuantRDLoop so a transient LLM outage
skips the current loop iteration instead of killing the whole process.
"""
class FormatError(WorkflowError):
"""
After multiple attempts, we are unable to obtain the answer in the correct format to proceed.
@@ -1,4 +1,5 @@
import json
import os
import random
from typing import Tuple
@@ -59,9 +60,16 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
# ========= Bandit ==========
if QUANT_PROP_SETTING.action_selection == "bandit":
if len(trace.hist) > 0:
metric = extract_metrics_from_experiment(trace.hist[-1][0])
prev_action = trace.hist[-1][0].hypothesis.action
# Find the most recent hist entry that has a valid experiment+hypothesis.
# Entries can be None/corrupt when a loop was reset mid-way (LoopResumeError).
last_valid = next(
(entry for entry in reversed(trace.hist)
if entry[0] is not None and getattr(entry[0], "hypothesis", None) is not None),
None,
)
if last_valid is not None:
metric = extract_metrics_from_experiment(last_valid[0])
prev_action = last_valid[0].hypothesis.action
trace.controller.record(metric, prev_action)
action = trace.controller.decide(metric)
else:
@@ -108,12 +116,18 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
else:
specific_trace = Trace(trace.scen)
# Limit history to avoid exceeding the LLM context window.
# With 2000+ experiments the prompt easily hits 76k+ tokens on an 80k ctx model.
MAX_FACTOR_HISTORY = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
MAX_MODEL_HISTORY = int(os.environ.get("QLIB_QUANT_MAX_MODEL_HISTORY", "10"))
if action == "factor":
# all factor experiments and the SOTA model experiment
# Most-recent N factor experiments + best SOTA model experiment
model_inserted = False
factor_count = 0
for i in range(len(trace.hist) - 1, -1, -1): # Reverse iteration
if trace.hist[i][0].hypothesis.action == "factor":
if trace.hist[i][0].hypothesis.action == "factor" and factor_count < MAX_FACTOR_HISTORY:
specific_trace.hist.insert(0, trace.hist[i])
factor_count += 1
elif (
trace.hist[i][0].hypothesis.action == "model"
and trace.hist[i][1].decision is True
@@ -122,11 +136,13 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
specific_trace.hist.insert(0, trace.hist[i])
model_inserted = True
elif action == "model":
# all model experiments and all SOTA factor experiments
# Most-recent N model experiments + best SOTA factor experiment
factor_inserted = False
model_count = 0
for i in range(len(trace.hist) - 1, -1, -1): # Reverse iteration
if trace.hist[i][0].hypothesis.action == "model":
if trace.hist[i][0].hypothesis.action == "model" and model_count < MAX_MODEL_HISTORY:
specific_trace.hist.insert(0, trace.hist[i])
model_count += 1
elif (
trace.hist[i][0].hypothesis.action == "factor"
and trace.hist[i][1].decision is True
+63 -90
View File
@@ -25,9 +25,19 @@ from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
from dotenv import load_dotenv
# Suppress warnings
# Suppress warnings and noisy loggers that bleed into Rich progress output
warnings.filterwarnings('ignore')
logging.getLogger('rdagent').setLevel(logging.WARNING)
for _noisy in ('rdagent', 'litellm', 'LiteLLM', 'litellm.utils',
'litellm.main', 'httpx', 'httpcore', 'openai', 'urllib3'):
logging.getLogger(_noisy).setLevel(logging.CRITICAL)
# Suppress litellm verbose flag if already imported
try:
import litellm as _ll
_ll.suppress_debug_info = True
_ll.verbose = False
_ll.set_verbose = False
except Exception:
pass
# ============================================================================
# Configuration
@@ -54,10 +64,12 @@ else:
MIN_IC = 0.02
MIN_SHARPE = 0.5
MIN_TRADES = 10
MAX_DRAWDOWN = -1.0
MAX_DRAWDOWN = -0.30
STYLE_EMOJI = '📈 Swing'
STYLE_DESC = 'medium-term intraday'
TXN_COST_BPS = float(os.getenv('TXN_COST_BPS', '1.0'))
console = Console()
# ============================================================================
@@ -65,10 +77,10 @@ console = Console()
# ============================================================================
def setup_llm_env():
"""Setup LLM environment variables."""
load_dotenv(Path(__file__).parent / '.env')
router_key = os.getenv('OPENROUTER_API_KEY') or os.getenv('OPENAI_API_KEY', '')
if not router_key or router_key == 'local':
router_key = os.getenv('OPENROUTER_API_KEY', '')
load_dotenv(Path(__file__).parent.parent / '.env')
if os.getenv('OPENAI_API_KEY') == 'local' or os.getenv('LLM_BACKEND', '').lower() == 'local':
return
router_key = os.getenv('OPENROUTER_API_KEY', '')
if router_key:
os.environ['OPENAI_API_KEY'] = router_key
os.environ['OPENAI_API_BASE'] = 'https://openrouter.ai/api/v1'
@@ -211,121 +223,80 @@ Output ONLY valid JSON with these fields:
# Backtest Runner (runs in main process to avoid re-loading data)
# ============================================================================
def run_backtest(close, factors_df, strategy_code):
"""Run real backtest with actual OHLCV data."""
"""
Execute LLM-generated strategy code in a sandboxed subprocess to produce
the signal, then delegate all metric computation to the unified
``backtest_signal`` engine in the main process.
"""
if close is None or factors_df is None or len(factors_df.columns) < 2:
return None
import tempfile
# Subprocess stays minimal: it only runs the untrusted strategy code
# and pickles the resulting signal. All numbers come from the shared engine.
script = f"""
import pandas as pd
import numpy as np
import json
close = pd.read_pickle('close.pkl')
factors = pd.read_pickle('factors.pkl')
try:
{chr(10).join(' ' + l for l in strategy_code.split(chr(10)))}
except:
print("ERROR: Strategy execution failed")
exit(1)
except Exception as e:
print(f"ERROR: Strategy execution failed: {{e}}")
raise SystemExit(1)
if 'signal' not in dir():
print("ERROR: No signal generated")
exit(1)
raise SystemExit(1)
signal = signal.fillna(0)
common_idx = close.index.intersection(signal.index)
close = close.loc[common_idx]
signal = signal.loc[common_idx]
FORWARD_BARS = {FORWARD_BARS}
returns_fwd = close.pct_change(FORWARD_BARS).shift(-FORWARD_BARS)
signal_aligned = signal.loc[returns_fwd.dropna().index]
fwd_returns = returns_fwd.loc[signal_aligned.index]
if len(signal_aligned) < 100 or len(fwd_returns) < 100:
print("ERROR: Not enough data after alignment")
exit(1)
ic = signal_aligned.corr(fwd_returns)
strategy_returns = signal_aligned * fwd_returns
if strategy_returns.std() > 0:
sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(252 * 1440 / {FORWARD_BARS})
else:
sharpe = 0
cum = (1 + strategy_returns).cumprod()
running_max = cum.expanding().max()
drawdown = (cum - running_max) / running_max.replace(0, np.nan)
max_dd = drawdown.min() if len(drawdown) > 0 else 0
win_rate = (strategy_returns > 0).sum() / len(strategy_returns) if len(strategy_returns) > 0 else 0
n_trades = int((signal_aligned != signal_aligned.shift(1)).sum())
total_return = cum.iloc[-1] - 1
n_bars = len(strategy_returns)
n_months = n_bars / (252 * 1440 / {FORWARD_BARS} / 12) if n_bars > 0 else 1
if n_months > 0 and (1 + total_return) > 0:
monthly_return = (1 + total_return) ** (1 / n_months) - 1
annual_return = (1 + total_return) ** (12 / n_months) - 1
else:
monthly_return = total_return
annual_return = total_return * 12
result = {{
"status": "success",
"sharpe": float(sharpe),
"max_drawdown": float(max_dd) if not np.isnan(max_dd) else -0.20,
"win_rate": float(win_rate),
"ic": float(ic) if not np.isnan(ic) else 0,
"n_trades": n_trades,
"total_return": float(total_return),
"monthly_return_pct": float(monthly_return * 100),
"annual_return_pct": float(annual_return * 100),
"n_bars": int(n_bars),
"n_months": float(n_months),
"signal_long": int((signal_aligned == 1).sum()),
"signal_short": int((signal_aligned == -1).sum()),
"signal_neutral": int((signal_aligned == 0).sum()),
}}
print(json.dumps(result))
signal.fillna(0).to_pickle('signal.pkl')
"""
with tempfile.TemporaryDirectory() as td:
tdp = Path(td)
close.to_pickle(str(tdp / 'close.pkl'))
factors_df.to_pickle(str(tdp / 'factors.pkl'))
script_path = tdp / 'run.py'
script_path.write_text(script)
(tdp / 'run.py').write_text(script)
try:
result = subprocess.run(
['python', str(script_path)],
['python', 'run.py'],
capture_output=True, text=True, timeout=60,
cwd=str(tdp)
)
if result.returncode != 0:
return {'status': 'failed', 'reason': result.stderr[:200] or result.stdout[:200]}
for line in result.stdout.strip().split('\n'):
try:
return json.loads(line)
except:
continue
return {'status': 'failed', 'reason': 'No valid JSON output'}
return {'status': 'failed', 'reason': (result.stderr or result.stdout)[:200]}
signal = pd.read_pickle(tdp / 'signal.pkl')
except subprocess.TimeoutExpired:
return {'status': 'failed', 'reason': 'Timeout (60s)'}
except Exception as e:
return {'status': 'failed', 'reason': str(e)[:200]}
# Main process: unified backtest (identical formulas everywhere).
from rdagent.components.backtesting.vbt_backtest import backtest_signal
common = close.index.intersection(signal.index)
if len(common) < 100:
return {'status': 'failed', 'reason': f'Not enough aligned data ({len(common)} bars)'}
close_a = close.loc[common]
signal_a = signal.reindex(common).fillna(0)
# Forward returns at the configured horizon feed IC computation.
fwd_returns = close_a.pct_change(FORWARD_BARS).shift(-FORWARD_BARS)
return backtest_signal(
close=close_a,
signal=signal_a,
txn_cost_bps=TXN_COST_BPS,
freq='1min',
forward_returns=fwd_returns,
)
# ============================================================================
# Main Parallel Strategy Generation
# ============================================================================
@@ -393,6 +364,8 @@ def main(target_count=10):
BarColumn(),
TextColumn("[bold green]{task.completed}/{task.total}"),
TimeElapsedColumn(),
redirect_stdout=True,
redirect_stderr=True,
) as progress:
task = progress.add_task("Generating...", total=max_attempts)
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python
"""
Re-run existing strategies through the unified backtest engine.
For every strategy JSON in results/strategies_new (or a user-supplied dir):
1. Load the factor values it references.
2. Execute its ``code`` in a sandboxed subprocess to produce the signal.
3. Run the signal through ``backtest_signal`` on REAL 1-min EUR/USD close.
4. Print old-vs-new sharpe / DD / trades / total-return so the impact of
the unified engine (no return clipping, proper 1-min annualization,
trade-epoch win rate) is visible.
Does NOT mutate the strategy JSON files read-only comparison.
Usage:
python scripts/predix_rebacktest_unified.py # all strategies
python scripts/predix_rebacktest_unified.py 50 # first 50
python scripts/predix_rebacktest_unified.py 50 --csv report.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
import pandas as pd
from rich.console import Console
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from rdagent.components.backtesting.vbt_backtest import backtest_signal # noqa: E402
OHLCV_PATH = Path("/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
FACTORS_VALUES_DIR = Path("/home/nico/Predix/results/factors/values")
STRATEGIES_DIR = Path("/home/nico/Predix/results/strategies_new")
console = Console()
def load_close() -> pd.Series:
ohlcv = pd.read_hdf(str(OHLCV_PATH), key="data")
col = "$close" if "$close" in ohlcv.columns else "close"
close = ohlcv[col].dropna()
# Drop the "EURUSD" instrument level if present — the strategies work
# on a single series indexed by timestamp.
if isinstance(close.index, pd.MultiIndex):
close = close.droplevel(-1)
return close.astype(float).sort_index()
def load_factor_series(names: List[str]) -> Dict[str, pd.Series]:
out: Dict[str, pd.Series] = {}
for name in names:
for variant in (name, name.replace("/", "_").replace("\\", "_")[:150]):
path = FACTORS_VALUES_DIR / f"{variant}.parquet"
if path.exists():
try:
df = pd.read_parquet(str(path))
if df is not None and len(df.columns) > 0:
out[name] = df.iloc[:, 0]
break
except Exception:
pass
return out
def execute_strategy(
factors_df: pd.DataFrame,
close: pd.Series,
strategy_code: str,
timeout: int = 45,
) -> Optional[pd.Series]:
"""Run untrusted LLM code in a subprocess and return the resulting signal."""
script = f"""
import pandas as pd, numpy as np
factors = pd.read_pickle('factors.pkl')
close = pd.read_pickle('close.pkl')
df = factors # some strategies reference 'df', others 'factors'
try:
{chr(10).join(' ' + line for line in strategy_code.split(chr(10)))}
except Exception as e:
print(f"ERROR: {{e}}")
raise SystemExit(1)
if 'signal' not in dir():
print("ERROR: no signal")
raise SystemExit(1)
pd.Series(signal).fillna(0).to_pickle('signal.pkl')
"""
with tempfile.TemporaryDirectory() as td:
tdp = Path(td)
factors_df.to_pickle(str(tdp / "factors.pkl"))
close.to_pickle(str(tdp / "close.pkl"))
(tdp / "run.py").write_text(script)
try:
result = subprocess.run(
["python", "run.py"],
capture_output=True,
text=True,
timeout=timeout,
cwd=str(tdp),
)
if result.returncode != 0:
return None
signal = pd.read_pickle(tdp / "signal.pkl")
return signal
except (subprocess.TimeoutExpired, Exception):
return None
def rebacktest_one(
strategy_data: Dict[str, Any],
close: pd.Series,
txn_cost_bps: float,
) -> Dict[str, Any]:
factor_names = strategy_data.get("factor_names") or strategy_data.get("factors_used") or []
code = strategy_data.get("code", "")
if not factor_names or not code:
return {"status": "skipped", "reason": "missing factors or code"}
factor_series = load_factor_series(factor_names)
if len(factor_series) < 2:
return {"status": "skipped", "reason": f"only {len(factor_series)} factor files found"}
factors_df = pd.DataFrame(factor_series).dropna(how="all")
if isinstance(factors_df.index, pd.MultiIndex):
factors_df = factors_df.droplevel(-1)
factors_df = factors_df.sort_index()
# Factors are typically daily-timestamped; close is 1-min.
# Direct index intersection would be near-zero → reindex and ffill first,
# matching exactly what the orchestrator's evaluate_strategy does.
factors_1min = factors_df.reindex(close.index).ffill()
valid_rows = factors_1min.notna().any(axis=1)
if valid_rows.sum() < 1000:
return {"status": "skipped", "reason": f"only {valid_rows.sum()} valid rows after ffill"}
close_a = close.loc[valid_rows]
factors_a = factors_1min.loc[valid_rows]
signal = execute_strategy(factors_a, close_a, code)
if signal is None:
return {"status": "code_failed"}
# Signal can arrive on either the factor index or the close index.
signal = signal.reindex(close_a.index).ffill().fillna(0)
result = backtest_signal(
close=close_a,
signal=signal,
txn_cost_bps=txn_cost_bps,
freq="1min",
)
result["status_detail"] = result.pop("status")
result["status"] = "ok"
return result
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("count", type=int, nargs="?", default=None,
help="Limit to first N strategies (default: all)")
parser.add_argument("--dir", type=Path, default=STRATEGIES_DIR,
help="Strategy directory to re-backtest")
parser.add_argument("--csv", type=Path, default=None,
help="Write a CSV report to this path")
parser.add_argument("--txn-cost-bps", type=float, default=1.5)
args = parser.parse_args()
console.print(f"[cyan]Loading OHLCV close...[/cyan]")
close = load_close()
console.print(f"[green]✓[/green] {len(close):,} 1-min bars "
f"({close.index[0]}{close.index[-1]})\n")
files = sorted(args.dir.glob("*.json"))
if args.count:
files = files[:args.count]
console.print(f"[cyan]Re-backtesting {len(files)} strategies with unified engine...[/cyan]\n")
rows: List[Dict[str, Any]] = []
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]{task.description}"),
BarColumn(),
TextColumn("[bold green]{task.completed}/{task.total}"),
TimeElapsedColumn(),
) as progress:
task = progress.add_task("Backtesting", total=len(files))
for f in files:
try:
data = json.load(open(f))
except Exception:
progress.update(task, advance=1)
continue
old = data.get("summary", {})
name = data.get("strategy_name", f.stem)[:38]
bt = rebacktest_one(data, close, args.txn_cost_bps)
row = {
"file": f.name,
"name": name,
"status": bt.get("status"),
"reason": bt.get("reason", bt.get("status_detail", "")),
"old_sharpe": old.get("sharpe"),
"old_dd": old.get("max_drawdown"),
"old_trades": old.get("real_n_trades"),
"old_monthly_pct": old.get("monthly_return_pct"),
"new_sharpe": bt.get("sharpe"),
"new_dd": bt.get("max_drawdown"),
"new_trades": bt.get("n_trades"),
"new_total_return": bt.get("total_return"),
"new_annual_return_cagr": None,
"data_quality": bt.get("data_quality_flag"),
}
# annualized CAGR is not in forward_returns wrapper; use annual_return_pct/100 proxy
if "annualized_return" in bt:
row["new_annual_return_cagr"] = bt["annualized_return"]
rows.append(row)
progress.update(task, advance=1)
# Summary
ok_rows = [r for r in rows if r["status"] == "ok"]
console.print(f"\n[bold]{len(ok_rows)}/{len(rows)} strategies successfully re-backtested[/bold]\n")
status_counts: Dict[str, int] = {}
for r in rows:
status_counts[r["status"]] = status_counts.get(r["status"], 0) + 1
for status, n in sorted(status_counts.items(), key=lambda kv: -kv[1]):
console.print(f" {status}: {n}")
if ok_rows:
# Compare old vs new where both exist
comparable = [r for r in ok_rows if r["old_sharpe"] is not None]
if comparable:
old_sharpe = np.array([r["old_sharpe"] for r in comparable], dtype=float)
new_sharpe = np.array([r["new_sharpe"] for r in comparable], dtype=float)
console.print(f"\n[bold]Sharpe drift ({len(comparable)} strategies with old metrics):[/bold]")
console.print(f" old mean={old_sharpe.mean():+.3f} median={np.median(old_sharpe):+.3f} max={old_sharpe.max():+.3f}")
console.print(f" new mean={new_sharpe.mean():+.3f} median={np.median(new_sharpe):+.3f} max={new_sharpe.max():+.3f}")
diff = new_sharpe - old_sharpe
console.print(f" Δ mean={diff.mean():+.3f} median={np.median(diff):+.3f}")
agree_sign = int(((np.sign(old_sharpe) == np.sign(new_sharpe)) | (np.abs(new_sharpe) < 0.1)).sum())
console.print(f" sign-agreement: {agree_sign}/{len(comparable)} "
f"({agree_sign/len(comparable):.0%})")
ok_rows.sort(key=lambda r: r["new_sharpe"] if r["new_sharpe"] is not None else -1e9, reverse=True)
console.print(f"\n[bold]Top 15 by new Sharpe:[/bold]")
console.print(f" {'name':<38} {'old_sh':>7} {'new_sh':>7} {'new_dd':>8} {'new_trd':>7} {'new_ret':>9}")
for r in ok_rows[:15]:
osh = f"{r['old_sharpe']:+.2f}" if r["old_sharpe"] is not None else ""
ddv = f"{r['new_dd']:.2%}" if r["new_dd"] is not None else ""
rtv = f"{r['new_total_return']:+.2%}" if r["new_total_return"] is not None else ""
console.print(f" {r['name']:<38} {osh:>7} {r['new_sharpe']:>+7.2f} {ddv:>8} {r['new_trades'] or 0:>7} {rtv:>9}")
flagged = [r for r in ok_rows if r["data_quality"]]
if flagged:
console.print(f"\n[yellow]⚠ {len(flagged)} strategies flagged with extreme bars "
f"(would have been hidden by old ±10% clipping)[/yellow]")
if args.csv:
with open(args.csv, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
console.print(f"\n[green]✓[/green] CSV report written to {args.csv}")
if __name__ == "__main__":
main()
+25 -16
View File
@@ -86,20 +86,26 @@ class TestBacktestMetricsCalculateIC:
class TestBacktestMetricsCalculateSharpe:
"""Tests für BacktestMetrics.calculate_sharpe()"""
def test_calculate_sharpe_normal_data(self, backtest_metrics, sample_returns_data):
"""Sharpe Ratio mit normalen Daten sollte korrekt berechnet werden"""
returns, equity = sample_returns_data
sharpe = backtest_metrics.calculate_sharpe(returns)
# Sharpe sollte im typischen Bereich liegen (-5 bis 5)
def test_calculate_sharpe_normal_data(self, sample_returns_data):
"""Sharpe Ratio mit Daily-Daten sollte im typischen Bereich liegen."""
from rdagent.components.backtesting.backtest_engine import BacktestMetrics
returns, _ = sample_returns_data
# sample_returns_data is business-daily → use daily annualization.
bm_daily = BacktestMetrics(risk_free_rate=0.02, bars_per_year=252)
sharpe = bm_daily.calculate_sharpe(returns)
assert -5 <= sharpe <= 5, f"Sharpe {sharpe} liegt außerhalb typischen Bereichs"
def test_calculate_sharpe_annualized_vs_raw(self, backtest_metrics, sample_returns_data):
"""Annualisierte Sharpe sollte sqrt(252) * raw Sharpe sein"""
returns, equity = sample_returns_data
sharpe_raw = backtest_metrics.calculate_sharpe(returns, annualize=False)
sharpe_ann = backtest_metrics.calculate_sharpe(returns, annualize=True)
def test_calculate_sharpe_annualized_vs_raw(self, sample_returns_data):
"""Annualisierte Sharpe = √(bars_per_year) * raw Sharpe — convention-agnostic."""
from rdagent.components.backtesting.backtest_engine import BacktestMetrics
returns, _ = sample_returns_data
bm_daily = BacktestMetrics(risk_free_rate=0.02, bars_per_year=252)
sharpe_raw = bm_daily.calculate_sharpe(returns, annualize=False)
sharpe_ann = bm_daily.calculate_sharpe(returns, annualize=True)
expected_ann = sharpe_raw * np.sqrt(252)
assert abs(sharpe_ann - expected_ann) < 1e-10, \
f"Annualisierte Sharpe {sharpe_ann} != erwartet {expected_ann}"
@@ -127,13 +133,16 @@ class TestBacktestMetricsCalculateSharpe:
# Die Implementierung gibt keinen NaN zurück wenn std != 0
assert np.isfinite(sharpe) or np.isnan(sharpe), "Sharpe sollte finite oder NaN sein"
def test_calculate_sharpe_negative_returns(self, backtest_metrics):
"""Sharpe sollte mit negativen Returns korrekt umgehen"""
def test_calculate_sharpe_negative_returns(self):
"""Sharpe sollte mit negativen Daily-Returns korrekt umgehen"""
from rdagent.components.backtesting.backtest_engine import BacktestMetrics
n = 100
dates = pd.date_range(start='2024-01-01', periods=n, freq='B')
returns = pd.Series(np.random.randn(n) * 0.02 - 0.001, index=dates)
sharpe = backtest_metrics.calculate_sharpe(returns)
bm_daily = BacktestMetrics(risk_free_rate=0.02, bars_per_year=252)
sharpe = bm_daily.calculate_sharpe(returns)
assert -5 <= sharpe <= 5, f"Sharpe {sharpe} liegt außerhalb typischen Bereichs"
+262
View File
@@ -0,0 +1,262 @@
"""
Oracle + consistency tests for the unified backtest engine.
Every metric is checked against a value we can reproduce by hand (or via
vectorbt). Same ``(close, signal)`` inputs must yield the same numbers no
matter which call-site is used.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from rdagent.components.backtesting.vbt_backtest import (
DEFAULT_BARS_PER_YEAR,
backtest_from_forward_returns,
backtest_signal,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def flat_close() -> pd.Series:
"""Constant close: any signal should produce zero returns."""
idx = pd.date_range("2024-01-01", periods=1000, freq="1min")
return pd.Series(100.0, index=idx)
@pytest.fixture
def trending_close() -> pd.Series:
"""Monotonically increasing close (0.01% per bar)."""
idx = pd.date_range("2024-01-01", periods=1000, freq="1min")
return pd.Series(100.0 * (1.0001 ** np.arange(1000)), index=idx)
@pytest.fixture
def random_close() -> pd.Series:
np.random.seed(42)
idx = pd.date_range("2024-01-01", periods=5000, freq="1min")
return pd.Series(100.0 + np.random.randn(5000).cumsum() * 0.05, index=idx)
# ---------------------------------------------------------------------------
# Oracle tests — numbers we can verify by hand
# ---------------------------------------------------------------------------
def test_flat_close_all_long_returns_zero(flat_close):
"""Constant price → strategy returns are exactly cost per position-change bar."""
signal = pd.Series(1.0, index=flat_close.index)
r = backtest_signal(flat_close, signal, txn_cost_bps=1.5)
# Only one position change (bar 0 → long 1.0): total cost = 1 * 1.5e-4.
assert r["status"] == "success"
assert r["total_return"] == pytest.approx(-1.5e-4, abs=1e-8)
assert r["n_trades"] == 1 # one open trade (still in position at end)
# Flat price → zero variance → sharpe cannot be computed; returned as 0.
# But cost introduces a tiny constant return, so std is 0 and sharpe is 0.
def test_trending_close_always_long_matches_price_return(trending_close):
"""
position=+1 always strategy return per bar bar_ret (minus one-time cost).
total_return should equal (close[-1]/close[0]) - 1 minus the entry cost.
"""
signal = pd.Series(1.0, index=trending_close.index)
r = backtest_signal(trending_close, signal, txn_cost_bps=1.5)
price_tr = trending_close.iloc[-1] / trending_close.iloc[0] - 1
# Manual: product over (1 + bar_ret - one-time cost at bar 0).
bar_ret = trending_close.pct_change().fillna(0)
position = signal.shift(1).fillna(0)
position_change = position.diff().abs().fillna(position.abs())
expected_total = float(((1 + position * bar_ret - position_change * 1.5e-4).prod()) - 1)
assert r["total_return"] == pytest.approx(expected_total, rel=1e-9)
# Within 1 bp of the raw price trend.
assert abs(r["total_return"] - price_tr) < 2e-4
def test_always_flat_returns_zero(random_close):
signal = pd.Series(0.0, index=random_close.index)
r = backtest_signal(random_close, signal, txn_cost_bps=1.5)
assert r["total_return"] == 0.0
assert r["sharpe"] == 0.0
assert r["max_drawdown"] == 0.0
assert r["n_trades"] == 0
assert r["n_position_changes"] == 0
def test_sharpe_annualization_uses_1min_bars(random_close):
"""Sharpe must use √(252*1440), not √252."""
np.random.seed(0)
signal = pd.Series(np.random.choice([-1, 0, 1], size=len(random_close)), index=random_close.index)
r = backtest_signal(random_close, signal, txn_cost_bps=0.0) # no cost → clean check
# Reproduce manually.
bar_ret = random_close.pct_change().fillna(0)
position = signal.astype(float).shift(1).fillna(0)
strat_ret = position * bar_ret
expected_sharpe = strat_ret.mean() / strat_ret.std() * np.sqrt(DEFAULT_BARS_PER_YEAR)
assert r["sharpe"] == pytest.approx(expected_sharpe, rel=1e-9)
assert r["bars_per_year"] == 252 * 1440
def test_txn_cost_applied_per_position_change(random_close):
"""With 50% of bars flipping, cost ≈ 0.5 * |Δposition|_mean * txn_cost_bps."""
idx = random_close.index
# Alternate every bar: -1, 1, -1, 1, ...
signal = pd.Series([(-1.0) ** i for i in range(len(idx))], index=idx)
zero_cost = backtest_signal(random_close, signal, txn_cost_bps=0.0)
with_cost = backtest_signal(random_close, signal, txn_cost_bps=10.0) # 10 bps
# Cost difference = |Δposition|.sum() * 10e-4, summed over bars.
# Alternating ±1 → |Δposition| = 2 per bar (except first: 1).
bar_ret_diff = zero_cost["total_return"] - with_cost["total_return"]
assert bar_ret_diff > 0 # with cost must be worse
def test_drawdown_never_clipped():
"""
A single blow-up bar must not be silently absorbed. Old code clipped
returns to ±10%; the new engine reports the true drawdown and flags.
"""
idx = pd.date_range("2024-01-01", periods=500, freq="1min")
# Prices: gentle rise, then a 20% crash at bar 250, then recovery.
closes = np.concatenate(
[np.linspace(100, 101, 250), [80.0], np.linspace(80, 85, 249)]
)
close = pd.Series(closes, index=idx)
signal = pd.Series(1.0, index=idx)
r = backtest_signal(close, signal, txn_cost_bps=0.0)
assert r["max_drawdown"] < -0.15 # real DD preserved
assert "data_quality_flag" in r
assert "extreme_returns" in r["data_quality_flag"]
def test_forward_returns_ic_computation():
np.random.seed(7)
idx = pd.date_range("2024-01-01", periods=2000, freq="1min")
noise = pd.Series(np.random.randn(2000), index=idx)
close = pd.Series(100 + noise.cumsum() * 0.01, index=idx)
fwd = close.pct_change().shift(-1).fillna(0)
# sign(fwd) correlates with fwd at ~√(2/π) ≈ 0.798 for Gaussian returns.
sign_signal = pd.Series(np.sign(fwd), index=idx).replace(0, 1)
r_sign = backtest_signal(close, sign_signal, forward_returns=fwd, txn_cost_bps=0.0)
assert r_sign["ic"] is not None
assert 0.7 < r_sign["ic"] < 0.85
# Passing fwd itself as the signal (clipped to [-1,1]) yields corr = 1.0.
fwd_signal = fwd.clip(-1, 1)
r_perfect = backtest_signal(close, fwd_signal, forward_returns=fwd, txn_cost_bps=0.0)
assert r_perfect["ic"] == pytest.approx(1.0, abs=1e-9)
def test_trade_count_matches_epoch_count():
"""n_trades must equal the number of distinct non-flat position epochs."""
idx = pd.date_range("2024-01-01", periods=10, freq="1min")
# Position: 0, 1, 1, 0, -1, -1, 0, 1, 0, 0 → 3 trades
signal = pd.Series([0, 1, 1, 0, -1, -1, 0, 1, 0, 0], index=idx).astype(float)
close = pd.Series(np.linspace(100, 101, 10), index=idx)
r = backtest_signal(close, signal, txn_cost_bps=0.0)
assert r["n_trades"] == 3
def test_win_rate_uses_per_trade_pnl():
"""Win rate must reflect per-trade P&L, not per-bar returns."""
idx = pd.date_range("2024-01-01", periods=20, freq="1min")
# Craft a scenario: 2 clearly winning long trades, 1 losing.
close = pd.Series(
[100, 101, 102, 103, 102, 101, 100, 99, 98, 99, # bars 0-9
100, 101, 102, 103, 104, 103, 102, 101, 100, 100], # bars 10-19
index=idx,
).astype(float)
# Trade 1: long bars 1..3 (price 101→103 = +2, win)
# Trade 2: long bars 5..7 (price 101→99 = -2, loss)
# Trade 3: long bars 11..14 (price 101→104 = +3, win)
sig = pd.Series(0, index=idx).astype(float)
sig.iloc[1:4] = 1
sig.iloc[5:8] = 1
sig.iloc[11:15] = 1
r = backtest_signal(close, sig, txn_cost_bps=0.0)
# Due to the shift(1) lag, actual entry/exit shifts by 1 bar — but the
# number of epochs and their sign are preserved.
assert r["n_trades"] == 3
assert r["win_rate"] == pytest.approx(2 / 3, abs=1e-9)
# ---------------------------------------------------------------------------
# Consistency tests — all four call sites produce identical numbers
# ---------------------------------------------------------------------------
def test_orchestrator_path_matches_direct_call(random_close):
"""Orchestrator's evaluate_strategy should produce the same bt numbers."""
np.random.seed(11)
idx = random_close.index
signal = pd.Series(np.random.choice([-1, 0, 1], size=len(idx)), index=idx).astype(float)
direct = backtest_signal(random_close, signal, txn_cost_bps=1.5)
# Reproduce the orchestrator's call signature.
from rdagent.components.backtesting.vbt_backtest import backtest_signal as orch_bt
orch = orch_bt(close=random_close.reindex(signal.index).ffill(), signal=signal,
txn_cost_bps=1.5, freq="1min")
for key in ("sharpe", "max_drawdown", "total_return", "n_trades", "win_rate"):
assert direct[key] == orch[key], f"{key}: {direct[key]} != {orch[key]}"
def test_factor_backtester_wrapper_consistent_with_engine():
"""Legacy FactorBacktester must return the same IC/Sharpe as the unified engine."""
from rdagent.components.backtesting.backtest_engine import FactorBacktester
np.random.seed(99)
n = 500
idx = pd.date_range("2024-01-01", periods=n, freq="1min")
factor = pd.Series(np.random.randn(n), index=idx)
fwd_ret = pd.Series(factor.values * 0.001 + np.random.randn(n) * 0.01, index=idx)
direct = backtest_from_forward_returns(factor, fwd_ret, txn_cost_bps=1.5)
fb = FactorBacktester()
fb.results_path = fb.results_path / "_test_tmp"
fb.results_path.mkdir(parents=True, exist_ok=True)
legacy = fb.run_backtest(factor, fwd_ret, "TestFactor", transaction_cost=0.00015)
assert legacy["sharpe_ratio"] == pytest.approx(direct["sharpe"], rel=1e-9)
assert legacy["max_drawdown"] == pytest.approx(direct["max_drawdown"], rel=1e-9)
assert legacy["ic"] == pytest.approx(direct["ic"], rel=1e-9)
# ---------------------------------------------------------------------------
# Cross-check vs vectorbt simulation
# ---------------------------------------------------------------------------
def test_vbt_cross_check_matches_within_tolerance(random_close):
"""
Our manual total_return and vbt's compounded total_return should agree
within a few basis points on a realistic 1-min scenario.
"""
np.random.seed(3)
idx = random_close.index
fast = random_close.rolling(10).mean()
slow = random_close.rolling(50).mean()
signal = pd.Series(
np.where(fast > slow, 1.0, np.where(fast < slow, -1.0, 0.0)), index=idx
)
r = backtest_signal(random_close, signal, txn_cost_bps=1.5, cross_check=True)
assert "vbt_total_return" in r
if r["vbt_total_return"] is not None:
# Manual: simple (1+position*ret) compounding, one fixed leverage.
# vbt: target-percent rebalancing on every bar, leverage drifts with equity.
# The two can differ by O(|return|^2) per bar on flipping strategies;
# we only require the sign and rough magnitude to agree.
assert abs(r["total_return"] - r["vbt_total_return"]) < 0.05