quant-research-env / docs /task_evaluation_guide.md
yobro4619's picture
Upload folder using huggingface_hub
74d799b verified
|
Raw
History Blame Contribute Delete
10 kB
# Quant Research Environment β€” Task & Evaluation Guide
---
## Task 1: Easy β€” SMA Crossover Implementation
### What the Agent Receives on reset()
- A description telling it to implement SMA(20)/SMA(50) crossover on `Close_nifty`
- The merged DataFrame (737,587 rows of minute-bar OHLC for both NIFTY and BANKNIFTY)
- The exact function signature: `generate_trades(df) -> DataFrame[bar, position]`
### What the Agent Must Do
Write a Python function that:
1. Computes 20-bar and 50-bar simple moving averages on NIFTY close prices
2. Goes long (+1) when fast SMA > slow SMA, short (-1) when fast < slow
3. Stays flat (0) during warmup (first 50 bars)
4. Uses next-bar execution (signal at bar i β†’ position at bar i+1)
5. Returns only rows where position changes
### How It's Executed
1. Agent sends code via `submit_code` β†’ server checks syntax, scans for `import os` / `.shift(-1)` etc.
2. Agent calls `run_backtest` β†’ server writes code to a temp file, runs it in a **subprocess** with 30s timeout
3. The subprocess calls `generate_trades(merged_df)` and saves the resulting DataFrame
4. Server loads the result, passes it to `backtester.replay_trades_single()` which replays bar-by-bar:
- At each bar: `pnl = position * (close[i] - close[i-1])`
- On position change: deduct `0.00009 * price * |position_change|` (transaction cost)
- Track cumulative PnL, peak, max drawdown
- Sharpe = `mean(bar_pnls) / std(bar_pnls) * sqrt(252 * 375)`
### How the Score Is Computed
The grader compares backtest metrics against ground truth from the Harbor eval:
| Metric | Ground Truth | Tolerance |
|--------|-------------|-----------|
| Trade count | 16,639 | Β±5% (15,807 to 17,471) |
| Total PnL | -20,630.75 | Β±2% |
| Max drawdown | 23,882.23 | Β±2% |
| Sharpe | -1.3693 | Β±0.05 absolute |
**Scoring ladder** (each level requires passing all previous checks):
| Score | Condition |
|-------|-----------|
| 0.00 | Code doesn't parse (syntax error) |
| 0.05 | Parses but no `generate_trades()` function found |
| 0.10 | Has `generate_trades()` function |
| 0.20 | Code runs without crashing |
| 0.30 | Output has correct columns `[bar, position]` |
| 0.45 | Produces actual trades (not all zeros) |
| 0.60 | Trade count within 20% of 16,639 |
| 0.75 | Trade count within 5% AND Sharpe within 1.0 of target |
| 0.90 | Sharpe within 0.5 of target (-1.37) |
| 1.00 | ALL four metrics within their tolerances |
**Real LLM result (Gemini Flash Lite)**: First attempt crashed (score 0.10), revised code produced 16,657 trades and Sharpe -1.3683 β€” all within tolerance β†’ **1.00**
---
## Task 2: Medium β€” Pairs Trading with Constraints
### What the Agent Receives on reset()
- A description with the Z-score pairs trading spec including pseudocode
- Two separate DataFrames (NIFTY and BANKNIFTY, aligned)
- Function signature: `generate_trades(nifty_df, banknifty_df) -> DataFrame[bar, nifty_position, banknifty_position]`
### What the Agent Must Do
Write a function that:
1. Computes spread = `nifty_close - 0.35 * banknifty_close`
2. Computes Z-score with rolling(60) mean/std (ddof=1)
3. Enters short spread (nifty=-1, banknifty=+1) when z > 2.0
4. Enters long spread (nifty=+1, banknifty=-1) when z < -2.0
5. Exits (both to 0) when |z| < 0.5
6. After each exit, enforces **30-bar cooldown** before next entry
7. Uses next-bar execution
8. Positions always exactly +1, -1, or 0
### How It's Executed
Same as Easy, but:
- Subprocess calls `generate_trades(nifty_df, banknifty_df)` (two DataFrames instead of one)
- Server uses `backtester.replay_trades_multi()` which tracks **both legs**:
- PnL = `nifty_pos * nifty_price_change + bn_pos * bn_price_change`
- Transaction cost applied per leg independently
- Exposure ratio checked at **every bar**: `|net_notional| / gross_notional`
- Violations recorded when ratio > 0.85 (0.80 limit + 0.05 tolerance buffer)
### How the Score Is Computed
Ground truth:
| Metric | Ground Truth | Tolerance |
|--------|-------------|-----------|
| Trades per leg | 18,815 | Β±5% |
| Spread entries | 9,408 | Β±5% |
| Total PnL | -60,226.70 | Β±2% |
| Max drawdown | 60,677.26 | Β±2% |
| Sharpe | -2.8732 | Β±0.05 absolute |
**Scoring ladder**:
| Score | Condition |
|-------|-----------|
| 0.00 | Code doesn't parse |
| 0.05 | Parses but no `generate_trades()` |
| 0.10 | Has `generate_trades()` with correct signature |
| 0.20 | Code runs, produces DataFrame with 3 columns |
| 0.30 | Has non-zero positions in both instruments |
| 0.40 | Spread entries > 0 (z-score logic is directionally correct) |
| 0.55 | Trade count within 20% of 18,815 |
| 0.70 | Trade count within 10% AND Sharpe within 1.0 of target |
| 0.85 | ALL core metrics within 10% tolerance AND Sharpe within 0.5 |
| 1.00 | ALL metrics within spec tolerance + zero exposure violations |
**Why models get 0.85 but not 1.00**: The cooldown logic is the trap. The spec says "30-bar cooldown after exit, decrement every bar, allow entry on the bar counter reaches zero." Most LLMs get this slightly wrong β€” they might start counting from the wrong bar, or check cooldown before/after the z-score check in the wrong order. That produces ~18,551 trades instead of 18,815 β€” within 10% (scores 0.85) but outside 5% (misses 1.00).
**Real LLM result (Gemini Flash Lite)**: Got 18,551 trades, Sharpe -2.7611 β€” within 10% but not 5% β†’ **0.85**
---
## Task 3: Hard β€” Alpha Research
### What the Agent Receives on reset()
- An open-ended description: "discover a hedged trading strategy that generates positive risk-adjusted returns"
- Same two DataFrames (NIFTY + BANKNIFTY training data, 2015-2022)
- Tips about strategy families (trend, mean-reversion, seasonality, volatility)
- Function signature: `generate_trades(nifty_df, banknifty_df) -> DataFrame[bar, nifty_position, banknifty_position]`
- Positions can be **any float** (not limited to Β±1/0)
### What the Agent Must Do
No spec to follow. The agent must:
1. Explore the data to find patterns
2. Hypothesize and implement trading strategies
3. Combine multiple signals into a hedged portfolio
4. Ensure net exposure stays ≀ 80% at all times
5. Avoid lookahead (no future data leakage)
6. Produce positive Sharpe on data it has never seen
### How It's Executed
**On `run_backtest`** (during the episode):
- Same subprocess execution and `replay_trades_multi()` as Medium
- Graded only on **training data** β€” maximum possible score during backtest is 0.30
**On `submit_final`** (the critical difference):
1. **Runtime lookahead detection**: Server loads the agent's code via `importlib`, runs `generate_trades()` on full data, then runs it again with 375/750/1500 bars removed from the end. If trades in the middle of the data change when future bars are removed β†’ lookahead detected β†’ capped at 0.20
2. **Out-of-sample evaluation**: Server loads the **hidden test data** (2023-2026, 282,950 bars that the agent never sees) and runs `generate_trades()` on it
3. OOS Sharpe is computed and mapped to the final score
### How the Score Is Computed
No ground truth. The grading is a **gate system** followed by a **piecewise linear mapping** of OOS Sharpe.
**Gates (must pass sequentially β€” failing any gate caps the score at that level)**:
| Score | Gate |
|-------|------|
| 0.00 | Code doesn't parse |
| 0.10 | Code runs but output is wrong format |
| 0.12 | Output has correct columns |
| 0.15 | Reasonable position values |
| 0.20 | Exposure constraint satisfied (net ≀ 80% at all bars) |
| 0.25 | No lookahead detected (static scan + truncation test passed) |
| 0.30 | Training Sharpe β‰₯ 0.25 (not a flat/random strategy) |
**OOS Sharpe mapping (only reached if ALL gates pass)**:
| OOS Sharpe | Score | Interpretation |
|-----------|-------|----------------|
| ≀ -1.0 | 0.30 | Bad strategy, losing money |
| -1.0 to 0.0 | 0.30 β†’ 0.50 | Gradually less bad (linear) |
| 0.0 | 0.50 | Breakeven β€” this alone is a strong result |
| 0.0 to 0.5 | 0.50 β†’ 0.65 | Profitable (linear) |
| 0.5 to 1.0 | 0.65 β†’ 0.80 | Good alpha |
| 1.0 to 1.5 | 0.80 β†’ 0.90 | Strong alpha |
| 1.5 to 2.0 | 0.90 β†’ 1.00 | Exceptional |
| β‰₯ 2.0 | 1.00 | World-class (human expert scored 2.784) |
### Why This Task Is Genuinely Hard
In the RAETH Trading Eval, across 30 trials with 6 frontier models (Claude Opus 4.6, GPT-5.4, Gemini 3.1 Pro, GPT-5.3 Codex, Grok-4, Qwen3 Coder Next), only **1 out of 30 trials** produced a positive OOS Sharpe ratio.
The constraint that makes it hard is the **80% net exposure limit**. Without it, models achieve Sharpe > 2.0 by simply going 100% long (recalling from training data that Indian markets went up). With the hedging requirement, that free lunch disappears and models must discover genuinely profitable hedged strategies.
| Model | Best Trial | Average | Positive OOS? |
|-------|-----------|---------|---------------|
| Claude Opus 4.6 | -0.328 | -2.098 | No |
| Gemini 3.1 Pro | **+0.419** | -2.724 | **Yes (1 trial)** |
| GPT-5.4 | -3.0 | -3.0 | No |
| GPT-5.3 Codex | -3.0 | -3.3 | No |
| Grok-4 | -3.0 | -3.6 | No |
| Qwen3 Coder Next | -5.0 | -5.0 | No |
| **Human Expert** | **+2.784** | **+2.784** | **Yes** |
**Real LLM result (Gemini Flash Lite)**: Produced 737,213 trades with NaN PnL and Sharpe 0.0. Passed format and exposure checks (0.20), passed lookahead scan (0.25), but training Sharpe was 0.0 < 0.25 threshold β†’ capped at **0.25**. Never reached OOS evaluation.
---
## Summary: Score Distribution Across Tasks
| Task | What It Tests | Expected Score Range | Perfect Score Requires |
|------|--------------|---------------------|----------------------|
| **Easy** | Can the LLM write correct pandas code from a spec? | 0.4 – 1.0 | Exact metric match to ground truth |
| **Medium** | Can it handle stateful, constraint-heavy logic? | 0.2 – 0.85 | Perfect cooldown logic + all tolerances |
| **Hard** | Can it discover profitable strategies autonomously? | 0.1 – 0.3 (typical) | OOS Sharpe > 2.0 on hidden 2023-2026 data |