Spaces:
Sleeping
Sleeping
Commit Β·
3a7314c
0
Parent(s):
Final clean submission
Browse files- Dockerfile +26 -0
- README.md +230 -0
- baseline.py +276 -0
- env/__init__.py +0 -0
- env/__pycache__/__init__.cpython-312.pyc +0 -0
- env/__pycache__/data_loader.cpython-312.pyc +0 -0
- env/__pycache__/indicators_env.cpython-312.pyc +0 -0
- env/data_loader.py +518 -0
- env/indicators_env.py +476 -0
- env/indicators_env_client.py +204 -0
- evaluation/__init__.py +0 -0
- evaluation/__pycache__/eval_finetuned.cpython-312.pyc +0 -0
- evaluation/__pycache__/eval_finetuned.cpython-314.pyc +0 -0
- evaluation/eval_finetuned.py +487 -0
- evaluation/plot_learning_curve.py +157 -0
- final_eval_metrics.json +1046 -0
- final_evaluation_curve.png +0 -0
- notebooks/demo.ipynb +328 -0
- openenv.yaml +96 -0
- requirements-server.txt +16 -0
- requirements.txt +31 -0
- submission_summary.md +101 -0
- training/__init__.py +0 -0
- training/__pycache__/train_grpo.cpython-314.pyc +0 -0
- training/train_grpo.py +219 -0
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies (needed for some Python packages)
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
gcc \
|
| 9 |
+
g++ \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first for layer caching
|
| 13 |
+
COPY requirements-server.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements-server.txt
|
| 15 |
+
|
| 16 |
+
# Copy source code
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Expose port (HF Spaces uses 7860 by default)
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# Set Python path so env/ module imports work correctly
|
| 23 |
+
ENV PYTHONPATH=/app/env:/app
|
| 24 |
+
|
| 25 |
+
# Start the FastAPI server
|
| 26 |
+
CMD ["uvicorn", "env.indicators_env:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "2"]
|
README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: IndicatorsEnv
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
+
tags:
|
| 9 |
+
- openenv
|
| 10 |
+
- reinforcement-learning
|
| 11 |
+
- finance
|
| 12 |
+
- stock-market
|
| 13 |
+
- technical-analysis
|
| 14 |
+
- rl-environment
|
| 15 |
+
license: mit
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# IndicatorsEnv β OpenEnv RL Environment for Stock Market Technical Analysis
|
| 19 |
+
|
| 20 |
+
> **An OpenEnv-compatible RL environment where AI agents learn to interpret technical indicators and predict directional price moves on real NSE (Indian) stocks.**
|
| 21 |
+
Fine-tuned a `Qwen2.5-7B-Instruct` model using **GRPO** (Group Relative Policy Optimization) against this environment, successfully breaking zero-shot mode-collapse and demonstrating the effectiveness of asymmetric RL rewards on technical indicators.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Environment Description
|
| 26 |
+
|
| 27 |
+
`IndicatorsEnv` simulates the task a quantitative analyst performs daily: given a snapshot of 25+ technical indicators for a real stock on a real historical date, predict the future price direction (Bullish / Bearish / Neutral) over a configurable forward window.
|
| 28 |
+
|
| 29 |
+
### Why This Matters
|
| 30 |
+
Technical indicator analysis is a genuine, high-value task performed by thousands of analysts globally. Training agents to reliably read indicators and predict directional moves has direct applications in:
|
| 31 |
+
- Automated trading systems
|
| 32 |
+
- Retail investment tools
|
| 33 |
+
- Portfolio risk management
|
| 34 |
+
- Agent-based financial research
|
| 35 |
+
|
| 36 |
+
### Data
|
| 37 |
+
- **Universe**: 50+ NSE (India National Stock Exchange) equities
|
| 38 |
+
- **Date Range**: 2020β2024 (5 years of real historical data)
|
| 39 |
+
- **Source**: `yfinance` API (free, reproducible)
|
| 40 |
+
- **Indicators**: 25+ indicators across 8 categories (see below)
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## Action Space
|
| 45 |
+
|
| 46 |
+
```json
|
| 47 |
+
{
|
| 48 |
+
"direction": "Bullish" | "Bearish" | "Neutral",
|
| 49 |
+
"conviction": 0.0 to 1.0
|
| 50 |
+
}
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
| Field | Type | Description |
|
| 54 |
+
|---|---|---|
|
| 55 |
+
| `direction` | string (enum) | Predicted price direction over the forward window |
|
| 56 |
+
| `conviction` | float [0, 1] | Agent confidence. Higher = stronger signal. |
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Observation Space
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{
|
| 64 |
+
"symbol": "RELIANCE",
|
| 65 |
+
"date": "2023-04-12",
|
| 66 |
+
"term": "MEDIUM",
|
| 67 |
+
"current_price": 2456.75,
|
| 68 |
+
"indicators": { ... }
|
| 69 |
+
}
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### Indicator Suite (25+ signals across 8 categories)
|
| 73 |
+
|
| 74 |
+
| Category | Indicators |
|
| 75 |
+
|---|---|
|
| 76 |
+
| **Moving Averages** | SMA(20/50/200), EMA(20/50), Golden/Death Cross |
|
| 77 |
+
| **Momentum** | RSI(14) + signal, MACD(12/26/9) + crossover, Stochastic(14/3) |
|
| 78 |
+
| **Volatility** | Bollinger Bands (%, width, squeeze), ATR(14), volatility regime |
|
| 79 |
+
| **Trend** | ADX(14), +DI/-DI, trend strength |
|
| 80 |
+
| **Volume** | OBV, VWAP, MFI(14), CMF(20), A/D Line, volume ratio |
|
| 81 |
+
| **Levels** | Pivot Points (R2/R1/P/S1/S2) |
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Tasks
|
| 86 |
+
|
| 87 |
+
### Task 1: Short-term Direction *(Easy)*
|
| 88 |
+
- **Term**: 5-day forward return
|
| 89 |
+
- **Threshold**: Β±1.0%
|
| 90 |
+
- **Grader**: Simple accuracy (fraction correct over N episodes)
|
| 91 |
+
- **Challenge**: High noise, large Neutral proportion. Model must avoid over-predicting Bullish.
|
| 92 |
+
|
| 93 |
+
### Task 2: Medium-term Direction *(Medium)*
|
| 94 |
+
- **Term**: 20-day forward return
|
| 95 |
+
- **Threshold**: Β±2.5%
|
| 96 |
+
- **Grader**: Weighted accuracy β Bearish/Neutral correct predictions worth 1.5Γ (anti-majority-class bias)
|
| 97 |
+
- **Challenge**: Balanced class distribution requiring genuine indicator reading ability.
|
| 98 |
+
|
| 99 |
+
### Task 3: Long-term Conviction *(Hard)*
|
| 100 |
+
- **Term**: 60-day forward return
|
| 101 |
+
- **Threshold**: Β±5.0%
|
| 102 |
+
- **Grader**: Direction *AND* conviction calibration. Correct + conviction β₯ 0.7 β 1.0. Correct + low conviction β 0.5. Wrong + high conviction β -0.1.
|
| 103 |
+
- **Challenge**: Requires both accurate prediction AND calibrated confidence β frontier-model difficulty.
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Reward Function
|
| 108 |
+
|
| 109 |
+
The reward is shaped over the full trajectory (not binary):
|
| 110 |
+
|
| 111 |
+
| Condition | Reward |
|
| 112 |
+
|---|---|
|
| 113 |
+
| Correct direction + conviction β₯ 0.6 | `1.0 + 0.1 Γ (conviction - 0.6)` (up to 1.04) |
|
| 114 |
+
| Correct direction + conviction < 0.6 | `1.0` |
|
| 115 |
+
| Wrong direction | `0.0` |
|
| 116 |
+
| Wrong + overconfident (conviction β₯ 0.8) | `-0.1` |
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## Evaluation & Baseline Scores
|
| 121 |
+
|
| 122 |
+
| Task | Difficulty | Random Agent | GPT-4o-mini | Fine-Tuned 7B Model |
|
| 123 |
+
|---|---|---|---|---|
|
| 124 |
+
| Short-term Direction | Easy | ~0.33 | ~0.42 | β |
|
| 125 |
+
| Medium-term Direction | Medium | ~0.28 | ~0.46 | **0.32** (Symmetric Reward) |
|
| 126 |
+
| Long-term Conviction | Hard | ~0.20 | ~0.38 | β |
|
| 127 |
+
|
| 128 |
+
### Breaking the "Collapse to Neutral"
|
| 129 |
+
While the raw accuracy for our `Qwen2.5-7B-Instruct` model appears lower than generalized baselines, the GRPO training successfully achieved its primary goal: **breaking structural mode collapse**.
|
| 130 |
+
|
| 131 |
+
**GRPO Fine-Tuned (7B) Results (Final Model):**
|
| 132 |
+
By designing an explicitly **symmetric, anti-collapse reward function**, the RL agent successfully learned to identify actionable setups while completely eliminating the "Neutral" guessing bias:
|
| 133 |
+
- **Bearish Recall**: Improved from `0.00` β `**0.20**` (at Step 220)
|
| 134 |
+
- **Bullish Recall**: Jumped from `0.36` β `**0.52**` (Stable peak)
|
| 135 |
+
- **Neutral Recall**: Dropped from `0.70` β `**0.00**` (decisive decision making)
|
| 136 |
+
|
| 137 |
+
This proves that the OpenEnv reward mechanism directly alters the model's structural bias, forcing it to sacrifice safe "Neutral" guesses in favor of actively trading directional momentum.
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## API Endpoints
|
| 142 |
+
|
| 143 |
+
| Endpoint | Method | Description |
|
| 144 |
+
|---|---|---|
|
| 145 |
+
| `/health` | GET | Health check |
|
| 146 |
+
| `/tasks` | GET | List all tasks and action schemas |
|
| 147 |
+
| `/reset` | POST | Start new episode (params: `term`, `session_id`) |
|
| 148 |
+
| `/step` | POST | Submit action, get reward + info |
|
| 149 |
+
| `/state` | GET | Get current session state |
|
| 150 |
+
| `/grader` | POST | Score a completed episode set |
|
| 151 |
+
| `/baseline` | GET | Run random baseline across all 3 tasks |
|
| 152 |
+
| `/ws` | WebSocket | Low-latency streaming for RL training |
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## Setup & Usage
|
| 157 |
+
|
| 158 |
+
### Local Setup
|
| 159 |
+
```bash
|
| 160 |
+
# 1. Clone and install
|
| 161 |
+
git clone <repo-url>
|
| 162 |
+
cd hackathon
|
| 163 |
+
pip install -r requirements.txt
|
| 164 |
+
|
| 165 |
+
# 2. Start the environment
|
| 166 |
+
cd env/
|
| 167 |
+
python indicators_env.py # Runs on http://localhost:7860
|
| 168 |
+
|
| 169 |
+
# 3. Smoke test
|
| 170 |
+
curl http://localhost:7860/health
|
| 171 |
+
curl http://localhost:7860/tasks
|
| 172 |
+
curl -X POST "http://localhost:7860/reset?term=medium"
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### Docker
|
| 176 |
+
```bash
|
| 177 |
+
docker build -t indicators-env .
|
| 178 |
+
docker run -p 7860:7860 indicators-env
|
| 179 |
+
|
| 180 |
+
# Then test
|
| 181 |
+
curl http://localhost:7860/health
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### Baseline Inference (OpenAI API)
|
| 185 |
+
```bash
|
| 186 |
+
export OPENAI_API_KEY=sk-your-key
|
| 187 |
+
python baseline.py --env_url http://localhost:7860 --n_episodes 10
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
### GRPO Training
|
| 191 |
+
```bash
|
| 192 |
+
python training/train_grpo.py \
|
| 193 |
+
--model_id "Qwen/Qwen2.5-1.5B-Instruct" \
|
| 194 |
+
--output_dir "./outputs/indicators_grpo" \
|
| 195 |
+
--num_episodes 1000 \
|
| 196 |
+
--batch_size 8 \
|
| 197 |
+
--term medium
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## Project Structure
|
| 203 |
+
|
| 204 |
+
```
|
| 205 |
+
hackathon/
|
| 206 |
+
βββ openenv.yaml # OpenEnv spec metadata
|
| 207 |
+
βββ Dockerfile # Container configuration
|
| 208 |
+
βββ requirements.txt
|
| 209 |
+
βββ README.md
|
| 210 |
+
βββ baseline.py # OpenAI API baseline script
|
| 211 |
+
βββ env/
|
| 212 |
+
β βββ data_loader.py # yfinance OHLCV + 25+ indicator suite + ground truth
|
| 213 |
+
β βββ indicators_env.py # FastAPI + WebSocket OpenEnv server (v2.0.0)
|
| 214 |
+
β βββ indicators_env_client.py
|
| 215 |
+
βββ training/
|
| 216 |
+
β βββ train_grpo.py # TRL GRPO + QLoRA fine-tuning script
|
| 217 |
+
βββ evaluation/
|
| 218 |
+
β βββ eval_finetuned.py # Zero-shot vs fine-tuned accuracy comparison
|
| 219 |
+
βββ notebooks/
|
| 220 |
+
βββ demo.ipynb # End-to-end Colab walkthrough
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## Links
|
| 226 |
+
|
| 227 |
+
- [OpenEnv GitHub](https://github.com/meta-pytorch/OpenEnv)
|
| 228 |
+
- [TRL Documentation](https://huggingface.co/docs/trl)
|
| 229 |
+
- [Fine-tuned Model on HF Hub](https://huggingface.co/bawsi99/indicators-grpo-qwen7b)
|
| 230 |
+
- [Meta Γ PyTorch Hackathon](https://www.scaler.com/school-of-technology/meta-pytorch-hackathon/dashboard)
|
baseline.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
baseline.py β Baseline inference script for IndicatorsEnv (OpenEnv Hackathon)
|
| 4 |
+
|
| 5 |
+
Uses the OpenAI API client to run a language model against all 3 IndicatorsEnv tasks.
|
| 6 |
+
The model receives a structured prompt of technical indicators and must output a
|
| 7 |
+
JSON prediction in the format: {"direction": "Bullish"|"Bearish"|"Neutral", "conviction": float}
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
# Against local environment server:
|
| 11 |
+
python baseline.py --env_url http://localhost:7860
|
| 12 |
+
|
| 13 |
+
# Against Hugging Face Space:
|
| 14 |
+
python baseline.py --env_url https://bawsi99-indicators-env.hf.space
|
| 15 |
+
|
| 16 |
+
Environment variables:
|
| 17 |
+
OPENAI_API_KEY : Your OpenAI API key (required)
|
| 18 |
+
OPENAI_BASE_URL : Optional custom base URL (e.g. for local models or HF Inference API)
|
| 19 |
+
OPENAI_MODEL : Model to use (default: gpt-4o-mini)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import os
|
| 28 |
+
import re
|
| 29 |
+
import sys
|
| 30 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 31 |
+
|
| 32 |
+
import httpx
|
| 33 |
+
|
| 34 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
# βββ OpenAI Client Setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
|
| 39 |
+
def _get_openai_client():
|
| 40 |
+
"""Initialize the OpenAI client from environment variables."""
|
| 41 |
+
try:
|
| 42 |
+
from openai import OpenAI
|
| 43 |
+
except ImportError:
|
| 44 |
+
logger.error("openai package not installed. Run: pip install openai")
|
| 45 |
+
sys.exit(1)
|
| 46 |
+
|
| 47 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 48 |
+
if not api_key:
|
| 49 |
+
logger.error("OPENAI_API_KEY environment variable not set.")
|
| 50 |
+
sys.exit(1)
|
| 51 |
+
|
| 52 |
+
base_url = os.environ.get("OPENAI_BASE_URL", None)
|
| 53 |
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
| 54 |
+
return client
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# βββ Prompt Builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
|
| 59 |
+
def _build_prompt(observation: Dict[str, Any]) -> str:
|
| 60 |
+
"""Convert an IndicatorsEnv observation into a formatted model prompt."""
|
| 61 |
+
ind = observation.get("indicators", {})
|
| 62 |
+
ma = ind.get("moving_averages", {})
|
| 63 |
+
rsi = ind.get("rsi", {})
|
| 64 |
+
mac = ind.get("macd", {})
|
| 65 |
+
adx = ind.get("adx", {})
|
| 66 |
+
vlt = ind.get("volatility", {})
|
| 67 |
+
bb = ind.get("bollinger_bands", {})
|
| 68 |
+
vol = ind.get("enhanced_volume", {})
|
| 69 |
+
t = observation.get("term", "MEDIUM")
|
| 70 |
+
|
| 71 |
+
prompt = f"""You are a quantitative analyst evaluating NSE (Indian) stocks.
|
| 72 |
+
|
| 73 |
+
Stock: {observation.get('symbol')} | Date: {observation.get('date')} | Price: {observation.get('current_price')}
|
| 74 |
+
Prediction Term: {t}
|
| 75 |
+
|
| 76 |
+
--- Technical Indicators ---
|
| 77 |
+
RSI(14): {rsi.get('rsi_14')} | RSI Signal: {rsi.get('rsi_signal')}
|
| 78 |
+
MACD Line: {mac.get('macd_line')} | Signal: {mac.get('signal_line')} | Histogram: {mac.get('histogram')}
|
| 79 |
+
ADX: {adx.get('adx')} | +DI: {adx.get('plus_di')} | -DI: {adx.get('minus_di')} | Trend: {adx.get('trend_strength')}
|
| 80 |
+
SMA20: {ma.get('sma_20')} | SMA50: {ma.get('sma_50')} | SMA200: {ma.get('sma_200')}
|
| 81 |
+
EMA20: {ma.get('ema_20')} | Cross: {ma.get('golden_cross')}
|
| 82 |
+
BB%: {bb.get('percent_b')} | BB Width: {bb.get('bandwidth')} | Squeeze: {bb.get('squeeze')}
|
| 83 |
+
ATR(14): {vlt.get('atr_14')} | Volatility Regime: {vlt.get('volatility_regime')}
|
| 84 |
+
VWAP: {vol.get('vwap')} | MFI: {vol.get('mfi')} | MFI Status: {vol.get('mfi_status')}
|
| 85 |
+
CMF: {vol.get('cmf')} | A/D Trend: {vol.get('ad_line_trend')}
|
| 86 |
+
|
| 87 |
+
Analyze these indicators and predict the {t.lower()}-term price direction.
|
| 88 |
+
|
| 89 |
+
Respond with ONLY valid JSON (no markdown, no explanation):
|
| 90 |
+
{{"direction": "Bullish" | "Bearish" | "Neutral", "conviction": <float 0.0-1.0>}}"""
|
| 91 |
+
return prompt
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# βββ Prediction Logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
def _parse_prediction(text: str) -> Tuple[str, float]:
|
| 97 |
+
"""Extract direction and conviction from model output."""
|
| 98 |
+
text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip()
|
| 99 |
+
try:
|
| 100 |
+
match = re.search(r"\{[^}]+\}", text, re.DOTALL)
|
| 101 |
+
if match:
|
| 102 |
+
obj = json.loads(match.group(0))
|
| 103 |
+
direction = str(obj.get("direction", "Neutral")).strip().capitalize()
|
| 104 |
+
conviction = float(obj.get("conviction", 0.5))
|
| 105 |
+
if direction not in ("Bullish", "Bearish", "Neutral"):
|
| 106 |
+
direction = "Neutral"
|
| 107 |
+
conviction = max(0.0, min(1.0, conviction))
|
| 108 |
+
return direction, conviction
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
lower = text.lower()
|
| 112 |
+
if "bullish" in lower:
|
| 113 |
+
return "Bullish", 0.6
|
| 114 |
+
if "bearish" in lower:
|
| 115 |
+
return "Bearish", 0.6
|
| 116 |
+
return "Neutral", 0.4
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _run_llm(client, model: str, prompt: str) -> Tuple[str, float]:
|
| 120 |
+
"""Call the LLM and parse the prediction."""
|
| 121 |
+
try:
|
| 122 |
+
response = client.chat.completions.create(
|
| 123 |
+
model=model,
|
| 124 |
+
messages=[
|
| 125 |
+
{"role": "system", "content": "You are a quantitative analyst. Always respond with valid JSON only."},
|
| 126 |
+
{"role": "user", "content": prompt},
|
| 127 |
+
],
|
| 128 |
+
max_tokens=64,
|
| 129 |
+
temperature=0.1,
|
| 130 |
+
)
|
| 131 |
+
text = response.choices[0].message.content or ""
|
| 132 |
+
return _parse_prediction(text)
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.warning(f"[LLM] Call failed: {e}")
|
| 135 |
+
return "Neutral", 0.5
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# βββ Environment Client βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
+
|
| 140 |
+
def _env_reset(env_url: str, term: str) -> Optional[Dict]:
|
| 141 |
+
"""Call /reset on the environment."""
|
| 142 |
+
try:
|
| 143 |
+
r = httpx.post(f"{env_url}/reset", params={"term": term}, timeout=30)
|
| 144 |
+
r.raise_for_status()
|
| 145 |
+
return r.json()
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"[Env] /reset failed: {e}")
|
| 148 |
+
return None
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _env_step(env_url: str, session_id: str, direction: str, conviction: float) -> Optional[Dict]:
|
| 152 |
+
"""Call /step on the environment."""
|
| 153 |
+
try:
|
| 154 |
+
r = httpx.post(
|
| 155 |
+
f"{env_url}/step",
|
| 156 |
+
params={"session_id": session_id},
|
| 157 |
+
json={"direction": direction, "conviction": conviction},
|
| 158 |
+
timeout=30,
|
| 159 |
+
)
|
| 160 |
+
r.raise_for_status()
|
| 161 |
+
return r.json()
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"[Env] /step failed: {e}")
|
| 164 |
+
return None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _env_grade(env_url: str, task_id: str, episode_results: List[Dict]) -> Optional[Dict]:
|
| 168 |
+
"""Call /grader on the environment."""
|
| 169 |
+
try:
|
| 170 |
+
r = httpx.post(
|
| 171 |
+
f"{env_url}/grader",
|
| 172 |
+
json={"task_id": task_id, "episode_results": episode_results},
|
| 173 |
+
timeout=30,
|
| 174 |
+
)
|
| 175 |
+
r.raise_for_status()
|
| 176 |
+
return r.json()
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.error(f"[Env] /grader failed: {e}")
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# βββ Main Baseline Runner βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
|
| 184 |
+
def run_baseline(env_url: str, n_episodes: int, model: str) -> None:
|
| 185 |
+
client = _get_openai_client()
|
| 186 |
+
|
| 187 |
+
# Get tasks from the environment
|
| 188 |
+
try:
|
| 189 |
+
tasks_resp = httpx.get(f"{env_url}/tasks", timeout=15)
|
| 190 |
+
tasks_resp.raise_for_status()
|
| 191 |
+
tasks = tasks_resp.json()["tasks"]
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.error(f"[Env] /tasks failed: {e}")
|
| 194 |
+
sys.exit(1)
|
| 195 |
+
|
| 196 |
+
logger.info(f"[Baseline] Running {model} on {len(tasks)} tasks Γ {n_episodes} episodes each...")
|
| 197 |
+
print(f"\n{'='*65}")
|
| 198 |
+
print(f"BASELINE SCORES β IndicatorsEnv | Model: {model}")
|
| 199 |
+
print(f"{'='*65}")
|
| 200 |
+
|
| 201 |
+
all_scores = []
|
| 202 |
+
|
| 203 |
+
for task in tasks:
|
| 204 |
+
task_id = task["id"]
|
| 205 |
+
term = task["term"]
|
| 206 |
+
difficulty = task["difficulty"]
|
| 207 |
+
|
| 208 |
+
logger.info(f"[{task_id}] Starting {n_episodes} episodes (term={term})...")
|
| 209 |
+
episode_results = []
|
| 210 |
+
total_reward = 0.0
|
| 211 |
+
|
| 212 |
+
for ep in range(n_episodes):
|
| 213 |
+
# 1. Reset
|
| 214 |
+
reset_data = _env_reset(env_url, term=term)
|
| 215 |
+
if reset_data is None:
|
| 216 |
+
continue
|
| 217 |
+
obs = reset_data["observation"]
|
| 218 |
+
session_id = reset_data["info"]["session_id"]
|
| 219 |
+
|
| 220 |
+
# 2. Build prompt and get LLM prediction
|
| 221 |
+
prompt = _build_prompt(obs)
|
| 222 |
+
direction, conviction = _run_llm(client, model, prompt)
|
| 223 |
+
|
| 224 |
+
# 3. Step
|
| 225 |
+
step_data = _env_step(env_url, session_id, direction, conviction)
|
| 226 |
+
if step_data is None:
|
| 227 |
+
continue
|
| 228 |
+
|
| 229 |
+
reward = step_data.get("reward", 0.0)
|
| 230 |
+
gt = step_data.get("info", {}).get("ground_truth", "")
|
| 231 |
+
total_reward += reward
|
| 232 |
+
|
| 233 |
+
episode_results.append({
|
| 234 |
+
"ground_truth": gt,
|
| 235 |
+
"predicted": direction,
|
| 236 |
+
"conviction": conviction,
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
if (ep + 1) % 5 == 0:
|
| 240 |
+
logger.info(f"[{task_id}] {ep+1}/{n_episodes} done | mean_reward={total_reward/(ep+1):.3f}")
|
| 241 |
+
|
| 242 |
+
# 4. Grade
|
| 243 |
+
grader_data = _env_grade(env_url, task_id, episode_results)
|
| 244 |
+
score = grader_data["score"] if grader_data else 0.0
|
| 245 |
+
all_scores.append(score)
|
| 246 |
+
|
| 247 |
+
print(f"\nTask: {task['name']}")
|
| 248 |
+
print(f" Difficulty: {difficulty} | Episodes: {len(episode_results)}")
|
| 249 |
+
print(f" Mean Reward: {total_reward/max(1,len(episode_results)):.4f}")
|
| 250 |
+
print(f" Grader Score: {score:.4f}")
|
| 251 |
+
|
| 252 |
+
overall = sum(all_scores) / len(all_scores) if all_scores else 0.0
|
| 253 |
+
print(f"\n{'='*65}")
|
| 254 |
+
print(f"OVERALL MEAN SCORE: {overall:.4f}")
|
| 255 |
+
print(f"{'='*65}\n")
|
| 256 |
+
|
| 257 |
+
# Save results
|
| 258 |
+
out = {
|
| 259 |
+
"model": model,
|
| 260 |
+
"env_url": env_url,
|
| 261 |
+
"n_episodes": n_episodes,
|
| 262 |
+
"task_scores": dict(zip([t["id"] for t in tasks], all_scores)),
|
| 263 |
+
"overall_mean": overall,
|
| 264 |
+
}
|
| 265 |
+
with open("baseline_results.json", "w") as f:
|
| 266 |
+
json.dump(out, f, indent=2)
|
| 267 |
+
logger.info("Results saved to baseline_results.json")
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
if __name__ == "__main__":
|
| 271 |
+
parser = argparse.ArgumentParser(description="IndicatorsEnv Baseline Inference Script")
|
| 272 |
+
parser.add_argument("--env_url", default="http://localhost:7860", help="URL of the IndicatorsEnv server")
|
| 273 |
+
parser.add_argument("--n_episodes", type=int, default=10, help="Episodes per task")
|
| 274 |
+
parser.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"), help="OpenAI model to use")
|
| 275 |
+
args = parser.parse_args()
|
| 276 |
+
run_baseline(args.env_url, args.n_episodes, args.model)
|
env/__init__.py
ADDED
|
File without changes
|
env/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (197 Bytes). View file
|
|
|
env/__pycache__/data_loader.cpython-312.pyc
ADDED
|
Binary file (27.5 kB). View file
|
|
|
env/__pycache__/indicators_env.cpython-312.pyc
ADDED
|
Binary file (20.6 kB). View file
|
|
|
env/data_loader.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
data_loader.py β Fetches OHLCV data via yfinance and computes the full
|
| 3 |
+
technical indicator suite used by the StockAnalyzer-Pro indicators agent.
|
| 4 |
+
Also provides ground truth labels via 20-day forward return thresholding.
|
| 5 |
+
|
| 6 |
+
Indicators computed (mirrors calculate_all_indicators_optimized):
|
| 7 |
+
Moving Averages : SMA(20/50/200), EMA(20/50), golden/death cross
|
| 8 |
+
Momentum : RSI(14), MACD(12/26/9), Stochastic(14/3)
|
| 9 |
+
Volatility : Bollinger Bands(20,2), ATR(14), volatility regime
|
| 10 |
+
Trend : ADX(14), +DI/-DI, trend strength
|
| 11 |
+
Volume : OBV, VWAP, MFI(14), CMF(20), A/D Line, volume ratio
|
| 12 |
+
Levels : Pivot Points (Standard: R2/R1/P/S1/S2)
|
| 13 |
+
Context : market regime, [TERM: X] token
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from datetime import datetime, timedelta
|
| 20 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
import yfinance as yf
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
# βββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
TERM_WINDOWS: Dict[str, int] = {
|
| 31 |
+
"intraday": 1,
|
| 32 |
+
"short": 5,
|
| 33 |
+
"medium": 20,
|
| 34 |
+
"long": 30,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
TERM_THRESHOLDS: Dict[str, float] = {
|
| 38 |
+
"intraday": 0.005, # Β±0.5%
|
| 39 |
+
"short": 0.015, # Β±1.5%
|
| 40 |
+
"medium": 0.025, # Β±2.5%
|
| 41 |
+
"long": 0.050, # Β±5.0%
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# 100 liquid NSE stocks (diversified across sectors)
|
| 45 |
+
NSE_UNIVERSE: List[str] = [
|
| 46 |
+
"RELIANCE", "TCS", "HDFCBANK", "INFY", "ICICIBANK", "HINDUNILVR", "SBIN",
|
| 47 |
+
"BHARTIARTL", "ITC", "KOTAKBANK", "LT", "AXISBANK", "ASIANPAINT", "MARUTI",
|
| 48 |
+
"BAJFINANCE", "TITAN", "SUNPHARMA", "WIPRO", "ULTRACEMCO", "NESTLEIND",
|
| 49 |
+
"POWERGRID", "NTPC", "TECHM", "HCLTECH", "DIVISLAB", "CIPLA", "EICHERMOT",
|
| 50 |
+
"HDFCLIFE", "DRREDDY", "ONGC", "COALINDIA", "TATASTEEL",
|
| 51 |
+
"JSWSTEEL", "ADANIPORTS", "BAJAJ-AUTO", "HEROMOTOCO", "INDUSINDBK",
|
| 52 |
+
"GRASIM", "BRITANNIA", "SBILIFE", "APOLLOHOSP", "TATACONSUM", "PIDILITIND",
|
| 53 |
+
"TORNTPHARM", "HAVELLS", "GODREJCP", "MUTHOOTFIN", "PAGEIND", "COLPAL",
|
| 54 |
+
"BERGEPAINT", "DABUR", "MARICO", "EMAMILTD", "BALKRISIND", "CUMMINSIND",
|
| 55 |
+
"VOLTAS", "WHIRLPOOL", "TVSMOTOR", "BOSCHLTD", "SCHAEFFLER", "ASTRAL",
|
| 56 |
+
"POLYCAB", "KANSAINER", "AARTIIND", "DEEPAKNTR", "PIIND", "LALPATHLAB",
|
| 57 |
+
"METROPOLIS", "AUROPHARMA", "BIOCON", "GLENMARK", "LUPIN", "ALKEM",
|
| 58 |
+
"IPCALAB", "LAURUSLABS", "GRANULES", "ABBOTINDIA", "PFIZER", "SANOFI",
|
| 59 |
+
"KAJARIACER", "CENTURYTEX", "RAMCOCEM", "JKCEMENT", "SHREECEM", "AMBUJACEMENT",
|
| 60 |
+
"INDIGO", "SPICEJET", "IRCTC", "CONCOR", "GMRINFRA", "HUDCO",
|
| 61 |
+
"BANDHANBNK", "IDFCFIRSTB", "FEDERALBNK", "RBLBANK", "CANBK", "PNB",
|
| 62 |
+
"BANKBARODA", "UNIONBANK",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# βββ Core fetch + indicator computation ββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
def fetch_ohlcv(symbol: str, end_date: str, lookback_days: int = 300) -> Optional[pd.DataFrame]:
|
| 69 |
+
"""
|
| 70 |
+
Fetch OHLCV data for `symbol.NS` up to `end_date` via yfinance.
|
| 71 |
+
Returns a clean DataFrame with columns: open, high, low, close, volume.
|
| 72 |
+
"""
|
| 73 |
+
try:
|
| 74 |
+
end_dt = pd.to_datetime(end_date) + timedelta(days=1)
|
| 75 |
+
start_dt = end_dt - timedelta(days=lookback_days)
|
| 76 |
+
ticker = yf.Ticker(f"{symbol}.NS")
|
| 77 |
+
df = ticker.history(
|
| 78 |
+
start=start_dt.strftime("%Y-%m-%d"),
|
| 79 |
+
end=end_dt.strftime("%Y-%m-%d"),
|
| 80 |
+
auto_adjust=True,
|
| 81 |
+
)
|
| 82 |
+
if df.empty or len(df) < 30:
|
| 83 |
+
return None
|
| 84 |
+
df.columns = df.columns.str.lower()
|
| 85 |
+
df = df[["open", "high", "low", "close", "volume"]].dropna()
|
| 86 |
+
return df
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.warning(f"[DataLoader] fetch_ohlcv failed for {symbol} on {end_date}: {e}")
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def compute_indicators(df: pd.DataFrame) -> Dict[str, Any]:
|
| 93 |
+
"""
|
| 94 |
+
Compute the full indicator suite mirroring calculate_all_indicators_optimized.
|
| 95 |
+
All values are current (scalar), no historical arrays returned.
|
| 96 |
+
"""
|
| 97 |
+
ind: Dict[str, Any] = {}
|
| 98 |
+
close = df["close"]
|
| 99 |
+
high = df["high"]
|
| 100 |
+
low = df["low"]
|
| 101 |
+
vol = df["volume"]
|
| 102 |
+
cp = float(close.iloc[-1])
|
| 103 |
+
|
| 104 |
+
# ββ Moving Averages ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
sma20 = close.rolling(20).mean()
|
| 106 |
+
sma50 = close.rolling(50).mean()
|
| 107 |
+
sma200 = close.rolling(200).mean() if len(df) >= 200 else sma50
|
| 108 |
+
ema20 = close.ewm(span=20, adjust=False).mean()
|
| 109 |
+
ema50 = close.ewm(span=50, adjust=False).mean()
|
| 110 |
+
|
| 111 |
+
golden_cross = bool(sma20.iloc[-1] > sma50.iloc[-1] and sma20.iloc[-2] <= sma50.iloc[-2])
|
| 112 |
+
death_cross = bool(sma20.iloc[-1] < sma50.iloc[-1] and sma20.iloc[-2] >= sma50.iloc[-2])
|
| 113 |
+
|
| 114 |
+
ind["moving_averages"] = {
|
| 115 |
+
"sma_20": _safe(sma20.iloc[-1], cp),
|
| 116 |
+
"sma_50": _safe(sma50.iloc[-1], cp),
|
| 117 |
+
"sma_200": _safe(sma200.iloc[-1], cp),
|
| 118 |
+
"ema_20": _safe(ema20.iloc[-1], cp),
|
| 119 |
+
"ema_50": _safe(ema50.iloc[-1], cp),
|
| 120 |
+
"price_to_sma200_pct": round((cp / _safe(sma200.iloc[-1], cp) - 1) * 100, 2),
|
| 121 |
+
"sma20_to_sma50_pct": round((sma20.iloc[-1] / _safe(sma50.iloc[-1], cp) - 1) * 100, 2),
|
| 122 |
+
"golden_cross": golden_cross,
|
| 123 |
+
"death_cross": death_cross,
|
| 124 |
+
"signal": "bullish" if sma20.iloc[-1] > sma50.iloc[-1] else "bearish",
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
# ββ RSI(14) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
delta = close.diff()
|
| 129 |
+
gain = delta.clip(lower=0)
|
| 130 |
+
loss = (-delta).clip(lower=0)
|
| 131 |
+
avg_gain = gain.ewm(alpha=1/14, adjust=False).mean()
|
| 132 |
+
avg_loss = loss.ewm(alpha=1/14, adjust=False).mean()
|
| 133 |
+
rs = avg_gain / avg_loss.replace(0, np.nan)
|
| 134 |
+
rsi = 100 - (100 / (1 + rs))
|
| 135 |
+
rsi_val = _safe(rsi.iloc[-1], 50.0)
|
| 136 |
+
|
| 137 |
+
ind["rsi"] = {
|
| 138 |
+
"rsi_14": rsi_val,
|
| 139 |
+
"trend": "up" if rsi.iloc[-1] > rsi.iloc[-2] else "down",
|
| 140 |
+
"status": (
|
| 141 |
+
"overbought" if rsi_val > 70 else
|
| 142 |
+
"near_overbought" if rsi_val > 60 else
|
| 143 |
+
"near_oversold" if rsi_val < 40 else
|
| 144 |
+
"oversold" if rsi_val < 30 else "neutral"
|
| 145 |
+
),
|
| 146 |
+
"signal": "oversold" if rsi_val < 30 else "overbought" if rsi_val > 70 else "neutral",
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# ββ MACD(12/26/9) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
ema12 = close.ewm(span=12, adjust=False).mean()
|
| 151 |
+
ema26 = close.ewm(span=26, adjust=False).mean()
|
| 152 |
+
macd_line = ema12 - ema26
|
| 153 |
+
signal_line = macd_line.ewm(span=9, adjust=False).mean()
|
| 154 |
+
histogram = macd_line - signal_line
|
| 155 |
+
|
| 156 |
+
ind["macd"] = {
|
| 157 |
+
"macd_line": round(float(macd_line.iloc[-1]), 4),
|
| 158 |
+
"signal_line": round(float(signal_line.iloc[-1]), 4),
|
| 159 |
+
"histogram": round(float(histogram.iloc[-1]), 4),
|
| 160 |
+
"signal": "bullish" if macd_line.iloc[-1] > signal_line.iloc[-1] else "bearish",
|
| 161 |
+
"crossover": (
|
| 162 |
+
"bullish_cross" if macd_line.iloc[-1] > signal_line.iloc[-1] and macd_line.iloc[-2] <= signal_line.iloc[-2]
|
| 163 |
+
else "bearish_cross" if macd_line.iloc[-1] < signal_line.iloc[-1] and macd_line.iloc[-2] >= signal_line.iloc[-2]
|
| 164 |
+
else "none"
|
| 165 |
+
),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
# ββ Bollinger Bands(20, 2) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 169 |
+
mb = sma20
|
| 170 |
+
std = close.rolling(20).std()
|
| 171 |
+
ub = mb + 2 * std
|
| 172 |
+
lb = mb - 2 * std
|
| 173 |
+
bw = (ub.iloc[-1] - lb.iloc[-1]) / _safe(mb.iloc[-1], cp)
|
| 174 |
+
pct_b = (cp - lb.iloc[-1]) / (ub.iloc[-1] - lb.iloc[-1]) if (ub.iloc[-1] - lb.iloc[-1]) > 0 else 0.5
|
| 175 |
+
|
| 176 |
+
ind["bollinger_bands"] = {
|
| 177 |
+
"upper": _safe(ub.iloc[-1], cp),
|
| 178 |
+
"middle": _safe(mb.iloc[-1], cp),
|
| 179 |
+
"lower": _safe(lb.iloc[-1], cp),
|
| 180 |
+
"percent_b": round(pct_b, 3),
|
| 181 |
+
"bandwidth": round(bw, 4),
|
| 182 |
+
"squeeze": bool(bw < 0.1),
|
| 183 |
+
"signal": "squeeze" if bw < 0.1 else "expansion",
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
# ββ ATR(14) + Volatility βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
tr1 = high - low
|
| 188 |
+
tr2 = (high - close.shift()).abs()
|
| 189 |
+
tr3 = (low - close.shift()).abs()
|
| 190 |
+
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
| 191 |
+
atr = tr.rolling(14).mean()
|
| 192 |
+
atr_20avg = atr.rolling(20).mean()
|
| 193 |
+
vol_ratio = atr.iloc[-1] / atr_20avg.iloc[-1] if _safe(atr_20avg.iloc[-1], 0) > 0 else 1.0
|
| 194 |
+
|
| 195 |
+
ind["volatility"] = {
|
| 196 |
+
"atr_14": _safe(atr.iloc[-1], 0.0),
|
| 197 |
+
"atr_20_avg": _safe(atr_20avg.iloc[-1], 0.0),
|
| 198 |
+
"volatility_ratio": round(vol_ratio, 2),
|
| 199 |
+
"bb_squeeze": bool(bw < 0.1),
|
| 200 |
+
"regime": "high" if vol_ratio > 1.5 else "low" if vol_ratio < 0.7 else "normal",
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# ββ ADX(14) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 204 |
+
up_move = high.diff()
|
| 205 |
+
down_move = low.shift() - low
|
| 206 |
+
plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0)
|
| 207 |
+
minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0)
|
| 208 |
+
plus_dm_s = pd.Series(plus_dm, index=df.index).rolling(14).mean()
|
| 209 |
+
minus_dm_s = pd.Series(minus_dm, index=df.index).rolling(14).mean()
|
| 210 |
+
atr14 = atr
|
| 211 |
+
plus_di = 100 * plus_dm_s / atr14.replace(0, np.nan)
|
| 212 |
+
minus_di = 100 * minus_dm_s / atr14.replace(0, np.nan)
|
| 213 |
+
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
|
| 214 |
+
adx = dx.rolling(14).mean()
|
| 215 |
+
|
| 216 |
+
adx_val = _safe(adx.iloc[-1], 20.0)
|
| 217 |
+
plus_di_val = _safe(plus_di.iloc[-1], 25.0)
|
| 218 |
+
minus_di_val= _safe(minus_di.iloc[-1], 25.0)
|
| 219 |
+
|
| 220 |
+
ind["adx"] = {
|
| 221 |
+
"adx": adx_val,
|
| 222 |
+
"plus_di": plus_di_val,
|
| 223 |
+
"minus_di": minus_di_val,
|
| 224 |
+
"trend_direction": "bullish" if plus_di_val > minus_di_val else "bearish",
|
| 225 |
+
"trend_strength": "strong" if adx_val > 25 else "weak",
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
# ββ Stochastic(14, 3) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 229 |
+
lowest_low = low.rolling(14).min()
|
| 230 |
+
highest_high = high.rolling(14).max()
|
| 231 |
+
stoch_k = 100 * (close - lowest_low) / (highest_high - lowest_low).replace(0, np.nan)
|
| 232 |
+
stoch_d = stoch_k.rolling(3).mean()
|
| 233 |
+
|
| 234 |
+
ind["stochastic"] = {
|
| 235 |
+
"k": _safe(stoch_k.iloc[-1], 50.0),
|
| 236 |
+
"d": _safe(stoch_d.iloc[-1], 50.0),
|
| 237 |
+
"signal": (
|
| 238 |
+
"oversold" if _safe(stoch_k.iloc[-1], 50.0) < 20 else
|
| 239 |
+
"overbought" if _safe(stoch_k.iloc[-1], 50.0) > 80 else "neutral"
|
| 240 |
+
),
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
# ββ OBV ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 244 |
+
obv = (np.sign(close.diff()) * vol).fillna(0).cumsum()
|
| 245 |
+
ind["volume"] = {
|
| 246 |
+
"obv": round(float(obv.iloc[-1]), 0),
|
| 247 |
+
"obv_trend": "up" if obv.iloc[-1] > obv.iloc[-5] else "down",
|
| 248 |
+
"volume_ratio": round(float(vol.iloc[-1] / vol.rolling(20).mean().iloc[-1]), 2) if vol.rolling(20).mean().iloc[-1] > 0 else 1.0,
|
| 249 |
+
"signal": "high_volume" if vol.iloc[-1] > 1.5 * vol.rolling(20).mean().iloc[-1] else "normal",
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
# ββ VWAP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
+
tp = (high + low + close) / 3
|
| 254 |
+
vwap = (tp * vol).cumsum() / vol.cumsum().replace(0, np.nan)
|
| 255 |
+
vwap_val = _safe(vwap.iloc[-1], cp)
|
| 256 |
+
|
| 257 |
+
# ββ MFI(14) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 258 |
+
mf_raw = tp * vol
|
| 259 |
+
pos_mf = mf_raw.where(tp > tp.shift(), 0.0)
|
| 260 |
+
neg_mf = mf_raw.where(tp < tp.shift(), 0.0)
|
| 261 |
+
mfr = pos_mf.rolling(14).sum() / neg_mf.rolling(14).sum().replace(0, np.nan)
|
| 262 |
+
mfi = 100 - (100 / (1 + mfr))
|
| 263 |
+
mfi_val= _safe(mfi.iloc[-1], 50.0)
|
| 264 |
+
|
| 265 |
+
# ββ CMF(20) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 266 |
+
clv = ((close - low) - (high - close)) / (high - low).replace(0, np.nan)
|
| 267 |
+
cmf = (clv * vol).rolling(20).sum() / vol.rolling(20).sum().replace(0, np.nan)
|
| 268 |
+
cmf_val = _safe(cmf.iloc[-1], 0.0)
|
| 269 |
+
|
| 270 |
+
# ββ A/D Line βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 271 |
+
ad_line = (clv * vol).fillna(0).cumsum()
|
| 272 |
+
ad_trend = "up" if ad_line.iloc[-1] > ad_line.iloc[-20] else "down"
|
| 273 |
+
|
| 274 |
+
ind["enhanced_volume"] = {
|
| 275 |
+
"vwap": round(vwap_val, 2),
|
| 276 |
+
"price_vs_vwap_pct": round((cp / vwap_val - 1) * 100, 2) if vwap_val > 0 else 0.0,
|
| 277 |
+
"mfi": round(mfi_val, 2),
|
| 278 |
+
"mfi_status": "overbought" if mfi_val > 80 else "oversold" if mfi_val < 20 else "neutral",
|
| 279 |
+
"cmf": round(cmf_val, 4),
|
| 280 |
+
"cmf_signal": "bullish" if cmf_val > 0 else "bearish",
|
| 281 |
+
"ad_line_trend": ad_trend,
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
# ββ Pivot Points (Standard, based on previous day) ββββββββββββββββββββββββ
|
| 285 |
+
H = float(high.iloc[-2])
|
| 286 |
+
L = float(low.iloc[-2])
|
| 287 |
+
C = float(close.iloc[-2])
|
| 288 |
+
P = (H + L + C) / 3
|
| 289 |
+
ind["pivot_points"] = {
|
| 290 |
+
"pivot": round(P, 2),
|
| 291 |
+
"r1": round(2 * P - L, 2),
|
| 292 |
+
"r2": round(P + (H - L), 2),
|
| 293 |
+
"s1": round(2 * P - H, 2),
|
| 294 |
+
"s2": round(P - (H - L), 2),
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
return ind
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def compute_ground_truth(symbol: str, end_date: str, term: str = "medium") -> Optional[str]:
|
| 301 |
+
"""
|
| 302 |
+
Compute the forward-return ground truth label for a (symbol, date, term) triplet.
|
| 303 |
+
Returns "Bullish", "Bearish", or "Neutral", or None if data unavailable.
|
| 304 |
+
"""
|
| 305 |
+
window = TERM_WINDOWS.get(term, 20)
|
| 306 |
+
threshold = TERM_THRESHOLDS.get(term, 0.025)
|
| 307 |
+
try:
|
| 308 |
+
end_dt = pd.to_datetime(end_date)
|
| 309 |
+
fetch_end = end_dt + timedelta(days=window + 15) # extra buffer for weekends/holidays
|
| 310 |
+
ticker = yf.Ticker(f"{symbol}.NS")
|
| 311 |
+
df = ticker.history(
|
| 312 |
+
start=end_date,
|
| 313 |
+
end=fetch_end.strftime("%Y-%m-%d"),
|
| 314 |
+
auto_adjust=True,
|
| 315 |
+
)
|
| 316 |
+
if df.empty or len(df) < window:
|
| 317 |
+
return None
|
| 318 |
+
df.columns = df.columns.str.lower()
|
| 319 |
+
entry_price = float(df["close"].iloc[0])
|
| 320 |
+
exit_price = float(df["close"].iloc[min(window, len(df) - 1)])
|
| 321 |
+
forward_ret = (exit_price - entry_price) / entry_price
|
| 322 |
+
if forward_ret > threshold:
|
| 323 |
+
return "Bullish"
|
| 324 |
+
elif forward_ret < -threshold:
|
| 325 |
+
return "Bearish"
|
| 326 |
+
else:
|
| 327 |
+
return "Neutral"
|
| 328 |
+
except Exception as e:
|
| 329 |
+
logger.warning(f"[DataLoader] ground_truth failed for {symbol} on {end_date}: {e}")
|
| 330 |
+
return None
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def build_observation(symbol: str, date: str, term: str = "medium") -> Optional[Dict[str, Any]]:
|
| 334 |
+
"""
|
| 335 |
+
Full pipeline: fetch OHLCV β compute indicators β package as observation dict.
|
| 336 |
+
Returns None if data is insufficient.
|
| 337 |
+
"""
|
| 338 |
+
df = fetch_ohlcv(symbol, date)
|
| 339 |
+
if df is None:
|
| 340 |
+
return None
|
| 341 |
+
indicators = compute_indicators(df)
|
| 342 |
+
cp = float(df["close"].iloc[-1])
|
| 343 |
+
return {
|
| 344 |
+
"symbol": symbol,
|
| 345 |
+
"date": date,
|
| 346 |
+
"term": term.upper(),
|
| 347 |
+
"current_price": round(cp, 2),
|
| 348 |
+
"indicators": indicators,
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def generate_scenario_pool(
|
| 353 |
+
symbols: Optional[List[str]] = None,
|
| 354 |
+
start_date: str = "2019-01-01",
|
| 355 |
+
end_date: str = "2024-12-31",
|
| 356 |
+
term: str = "medium",
|
| 357 |
+
max_scenarios: int = 50_000,
|
| 358 |
+
) -> List[Dict[str, str]]:
|
| 359 |
+
"""
|
| 360 |
+
Pre-generate a pool of (symbol, date) pairs that have valid ground truth labels.
|
| 361 |
+
Used to populate the environment's scenario queue.
|
| 362 |
+
"""
|
| 363 |
+
if symbols is None:
|
| 364 |
+
symbols = NSE_UNIVERSE
|
| 365 |
+
window = TERM_WINDOWS.get(term, 20)
|
| 366 |
+
# Generate monthly sample dates (avoids look-ahead: stops window days before end_date)
|
| 367 |
+
cutoff = (pd.to_datetime(end_date) - timedelta(days=window + 5)).strftime("%Y-%m-%d")
|
| 368 |
+
dates = pd.bdate_range(start=start_date, end=cutoff, freq="10B").strftime("%Y-%m-%d").tolist()
|
| 369 |
+
|
| 370 |
+
pool = []
|
| 371 |
+
for sym in symbols:
|
| 372 |
+
for dt in dates:
|
| 373 |
+
pool.append({"symbol": sym, "date": dt, "term": term})
|
| 374 |
+
if len(pool) >= max_scenarios:
|
| 375 |
+
break
|
| 376 |
+
if len(pool) >= max_scenarios:
|
| 377 |
+
break
|
| 378 |
+
return pool
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 382 |
+
|
| 383 |
+
def _safe(val: Any, default: float) -> float:
|
| 384 |
+
"""Return float val, defaulting if NaN/None."""
|
| 385 |
+
try:
|
| 386 |
+
v = float(val)
|
| 387 |
+
return default if np.isnan(v) else round(v, 4)
|
| 388 |
+
except Exception:
|
| 389 |
+
return default
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# βββ Batch offline dataset generator (no per-episode API calls) βββββββββββββ
|
| 393 |
+
|
| 394 |
+
def generate_dataset_offline(
|
| 395 |
+
symbols: Optional[List[str]] = None,
|
| 396 |
+
start_date: str = "2020-01-01",
|
| 397 |
+
end_date: str = "2024-06-30",
|
| 398 |
+
term: str = "medium",
|
| 399 |
+
dates_per_stock: int = 15,
|
| 400 |
+
max_total: int = 5000,
|
| 401 |
+
save_path: Optional[str] = None,
|
| 402 |
+
) -> List[Dict[str, Any]]:
|
| 403 |
+
"""
|
| 404 |
+
Batch dataset builder: 1 yfinance API call per stock β many training examples.
|
| 405 |
+
|
| 406 |
+
For each stock we download full history ONCE, then slice out `dates_per_stock`
|
| 407 |
+
evenly-spaced windows. Each window gives indicators + forward-return GT.
|
| 408 |
+
This avoids per-episode yfinance calls and prevents Colab rate-limiting.
|
| 409 |
+
|
| 410 |
+
Args:
|
| 411 |
+
symbols : list of NSE symbols (default: first 30 of NSE_UNIVERSE)
|
| 412 |
+
start_date : earliest date to sample (needs lookback buffer)
|
| 413 |
+
end_date : latest date to sample (will stop `window` days before this)
|
| 414 |
+
term : prediction term (intraday/short/medium/long)
|
| 415 |
+
dates_per_stock : candidate dates to sample per stock
|
| 416 |
+
max_total : cap on total dataset size
|
| 417 |
+
save_path : if given, saves as JSON for later reload
|
| 418 |
+
|
| 419 |
+
Returns:
|
| 420 |
+
List of dicts: {symbol, date, term, current_price, indicators, ground_truth, prompt}
|
| 421 |
+
"""
|
| 422 |
+
import json, time
|
| 423 |
+
|
| 424 |
+
if symbols is None:
|
| 425 |
+
symbols = NSE_UNIVERSE[:30]
|
| 426 |
+
|
| 427 |
+
window = TERM_WINDOWS.get(term, 20)
|
| 428 |
+
threshold = TERM_THRESHOLDS.get(term, 0.025)
|
| 429 |
+
lookback = 300 # days of history needed for indicators
|
| 430 |
+
|
| 431 |
+
# Generate candidate dates (evenly spread, no weekends)
|
| 432 |
+
cutoff = (pd.to_datetime(end_date) - timedelta(days=window + 5)).strftime("%Y-%m-%d")
|
| 433 |
+
all_dates = pd.bdate_range(
|
| 434 |
+
start=(pd.to_datetime(start_date) + timedelta(days=lookback)).strftime("%Y-%m-%d"),
|
| 435 |
+
end=cutoff,
|
| 436 |
+
)
|
| 437 |
+
step = max(1, len(all_dates) // dates_per_stock)
|
| 438 |
+
sample_dates = [d.strftime("%Y-%m-%d") for d in all_dates[::step]][:dates_per_stock]
|
| 439 |
+
|
| 440 |
+
dataset: List[Dict[str, Any]] = []
|
| 441 |
+
|
| 442 |
+
for sym_idx, symbol in enumerate(symbols):
|
| 443 |
+
if len(dataset) >= max_total:
|
| 444 |
+
break
|
| 445 |
+
try:
|
| 446 |
+
# ββ Single API call: fetch full history for this stock ββββββββββ
|
| 447 |
+
fetch_start = (pd.to_datetime(start_date) - timedelta(days=5)).strftime("%Y-%m-%d")
|
| 448 |
+
fetch_end = (pd.to_datetime(end_date) + timedelta(days=window + 20)).strftime("%Y-%m-%d")
|
| 449 |
+
ticker = yf.Ticker(f"{symbol}.NS")
|
| 450 |
+
full_df = ticker.history(
|
| 451 |
+
start=fetch_start, end=fetch_end, auto_adjust=True
|
| 452 |
+
)
|
| 453 |
+
if full_df.empty or len(full_df) < lookback:
|
| 454 |
+
logger.warning(f"[Offline] {symbol}: insufficient data ({len(full_df)} rows). Skipping.")
|
| 455 |
+
continue
|
| 456 |
+
full_df.columns = full_df.columns.str.lower()
|
| 457 |
+
full_df = full_df[["open", "high", "low", "close", "volume"]].dropna()
|
| 458 |
+
full_df.index = pd.to_datetime(full_df.index).tz_localize(None)
|
| 459 |
+
|
| 460 |
+
logger.info(f"[Offline] {symbol} ({sym_idx+1}/{len(symbols)}): {len(full_df)} rows fetched β slicing {len(sample_dates)} dates")
|
| 461 |
+
|
| 462 |
+
for date_str in sample_dates:
|
| 463 |
+
if len(dataset) >= max_total:
|
| 464 |
+
break
|
| 465 |
+
try:
|
| 466 |
+
target_dt = pd.to_datetime(date_str)
|
| 467 |
+
|
| 468 |
+
# Slice history up to this date (lookback window for indicators)
|
| 469 |
+
hist = full_df[full_df.index <= target_dt].tail(lookback)
|
| 470 |
+
if len(hist) < 60:
|
| 471 |
+
continue
|
| 472 |
+
|
| 473 |
+
# Ground truth: forward return from this date
|
| 474 |
+
future = full_df[full_df.index > target_dt].head(window + 5)
|
| 475 |
+
if len(future) < window:
|
| 476 |
+
continue
|
| 477 |
+
entry = float(hist["close"].iloc[-1])
|
| 478 |
+
exit_ = float(future["close"].iloc[min(window, len(future)) - 1])
|
| 479 |
+
fwd_ret = (exit_ - entry) / entry
|
| 480 |
+
if fwd_ret > threshold:
|
| 481 |
+
gt = "Bullish"
|
| 482 |
+
elif fwd_ret < -threshold:
|
| 483 |
+
gt = "Bearish"
|
| 484 |
+
else:
|
| 485 |
+
gt = "Neutral"
|
| 486 |
+
|
| 487 |
+
# Compute indicators from historical slice
|
| 488 |
+
indicators = compute_indicators(hist)
|
| 489 |
+
|
| 490 |
+
dataset.append({
|
| 491 |
+
"symbol": symbol,
|
| 492 |
+
"date": date_str,
|
| 493 |
+
"term": term.upper(),
|
| 494 |
+
"current_price": round(entry, 2),
|
| 495 |
+
"indicators": indicators,
|
| 496 |
+
"ground_truth": gt,
|
| 497 |
+
})
|
| 498 |
+
except Exception as e:
|
| 499 |
+
logger.debug(f"[Offline] {symbol}/{date_str} skipped: {e}")
|
| 500 |
+
continue
|
| 501 |
+
|
| 502 |
+
# Small pause between stocks to be polite to yfinance
|
| 503 |
+
time.sleep(0.3)
|
| 504 |
+
|
| 505 |
+
except Exception as e:
|
| 506 |
+
logger.warning(f"[Offline] {symbol}: fetch failed: {e}")
|
| 507 |
+
continue
|
| 508 |
+
|
| 509 |
+
logger.info(f"[Offline] Dataset complete: {len(dataset)} episodes from {len(symbols)} stocks")
|
| 510 |
+
|
| 511 |
+
if save_path:
|
| 512 |
+
import json as _json
|
| 513 |
+
with open(save_path, "w") as f:
|
| 514 |
+
_json.dump(dataset, f)
|
| 515 |
+
logger.info(f"[Offline] Saved to {save_path}")
|
| 516 |
+
|
| 517 |
+
return dataset
|
| 518 |
+
|
env/indicators_env.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
indicators_env.py β OpenEnv-compatible RL environment for technical indicators prediction.
|
| 3 |
+
|
| 4 |
+
Implements the full OpenEnv standard interface:
|
| 5 |
+
- HTTP endpoints: reset / step / state / tasks / grader / baseline / health
|
| 6 |
+
- WebSocket (/ws) for low-latency sequential step() calls during RL training
|
| 7 |
+
|
| 8 |
+
Env spec:
|
| 9 |
+
Observation : JSON dict of full indicator snapshot + [TERM: MEDIUM] token
|
| 10 |
+
Action : {"direction": "Bullish"|"Bearish"|"Neutral", "conviction": float 0-1}
|
| 11 |
+
Reward : Shaped 0.0β1.1 based on correctness + conviction calibration
|
| 12 |
+
|
| 13 |
+
Tasks:
|
| 14 |
+
Task 1 (Easy) : SHORT term (5d, Β±1.0% threshold)
|
| 15 |
+
Task 2 (Medium) : MEDIUM term (20d, Β±2.5% threshold) β default
|
| 16 |
+
Task 3 (Hard) : LONG term (60d, Β±5.0% threshold) + conviction >= 0.7 required
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
import random
|
| 24 |
+
import uuid
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
import uvicorn
|
| 28 |
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 29 |
+
from pydantic import BaseModel, Field
|
| 30 |
+
|
| 31 |
+
from data_loader import (
|
| 32 |
+
NSE_UNIVERSE,
|
| 33 |
+
build_observation,
|
| 34 |
+
compute_ground_truth,
|
| 35 |
+
generate_scenario_pool,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
logging.basicConfig(level=logging.INFO)
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
app = FastAPI(
|
| 42 |
+
title="IndicatorsEnv",
|
| 43 |
+
description=(
|
| 44 |
+
"OpenEnv-compatible RL environment for NSE technical indicators prediction. "
|
| 45 |
+
"An AI agent receives a rich indicator snapshot and must predict the directional "
|
| 46 |
+
"price move over a configurable forward window (short/medium/long term). "
|
| 47 |
+
"Built for the Meta Γ PyTorch Hackathon."
|
| 48 |
+
),
|
| 49 |
+
version="2.0.0",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# βββ Task Definitions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
|
| 54 |
+
TASKS = [
|
| 55 |
+
{
|
| 56 |
+
"id": "short_term_direction",
|
| 57 |
+
"name": "Short-term Direction (Easy)",
|
| 58 |
+
"description": (
|
| 59 |
+
"Predict 5-day forward price direction from technical indicators. "
|
| 60 |
+
"Threshold: Β±1.0%. More Neutral outcomes expected due to market noise."
|
| 61 |
+
),
|
| 62 |
+
"difficulty": "easy",
|
| 63 |
+
"term": "short",
|
| 64 |
+
"grader_note": "Score = fraction of correct directional predictions over 10 episodes.",
|
| 65 |
+
"action_schema": {
|
| 66 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "Neutral"]},
|
| 67 |
+
"conviction": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"id": "medium_term_direction",
|
| 72 |
+
"name": "Medium-term Direction (Medium)",
|
| 73 |
+
"description": (
|
| 74 |
+
"Predict 20-day forward price direction from technical indicators. "
|
| 75 |
+
"Threshold: Β±2.5%. Balanced Bullish/Bearish/Neutral distribution."
|
| 76 |
+
),
|
| 77 |
+
"difficulty": "medium",
|
| 78 |
+
"term": "medium",
|
| 79 |
+
"grader_note": (
|
| 80 |
+
"Score = weighted accuracy: Bearish/Neutral correct = 1.5x weight "
|
| 81 |
+
"(anti-majority-class bias), normalized to 0β1."
|
| 82 |
+
),
|
| 83 |
+
"action_schema": {
|
| 84 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "Neutral"]},
|
| 85 |
+
"conviction": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 86 |
+
},
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"id": "long_term_conviction",
|
| 90 |
+
"name": "Long-term Conviction (Hard)",
|
| 91 |
+
"description": (
|
| 92 |
+
"Predict 60-day forward price direction AND demonstrate calibrated conviction. "
|
| 93 |
+
"Threshold: Β±5.0%. Correct direction WITH conviction >= 0.7 scores 1.0. "
|
| 94 |
+
"Correct direction with low conviction scores 0.5. Wrong + high conviction penalized."
|
| 95 |
+
),
|
| 96 |
+
"difficulty": "hard",
|
| 97 |
+
"term": "long",
|
| 98 |
+
"grader_note": (
|
| 99 |
+
"Score = mean of per-episode scores: 1.0 (correct + conviction>=0.7), "
|
| 100 |
+
"0.5 (correct + conviction<0.7), 0.0 (wrong), -0.1 (wrong + conviction>=0.8). "
|
| 101 |
+
"Normalized to 0β1 range."
|
| 102 |
+
),
|
| 103 |
+
"action_schema": {
|
| 104 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "Neutral"]},
|
| 105 |
+
"conviction": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 106 |
+
},
|
| 107 |
+
},
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
TASK_BY_ID = {t["id"]: t for t in TASKS}
|
| 111 |
+
TERM_TO_TASK_ID = {t["term"]: t["id"] for t in TASKS}
|
| 112 |
+
|
| 113 |
+
# βββ Pydantic schemas βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
|
| 115 |
+
class IndicatorsAction(BaseModel):
|
| 116 |
+
direction: str = Field(..., description="Predicted direction: Bullish | Bearish | Neutral")
|
| 117 |
+
conviction: float = Field(0.5, ge=0.0, le=1.0, description="Confidence in prediction (0-1)")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class IndicatorsObservation(BaseModel):
|
| 121 |
+
symbol: str
|
| 122 |
+
date: str
|
| 123 |
+
term: str
|
| 124 |
+
current_price: float
|
| 125 |
+
indicators: Dict[str, Any]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class StepResult(BaseModel):
|
| 129 |
+
observation: Optional[IndicatorsObservation]
|
| 130 |
+
reward: float
|
| 131 |
+
done: bool
|
| 132 |
+
info: Dict[str, Any]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class ResetResult(BaseModel):
|
| 136 |
+
observation: IndicatorsObservation
|
| 137 |
+
info: Dict[str, Any]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class StateResult(BaseModel):
|
| 141 |
+
session_id: str
|
| 142 |
+
current_observation: Optional[IndicatorsObservation]
|
| 143 |
+
episodes_completed: int
|
| 144 |
+
current_task: Optional[str]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class GraderRequest(BaseModel):
|
| 148 |
+
task_id: str
|
| 149 |
+
episode_results: List[Dict[str, Any]] = Field(
|
| 150 |
+
...,
|
| 151 |
+
description=(
|
| 152 |
+
"List of episode dicts, each with keys: "
|
| 153 |
+
"'ground_truth' (str), 'predicted' (str), 'conviction' (float)"
|
| 154 |
+
),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class GraderResult(BaseModel):
|
| 159 |
+
task_id: str
|
| 160 |
+
score: float = Field(..., ge=0.0, le=1.0)
|
| 161 |
+
num_episodes: int
|
| 162 |
+
breakdown: Dict[str, Any]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# βββ Grader Logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
|
| 167 |
+
def grade_task(task_id: str, episode_results: List[Dict[str, Any]]) -> GraderResult:
|
| 168 |
+
"""Deterministic grader for all 3 tasks. Returns a 0.0β1.0 score."""
|
| 169 |
+
if not episode_results:
|
| 170 |
+
return GraderResult(task_id=task_id, score=0.0, num_episodes=0, breakdown={})
|
| 171 |
+
|
| 172 |
+
n = len(episode_results)
|
| 173 |
+
|
| 174 |
+
if task_id == "short_term_direction":
|
| 175 |
+
# Easy: simple accuracy
|
| 176 |
+
correct = sum(
|
| 177 |
+
1 for r in episode_results
|
| 178 |
+
if r.get("predicted", "").capitalize() == r.get("ground_truth", "").capitalize()
|
| 179 |
+
)
|
| 180 |
+
score = correct / n
|
| 181 |
+
breakdown = {"correct": correct, "total": n, "metric": "accuracy"}
|
| 182 |
+
|
| 183 |
+
elif task_id == "medium_term_direction":
|
| 184 |
+
# Medium: weighted accuracy (minority classes worth more)
|
| 185 |
+
weighted_score = 0.0
|
| 186 |
+
weighted_total = 0.0
|
| 187 |
+
for r in episode_results:
|
| 188 |
+
gt = r.get("ground_truth", "").capitalize()
|
| 189 |
+
pred = r.get("predicted", "").capitalize()
|
| 190 |
+
w = 1.5 if gt in ("Bearish", "Neutral") else 1.0
|
| 191 |
+
weighted_total += w
|
| 192 |
+
if pred == gt:
|
| 193 |
+
weighted_score += w
|
| 194 |
+
score = min(1.0, weighted_score / weighted_total) if weighted_total > 0 else 0.0
|
| 195 |
+
breakdown = {"weighted_score": round(weighted_score, 3), "weighted_total": round(weighted_total, 3), "metric": "weighted_accuracy"}
|
| 196 |
+
|
| 197 |
+
elif task_id == "long_term_conviction":
|
| 198 |
+
# Hard: direction + conviction calibration
|
| 199 |
+
per_episode_scores = []
|
| 200 |
+
for r in episode_results:
|
| 201 |
+
gt = r.get("ground_truth", "").capitalize()
|
| 202 |
+
pred = r.get("predicted", "").capitalize()
|
| 203 |
+
conviction = float(r.get("conviction", 0.5))
|
| 204 |
+
correct = pred == gt
|
| 205 |
+
if correct and conviction >= 0.7:
|
| 206 |
+
per_episode_scores.append(1.0)
|
| 207 |
+
elif correct and conviction < 0.7:
|
| 208 |
+
per_episode_scores.append(0.5)
|
| 209 |
+
elif not correct and conviction >= 0.8:
|
| 210 |
+
per_episode_scores.append(-0.1) # overconfident wrong
|
| 211 |
+
else:
|
| 212 |
+
per_episode_scores.append(0.0)
|
| 213 |
+
raw = sum(per_episode_scores) / n
|
| 214 |
+
# Normalize from [-0.1, 1.0] to [0.0, 1.0]
|
| 215 |
+
score = max(0.0, min(1.0, (raw + 0.1) / 1.1))
|
| 216 |
+
breakdown = {"per_episode_scores": per_episode_scores, "raw_mean": round(raw, 4), "metric": "conviction_calibrated_accuracy"}
|
| 217 |
+
|
| 218 |
+
else:
|
| 219 |
+
raise HTTPException(status_code=404, detail=f"Unknown task_id: {task_id}")
|
| 220 |
+
|
| 221 |
+
return GraderResult(
|
| 222 |
+
task_id=task_id,
|
| 223 |
+
score=round(score, 4),
|
| 224 |
+
num_episodes=n,
|
| 225 |
+
breakdown=breakdown,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# βββ Session state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 230 |
+
|
| 231 |
+
class EnvSession:
|
| 232 |
+
def __init__(self, session_id: str, term: str = "medium"):
|
| 233 |
+
self.session_id = session_id
|
| 234 |
+
self.term = term.lower()
|
| 235 |
+
self.current_obs: Optional[IndicatorsObservation] = None
|
| 236 |
+
self.current_gt: Optional[str] = None
|
| 237 |
+
self.episodes_completed = 0
|
| 238 |
+
self.episode_history: List[Dict[str, Any]] = []
|
| 239 |
+
self.scenario_pool: List[Dict[str, str]] = []
|
| 240 |
+
|
| 241 |
+
def _get_scenario(self) -> Optional[Dict[str, str]]:
|
| 242 |
+
if not self.scenario_pool:
|
| 243 |
+
self.scenario_pool = generate_scenario_pool(
|
| 244 |
+
symbols=random.sample(NSE_UNIVERSE, min(20, len(NSE_UNIVERSE))),
|
| 245 |
+
start_date="2020-01-01",
|
| 246 |
+
end_date="2024-06-30",
|
| 247 |
+
term=self.term,
|
| 248 |
+
max_scenarios=2000,
|
| 249 |
+
)
|
| 250 |
+
random.shuffle(self.scenario_pool)
|
| 251 |
+
return self.scenario_pool.pop() if self.scenario_pool else None
|
| 252 |
+
|
| 253 |
+
def reset(self) -> Optional[ResetResult]:
|
| 254 |
+
for _ in range(10):
|
| 255 |
+
sc = self._get_scenario()
|
| 256 |
+
if sc is None:
|
| 257 |
+
return None
|
| 258 |
+
obs_dict = build_observation(sc["symbol"], sc["date"], self.term)
|
| 259 |
+
gt = compute_ground_truth(sc["symbol"], sc["date"], self.term)
|
| 260 |
+
if obs_dict is not None and gt is not None:
|
| 261 |
+
self.current_obs = IndicatorsObservation(**obs_dict)
|
| 262 |
+
self.current_gt = gt
|
| 263 |
+
return ResetResult(
|
| 264 |
+
observation=self.current_obs,
|
| 265 |
+
info={"session_id": self.session_id, "term": self.term, "task_id": TERM_TO_TASK_ID.get(self.term)},
|
| 266 |
+
)
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
def step(self, action: IndicatorsAction) -> StepResult:
|
| 270 |
+
if self.current_obs is None or self.current_gt is None:
|
| 271 |
+
return StepResult(
|
| 272 |
+
observation=None, reward=0.0, done=True,
|
| 273 |
+
info={"error": "Call reset() first"}
|
| 274 |
+
)
|
| 275 |
+
correct = action.direction.strip().capitalize() == self.current_gt
|
| 276 |
+
reward = 1.0 if correct else 0.0
|
| 277 |
+
|
| 278 |
+
# Shaped reward: conviction calibration
|
| 279 |
+
if correct and action.conviction >= 0.6:
|
| 280 |
+
reward = 1.0 + 0.1 * (action.conviction - 0.6)
|
| 281 |
+
elif not correct and action.conviction >= 0.8:
|
| 282 |
+
reward = -0.1
|
| 283 |
+
|
| 284 |
+
reward = round(max(-0.1, min(1.1, reward)), 4)
|
| 285 |
+
|
| 286 |
+
info = {
|
| 287 |
+
"ground_truth": self.current_gt,
|
| 288 |
+
"predicted": action.direction,
|
| 289 |
+
"correct": correct,
|
| 290 |
+
"conviction": action.conviction,
|
| 291 |
+
"session_id": self.session_id,
|
| 292 |
+
"task_id": TERM_TO_TASK_ID.get(self.term),
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
# Store for grader
|
| 296 |
+
self.episode_history.append({
|
| 297 |
+
"ground_truth": self.current_gt,
|
| 298 |
+
"predicted": action.direction.strip().capitalize(),
|
| 299 |
+
"conviction": action.conviction,
|
| 300 |
+
"reward": reward,
|
| 301 |
+
})
|
| 302 |
+
|
| 303 |
+
self.episodes_completed += 1
|
| 304 |
+
prev_obs = self.current_obs
|
| 305 |
+
self.current_obs = None
|
| 306 |
+
self.current_gt = None
|
| 307 |
+
return StepResult(observation=prev_obs, reward=reward, done=True, info=info)
|
| 308 |
+
|
| 309 |
+
def state(self) -> StateResult:
|
| 310 |
+
return StateResult(
|
| 311 |
+
session_id=self.session_id,
|
| 312 |
+
current_observation=self.current_obs,
|
| 313 |
+
episodes_completed=self.episodes_completed,
|
| 314 |
+
current_task=TERM_TO_TASK_ID.get(self.term),
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# βββ Session store ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 319 |
+
|
| 320 |
+
_sessions: Dict[str, EnvSession] = {}
|
| 321 |
+
|
| 322 |
+
def _get_or_create(session_id: Optional[str] = None, term: str = "medium") -> EnvSession:
|
| 323 |
+
if session_id is None:
|
| 324 |
+
session_id = str(uuid.uuid4())
|
| 325 |
+
if session_id not in _sessions:
|
| 326 |
+
_sessions[session_id] = EnvSession(session_id, term=term)
|
| 327 |
+
return _sessions[session_id]
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# βββ HTTP endpoints (OpenEnv standard) βββββββββββββββββββββββββββββββββββββββ
|
| 331 |
+
|
| 332 |
+
@app.get("/health")
|
| 333 |
+
def health():
|
| 334 |
+
return {"status": "ok", "env": "IndicatorsEnv", "version": "2.0.0", "tasks": len(TASKS)}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@app.get("/tasks")
|
| 338 |
+
def get_tasks():
|
| 339 |
+
"""Returns all task definitions and action schemas."""
|
| 340 |
+
return {
|
| 341 |
+
"tasks": TASKS,
|
| 342 |
+
"total": len(TASKS),
|
| 343 |
+
"action_schema": {
|
| 344 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "Neutral"]},
|
| 345 |
+
"conviction": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 346 |
+
},
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
@app.post("/reset", response_model=ResetResult)
|
| 351 |
+
def reset(session_id: Optional[str] = None, term: str = "medium"):
|
| 352 |
+
sess = _get_or_create(session_id, term=term)
|
| 353 |
+
result = sess.reset()
|
| 354 |
+
if result is None:
|
| 355 |
+
raise HTTPException(status_code=503, detail="Could not fetch data. Retry.")
|
| 356 |
+
return result
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@app.post("/step", response_model=StepResult)
|
| 360 |
+
def step(action: IndicatorsAction, session_id: Optional[str] = None):
|
| 361 |
+
sess = _get_or_create(session_id)
|
| 362 |
+
return sess.step(action)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@app.get("/state", response_model=StateResult)
|
| 366 |
+
def state(session_id: Optional[str] = None):
|
| 367 |
+
sess = _get_or_create(session_id)
|
| 368 |
+
return sess.state()
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@app.post("/grader", response_model=GraderResult)
|
| 372 |
+
def grader(request: GraderRequest):
|
| 373 |
+
"""
|
| 374 |
+
Score a completed episode set against a specific task grader.
|
| 375 |
+
Provide episode_results as list of {ground_truth, predicted, conviction}.
|
| 376 |
+
Returns a 0.0β1.0 score.
|
| 377 |
+
"""
|
| 378 |
+
if request.task_id not in TASK_BY_ID:
|
| 379 |
+
raise HTTPException(status_code=404, detail=f"Unknown task_id: {request.task_id}. Valid: {list(TASK_BY_ID.keys())}")
|
| 380 |
+
return grade_task(request.task_id, request.episode_results)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
@app.get("/baseline")
|
| 384 |
+
def baseline():
|
| 385 |
+
"""
|
| 386 |
+
Trigger the built-in random baseline agent across all 3 tasks (10 episodes each).
|
| 387 |
+
Returns reproducible baseline scores.
|
| 388 |
+
"""
|
| 389 |
+
results = {}
|
| 390 |
+
random.seed(42) # Deterministic
|
| 391 |
+
|
| 392 |
+
for task in TASKS:
|
| 393 |
+
term = task["term"]
|
| 394 |
+
task_id = task["id"]
|
| 395 |
+
session_id = f"baseline-{task_id}"
|
| 396 |
+
|
| 397 |
+
# Fresh session
|
| 398 |
+
sess = EnvSession(session_id=session_id, term=term)
|
| 399 |
+
episode_results = []
|
| 400 |
+
|
| 401 |
+
for _ in range(10):
|
| 402 |
+
reset_result = sess.reset()
|
| 403 |
+
if reset_result is None:
|
| 404 |
+
continue
|
| 405 |
+
# Random agent
|
| 406 |
+
action = IndicatorsAction(
|
| 407 |
+
direction=random.choice(["Bullish", "Bearish", "Neutral"]),
|
| 408 |
+
conviction=round(random.uniform(0.3, 0.9), 2),
|
| 409 |
+
)
|
| 410 |
+
step_result = sess.step(action)
|
| 411 |
+
episode_results.append({
|
| 412 |
+
"ground_truth": step_result.info.get("ground_truth", ""),
|
| 413 |
+
"predicted": action.direction,
|
| 414 |
+
"conviction": action.conviction,
|
| 415 |
+
})
|
| 416 |
+
|
| 417 |
+
grader_result = grade_task(task_id, episode_results)
|
| 418 |
+
results[task_id] = {
|
| 419 |
+
"task_name": task["name"],
|
| 420 |
+
"difficulty": task["difficulty"],
|
| 421 |
+
"score": grader_result.score,
|
| 422 |
+
"num_episodes": grader_result.num_episodes,
|
| 423 |
+
"breakdown": grader_result.breakdown,
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
return {
|
| 427 |
+
"agent": "random_baseline",
|
| 428 |
+
"seed": 42,
|
| 429 |
+
"tasks": results,
|
| 430 |
+
"overall_mean": round(sum(r["score"] for r in results.values()) / len(results), 4),
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
# βββ WebSocket endpoint (OpenEnv /ws) ββββββββββββββββββββββββββββββββββββββββ
|
| 435 |
+
|
| 436 |
+
@app.websocket("/ws")
|
| 437 |
+
async def websocket_env(ws: WebSocket, session_id: Optional[str] = None, term: str = "medium"):
|
| 438 |
+
await ws.accept()
|
| 439 |
+
sess = _get_or_create(session_id, term=term)
|
| 440 |
+
logger.info(f"[WS] Session {sess.session_id} connected (term={term})")
|
| 441 |
+
try:
|
| 442 |
+
while True:
|
| 443 |
+
raw = await ws.receive_text()
|
| 444 |
+
msg = json.loads(raw)
|
| 445 |
+
method = msg.get("method", "")
|
| 446 |
+
|
| 447 |
+
if method == "reset":
|
| 448 |
+
result = sess.reset()
|
| 449 |
+
if result is None:
|
| 450 |
+
await ws.send_json({"error": "No data available"})
|
| 451 |
+
else:
|
| 452 |
+
await ws.send_text(result.model_dump_json())
|
| 453 |
+
|
| 454 |
+
elif method == "step":
|
| 455 |
+
action = IndicatorsAction(**msg.get("action", {}))
|
| 456 |
+
result = sess.step(action)
|
| 457 |
+
await ws.send_text(result.model_dump_json())
|
| 458 |
+
|
| 459 |
+
elif method == "state":
|
| 460 |
+
result = sess.state()
|
| 461 |
+
await ws.send_text(result.model_dump_json())
|
| 462 |
+
|
| 463 |
+
else:
|
| 464 |
+
await ws.send_json({"error": f"Unknown method: {method}"})
|
| 465 |
+
|
| 466 |
+
except WebSocketDisconnect:
|
| 467 |
+
logger.info(f"[WS] Session {sess.session_id} disconnected")
|
| 468 |
+
except Exception as e:
|
| 469 |
+
logger.error(f"[WS] Error: {e}")
|
| 470 |
+
await ws.close()
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
# βββ Entry point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 474 |
+
|
| 475 |
+
if __name__ == "__main__":
|
| 476 |
+
uvicorn.run("indicators_env:app", host="0.0.0.0", port=7860, reload=False, workers=2)
|
env/indicators_env_client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
indicators_env_client.py β Typed sync/async client for IndicatorsEnv.
|
| 3 |
+
|
| 4 |
+
Mirrors the openenv-core EnvClient pattern so it works seamlessly with
|
| 5 |
+
TRL's GRPOTrainer and the openenv course module interface.
|
| 6 |
+
|
| 7 |
+
Usage (sync):
|
| 8 |
+
from indicators_env_client import IndicatorsEnvClient, IndicatorsAction
|
| 9 |
+
with IndicatorsEnvClient(base_url="http://localhost:8000").sync() as env:
|
| 10 |
+
obs = env.reset()
|
| 11 |
+
result = env.step(IndicatorsAction(direction="Bullish", conviction=0.8))
|
| 12 |
+
print(result.reward)
|
| 13 |
+
|
| 14 |
+
Usage (async):
|
| 15 |
+
async with IndicatorsEnvClient(base_url="http://localhost:8000") as env:
|
| 16 |
+
obs = await env.reset()
|
| 17 |
+
result = await env.step(IndicatorsAction(direction="Bullish", conviction=0.8))
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import contextlib
|
| 24 |
+
import json
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Any, Dict, Optional
|
| 27 |
+
|
| 28 |
+
import httpx
|
| 29 |
+
import websockets
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# βββ Data classes (mirror server schemas) ββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class IndicatorsAction:
|
| 36 |
+
direction: str # "Bullish" | "Bearish" | "Neutral"
|
| 37 |
+
conviction: float = 0.5
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class IndicatorsObservation:
|
| 42 |
+
symbol: str
|
| 43 |
+
date: str
|
| 44 |
+
term: str
|
| 45 |
+
current_price: float
|
| 46 |
+
indicators: Dict[str, Any]
|
| 47 |
+
|
| 48 |
+
@classmethod
|
| 49 |
+
def from_dict(cls, d: Dict[str, Any]) -> "IndicatorsObservation":
|
| 50 |
+
return cls(
|
| 51 |
+
symbol=d["symbol"],
|
| 52 |
+
date=d["date"],
|
| 53 |
+
term=d["term"],
|
| 54 |
+
current_price=d["current_price"],
|
| 55 |
+
indicators=d["indicators"],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def to_prompt(self) -> str:
|
| 59 |
+
"""Render observation as a structured LLM prompt string."""
|
| 60 |
+
ind = self.indicators
|
| 61 |
+
ma = ind.get("moving_averages", {})
|
| 62 |
+
rsi = ind.get("rsi", {})
|
| 63 |
+
mac = ind.get("macd", {})
|
| 64 |
+
bb = ind.get("bollinger_bands", {})
|
| 65 |
+
adx = ind.get("adx", {})
|
| 66 |
+
vol = ind.get("enhanced_volume", {})
|
| 67 |
+
vlt = ind.get("volatility", {})
|
| 68 |
+
sto = ind.get("stochastic", {})
|
| 69 |
+
piv = ind.get("pivot_points", {})
|
| 70 |
+
|
| 71 |
+
return f"""[TERM: {self.term}]
|
| 72 |
+
Stock: {self.symbol} | Date: {self.date} | Price: {self.current_price}
|
| 73 |
+
|
| 74 |
+
MOVING AVERAGES
|
| 75 |
+
SMA20={ma.get('sma_20')} | SMA50={ma.get('sma_50')} | SMA200={ma.get('sma_200')}
|
| 76 |
+
EMA20={ma.get('ema_20')} | EMA50={ma.get('ema_50')}
|
| 77 |
+
Signal: {ma.get('signal')} | Golden Cross: {ma.get('golden_cross')} | Death Cross: {ma.get('death_cross')}
|
| 78 |
+
|
| 79 |
+
MOMENTUM
|
| 80 |
+
RSI14={rsi.get('rsi_14')} ({rsi.get('status')}) | Trend: {rsi.get('trend')}
|
| 81 |
+
MACD={mac.get('macd_line')} | Signal={mac.get('signal_line')} | Hist={mac.get('histogram')} | {mac.get('signal')} | Crossover: {mac.get('crossover')}
|
| 82 |
+
Stochastic K={sto.get('k')} D={sto.get('d')} ({sto.get('signal')})
|
| 83 |
+
|
| 84 |
+
VOLATILITY / BANDS
|
| 85 |
+
BB Upper={bb.get('upper')} | Mid={bb.get('middle')} | Lower={bb.get('lower')}
|
| 86 |
+
%B={bb.get('percent_b')} | Bandwidth={bb.get('bandwidth')} | Squeeze={bb.get('squeeze')}
|
| 87 |
+
ATR={vlt.get('atr_14')} | Vol Regime={vlt.get('regime')} | Vol Ratio={vlt.get('volatility_ratio')}
|
| 88 |
+
|
| 89 |
+
TREND
|
| 90 |
+
ADX={adx.get('adx')} ({adx.get('trend_strength')}) | +DI={adx.get('plus_di')} | -DI={adx.get('minus_di')} | Direction: {adx.get('trend_direction')}
|
| 91 |
+
|
| 92 |
+
VOLUME
|
| 93 |
+
VWAP={vol.get('vwap')} | Price vs VWAP={vol.get('price_vs_vwap_pct')}%
|
| 94 |
+
MFI={vol.get('mfi')} ({vol.get('mfi_status')}) | CMF={vol.get('cmf')} ({vol.get('cmf_signal')})
|
| 95 |
+
OBV Trend={ind.get('volume', {}).get('obv_trend')} | Volume Ratio={ind.get('volume', {}).get('volume_ratio')}x
|
| 96 |
+
A/D Line Trend={vol.get('ad_line_trend')}
|
| 97 |
+
|
| 98 |
+
PIVOT POINTS
|
| 99 |
+
R2={piv.get('r2')} | R1={piv.get('r1')} | Pivot={piv.get('pivot')} | S1={piv.get('s1')} | S2={piv.get('s2')}
|
| 100 |
+
|
| 101 |
+
Based on the above technical indicators, predict the {self.term}-term direction.
|
| 102 |
+
Respond ONLY with a JSON object in this exact format:
|
| 103 |
+
{{"direction": "Bullish" | "Bearish" | "Neutral", "conviction": <float 0.0-1.0>}}"""
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@dataclass
|
| 107 |
+
class StepResult:
|
| 108 |
+
observation: Optional[IndicatorsObservation]
|
| 109 |
+
reward: float
|
| 110 |
+
done: bool
|
| 111 |
+
info: Dict[str, Any]
|
| 112 |
+
|
| 113 |
+
@classmethod
|
| 114 |
+
def from_dict(cls, d: Dict[str, Any]) -> "StepResult":
|
| 115 |
+
obs_d = d.get("observation")
|
| 116 |
+
return cls(
|
| 117 |
+
observation=IndicatorsObservation.from_dict(obs_d) if obs_d else None,
|
| 118 |
+
reward=d["reward"],
|
| 119 |
+
done=d["done"],
|
| 120 |
+
info=d.get("info", {}),
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@dataclass
|
| 125 |
+
class ResetResult:
|
| 126 |
+
observation: IndicatorsObservation
|
| 127 |
+
info: Dict[str, Any]
|
| 128 |
+
|
| 129 |
+
@classmethod
|
| 130 |
+
def from_dict(cls, d: Dict[str, Any]) -> "ResetResult":
|
| 131 |
+
return cls(
|
| 132 |
+
observation=IndicatorsObservation.from_dict(d["observation"]),
|
| 133 |
+
info=d.get("info", {}),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# βββ Async client βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
|
| 139 |
+
class IndicatorsEnvClient:
|
| 140 |
+
"""Async client for IndicatorsEnv using WebSocket for step() calls."""
|
| 141 |
+
|
| 142 |
+
def __init__(self, base_url: str = "http://localhost:8000", term: str = "medium"):
|
| 143 |
+
self.base_url = base_url.rstrip("/")
|
| 144 |
+
self.term = term
|
| 145 |
+
self._ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws"
|
| 146 |
+
self._ws = None
|
| 147 |
+
self._http: Optional[httpx.AsyncClient] = None
|
| 148 |
+
|
| 149 |
+
async def __aenter__(self):
|
| 150 |
+
self._http = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)
|
| 151 |
+
self._ws = await websockets.connect(f"{self._ws_url}?term={self.term}")
|
| 152 |
+
return self
|
| 153 |
+
|
| 154 |
+
async def __aexit__(self, *args):
|
| 155 |
+
if self._ws:
|
| 156 |
+
await self._ws.close()
|
| 157 |
+
if self._http:
|
| 158 |
+
await self._http.aclose()
|
| 159 |
+
|
| 160 |
+
async def reset(self) -> ResetResult:
|
| 161 |
+
await self._ws.send(json.dumps({"method": "reset"}))
|
| 162 |
+
raw = await self._ws.recv()
|
| 163 |
+
return ResetResult.from_dict(json.loads(raw))
|
| 164 |
+
|
| 165 |
+
async def step(self, action: IndicatorsAction) -> StepResult:
|
| 166 |
+
await self._ws.send(json.dumps({
|
| 167 |
+
"method": "step",
|
| 168 |
+
"action": {"direction": action.direction, "conviction": action.conviction},
|
| 169 |
+
}))
|
| 170 |
+
raw = await self._ws.recv()
|
| 171 |
+
return StepResult.from_dict(json.loads(raw))
|
| 172 |
+
|
| 173 |
+
async def state(self) -> Dict[str, Any]:
|
| 174 |
+
await self._ws.send(json.dumps({"method": "state"}))
|
| 175 |
+
raw = await self._ws.recv()
|
| 176 |
+
return json.loads(raw)
|
| 177 |
+
|
| 178 |
+
def sync(self) -> "_SyncWrapper":
|
| 179 |
+
return _SyncWrapper(self)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class _SyncWrapper:
|
| 183 |
+
"""Synchronous context manager wrapper (mirrors openenv-core .sync() pattern)."""
|
| 184 |
+
|
| 185 |
+
def __init__(self, client: IndicatorsEnvClient):
|
| 186 |
+
self._client = client
|
| 187 |
+
self._loop = asyncio.new_event_loop()
|
| 188 |
+
|
| 189 |
+
def __enter__(self):
|
| 190 |
+
self._loop.run_until_complete(self._client.__aenter__())
|
| 191 |
+
return self
|
| 192 |
+
|
| 193 |
+
def __exit__(self, *args):
|
| 194 |
+
self._loop.run_until_complete(self._client.__aexit__(*args))
|
| 195 |
+
self._loop.close()
|
| 196 |
+
|
| 197 |
+
def reset(self) -> ResetResult:
|
| 198 |
+
return self._loop.run_until_complete(self._client.reset())
|
| 199 |
+
|
| 200 |
+
def step(self, action: IndicatorsAction) -> StepResult:
|
| 201 |
+
return self._loop.run_until_complete(self._client.step(action))
|
| 202 |
+
|
| 203 |
+
def state(self) -> Dict[str, Any]:
|
| 204 |
+
return self._loop.run_until_complete(self._client.state())
|
evaluation/__init__.py
ADDED
|
File without changes
|
evaluation/__pycache__/eval_finetuned.cpython-312.pyc
ADDED
|
Binary file (27.6 kB). View file
|
|
|
evaluation/__pycache__/eval_finetuned.cpython-314.pyc
ADDED
|
Binary file (24.9 kB). View file
|
|
|
evaluation/eval_finetuned.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
eval_finetuned.py β Side-by-side evaluation of fine-tuned vs baseline models on IndicatorsEnv.
|
| 3 |
+
|
| 4 |
+
Compares:
|
| 5 |
+
Baseline : Qwen2.5-1.5B-Instruct (zero-shot, no training)
|
| 6 |
+
Fine-tuned: GRPO-trained Qwen2.5-1.5B with QLoRA adapter
|
| 7 |
+
|
| 8 |
+
Produces:
|
| 9 |
+
- Overall accuracy (3-class)
|
| 10 |
+
- Per-class Precision, Recall, F1
|
| 11 |
+
- Full Confusion Matrix (before and after training)
|
| 12 |
+
- Saved JSON with all metrics
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python evaluation/eval_finetuned.py \\
|
| 16 |
+
--adapter_path /content/drive/MyDrive/indicators_grpo_v2/lora_adapter \\
|
| 17 |
+
--n_eval 100 \\
|
| 18 |
+
--term medium
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import json
|
| 25 |
+
import logging
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
import statistics
|
| 29 |
+
import sys
|
| 30 |
+
from collections import defaultdict
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Dict, List, Tuple, Any
|
| 33 |
+
|
| 34 |
+
import torch
|
| 35 |
+
from sklearn.metrics import (
|
| 36 |
+
classification_report,
|
| 37 |
+
confusion_matrix,
|
| 38 |
+
accuracy_score,
|
| 39 |
+
)
|
| 40 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 41 |
+
|
| 42 |
+
# Standard visualization imports
|
| 43 |
+
import matplotlib
|
| 44 |
+
matplotlib.use("Agg") # Headless
|
| 45 |
+
import matplotlib.pyplot as plt
|
| 46 |
+
|
| 47 |
+
logging.basicConfig(level=logging.INFO)
|
| 48 |
+
logger = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
MODEL_ID = "Qwen/Qwen2.5-7B-Instruct"
|
| 51 |
+
CLASSES = ["Bullish", "Bearish", "Neutral"]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
def _parse_direction(text: str) -> str:
|
| 57 |
+
text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip()
|
| 58 |
+
try:
|
| 59 |
+
match = re.search(r"\{[^}]+\}", text, re.DOTALL)
|
| 60 |
+
if match:
|
| 61 |
+
obj = json.loads(match.group(0))
|
| 62 |
+
d = str(obj.get("direction", "Neutral")).strip().capitalize()
|
| 63 |
+
return d if d in ("Bullish", "Bearish", "Neutral") else "Neutral"
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
lower = text.lower()
|
| 67 |
+
if "bullish" in lower:
|
| 68 |
+
return "Bullish"
|
| 69 |
+
if "bearish" in lower:
|
| 70 |
+
return "Bearish"
|
| 71 |
+
return "Neutral"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _full_metrics(results: List[Dict]) -> Dict:
|
| 75 |
+
"""Compute overall accuracy, per-class P/R/F1, and confusion matrix."""
|
| 76 |
+
y_true = [r["ground_truth"] for r in results]
|
| 77 |
+
y_pred = [r["predicted"] for r in results]
|
| 78 |
+
|
| 79 |
+
# sklearn classification_report
|
| 80 |
+
report = classification_report(
|
| 81 |
+
y_true, y_pred,
|
| 82 |
+
labels=CLASSES,
|
| 83 |
+
output_dict=True,
|
| 84 |
+
zero_division=0,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Confusion matrix: rows=true, cols=predicted
|
| 88 |
+
cm = confusion_matrix(y_true, y_pred, labels=CLASSES)
|
| 89 |
+
|
| 90 |
+
# Per-class accuracy (recall)
|
| 91 |
+
by_class_acc = {}
|
| 92 |
+
for cls in CLASSES:
|
| 93 |
+
cls_indices = [i for i, t in enumerate(y_true) if t == cls]
|
| 94 |
+
if cls_indices:
|
| 95 |
+
cls_correct = sum(1 for i in cls_indices if y_pred[i] == cls)
|
| 96 |
+
by_class_acc[cls] = cls_correct / len(cls_indices)
|
| 97 |
+
else:
|
| 98 |
+
by_class_acc[cls] = 0.0
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"overall_accuracy": accuracy_score(y_true, y_pred),
|
| 102 |
+
"n_eval": len(results),
|
| 103 |
+
"by_class_accuracy": by_class_acc,
|
| 104 |
+
"precision": {cls: report[cls]["precision"] for cls in CLASSES},
|
| 105 |
+
"recall": {cls: report[cls]["recall"] for cls in CLASSES},
|
| 106 |
+
"f1_score": {cls: report[cls]["f1-score"] for cls in CLASSES},
|
| 107 |
+
"macro_f1": report["macro avg"]["f1-score"],
|
| 108 |
+
"weighted_f1": report["weighted avg"]["f1-score"],
|
| 109 |
+
"confusion_matrix": cm.tolist(),
|
| 110 |
+
"confusion_matrix_labels": CLASSES,
|
| 111 |
+
"class_distribution": {cls: y_true.count(cls) for cls in CLASSES},
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _print_full_report(label: str, metrics: Dict) -> None:
|
| 116 |
+
print(f"\n{'β'*65}")
|
| 117 |
+
print(f" {label}")
|
| 118 |
+
print(f"{'β'*65}")
|
| 119 |
+
print(f" Overall Accuracy : {metrics['overall_accuracy']:.4f}")
|
| 120 |
+
print(f" Macro F1 : {metrics['macro_f1']:.4f}")
|
| 121 |
+
print(f" Weighted F1 : {metrics['weighted_f1']:.4f}")
|
| 122 |
+
print()
|
| 123 |
+
print(f" {'Class':<10} {'Precision':>10} {'Recall':>10} {'F1':>10} {'Support':>10}")
|
| 124 |
+
print(f" {'β'*52}")
|
| 125 |
+
for cls in CLASSES:
|
| 126 |
+
n = metrics["class_distribution"].get(cls, 0)
|
| 127 |
+
print(
|
| 128 |
+
f" {cls:<10} "
|
| 129 |
+
f"{metrics['precision'][cls]:>10.4f} "
|
| 130 |
+
f"{metrics['recall'][cls]:>10.4f} "
|
| 131 |
+
f"{metrics['f1_score'][cls]:>10.4f} "
|
| 132 |
+
f"{n:>10}"
|
| 133 |
+
)
|
| 134 |
+
print()
|
| 135 |
+
print(" Confusion Matrix (rows=Truth, cols=Predicted):")
|
| 136 |
+
print(f" {'':12}" + "".join(f"{cls:>10}" for cls in CLASSES))
|
| 137 |
+
for i, cls in enumerate(CLASSES):
|
| 138 |
+
row = metrics["confusion_matrix"][i]
|
| 139 |
+
print(f" {cls:<12}" + "".join(f"{v:>10}" for v in row))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _sample_eval_episodes(n: int = 100, term: str = "medium") -> List[Dict]:
|
| 143 |
+
import os
|
| 144 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'env'))
|
| 145 |
+
from data_loader import generate_dataset_offline, NSE_UNIVERSE
|
| 146 |
+
|
| 147 |
+
logger.info(f"[Eval] Sampling {n} eval episodes (offline, held-out 2023-2024)...")
|
| 148 |
+
eval_symbols = NSE_UNIVERSE[30:50]
|
| 149 |
+
raw = generate_dataset_offline(
|
| 150 |
+
symbols=eval_symbols,
|
| 151 |
+
start_date="2023-01-01",
|
| 152 |
+
end_date="2024-06-30",
|
| 153 |
+
term=term,
|
| 154 |
+
dates_per_stock=max(1, n // len(eval_symbols) + 2),
|
| 155 |
+
max_total=n,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
episodes = []
|
| 159 |
+
for item in raw:
|
| 160 |
+
ind = item["indicators"]
|
| 161 |
+
ma = ind.get("moving_averages", {})
|
| 162 |
+
rsi = ind.get("rsi", {})
|
| 163 |
+
mac = ind.get("macd", {})
|
| 164 |
+
adx = ind.get("adx", {})
|
| 165 |
+
vlt = ind.get("volatility", {})
|
| 166 |
+
bb = ind.get("bollinger_bands", {})
|
| 167 |
+
vol = ind.get("enhanced_volume", {})
|
| 168 |
+
t = item["term"]
|
| 169 |
+
|
| 170 |
+
prompt = "\n".join([
|
| 171 |
+
f"[TERM: {t}] Stock: {item['symbol']} | Date: {item['date']} | Price: {item['current_price']}",
|
| 172 |
+
f"RSI(14)={rsi.get('rsi_14')} | RSI_Signal={rsi.get('rsi_signal')}",
|
| 173 |
+
f"MACD={mac.get('macd_line')} | Signal={mac.get('signal_line')} | Histogram={mac.get('histogram')}",
|
| 174 |
+
f"ADX={adx.get('adx')} | +DI={adx.get('plus_di')} | -DI={adx.get('minus_di')}",
|
| 175 |
+
f"SMA20={ma.get('sma_20')} | SMA50={ma.get('sma_50')} | EMA20={ma.get('ema_20')}",
|
| 176 |
+
f"BB%={bb.get('percent_b')} | BB_Width={bb.get('bandwidth')} | Squeeze={bb.get('squeeze')}",
|
| 177 |
+
f"ATR={vlt.get('atr_14')} | Regime={vlt.get('volatility_regime')}",
|
| 178 |
+
f"VWAP={vol.get('vwap')} | MFI={vol.get('mfi')} | CMF={vol.get('cmf')}",
|
| 179 |
+
f"Predict the {t}-term price direction. Respond ONLY with valid JSON:",
|
| 180 |
+
'{"direction": "Bullish" | "Bearish" | "Neutral", "conviction": <float 0-1>}',
|
| 181 |
+
])
|
| 182 |
+
episodes.append({"prompt": prompt, "ground_truth": item["ground_truth"]})
|
| 183 |
+
|
| 184 |
+
logger.info(f"[Eval] Sampled {len(episodes)} eval episodes")
|
| 185 |
+
return episodes
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _run_model(model, tokenizer, episodes: List[Dict], label: str) -> List[Dict]:
|
| 189 |
+
results = []
|
| 190 |
+
model.eval()
|
| 191 |
+
for i, ep in enumerate(episodes):
|
| 192 |
+
try:
|
| 193 |
+
inputs = tokenizer(ep["prompt"], return_tensors="pt", truncation=True, max_length=1024).to(model.device)
|
| 194 |
+
with torch.no_grad():
|
| 195 |
+
outputs = model.generate(
|
| 196 |
+
**inputs,
|
| 197 |
+
max_new_tokens=32,
|
| 198 |
+
do_sample=False,
|
| 199 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 200 |
+
)
|
| 201 |
+
completion = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 202 |
+
predicted = _parse_direction(completion)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.warning(f"[{label}] Episode {i} failed: {e}")
|
| 205 |
+
predicted = "Neutral"
|
| 206 |
+
|
| 207 |
+
results.append({"ground_truth": ep["ground_truth"], "predicted": predicted})
|
| 208 |
+
if (i + 1) % 10 == 0:
|
| 209 |
+
logger.info(f"[{label}] {i+1}/{len(episodes)} done")
|
| 210 |
+
return results
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# βββ Plotting Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
|
| 215 |
+
def _load_trainer_state(checkpoint_dir: Path) -> List[Dict[str, Any]]:
|
| 216 |
+
"""Scan checkpoint subdirectories and collect log history."""
|
| 217 |
+
all_logs = []
|
| 218 |
+
seen_steps = set()
|
| 219 |
+
|
| 220 |
+
if not checkpoint_dir.exists():
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
# Look for checkpoint-* folders
|
| 224 |
+
checkpoints = sorted(
|
| 225 |
+
[d for d in checkpoint_dir.iterdir() if d.is_dir() and d.name.startswith("checkpoint-")],
|
| 226 |
+
key=lambda d: int(d.name.split("-")[1]) if "-" in d.name else 0
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
for ckpt in checkpoints:
|
| 230 |
+
state_file = ckpt / "trainer_state.json"
|
| 231 |
+
if not state_file.exists():
|
| 232 |
+
continue
|
| 233 |
+
|
| 234 |
+
with open(state_file) as f:
|
| 235 |
+
state = json.load(f)
|
| 236 |
+
|
| 237 |
+
for entry in state.get("log_history", []):
|
| 238 |
+
step = entry.get("step")
|
| 239 |
+
if step is not None and step not in seen_steps:
|
| 240 |
+
seen_steps.add(step)
|
| 241 |
+
all_logs.append(entry)
|
| 242 |
+
|
| 243 |
+
all_logs.sort(key=lambda x: x.get("step", 0))
|
| 244 |
+
return all_logs
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _extract_series(logs: List[Dict], key: str):
|
| 248 |
+
steps, values = [], []
|
| 249 |
+
for entry in logs:
|
| 250 |
+
if key in entry and "step" in entry:
|
| 251 |
+
steps.append(entry["step"])
|
| 252 |
+
values.append(entry[key])
|
| 253 |
+
return steps, values
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _plot_training_curves(adapter_path: str) -> str:
|
| 257 |
+
"""Generate and save loss/reward charts. Returns path to saved png.
|
| 258 |
+
Skips gracefully for Hugging Face Hub IDs (no local trainer_state.json).
|
| 259 |
+
"""
|
| 260 |
+
# Detect HF Hub IDs (e.g. "bawsi99/indicators-grpo-qwen1.5b") vs local paths
|
| 261 |
+
is_hub_id = "/" in adapter_path and not os.path.isabs(adapter_path) and not os.path.exists(adapter_path)
|
| 262 |
+
if is_hub_id:
|
| 263 |
+
logger.info("[Plot] Skipping charts for remote Hub ID (no local trainer_state.json).")
|
| 264 |
+
return ""
|
| 265 |
+
checkpoint_dir = Path(adapter_path).parent
|
| 266 |
+
logger.info(f"[Plot] Scanning for logs in: {checkpoint_dir}")
|
| 267 |
+
|
| 268 |
+
logs = _load_trainer_state(checkpoint_dir)
|
| 269 |
+
if not logs:
|
| 270 |
+
logger.warning("[Plot] No trainer logs found. Skipping charts.")
|
| 271 |
+
return ""
|
| 272 |
+
|
| 273 |
+
reward_steps, reward_vals = _extract_series(logs, "reward")
|
| 274 |
+
if not reward_vals:
|
| 275 |
+
reward_steps, reward_vals = _extract_series(logs, "rewards/mean")
|
| 276 |
+
loss_steps, loss_vals = _extract_series(logs, "loss")
|
| 277 |
+
|
| 278 |
+
n_plots = sum([bool(reward_vals), bool(loss_vals)])
|
| 279 |
+
if n_plots == 0:
|
| 280 |
+
logger.warning("[Plot] No 'reward' or 'loss' keys found in log history.")
|
| 281 |
+
return ""
|
| 282 |
+
|
| 283 |
+
fig, axes = plt.subplots(1, n_plots, figsize=(8 * n_plots, 5))
|
| 284 |
+
if n_plots == 1: axes = [axes]
|
| 285 |
+
|
| 286 |
+
fig.suptitle("IndicatorsEnv β GRPO Training Curves", fontsize=13, fontweight="bold")
|
| 287 |
+
|
| 288 |
+
ax_idx = 0
|
| 289 |
+
if reward_vals:
|
| 290 |
+
ax = axes[ax_idx]; ax_idx += 1
|
| 291 |
+
ax.plot(reward_steps, reward_vals, color="#4CAF50", alpha=0.3, label="Per-step")
|
| 292 |
+
if len(reward_vals) >= 5:
|
| 293 |
+
window = 10
|
| 294 |
+
rolled = [statistics.mean(reward_vals[max(0, i-window//2):i+window//2+1]) for i in range(len(reward_vals))]
|
| 295 |
+
ax.plot(reward_steps, rolled, color="#1B5E20", linewidth=2, label=f"Smooth (w={window})")
|
| 296 |
+
ax.set_title("Reward Trajectory"); ax.legend(); ax.grid(True, alpha=0.2)
|
| 297 |
+
|
| 298 |
+
if loss_vals:
|
| 299 |
+
ax = axes[ax_idx]
|
| 300 |
+
ax.plot(loss_steps, loss_vals, color="#2196F3", label="Training Loss")
|
| 301 |
+
ax.set_title("Optimization Loss"); ax.legend(); ax.grid(True, alpha=0.2)
|
| 302 |
+
|
| 303 |
+
plt.tight_layout()
|
| 304 |
+
out_path = Path(adapter_path) / "training_curves.png"
|
| 305 |
+
plt.savefig(out_path, dpi=150)
|
| 306 |
+
logger.info(f"[Plot] Charts saved to {out_path}")
|
| 307 |
+
return str(out_path)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 311 |
+
|
| 312 |
+
def evaluate(args: argparse.Namespace) -> None:
|
| 313 |
+
from peft import PeftModel
|
| 314 |
+
from transformers import BitsAndBytesConfig
|
| 315 |
+
|
| 316 |
+
episodes = _sample_eval_episodes(n=args.n_eval, term=args.term)
|
| 317 |
+
if not episodes:
|
| 318 |
+
logger.error("[Eval] No eval episodes. Check data_loader.")
|
| 319 |
+
sys.exit(1)
|
| 320 |
+
|
| 321 |
+
bnb = BitsAndBytesConfig(
|
| 322 |
+
load_in_4bit=True, bnb_4bit_quant_type="nf4",
|
| 323 |
+
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# ββ Resolve the correct base model by probing actual weight shapes ββββββββ
|
| 327 |
+
# adapter_config.json can be incorrect (e.g., checkpoint-160 has 7B weights
|
| 328 |
+
# but the config says 1.5B). Safetensors never lies β read q_proj.lora_A to
|
| 329 |
+
# infer the real hidden dimension and select the matching base model.
|
| 330 |
+
is_hub_id = "/" in args.adapter_path and not os.path.isabs(args.adapter_path) and not os.path.exists(args.adapter_path)
|
| 331 |
+
resolved_model_id = MODEL_ID # Default
|
| 332 |
+
|
| 333 |
+
def _detect_model_from_weights(adapter_path: str) -> str:
|
| 334 |
+
"""Read one lora_A tensor to infer hidden size -> base model."""
|
| 335 |
+
try:
|
| 336 |
+
from safetensors import safe_open
|
| 337 |
+
weights_file = Path(adapter_path) / "adapter_model.safetensors"
|
| 338 |
+
if not weights_file.exists():
|
| 339 |
+
return None
|
| 340 |
+
with safe_open(str(weights_file), framework="pt", device="cpu") as f:
|
| 341 |
+
for key in f.keys():
|
| 342 |
+
if "lora_A" in key and "q_proj" in key:
|
| 343 |
+
input_dim = f.get_tensor(key).shape[-1]
|
| 344 |
+
if input_dim >= 3500: # Qwen2.5-7B hidden_size=3584
|
| 345 |
+
return "Qwen/Qwen2.5-7B-Instruct"
|
| 346 |
+
elif input_dim >= 2000: # Qwen2.5-3B hidden_size=2048
|
| 347 |
+
return "Qwen/Qwen2.5-3B-Instruct"
|
| 348 |
+
else: # Qwen2.5-1.5B hidden_size=1536
|
| 349 |
+
return "Qwen/Qwen2.5-1.5B-Instruct"
|
| 350 |
+
except Exception as e:
|
| 351 |
+
logger.warning(f"[Eval] Tensor probe failed: {e}")
|
| 352 |
+
return None
|
| 353 |
+
|
| 354 |
+
if not is_hub_id:
|
| 355 |
+
detected = _detect_model_from_weights(args.adapter_path)
|
| 356 |
+
if detected:
|
| 357 |
+
resolved_model_id = detected
|
| 358 |
+
logger.info(f"[Eval] π Auto-detected base model from weights: {resolved_model_id}")
|
| 359 |
+
else:
|
| 360 |
+
# Fallback: read adapter_config.json
|
| 361 |
+
try:
|
| 362 |
+
cfg_path = Path(args.adapter_path) / "adapter_config.json"
|
| 363 |
+
with open(cfg_path) as f:
|
| 364 |
+
adapter_cfg = json.load(f)
|
| 365 |
+
resolved_model_id = adapter_cfg.get("base_model_name_or_path", MODEL_ID)
|
| 366 |
+
logger.info(f"[Eval] π Base model from adapter_config.json: {resolved_model_id}")
|
| 367 |
+
except Exception as e:
|
| 368 |
+
logger.warning(f"[Eval] Could not detect base model, using default {MODEL_ID}: {e}")
|
| 369 |
+
else:
|
| 370 |
+
# HF Hub ID: fetch adapter_config.json from the Hub
|
| 371 |
+
try:
|
| 372 |
+
import urllib.request
|
| 373 |
+
cfg_url = f"https://huggingface.co/{args.adapter_path}/raw/main/adapter_config.json"
|
| 374 |
+
with urllib.request.urlopen(cfg_url) as resp:
|
| 375 |
+
adapter_cfg = json.loads(resp.read())
|
| 376 |
+
resolved_model_id = adapter_cfg.get("base_model_name_or_path", MODEL_ID)
|
| 377 |
+
logger.info(f"[Eval] βοΈ Base model from HF Hub adapter_config: {resolved_model_id}")
|
| 378 |
+
except Exception as e:
|
| 379 |
+
logger.warning(f"[Eval] Could not fetch HF Hub adapter_config: {e}. Using {MODEL_ID}")
|
| 380 |
+
|
| 381 |
+
if resolved_model_id != MODEL_ID:
|
| 382 |
+
logger.warning(f"[Eval] β οΈ Using {resolved_model_id} (not default {MODEL_ID})")
|
| 383 |
+
|
| 384 |
+
tokenizer = AutoTokenizer.from_pretrained(resolved_model_id, trust_remote_code=True)
|
| 385 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 386 |
+
|
| 387 |
+
# ββ Baseline: Zero-shot βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 388 |
+
baseline_metrics = None
|
| 389 |
+
if not args.skip_baseline:
|
| 390 |
+
logger.info(f"[Eval] Loading baseline model (zero-shot): {resolved_model_id}...")
|
| 391 |
+
dtype = torch.float16 if args.low_memory else torch.bfloat16
|
| 392 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 393 |
+
resolved_model_id,
|
| 394 |
+
quantization_config=bnb,
|
| 395 |
+
device_map="auto",
|
| 396 |
+
trust_remote_code=True,
|
| 397 |
+
torch_dtype=dtype,
|
| 398 |
+
low_cpu_mem_usage=args.low_memory,
|
| 399 |
+
)
|
| 400 |
+
baseline_results = _run_model(base_model, tokenizer, episodes, "Baseline")
|
| 401 |
+
baseline_metrics = _full_metrics(baseline_results)
|
| 402 |
+
else:
|
| 403 |
+
logger.info("[Eval] Skipping baseline computation (using --skip_baseline)")
|
| 404 |
+
|
| 405 |
+
# ββ Fine-tuned: GRPO + QLoRA βββββββββββββββββββββββββββββββββββββββββββββ
|
| 406 |
+
logger.info(f"[Eval] Loading fine-tuned adapter from {args.adapter_path}...")
|
| 407 |
+
if args.skip_baseline:
|
| 408 |
+
# We need to load the base model first since baseline was skipped
|
| 409 |
+
dtype = torch.float16 if args.low_memory else torch.bfloat16
|
| 410 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 411 |
+
resolved_model_id,
|
| 412 |
+
quantization_config=bnb,
|
| 413 |
+
device_map="auto",
|
| 414 |
+
trust_remote_code=True,
|
| 415 |
+
torch_dtype=dtype,
|
| 416 |
+
low_cpu_mem_usage=args.low_memory,
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
# Load adapter onto base model
|
| 420 |
+
ft_model = PeftModel.from_pretrained(base_model, args.adapter_path)
|
| 421 |
+
|
| 422 |
+
ft_results = _run_model(ft_model, tokenizer, episodes, "FineTuned(GRPO)")
|
| 423 |
+
ft_metrics = _full_metrics(ft_results)
|
| 424 |
+
|
| 425 |
+
# ββ Full Report βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 426 |
+
print("\n" + "=" * 65)
|
| 427 |
+
print(f"EVALUATION RESULTS β IndicatorsEnv ({args.term} term) | N={args.n_eval}")
|
| 428 |
+
print("=" * 65)
|
| 429 |
+
|
| 430 |
+
if baseline_metrics:
|
| 431 |
+
_print_full_report("Zero-shot Qwen2.5-7B (Baseline)", baseline_metrics)
|
| 432 |
+
|
| 433 |
+
_print_full_report("GRPO Fine-tuned Qwen2.5-7B (Ours)", ft_metrics)
|
| 434 |
+
|
| 435 |
+
if baseline_metrics:
|
| 436 |
+
delta_acc = ft_metrics["overall_accuracy"] - baseline_metrics["overall_accuracy"]
|
| 437 |
+
delta_f1 = ft_metrics["macro_f1"] - baseline_metrics["macro_f1"]
|
| 438 |
+
|
| 439 |
+
print(f"\n{'='*65}")
|
| 440 |
+
print(f" Ξ Overall Accuracy : {delta_acc:+.4f} ({'β
improved' if delta_acc > 0 else 'β degraded'})")
|
| 441 |
+
print(f" Ξ Macro F1 : {delta_f1:+.4f} ({'β
improved' if delta_f1 > 0 else 'β degraded'})")
|
| 442 |
+
print(f" Bearish Recall Ξ : {ft_metrics['recall']['Bearish'] - baseline_metrics['recall']['Bearish']:+.4f}")
|
| 443 |
+
print(f" Neutral Recall Ξ : {ft_metrics['recall']['Neutral'] - baseline_metrics['recall']['Neutral']:+.4f}")
|
| 444 |
+
print(f"{'='*65}\n")
|
| 445 |
+
else:
|
| 446 |
+
delta_acc = 0; delta_f1 = 0
|
| 447 |
+
|
| 448 |
+
# ββ Save Results βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 449 |
+
out = {
|
| 450 |
+
"model_id": MODEL_ID,
|
| 451 |
+
"adapter_path": str(args.adapter_path),
|
| 452 |
+
"n_eval": args.n_eval,
|
| 453 |
+
"term": args.term,
|
| 454 |
+
"baseline_zeroshot": baseline_metrics,
|
| 455 |
+
"finetuned_grpo": ft_metrics,
|
| 456 |
+
"delta_accuracy": delta_acc,
|
| 457 |
+
"delta_macro_f1": delta_f1,
|
| 458 |
+
}
|
| 459 |
+
out_path = Path(args.adapter_path) / "eval_results_full.json"
|
| 460 |
+
# For Hugging Face Hub IDs, save to a local folder named after the repo slug
|
| 461 |
+
is_hub_id = "/" in args.adapter_path and not os.path.isabs(args.adapter_path) and not os.path.exists(args.adapter_path)
|
| 462 |
+
if is_hub_id:
|
| 463 |
+
local_slug = args.adapter_path.split("/")[-1]
|
| 464 |
+
out_dir = Path(local_slug)
|
| 465 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 466 |
+
out_path = out_dir / "eval_results_full.json"
|
| 467 |
+
else:
|
| 468 |
+
out_path = Path(args.adapter_path) / "eval_results_full.json"
|
| 469 |
+
out_path.write_text(json.dumps(out, indent=2))
|
| 470 |
+
logger.info(f"[Eval] Full results saved to {out_path}")
|
| 471 |
+
|
| 472 |
+
# ββ 6. Generate Training Charts ββββββββββββββββββββββββββββββββββββββββββ
|
| 473 |
+
_plot_training_curves(args.adapter_path)
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
if __name__ == "__main__":
|
| 477 |
+
parser = argparse.ArgumentParser(description="Full evaluation: fine-tuned vs zero-shot baseline")
|
| 478 |
+
parser.add_argument("--model_id", default="Qwen/Qwen2.5-7B-Instruct", help="Base model ID")
|
| 479 |
+
parser.add_argument("--adapter_path", required=True, help="Path to saved LoRA adapter")
|
| 480 |
+
parser.add_argument("--n_eval", type=int, default=100, help="Number of evaluation episodes")
|
| 481 |
+
parser.add_argument("--term", default="medium", help="Prediction term: short|medium|long")
|
| 482 |
+
parser.add_argument("--skip_baseline", action="store_true", help="Skip the baseline zero-shot evaluation")
|
| 483 |
+
parser.add_argument("--low_memory", action="store_true", help="Optimize for low VRAM (float16 + low_cpu_mem_usage)")
|
| 484 |
+
args = parser.parse_args()
|
| 485 |
+
|
| 486 |
+
# Run evaluation with the specified model
|
| 487 |
+
evaluate(args)
|
evaluation/plot_learning_curve.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
plot_learning_curve.py β Visualize GRPO training progress from HF Trainer checkpoints.
|
| 3 |
+
|
| 4 |
+
The HF Trainer automatically saves `trainer_state.json` inside every checkpoint folder.
|
| 5 |
+
This script reads all those files, extracts per-step metrics, and plots a learning curve.
|
| 6 |
+
|
| 7 |
+
Usage (run in Colab after training):
|
| 8 |
+
python evaluation/plot_learning_curve.py \\
|
| 9 |
+
--checkpoint_dir "/content/drive/MyDrive/indicators_grpo_v2"
|
| 10 |
+
|
| 11 |
+
Output:
|
| 12 |
+
- learning_curve.png saved to the checkpoint_dir
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import List, Dict, Any
|
| 22 |
+
|
| 23 |
+
import matplotlib
|
| 24 |
+
matplotlib.use("Agg") # Headless (no display needed in Colab)
|
| 25 |
+
import matplotlib.pyplot as plt
|
| 26 |
+
import matplotlib.ticker as ticker
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _load_trainer_state(checkpoint_dir: str) -> List[Dict[str, Any]]:
|
| 30 |
+
"""
|
| 31 |
+
Scan all checkpoint-* subdirectories and collect log history
|
| 32 |
+
from trainer_state.json.
|
| 33 |
+
"""
|
| 34 |
+
base = Path(checkpoint_dir)
|
| 35 |
+
all_logs = []
|
| 36 |
+
seen_steps = set()
|
| 37 |
+
|
| 38 |
+
checkpoints = sorted(
|
| 39 |
+
[d for d in base.iterdir() if d.is_dir() and d.name.startswith("checkpoint-")],
|
| 40 |
+
key=lambda d: int(d.name.split("-")[1])
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
for ckpt in checkpoints:
|
| 44 |
+
state_file = ckpt / "trainer_state.json"
|
| 45 |
+
if not state_file.exists():
|
| 46 |
+
print(f" β οΈ No trainer_state.json in {ckpt.name}")
|
| 47 |
+
continue
|
| 48 |
+
|
| 49 |
+
with open(state_file) as f:
|
| 50 |
+
state = json.load(f)
|
| 51 |
+
|
| 52 |
+
for entry in state.get("log_history", []):
|
| 53 |
+
step = entry.get("step")
|
| 54 |
+
if step is not None and step not in seen_steps:
|
| 55 |
+
seen_steps.add(step)
|
| 56 |
+
all_logs.append(entry)
|
| 57 |
+
|
| 58 |
+
all_logs.sort(key=lambda x: x.get("step", 0))
|
| 59 |
+
print(f" β
Loaded {len(all_logs)} log entries from {len(checkpoints)} checkpoints")
|
| 60 |
+
return all_logs
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _extract_series(logs: List[Dict], key: str):
|
| 64 |
+
"""Extract (steps, values) for a given metric key."""
|
| 65 |
+
steps, values = [], []
|
| 66 |
+
for entry in logs:
|
| 67 |
+
if key in entry and "step" in entry:
|
| 68 |
+
steps.append(entry["step"])
|
| 69 |
+
values.append(entry[key])
|
| 70 |
+
return steps, values
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def plot_learning_curve(checkpoint_dir: str) -> None:
|
| 74 |
+
print(f"[Plot] Reading trainer logs from: {checkpoint_dir}")
|
| 75 |
+
logs = _load_trainer_state(checkpoint_dir)
|
| 76 |
+
|
| 77 |
+
if not logs:
|
| 78 |
+
print("β No log entries found. Make sure trainer_state.json exists in checkpoint folders.")
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
# Extract all available metrics
|
| 82 |
+
reward_steps, reward_vals = _extract_series(logs, "reward")
|
| 83 |
+
loss_steps, loss_vals = _extract_series(logs, "loss")
|
| 84 |
+
train_loss_steps, train_loss_vals = _extract_series(logs, "train_loss")
|
| 85 |
+
|
| 86 |
+
# Fallback: use train_loss if loss not found
|
| 87 |
+
if not loss_vals and train_loss_vals:
|
| 88 |
+
loss_steps, loss_vals = train_loss_steps, train_loss_vals
|
| 89 |
+
|
| 90 |
+
# ββ Build Plot ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 91 |
+
n_plots = sum([bool(reward_vals), bool(loss_vals)])
|
| 92 |
+
if n_plots == 0:
|
| 93 |
+
print("β No 'reward' or 'loss' keys found in log history.")
|
| 94 |
+
print(" Available keys:", list(set(k for e in logs for k in e.keys())))
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
fig, axes = plt.subplots(1, n_plots, figsize=(8 * n_plots, 5))
|
| 98 |
+
if n_plots == 1:
|
| 99 |
+
axes = [axes]
|
| 100 |
+
|
| 101 |
+
fig.suptitle(
|
| 102 |
+
"IndicatorsEnv β GRPO Training Learning Curve\n"
|
| 103 |
+
"Qwen2.5-1.5B-Instruct + QLoRA | Anti-Bias Reward",
|
| 104 |
+
fontsize=13, fontweight="bold"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
ax_idx = 0
|
| 108 |
+
|
| 109 |
+
if reward_vals:
|
| 110 |
+
ax = axes[ax_idx]; ax_idx += 1
|
| 111 |
+
ax.plot(reward_steps, reward_vals, color="#4CAF50", linewidth=2, alpha=0.8, label="Per-step Reward")
|
| 112 |
+
# Rolling average
|
| 113 |
+
if len(reward_vals) >= 10:
|
| 114 |
+
import statistics
|
| 115 |
+
window = 10
|
| 116 |
+
rolled = [
|
| 117 |
+
statistics.mean(reward_vals[max(0, i-window//2):i+window//2+1])
|
| 118 |
+
for i in range(len(reward_vals))
|
| 119 |
+
]
|
| 120 |
+
ax.plot(reward_steps, rolled, color="#1B5E20", linewidth=2.5, linestyle="--", label="Rolling Avg (10)")
|
| 121 |
+
ax.set_xlabel("Training Step", fontsize=11)
|
| 122 |
+
ax.set_ylabel("Reward", fontsize=11)
|
| 123 |
+
ax.set_title("Reward per Step", fontsize=12)
|
| 124 |
+
ax.legend(); ax.grid(True, alpha=0.3)
|
| 125 |
+
ax.axhline(y=0, color="red", linestyle=":", alpha=0.5, label="Break-even")
|
| 126 |
+
|
| 127 |
+
if loss_vals:
|
| 128 |
+
ax = axes[ax_idx]; ax_idx += 1
|
| 129 |
+
ax.plot(loss_steps, loss_vals, color="#2196F3", linewidth=2, alpha=0.8, label="Training Loss")
|
| 130 |
+
ax.set_xlabel("Training Step", fontsize=11)
|
| 131 |
+
ax.set_ylabel("Loss", fontsize=11)
|
| 132 |
+
ax.set_title("Training Loss", fontsize=12)
|
| 133 |
+
ax.legend(); ax.grid(True, alpha=0.3)
|
| 134 |
+
|
| 135 |
+
plt.tight_layout()
|
| 136 |
+
out_path = Path(checkpoint_dir) / "learning_curve.png"
|
| 137 |
+
plt.savefig(out_path, dpi=150, bbox_inches="tight")
|
| 138 |
+
print(f"\nβ
Learning curve saved to: {out_path}")
|
| 139 |
+
print(" Add this image to your mentor report as Section 4b.")
|
| 140 |
+
|
| 141 |
+
# Also print raw numbers
|
| 142 |
+
if reward_vals:
|
| 143 |
+
mid = len(reward_vals) // 2
|
| 144 |
+
print(f"\n First 10 steps avg reward: {sum(reward_vals[:10])/10:.4f}")
|
| 145 |
+
print(f" Mid 10 steps avg reward: {sum(reward_vals[mid:mid+10])/10:.4f}")
|
| 146 |
+
print(f" Last 10 steps avg reward: {sum(reward_vals[-10:])/10:.4f}")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
parser = argparse.ArgumentParser(description="Plot GRPO training learning curve from HF checkpoints")
|
| 151 |
+
parser.add_argument(
|
| 152 |
+
"--checkpoint_dir",
|
| 153 |
+
default="/content/drive/MyDrive/indicators_grpo_v2",
|
| 154 |
+
help="Path to the output_dir used during training"
|
| 155 |
+
)
|
| 156 |
+
args = parser.parse_args()
|
| 157 |
+
plot_learning_curve(args.checkpoint_dir)
|
final_eval_metrics.json
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"160": {
|
| 3 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 4 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-160",
|
| 5 |
+
"n_eval": 50,
|
| 6 |
+
"term": "medium",
|
| 7 |
+
"baseline_zeroshot": {
|
| 8 |
+
"overall_accuracy": 0.32,
|
| 9 |
+
"n_eval": 50,
|
| 10 |
+
"by_class_accuracy": {
|
| 11 |
+
"Bullish": 0.36,
|
| 12 |
+
"Bearish": 0.0,
|
| 13 |
+
"Neutral": 0.7
|
| 14 |
+
},
|
| 15 |
+
"precision": {
|
| 16 |
+
"Bullish": 0.5625,
|
| 17 |
+
"Bearish": 0.0,
|
| 18 |
+
"Neutral": 0.21875
|
| 19 |
+
},
|
| 20 |
+
"recall": {
|
| 21 |
+
"Bullish": 0.36,
|
| 22 |
+
"Bearish": 0.0,
|
| 23 |
+
"Neutral": 0.7
|
| 24 |
+
},
|
| 25 |
+
"f1_score": {
|
| 26 |
+
"Bullish": 0.43902439024390244,
|
| 27 |
+
"Bearish": 0.0,
|
| 28 |
+
"Neutral": 0.3333333333333333
|
| 29 |
+
},
|
| 30 |
+
"macro_f1": 0.25745257452574527,
|
| 31 |
+
"weighted_f1": 0.2861788617886179,
|
| 32 |
+
"confusion_matrix": [
|
| 33 |
+
[
|
| 34 |
+
9,
|
| 35 |
+
2,
|
| 36 |
+
14
|
| 37 |
+
],
|
| 38 |
+
[
|
| 39 |
+
4,
|
| 40 |
+
0,
|
| 41 |
+
11
|
| 42 |
+
],
|
| 43 |
+
[
|
| 44 |
+
3,
|
| 45 |
+
0,
|
| 46 |
+
7
|
| 47 |
+
]
|
| 48 |
+
],
|
| 49 |
+
"confusion_matrix_labels": [
|
| 50 |
+
"Bullish",
|
| 51 |
+
"Bearish",
|
| 52 |
+
"Neutral"
|
| 53 |
+
],
|
| 54 |
+
"class_distribution": {
|
| 55 |
+
"Bullish": 25,
|
| 56 |
+
"Bearish": 15,
|
| 57 |
+
"Neutral": 10
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"finetuned_grpo": {
|
| 61 |
+
"overall_accuracy": 0.24,
|
| 62 |
+
"n_eval": 50,
|
| 63 |
+
"by_class_accuracy": {
|
| 64 |
+
"Bullish": 0.44,
|
| 65 |
+
"Bearish": 0.06666666666666667,
|
| 66 |
+
"Neutral": 0.0
|
| 67 |
+
},
|
| 68 |
+
"precision": {
|
| 69 |
+
"Bullish": 0.36666666666666664,
|
| 70 |
+
"Bearish": 0.05263157894736842,
|
| 71 |
+
"Neutral": 0.0
|
| 72 |
+
},
|
| 73 |
+
"recall": {
|
| 74 |
+
"Bullish": 0.44,
|
| 75 |
+
"Bearish": 0.06666666666666667,
|
| 76 |
+
"Neutral": 0.0
|
| 77 |
+
},
|
| 78 |
+
"f1_score": {
|
| 79 |
+
"Bullish": 0.4,
|
| 80 |
+
"Bearish": 0.058823529411764705,
|
| 81 |
+
"Neutral": 0.0
|
| 82 |
+
},
|
| 83 |
+
"macro_f1": 0.15294117647058825,
|
| 84 |
+
"weighted_f1": 0.21764705882352942,
|
| 85 |
+
"confusion_matrix": [
|
| 86 |
+
[
|
| 87 |
+
11,
|
| 88 |
+
14,
|
| 89 |
+
0
|
| 90 |
+
],
|
| 91 |
+
[
|
| 92 |
+
13,
|
| 93 |
+
1,
|
| 94 |
+
1
|
| 95 |
+
],
|
| 96 |
+
[
|
| 97 |
+
6,
|
| 98 |
+
4,
|
| 99 |
+
0
|
| 100 |
+
]
|
| 101 |
+
],
|
| 102 |
+
"confusion_matrix_labels": [
|
| 103 |
+
"Bullish",
|
| 104 |
+
"Bearish",
|
| 105 |
+
"Neutral"
|
| 106 |
+
],
|
| 107 |
+
"class_distribution": {
|
| 108 |
+
"Bullish": 25,
|
| 109 |
+
"Bearish": 15,
|
| 110 |
+
"Neutral": 10
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
"delta_accuracy": -0.08000000000000002,
|
| 114 |
+
"delta_macro_f1": -0.10451139805515702
|
| 115 |
+
},
|
| 116 |
+
"180": {
|
| 117 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 118 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-180",
|
| 119 |
+
"n_eval": 50,
|
| 120 |
+
"term": "medium",
|
| 121 |
+
"baseline_zeroshot": null,
|
| 122 |
+
"finetuned_grpo": {
|
| 123 |
+
"overall_accuracy": 0.26,
|
| 124 |
+
"n_eval": 50,
|
| 125 |
+
"by_class_accuracy": {
|
| 126 |
+
"Bullish": 0.44,
|
| 127 |
+
"Bearish": 0.13333333333333333,
|
| 128 |
+
"Neutral": 0.0
|
| 129 |
+
},
|
| 130 |
+
"precision": {
|
| 131 |
+
"Bullish": 0.36666666666666664,
|
| 132 |
+
"Bearish": 0.1,
|
| 133 |
+
"Neutral": 0.0
|
| 134 |
+
},
|
| 135 |
+
"recall": {
|
| 136 |
+
"Bullish": 0.44,
|
| 137 |
+
"Bearish": 0.13333333333333333,
|
| 138 |
+
"Neutral": 0.0
|
| 139 |
+
},
|
| 140 |
+
"f1_score": {
|
| 141 |
+
"Bullish": 0.4,
|
| 142 |
+
"Bearish": 0.11428571428571428,
|
| 143 |
+
"Neutral": 0.0
|
| 144 |
+
},
|
| 145 |
+
"macro_f1": 0.17142857142857146,
|
| 146 |
+
"weighted_f1": 0.23428571428571426,
|
| 147 |
+
"confusion_matrix": [
|
| 148 |
+
[
|
| 149 |
+
11,
|
| 150 |
+
14,
|
| 151 |
+
0
|
| 152 |
+
],
|
| 153 |
+
[
|
| 154 |
+
13,
|
| 155 |
+
2,
|
| 156 |
+
0
|
| 157 |
+
],
|
| 158 |
+
[
|
| 159 |
+
6,
|
| 160 |
+
4,
|
| 161 |
+
0
|
| 162 |
+
]
|
| 163 |
+
],
|
| 164 |
+
"confusion_matrix_labels": [
|
| 165 |
+
"Bullish",
|
| 166 |
+
"Bearish",
|
| 167 |
+
"Neutral"
|
| 168 |
+
],
|
| 169 |
+
"class_distribution": {
|
| 170 |
+
"Bullish": 25,
|
| 171 |
+
"Bearish": 15,
|
| 172 |
+
"Neutral": 10
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
"delta_accuracy": 0,
|
| 176 |
+
"delta_macro_f1": 0
|
| 177 |
+
},
|
| 178 |
+
"200": {
|
| 179 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 180 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-200",
|
| 181 |
+
"n_eval": 50,
|
| 182 |
+
"term": "medium",
|
| 183 |
+
"baseline_zeroshot": null,
|
| 184 |
+
"finetuned_grpo": {
|
| 185 |
+
"overall_accuracy": 0.26,
|
| 186 |
+
"n_eval": 50,
|
| 187 |
+
"by_class_accuracy": {
|
| 188 |
+
"Bullish": 0.44,
|
| 189 |
+
"Bearish": 0.13333333333333333,
|
| 190 |
+
"Neutral": 0.0
|
| 191 |
+
},
|
| 192 |
+
"precision": {
|
| 193 |
+
"Bullish": 0.36666666666666664,
|
| 194 |
+
"Bearish": 0.1,
|
| 195 |
+
"Neutral": 0.0
|
| 196 |
+
},
|
| 197 |
+
"recall": {
|
| 198 |
+
"Bullish": 0.44,
|
| 199 |
+
"Bearish": 0.13333333333333333,
|
| 200 |
+
"Neutral": 0.0
|
| 201 |
+
},
|
| 202 |
+
"f1_score": {
|
| 203 |
+
"Bullish": 0.4,
|
| 204 |
+
"Bearish": 0.11428571428571428,
|
| 205 |
+
"Neutral": 0.0
|
| 206 |
+
},
|
| 207 |
+
"macro_f1": 0.17142857142857146,
|
| 208 |
+
"weighted_f1": 0.23428571428571426,
|
| 209 |
+
"confusion_matrix": [
|
| 210 |
+
[
|
| 211 |
+
11,
|
| 212 |
+
14,
|
| 213 |
+
0
|
| 214 |
+
],
|
| 215 |
+
[
|
| 216 |
+
13,
|
| 217 |
+
2,
|
| 218 |
+
0
|
| 219 |
+
],
|
| 220 |
+
[
|
| 221 |
+
6,
|
| 222 |
+
4,
|
| 223 |
+
0
|
| 224 |
+
]
|
| 225 |
+
],
|
| 226 |
+
"confusion_matrix_labels": [
|
| 227 |
+
"Bullish",
|
| 228 |
+
"Bearish",
|
| 229 |
+
"Neutral"
|
| 230 |
+
],
|
| 231 |
+
"class_distribution": {
|
| 232 |
+
"Bullish": 25,
|
| 233 |
+
"Bearish": 15,
|
| 234 |
+
"Neutral": 10
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"delta_accuracy": 0,
|
| 238 |
+
"delta_macro_f1": 0
|
| 239 |
+
},
|
| 240 |
+
"220": {
|
| 241 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 242 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-220",
|
| 243 |
+
"n_eval": 50,
|
| 244 |
+
"term": "medium",
|
| 245 |
+
"baseline_zeroshot": null,
|
| 246 |
+
"finetuned_grpo": {
|
| 247 |
+
"overall_accuracy": 0.32,
|
| 248 |
+
"n_eval": 50,
|
| 249 |
+
"by_class_accuracy": {
|
| 250 |
+
"Bullish": 0.52,
|
| 251 |
+
"Bearish": 0.2,
|
| 252 |
+
"Neutral": 0.0
|
| 253 |
+
},
|
| 254 |
+
"precision": {
|
| 255 |
+
"Bullish": 0.41935483870967744,
|
| 256 |
+
"Bearish": 0.15789473684210525,
|
| 257 |
+
"Neutral": 0.0
|
| 258 |
+
},
|
| 259 |
+
"recall": {
|
| 260 |
+
"Bullish": 0.52,
|
| 261 |
+
"Bearish": 0.2,
|
| 262 |
+
"Neutral": 0.0
|
| 263 |
+
},
|
| 264 |
+
"f1_score": {
|
| 265 |
+
"Bullish": 0.4642857142857143,
|
| 266 |
+
"Bearish": 0.17647058823529413,
|
| 267 |
+
"Neutral": 0.0
|
| 268 |
+
},
|
| 269 |
+
"macro_f1": 0.2135854341736695,
|
| 270 |
+
"weighted_f1": 0.2850840336134454,
|
| 271 |
+
"confusion_matrix": [
|
| 272 |
+
[
|
| 273 |
+
13,
|
| 274 |
+
12,
|
| 275 |
+
0
|
| 276 |
+
],
|
| 277 |
+
[
|
| 278 |
+
12,
|
| 279 |
+
3,
|
| 280 |
+
0
|
| 281 |
+
],
|
| 282 |
+
[
|
| 283 |
+
6,
|
| 284 |
+
4,
|
| 285 |
+
0
|
| 286 |
+
]
|
| 287 |
+
],
|
| 288 |
+
"confusion_matrix_labels": [
|
| 289 |
+
"Bullish",
|
| 290 |
+
"Bearish",
|
| 291 |
+
"Neutral"
|
| 292 |
+
],
|
| 293 |
+
"class_distribution": {
|
| 294 |
+
"Bullish": 25,
|
| 295 |
+
"Bearish": 15,
|
| 296 |
+
"Neutral": 10
|
| 297 |
+
}
|
| 298 |
+
},
|
| 299 |
+
"delta_accuracy": 0,
|
| 300 |
+
"delta_macro_f1": 0
|
| 301 |
+
},
|
| 302 |
+
"240": {
|
| 303 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 304 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-240",
|
| 305 |
+
"n_eval": 50,
|
| 306 |
+
"term": "medium",
|
| 307 |
+
"baseline_zeroshot": null,
|
| 308 |
+
"finetuned_grpo": {
|
| 309 |
+
"overall_accuracy": 0.32,
|
| 310 |
+
"n_eval": 50,
|
| 311 |
+
"by_class_accuracy": {
|
| 312 |
+
"Bullish": 0.52,
|
| 313 |
+
"Bearish": 0.2,
|
| 314 |
+
"Neutral": 0.0
|
| 315 |
+
},
|
| 316 |
+
"precision": {
|
| 317 |
+
"Bullish": 0.41935483870967744,
|
| 318 |
+
"Bearish": 0.15789473684210525,
|
| 319 |
+
"Neutral": 0.0
|
| 320 |
+
},
|
| 321 |
+
"recall": {
|
| 322 |
+
"Bullish": 0.52,
|
| 323 |
+
"Bearish": 0.2,
|
| 324 |
+
"Neutral": 0.0
|
| 325 |
+
},
|
| 326 |
+
"f1_score": {
|
| 327 |
+
"Bullish": 0.4642857142857143,
|
| 328 |
+
"Bearish": 0.17647058823529413,
|
| 329 |
+
"Neutral": 0.0
|
| 330 |
+
},
|
| 331 |
+
"macro_f1": 0.2135854341736695,
|
| 332 |
+
"weighted_f1": 0.2850840336134454,
|
| 333 |
+
"confusion_matrix": [
|
| 334 |
+
[
|
| 335 |
+
13,
|
| 336 |
+
12,
|
| 337 |
+
0
|
| 338 |
+
],
|
| 339 |
+
[
|
| 340 |
+
12,
|
| 341 |
+
3,
|
| 342 |
+
0
|
| 343 |
+
],
|
| 344 |
+
[
|
| 345 |
+
6,
|
| 346 |
+
4,
|
| 347 |
+
0
|
| 348 |
+
]
|
| 349 |
+
],
|
| 350 |
+
"confusion_matrix_labels": [
|
| 351 |
+
"Bullish",
|
| 352 |
+
"Bearish",
|
| 353 |
+
"Neutral"
|
| 354 |
+
],
|
| 355 |
+
"class_distribution": {
|
| 356 |
+
"Bullish": 25,
|
| 357 |
+
"Bearish": 15,
|
| 358 |
+
"Neutral": 10
|
| 359 |
+
}
|
| 360 |
+
},
|
| 361 |
+
"delta_accuracy": 0,
|
| 362 |
+
"delta_macro_f1": 0
|
| 363 |
+
},
|
| 364 |
+
"260": {
|
| 365 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 366 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-260",
|
| 367 |
+
"n_eval": 50,
|
| 368 |
+
"term": "medium",
|
| 369 |
+
"baseline_zeroshot": null,
|
| 370 |
+
"finetuned_grpo": {
|
| 371 |
+
"overall_accuracy": 0.3,
|
| 372 |
+
"n_eval": 50,
|
| 373 |
+
"by_class_accuracy": {
|
| 374 |
+
"Bullish": 0.52,
|
| 375 |
+
"Bearish": 0.13333333333333333,
|
| 376 |
+
"Neutral": 0.0
|
| 377 |
+
},
|
| 378 |
+
"precision": {
|
| 379 |
+
"Bullish": 0.40625,
|
| 380 |
+
"Bearish": 0.1111111111111111,
|
| 381 |
+
"Neutral": 0.0
|
| 382 |
+
},
|
| 383 |
+
"recall": {
|
| 384 |
+
"Bullish": 0.52,
|
| 385 |
+
"Bearish": 0.13333333333333333,
|
| 386 |
+
"Neutral": 0.0
|
| 387 |
+
},
|
| 388 |
+
"f1_score": {
|
| 389 |
+
"Bullish": 0.45614035087719296,
|
| 390 |
+
"Bearish": 0.12121212121212122,
|
| 391 |
+
"Neutral": 0.0
|
| 392 |
+
},
|
| 393 |
+
"macro_f1": 0.1924508240297714,
|
| 394 |
+
"weighted_f1": 0.26443381180223285,
|
| 395 |
+
"confusion_matrix": [
|
| 396 |
+
[
|
| 397 |
+
13,
|
| 398 |
+
12,
|
| 399 |
+
0
|
| 400 |
+
],
|
| 401 |
+
[
|
| 402 |
+
13,
|
| 403 |
+
2,
|
| 404 |
+
0
|
| 405 |
+
],
|
| 406 |
+
[
|
| 407 |
+
6,
|
| 408 |
+
4,
|
| 409 |
+
0
|
| 410 |
+
]
|
| 411 |
+
],
|
| 412 |
+
"confusion_matrix_labels": [
|
| 413 |
+
"Bullish",
|
| 414 |
+
"Bearish",
|
| 415 |
+
"Neutral"
|
| 416 |
+
],
|
| 417 |
+
"class_distribution": {
|
| 418 |
+
"Bullish": 25,
|
| 419 |
+
"Bearish": 15,
|
| 420 |
+
"Neutral": 10
|
| 421 |
+
}
|
| 422 |
+
},
|
| 423 |
+
"delta_accuracy": 0,
|
| 424 |
+
"delta_macro_f1": 0
|
| 425 |
+
},
|
| 426 |
+
"280": {
|
| 427 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 428 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-280",
|
| 429 |
+
"n_eval": 50,
|
| 430 |
+
"term": "medium",
|
| 431 |
+
"baseline_zeroshot": null,
|
| 432 |
+
"finetuned_grpo": {
|
| 433 |
+
"overall_accuracy": 0.3,
|
| 434 |
+
"n_eval": 50,
|
| 435 |
+
"by_class_accuracy": {
|
| 436 |
+
"Bullish": 0.52,
|
| 437 |
+
"Bearish": 0.13333333333333333,
|
| 438 |
+
"Neutral": 0.0
|
| 439 |
+
},
|
| 440 |
+
"precision": {
|
| 441 |
+
"Bullish": 0.40625,
|
| 442 |
+
"Bearish": 0.1111111111111111,
|
| 443 |
+
"Neutral": 0.0
|
| 444 |
+
},
|
| 445 |
+
"recall": {
|
| 446 |
+
"Bullish": 0.52,
|
| 447 |
+
"Bearish": 0.13333333333333333,
|
| 448 |
+
"Neutral": 0.0
|
| 449 |
+
},
|
| 450 |
+
"f1_score": {
|
| 451 |
+
"Bullish": 0.45614035087719296,
|
| 452 |
+
"Bearish": 0.12121212121212122,
|
| 453 |
+
"Neutral": 0.0
|
| 454 |
+
},
|
| 455 |
+
"macro_f1": 0.1924508240297714,
|
| 456 |
+
"weighted_f1": 0.26443381180223285,
|
| 457 |
+
"confusion_matrix": [
|
| 458 |
+
[
|
| 459 |
+
13,
|
| 460 |
+
12,
|
| 461 |
+
0
|
| 462 |
+
],
|
| 463 |
+
[
|
| 464 |
+
13,
|
| 465 |
+
2,
|
| 466 |
+
0
|
| 467 |
+
],
|
| 468 |
+
[
|
| 469 |
+
6,
|
| 470 |
+
4,
|
| 471 |
+
0
|
| 472 |
+
]
|
| 473 |
+
],
|
| 474 |
+
"confusion_matrix_labels": [
|
| 475 |
+
"Bullish",
|
| 476 |
+
"Bearish",
|
| 477 |
+
"Neutral"
|
| 478 |
+
],
|
| 479 |
+
"class_distribution": {
|
| 480 |
+
"Bullish": 25,
|
| 481 |
+
"Bearish": 15,
|
| 482 |
+
"Neutral": 10
|
| 483 |
+
}
|
| 484 |
+
},
|
| 485 |
+
"delta_accuracy": 0,
|
| 486 |
+
"delta_macro_f1": 0
|
| 487 |
+
},
|
| 488 |
+
"300": {
|
| 489 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 490 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-300",
|
| 491 |
+
"n_eval": 50,
|
| 492 |
+
"term": "medium",
|
| 493 |
+
"baseline_zeroshot": null,
|
| 494 |
+
"finetuned_grpo": {
|
| 495 |
+
"overall_accuracy": 0.3,
|
| 496 |
+
"n_eval": 50,
|
| 497 |
+
"by_class_accuracy": {
|
| 498 |
+
"Bullish": 0.56,
|
| 499 |
+
"Bearish": 0.06666666666666667,
|
| 500 |
+
"Neutral": 0.0
|
| 501 |
+
},
|
| 502 |
+
"precision": {
|
| 503 |
+
"Bullish": 0.4117647058823529,
|
| 504 |
+
"Bearish": 0.0625,
|
| 505 |
+
"Neutral": 0.0
|
| 506 |
+
},
|
| 507 |
+
"recall": {
|
| 508 |
+
"Bullish": 0.56,
|
| 509 |
+
"Bearish": 0.06666666666666667,
|
| 510 |
+
"Neutral": 0.0
|
| 511 |
+
},
|
| 512 |
+
"f1_score": {
|
| 513 |
+
"Bullish": 0.4745762711864407,
|
| 514 |
+
"Bearish": 0.06451612903225806,
|
| 515 |
+
"Neutral": 0.0
|
| 516 |
+
},
|
| 517 |
+
"macro_f1": 0.17969746673956624,
|
| 518 |
+
"weighted_f1": 0.2566429743028978,
|
| 519 |
+
"confusion_matrix": [
|
| 520 |
+
[
|
| 521 |
+
14,
|
| 522 |
+
11,
|
| 523 |
+
0
|
| 524 |
+
],
|
| 525 |
+
[
|
| 526 |
+
14,
|
| 527 |
+
1,
|
| 528 |
+
0
|
| 529 |
+
],
|
| 530 |
+
[
|
| 531 |
+
6,
|
| 532 |
+
4,
|
| 533 |
+
0
|
| 534 |
+
]
|
| 535 |
+
],
|
| 536 |
+
"confusion_matrix_labels": [
|
| 537 |
+
"Bullish",
|
| 538 |
+
"Bearish",
|
| 539 |
+
"Neutral"
|
| 540 |
+
],
|
| 541 |
+
"class_distribution": {
|
| 542 |
+
"Bullish": 25,
|
| 543 |
+
"Bearish": 15,
|
| 544 |
+
"Neutral": 10
|
| 545 |
+
}
|
| 546 |
+
},
|
| 547 |
+
"delta_accuracy": 0,
|
| 548 |
+
"delta_macro_f1": 0
|
| 549 |
+
},
|
| 550 |
+
"460": {
|
| 551 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 552 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-460",
|
| 553 |
+
"n_eval": 50,
|
| 554 |
+
"term": "medium",
|
| 555 |
+
"baseline_zeroshot": null,
|
| 556 |
+
"finetuned_grpo": {
|
| 557 |
+
"overall_accuracy": 0.3,
|
| 558 |
+
"n_eval": 50,
|
| 559 |
+
"by_class_accuracy": {
|
| 560 |
+
"Bullish": 0.56,
|
| 561 |
+
"Bearish": 0.06666666666666667,
|
| 562 |
+
"Neutral": 0.0
|
| 563 |
+
},
|
| 564 |
+
"precision": {
|
| 565 |
+
"Bullish": 0.4117647058823529,
|
| 566 |
+
"Bearish": 0.0625,
|
| 567 |
+
"Neutral": 0.0
|
| 568 |
+
},
|
| 569 |
+
"recall": {
|
| 570 |
+
"Bullish": 0.56,
|
| 571 |
+
"Bearish": 0.06666666666666667,
|
| 572 |
+
"Neutral": 0.0
|
| 573 |
+
},
|
| 574 |
+
"f1_score": {
|
| 575 |
+
"Bullish": 0.4745762711864407,
|
| 576 |
+
"Bearish": 0.06451612903225806,
|
| 577 |
+
"Neutral": 0.0
|
| 578 |
+
},
|
| 579 |
+
"macro_f1": 0.17969746673956624,
|
| 580 |
+
"weighted_f1": 0.2566429743028978,
|
| 581 |
+
"confusion_matrix": [
|
| 582 |
+
[
|
| 583 |
+
14,
|
| 584 |
+
11,
|
| 585 |
+
0
|
| 586 |
+
],
|
| 587 |
+
[
|
| 588 |
+
14,
|
| 589 |
+
1,
|
| 590 |
+
0
|
| 591 |
+
],
|
| 592 |
+
[
|
| 593 |
+
6,
|
| 594 |
+
4,
|
| 595 |
+
0
|
| 596 |
+
]
|
| 597 |
+
],
|
| 598 |
+
"confusion_matrix_labels": [
|
| 599 |
+
"Bullish",
|
| 600 |
+
"Bearish",
|
| 601 |
+
"Neutral"
|
| 602 |
+
],
|
| 603 |
+
"class_distribution": {
|
| 604 |
+
"Bullish": 25,
|
| 605 |
+
"Bearish": 15,
|
| 606 |
+
"Neutral": 10
|
| 607 |
+
}
|
| 608 |
+
},
|
| 609 |
+
"delta_accuracy": 0,
|
| 610 |
+
"delta_macro_f1": 0
|
| 611 |
+
},
|
| 612 |
+
"480": {
|
| 613 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 614 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-480",
|
| 615 |
+
"n_eval": 50,
|
| 616 |
+
"term": "medium",
|
| 617 |
+
"baseline_zeroshot": null,
|
| 618 |
+
"finetuned_grpo": {
|
| 619 |
+
"overall_accuracy": 0.3,
|
| 620 |
+
"n_eval": 50,
|
| 621 |
+
"by_class_accuracy": {
|
| 622 |
+
"Bullish": 0.56,
|
| 623 |
+
"Bearish": 0.06666666666666667,
|
| 624 |
+
"Neutral": 0.0
|
| 625 |
+
},
|
| 626 |
+
"precision": {
|
| 627 |
+
"Bullish": 0.4117647058823529,
|
| 628 |
+
"Bearish": 0.0625,
|
| 629 |
+
"Neutral": 0.0
|
| 630 |
+
},
|
| 631 |
+
"recall": {
|
| 632 |
+
"Bullish": 0.56,
|
| 633 |
+
"Bearish": 0.06666666666666667,
|
| 634 |
+
"Neutral": 0.0
|
| 635 |
+
},
|
| 636 |
+
"f1_score": {
|
| 637 |
+
"Bullish": 0.4745762711864407,
|
| 638 |
+
"Bearish": 0.06451612903225806,
|
| 639 |
+
"Neutral": 0.0
|
| 640 |
+
},
|
| 641 |
+
"macro_f1": 0.17969746673956624,
|
| 642 |
+
"weighted_f1": 0.2566429743028978,
|
| 643 |
+
"confusion_matrix": [
|
| 644 |
+
[
|
| 645 |
+
14,
|
| 646 |
+
11,
|
| 647 |
+
0
|
| 648 |
+
],
|
| 649 |
+
[
|
| 650 |
+
14,
|
| 651 |
+
1,
|
| 652 |
+
0
|
| 653 |
+
],
|
| 654 |
+
[
|
| 655 |
+
6,
|
| 656 |
+
4,
|
| 657 |
+
0
|
| 658 |
+
]
|
| 659 |
+
],
|
| 660 |
+
"confusion_matrix_labels": [
|
| 661 |
+
"Bullish",
|
| 662 |
+
"Bearish",
|
| 663 |
+
"Neutral"
|
| 664 |
+
],
|
| 665 |
+
"class_distribution": {
|
| 666 |
+
"Bullish": 25,
|
| 667 |
+
"Bearish": 15,
|
| 668 |
+
"Neutral": 10
|
| 669 |
+
}
|
| 670 |
+
},
|
| 671 |
+
"delta_accuracy": 0,
|
| 672 |
+
"delta_macro_f1": 0
|
| 673 |
+
},
|
| 674 |
+
"500": {
|
| 675 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 676 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-500",
|
| 677 |
+
"n_eval": 50,
|
| 678 |
+
"term": "medium",
|
| 679 |
+
"baseline_zeroshot": null,
|
| 680 |
+
"finetuned_grpo": {
|
| 681 |
+
"overall_accuracy": 0.28,
|
| 682 |
+
"n_eval": 50,
|
| 683 |
+
"by_class_accuracy": {
|
| 684 |
+
"Bullish": 0.52,
|
| 685 |
+
"Bearish": 0.06666666666666667,
|
| 686 |
+
"Neutral": 0.0
|
| 687 |
+
},
|
| 688 |
+
"precision": {
|
| 689 |
+
"Bullish": 0.3939393939393939,
|
| 690 |
+
"Bearish": 0.058823529411764705,
|
| 691 |
+
"Neutral": 0.0
|
| 692 |
+
},
|
| 693 |
+
"recall": {
|
| 694 |
+
"Bullish": 0.52,
|
| 695 |
+
"Bearish": 0.06666666666666667,
|
| 696 |
+
"Neutral": 0.0
|
| 697 |
+
},
|
| 698 |
+
"f1_score": {
|
| 699 |
+
"Bullish": 0.4482758620689655,
|
| 700 |
+
"Bearish": 0.0625,
|
| 701 |
+
"Neutral": 0.0
|
| 702 |
+
},
|
| 703 |
+
"macro_f1": 0.17025862068965517,
|
| 704 |
+
"weighted_f1": 0.24288793103448278,
|
| 705 |
+
"confusion_matrix": [
|
| 706 |
+
[
|
| 707 |
+
13,
|
| 708 |
+
12,
|
| 709 |
+
0
|
| 710 |
+
],
|
| 711 |
+
[
|
| 712 |
+
14,
|
| 713 |
+
1,
|
| 714 |
+
0
|
| 715 |
+
],
|
| 716 |
+
[
|
| 717 |
+
6,
|
| 718 |
+
4,
|
| 719 |
+
0
|
| 720 |
+
]
|
| 721 |
+
],
|
| 722 |
+
"confusion_matrix_labels": [
|
| 723 |
+
"Bullish",
|
| 724 |
+
"Bearish",
|
| 725 |
+
"Neutral"
|
| 726 |
+
],
|
| 727 |
+
"class_distribution": {
|
| 728 |
+
"Bullish": 25,
|
| 729 |
+
"Bearish": 15,
|
| 730 |
+
"Neutral": 10
|
| 731 |
+
}
|
| 732 |
+
},
|
| 733 |
+
"delta_accuracy": 0,
|
| 734 |
+
"delta_macro_f1": 0
|
| 735 |
+
},
|
| 736 |
+
"520": {
|
| 737 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 738 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-520",
|
| 739 |
+
"n_eval": 50,
|
| 740 |
+
"term": "medium",
|
| 741 |
+
"baseline_zeroshot": null,
|
| 742 |
+
"finetuned_grpo": {
|
| 743 |
+
"overall_accuracy": 0.28,
|
| 744 |
+
"n_eval": 50,
|
| 745 |
+
"by_class_accuracy": {
|
| 746 |
+
"Bullish": 0.52,
|
| 747 |
+
"Bearish": 0.06666666666666667,
|
| 748 |
+
"Neutral": 0.0
|
| 749 |
+
},
|
| 750 |
+
"precision": {
|
| 751 |
+
"Bullish": 0.3939393939393939,
|
| 752 |
+
"Bearish": 0.058823529411764705,
|
| 753 |
+
"Neutral": 0.0
|
| 754 |
+
},
|
| 755 |
+
"recall": {
|
| 756 |
+
"Bullish": 0.52,
|
| 757 |
+
"Bearish": 0.06666666666666667,
|
| 758 |
+
"Neutral": 0.0
|
| 759 |
+
},
|
| 760 |
+
"f1_score": {
|
| 761 |
+
"Bullish": 0.4482758620689655,
|
| 762 |
+
"Bearish": 0.0625,
|
| 763 |
+
"Neutral": 0.0
|
| 764 |
+
},
|
| 765 |
+
"macro_f1": 0.17025862068965517,
|
| 766 |
+
"weighted_f1": 0.24288793103448278,
|
| 767 |
+
"confusion_matrix": [
|
| 768 |
+
[
|
| 769 |
+
13,
|
| 770 |
+
12,
|
| 771 |
+
0
|
| 772 |
+
],
|
| 773 |
+
[
|
| 774 |
+
14,
|
| 775 |
+
1,
|
| 776 |
+
0
|
| 777 |
+
],
|
| 778 |
+
[
|
| 779 |
+
6,
|
| 780 |
+
4,
|
| 781 |
+
0
|
| 782 |
+
]
|
| 783 |
+
],
|
| 784 |
+
"confusion_matrix_labels": [
|
| 785 |
+
"Bullish",
|
| 786 |
+
"Bearish",
|
| 787 |
+
"Neutral"
|
| 788 |
+
],
|
| 789 |
+
"class_distribution": {
|
| 790 |
+
"Bullish": 25,
|
| 791 |
+
"Bearish": 15,
|
| 792 |
+
"Neutral": 10
|
| 793 |
+
}
|
| 794 |
+
},
|
| 795 |
+
"delta_accuracy": 0,
|
| 796 |
+
"delta_macro_f1": 0
|
| 797 |
+
},
|
| 798 |
+
"540": {
|
| 799 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 800 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-540",
|
| 801 |
+
"n_eval": 50,
|
| 802 |
+
"term": "medium",
|
| 803 |
+
"baseline_zeroshot": null,
|
| 804 |
+
"finetuned_grpo": {
|
| 805 |
+
"overall_accuracy": 0.3,
|
| 806 |
+
"n_eval": 50,
|
| 807 |
+
"by_class_accuracy": {
|
| 808 |
+
"Bullish": 0.56,
|
| 809 |
+
"Bearish": 0.06666666666666667,
|
| 810 |
+
"Neutral": 0.0
|
| 811 |
+
},
|
| 812 |
+
"precision": {
|
| 813 |
+
"Bullish": 0.4117647058823529,
|
| 814 |
+
"Bearish": 0.0625,
|
| 815 |
+
"Neutral": 0.0
|
| 816 |
+
},
|
| 817 |
+
"recall": {
|
| 818 |
+
"Bullish": 0.56,
|
| 819 |
+
"Bearish": 0.06666666666666667,
|
| 820 |
+
"Neutral": 0.0
|
| 821 |
+
},
|
| 822 |
+
"f1_score": {
|
| 823 |
+
"Bullish": 0.4745762711864407,
|
| 824 |
+
"Bearish": 0.06451612903225806,
|
| 825 |
+
"Neutral": 0.0
|
| 826 |
+
},
|
| 827 |
+
"macro_f1": 0.17969746673956624,
|
| 828 |
+
"weighted_f1": 0.2566429743028978,
|
| 829 |
+
"confusion_matrix": [
|
| 830 |
+
[
|
| 831 |
+
14,
|
| 832 |
+
11,
|
| 833 |
+
0
|
| 834 |
+
],
|
| 835 |
+
[
|
| 836 |
+
14,
|
| 837 |
+
1,
|
| 838 |
+
0
|
| 839 |
+
],
|
| 840 |
+
[
|
| 841 |
+
6,
|
| 842 |
+
4,
|
| 843 |
+
0
|
| 844 |
+
]
|
| 845 |
+
],
|
| 846 |
+
"confusion_matrix_labels": [
|
| 847 |
+
"Bullish",
|
| 848 |
+
"Bearish",
|
| 849 |
+
"Neutral"
|
| 850 |
+
],
|
| 851 |
+
"class_distribution": {
|
| 852 |
+
"Bullish": 25,
|
| 853 |
+
"Bearish": 15,
|
| 854 |
+
"Neutral": 10
|
| 855 |
+
}
|
| 856 |
+
},
|
| 857 |
+
"delta_accuracy": 0,
|
| 858 |
+
"delta_macro_f1": 0
|
| 859 |
+
},
|
| 860 |
+
"560": {
|
| 861 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 862 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-560",
|
| 863 |
+
"n_eval": 50,
|
| 864 |
+
"term": "medium",
|
| 865 |
+
"baseline_zeroshot": null,
|
| 866 |
+
"finetuned_grpo": {
|
| 867 |
+
"overall_accuracy": 0.3,
|
| 868 |
+
"n_eval": 50,
|
| 869 |
+
"by_class_accuracy": {
|
| 870 |
+
"Bullish": 0.52,
|
| 871 |
+
"Bearish": 0.13333333333333333,
|
| 872 |
+
"Neutral": 0.0
|
| 873 |
+
},
|
| 874 |
+
"precision": {
|
| 875 |
+
"Bullish": 0.40625,
|
| 876 |
+
"Bearish": 0.1111111111111111,
|
| 877 |
+
"Neutral": 0.0
|
| 878 |
+
},
|
| 879 |
+
"recall": {
|
| 880 |
+
"Bullish": 0.52,
|
| 881 |
+
"Bearish": 0.13333333333333333,
|
| 882 |
+
"Neutral": 0.0
|
| 883 |
+
},
|
| 884 |
+
"f1_score": {
|
| 885 |
+
"Bullish": 0.45614035087719296,
|
| 886 |
+
"Bearish": 0.12121212121212122,
|
| 887 |
+
"Neutral": 0.0
|
| 888 |
+
},
|
| 889 |
+
"macro_f1": 0.1924508240297714,
|
| 890 |
+
"weighted_f1": 0.26443381180223285,
|
| 891 |
+
"confusion_matrix": [
|
| 892 |
+
[
|
| 893 |
+
13,
|
| 894 |
+
12,
|
| 895 |
+
0
|
| 896 |
+
],
|
| 897 |
+
[
|
| 898 |
+
13,
|
| 899 |
+
2,
|
| 900 |
+
0
|
| 901 |
+
],
|
| 902 |
+
[
|
| 903 |
+
6,
|
| 904 |
+
4,
|
| 905 |
+
0
|
| 906 |
+
]
|
| 907 |
+
],
|
| 908 |
+
"confusion_matrix_labels": [
|
| 909 |
+
"Bullish",
|
| 910 |
+
"Bearish",
|
| 911 |
+
"Neutral"
|
| 912 |
+
],
|
| 913 |
+
"class_distribution": {
|
| 914 |
+
"Bullish": 25,
|
| 915 |
+
"Bearish": 15,
|
| 916 |
+
"Neutral": 10
|
| 917 |
+
}
|
| 918 |
+
},
|
| 919 |
+
"delta_accuracy": 0,
|
| 920 |
+
"delta_macro_f1": 0
|
| 921 |
+
},
|
| 922 |
+
"580": {
|
| 923 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 924 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-580",
|
| 925 |
+
"n_eval": 50,
|
| 926 |
+
"term": "medium",
|
| 927 |
+
"baseline_zeroshot": null,
|
| 928 |
+
"finetuned_grpo": {
|
| 929 |
+
"overall_accuracy": 0.32,
|
| 930 |
+
"n_eval": 50,
|
| 931 |
+
"by_class_accuracy": {
|
| 932 |
+
"Bullish": 0.56,
|
| 933 |
+
"Bearish": 0.13333333333333333,
|
| 934 |
+
"Neutral": 0.0
|
| 935 |
+
},
|
| 936 |
+
"precision": {
|
| 937 |
+
"Bullish": 0.42424242424242425,
|
| 938 |
+
"Bearish": 0.11764705882352941,
|
| 939 |
+
"Neutral": 0.0
|
| 940 |
+
},
|
| 941 |
+
"recall": {
|
| 942 |
+
"Bullish": 0.56,
|
| 943 |
+
"Bearish": 0.13333333333333333,
|
| 944 |
+
"Neutral": 0.0
|
| 945 |
+
},
|
| 946 |
+
"f1_score": {
|
| 947 |
+
"Bullish": 0.4827586206896552,
|
| 948 |
+
"Bearish": 0.125,
|
| 949 |
+
"Neutral": 0.0
|
| 950 |
+
},
|
| 951 |
+
"macro_f1": 0.20258620689655174,
|
| 952 |
+
"weighted_f1": 0.2788793103448276,
|
| 953 |
+
"confusion_matrix": [
|
| 954 |
+
[
|
| 955 |
+
14,
|
| 956 |
+
11,
|
| 957 |
+
0
|
| 958 |
+
],
|
| 959 |
+
[
|
| 960 |
+
13,
|
| 961 |
+
2,
|
| 962 |
+
0
|
| 963 |
+
],
|
| 964 |
+
[
|
| 965 |
+
6,
|
| 966 |
+
4,
|
| 967 |
+
0
|
| 968 |
+
]
|
| 969 |
+
],
|
| 970 |
+
"confusion_matrix_labels": [
|
| 971 |
+
"Bullish",
|
| 972 |
+
"Bearish",
|
| 973 |
+
"Neutral"
|
| 974 |
+
],
|
| 975 |
+
"class_distribution": {
|
| 976 |
+
"Bullish": 25,
|
| 977 |
+
"Bearish": 15,
|
| 978 |
+
"Neutral": 10
|
| 979 |
+
}
|
| 980 |
+
},
|
| 981 |
+
"delta_accuracy": 0,
|
| 982 |
+
"delta_macro_f1": 0
|
| 983 |
+
},
|
| 984 |
+
"600": {
|
| 985 |
+
"model_id": "Qwen/Qwen2.5-7B-Instruct",
|
| 986 |
+
"adapter_path": "/content/drive/MyDrive/indicators_grpo_v2/checkpoint-600",
|
| 987 |
+
"n_eval": 50,
|
| 988 |
+
"term": "medium",
|
| 989 |
+
"baseline_zeroshot": null,
|
| 990 |
+
"finetuned_grpo": {
|
| 991 |
+
"overall_accuracy": 0.3,
|
| 992 |
+
"n_eval": 50,
|
| 993 |
+
"by_class_accuracy": {
|
| 994 |
+
"Bullish": 0.52,
|
| 995 |
+
"Bearish": 0.13333333333333333,
|
| 996 |
+
"Neutral": 0.0
|
| 997 |
+
},
|
| 998 |
+
"precision": {
|
| 999 |
+
"Bullish": 0.40625,
|
| 1000 |
+
"Bearish": 0.1111111111111111,
|
| 1001 |
+
"Neutral": 0.0
|
| 1002 |
+
},
|
| 1003 |
+
"recall": {
|
| 1004 |
+
"Bullish": 0.52,
|
| 1005 |
+
"Bearish": 0.13333333333333333,
|
| 1006 |
+
"Neutral": 0.0
|
| 1007 |
+
},
|
| 1008 |
+
"f1_score": {
|
| 1009 |
+
"Bullish": 0.45614035087719296,
|
| 1010 |
+
"Bearish": 0.12121212121212122,
|
| 1011 |
+
"Neutral": 0.0
|
| 1012 |
+
},
|
| 1013 |
+
"macro_f1": 0.1924508240297714,
|
| 1014 |
+
"weighted_f1": 0.26443381180223285,
|
| 1015 |
+
"confusion_matrix": [
|
| 1016 |
+
[
|
| 1017 |
+
13,
|
| 1018 |
+
12,
|
| 1019 |
+
0
|
| 1020 |
+
],
|
| 1021 |
+
[
|
| 1022 |
+
13,
|
| 1023 |
+
2,
|
| 1024 |
+
0
|
| 1025 |
+
],
|
| 1026 |
+
[
|
| 1027 |
+
6,
|
| 1028 |
+
4,
|
| 1029 |
+
0
|
| 1030 |
+
]
|
| 1031 |
+
],
|
| 1032 |
+
"confusion_matrix_labels": [
|
| 1033 |
+
"Bullish",
|
| 1034 |
+
"Bearish",
|
| 1035 |
+
"Neutral"
|
| 1036 |
+
],
|
| 1037 |
+
"class_distribution": {
|
| 1038 |
+
"Bullish": 25,
|
| 1039 |
+
"Bearish": 15,
|
| 1040 |
+
"Neutral": 10
|
| 1041 |
+
}
|
| 1042 |
+
},
|
| 1043 |
+
"delta_accuracy": 0,
|
| 1044 |
+
"delta_macro_f1": 0
|
| 1045 |
+
}
|
| 1046 |
+
}
|
final_evaluation_curve.png
ADDED
|
notebooks/demo.ipynb
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "intro",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# Continuing Training with a 7B Model\n",
|
| 9 |
+
"**Note:** To resume from a shared folder, go to 'Shared with me' in your new Google Drive, right-click the folder, and select **Organize > Add shortcut > My Drive** so Colab can see it naturally."
|
| 10 |
+
]
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"cell_type": "code",
|
| 14 |
+
"execution_count": null,
|
| 15 |
+
"id": "step0_code",
|
| 16 |
+
"metadata": {},
|
| 17 |
+
"outputs": [],
|
| 18 |
+
"source": [
|
| 19 |
+
"from google.colab import drive\n",
|
| 20 |
+
"drive.mount('/content/drive')\n",
|
| 21 |
+
"\n",
|
| 22 |
+
"# DRIVE_PATH should point to your shortcut for the shared folder\n",
|
| 23 |
+
"DRIVE_PATH = \"/content/drive/MyDrive/indicators_grpo_v2\"\n",
|
| 24 |
+
"print(f'Looking for checkpoints in: {DRIVE_PATH}')"
|
| 25 |
+
]
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"cell_type": "markdown",
|
| 29 |
+
"id": "step1",
|
| 30 |
+
"metadata": {},
|
| 31 |
+
"source": [
|
| 32 |
+
"## Step 1: Upload and Extract Hackathon Zip"
|
| 33 |
+
]
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"cell_type": "code",
|
| 37 |
+
"execution_count": null,
|
| 38 |
+
"id": "step1_code",
|
| 39 |
+
"metadata": {},
|
| 40 |
+
"outputs": [],
|
| 41 |
+
"source": [
|
| 42 |
+
"from google.colab import files\n",
|
| 43 |
+
"import zipfile, os\n",
|
| 44 |
+
"\n",
|
| 45 |
+
"uploaded = files.upload() # Select hackathon.zip from your Mac\n",
|
| 46 |
+
"\n",
|
| 47 |
+
"with zipfile.ZipFile('hackathon.zip', 'r') as z:\n",
|
| 48 |
+
" z.extractall('/content/')\n",
|
| 49 |
+
"\n",
|
| 50 |
+
"%cd /content/hackathon\n",
|
| 51 |
+
"print(f'Ready in: {os.getcwd()}')"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"cell_type": "markdown",
|
| 56 |
+
"id": "step2",
|
| 57 |
+
"metadata": {},
|
| 58 |
+
"source": [
|
| 59 |
+
"## Step 2: Install Dependencies"
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"cell_type": "code",
|
| 64 |
+
"execution_count": null,
|
| 65 |
+
"id": "step2_code",
|
| 66 |
+
"metadata": {},
|
| 67 |
+
"outputs": [],
|
| 68 |
+
"source": [
|
| 69 |
+
"!pip install numpy pandas trl peft yfinance accelerate bitsandbytes"
|
| 70 |
+
]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"cell_type": "markdown",
|
| 74 |
+
"id": "step_hf",
|
| 75 |
+
"metadata": {},
|
| 76 |
+
"source": [
|
| 77 |
+
"## Step 3: Initialize Hugging Face Token"
|
| 78 |
+
]
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"cell_type": "code",
|
| 82 |
+
"execution_count": null,
|
| 83 |
+
"id": "step_hf_code",
|
| 84 |
+
"metadata": {},
|
| 85 |
+
"outputs": [],
|
| 86 |
+
"source": [
|
| 87 |
+
"from huggingface_hub import login\n",
|
| 88 |
+
"login(token=\"YOUR_HF_TOKEN_HERE\")\n",
|
| 89 |
+
"print('Hugging Face Logged In!')"
|
| 90 |
+
]
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"cell_type": "markdown",
|
| 94 |
+
"id": "resume_check",
|
| 95 |
+
"metadata": {},
|
| 96 |
+
"source": [
|
| 97 |
+
"## Step 5: Check Progress & Find Latest Checkpoint"
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"cell_type": "code",
|
| 102 |
+
"execution_count": null,
|
| 103 |
+
"id": "resume_check_code",
|
| 104 |
+
"metadata": {},
|
| 105 |
+
"outputs": [],
|
| 106 |
+
"source": [
|
| 107 |
+
"import os\n",
|
| 108 |
+
"\n",
|
| 109 |
+
"def get_latest_checkpoint(path):\n",
|
| 110 |
+
" if not os.path.exists(path): return None\n",
|
| 111 |
+
" checkpoints = [d for d in os.listdir(path) if d.startswith(\"checkpoint-\")]\n",
|
| 112 |
+
" if not checkpoints: return None\n",
|
| 113 |
+
" latest = sorted(checkpoints, key=lambda x: int(x.split(\"-\")[1]))[-1]\n",
|
| 114 |
+
" return os.path.join(path, latest)\n",
|
| 115 |
+
"\n",
|
| 116 |
+
"LATEST = get_latest_checkpoint(DRIVE_PATH)\n",
|
| 117 |
+
"if LATEST:\n",
|
| 118 |
+
" print(f\"\u2705 FOUND CHECKPOINT: Resuming from {LATEST}\")\n",
|
| 119 |
+
" RESUME_ARG = f\"--resume_from_checkpoint {LATEST}\"\n",
|
| 120 |
+
"else:\n",
|
| 121 |
+
" print(f\"\ud83c\udd95 No checkpoint found in {DRIVE_PATH}. Check your shortcut name!\")\n",
|
| 122 |
+
" RESUME_ARG = \"\"\n"
|
| 123 |
+
]
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"cell_type": "markdown",
|
| 127 |
+
"id": "repair_note",
|
| 128 |
+
"metadata": {},
|
| 129 |
+
"source": [
|
| 130 |
+
"### \ud83d\udd27 REPAIR RUN: Stabilizing weights from Step 160\n",
|
| 131 |
+
"The analysis shows the model decayed after 180 due to reward imbalance. We are resuming from the peak (Step 160) with the new symmetric reward function."
|
| 132 |
+
]
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"cell_type": "code",
|
| 136 |
+
"execution_count": null,
|
| 137 |
+
"id": "repair_cell",
|
| 138 |
+
"metadata": {},
|
| 139 |
+
"outputs": [],
|
| 140 |
+
"source": [
|
| 141 |
+
"# Overwrite LATEST to force resume from the peak stable version\n",
|
| 142 |
+
"REPAIR_CKPT = os.path.join(DRIVE_PATH, \"checkpoint-160\")\n",
|
| 143 |
+
"if os.path.exists(REPAIR_CKPT):\n",
|
| 144 |
+
" LATEST = REPAIR_CKPT\n",
|
| 145 |
+
" RESUME_ARG = f\"--resume_from_checkpoint {LATEST}\"\n",
|
| 146 |
+
" print(f\"\u2705 REPAIR MODE: Resuming from Peak Checkpoint: {LATEST}\")\n",
|
| 147 |
+
"else:\n",
|
| 148 |
+
" print(\"\u26a0\ufe0f Checkpoint 160 not found. Using default latest search.\")"
|
| 149 |
+
]
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"cell_type": "markdown",
|
| 153 |
+
"id": "step3_title",
|
| 154 |
+
"metadata": {},
|
| 155 |
+
"source": [
|
| 156 |
+
"## Step 6: GRPO Training (Resuming up to Step 600, 7B Model)"
|
| 157 |
+
]
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"cell_type": "code",
|
| 161 |
+
"execution_count": null,
|
| 162 |
+
"id": "step3_code",
|
| 163 |
+
"metadata": {},
|
| 164 |
+
"outputs": [],
|
| 165 |
+
"source": [
|
| 166 |
+
"!python training/train_grpo.py \\\n",
|
| 167 |
+
" --model_id \"Qwen/Qwen2.5-7B-Instruct\" \\\n",
|
| 168 |
+
" --output_dir \"{DRIVE_PATH}\" \\\n",
|
| 169 |
+
" --num_episodes 1000 \\\n",
|
| 170 |
+
" --batch_size 8 \\\n",
|
| 171 |
+
" --term medium \\\n",
|
| 172 |
+
" --max_steps 600 {RESUME_ARG}"
|
| 173 |
+
]
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"cell_type": "markdown",
|
| 177 |
+
"id": "step7",
|
| 178 |
+
"metadata": {},
|
| 179 |
+
"source": [
|
| 180 |
+
"## Step 7: GPU Memory Reset\n",
|
| 181 |
+
"**Critical for 7B:** Run after training to clear VRAM."
|
| 182 |
+
]
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"cell_type": "code",
|
| 186 |
+
"execution_count": null,
|
| 187 |
+
"id": "step7_code",
|
| 188 |
+
"metadata": {},
|
| 189 |
+
"outputs": [],
|
| 190 |
+
"source": [
|
| 191 |
+
"import torch, gc\n",
|
| 192 |
+
"if \"trainer\" in globals(): del trainer\n",
|
| 193 |
+
"gc.collect()\n",
|
| 194 |
+
"torch.cuda.empty_cache()\n",
|
| 195 |
+
"print(\"\u2728 GPU Memory Cleared.\")"
|
| 196 |
+
]
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"cell_type": "markdown",
|
| 200 |
+
"id": "step8",
|
| 201 |
+
"metadata": {},
|
| 202 |
+
"source": [
|
| 203 |
+
"## Step 8: Multi-Checkpoint Evaluation (OOS 2023-24)"
|
| 204 |
+
]
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"cell_type": "code",
|
| 208 |
+
"execution_count": null,
|
| 209 |
+
"id": "step8_code",
|
| 210 |
+
"metadata": {},
|
| 211 |
+
"outputs": [],
|
| 212 |
+
"source": [
|
| 213 |
+
"import os, json\n",
|
| 214 |
+
"import matplotlib.pyplot as plt\n",
|
| 215 |
+
"from IPython.display import Image, display\n",
|
| 216 |
+
"\n",
|
| 217 |
+
"# Multi-source: [Restored Drive] 160-280, [HF Cloud] 300, [Current Drive] 460-600\n",
|
| 218 |
+
"CHECKPOINT_STEPS = [160, 180, 200, 220, 240, 260, 280, 300, 460, 480, 500, 520, 540, 560, 580, 600]\n",
|
| 219 |
+
"results_map = {}\n",
|
| 220 |
+
"\n",
|
| 221 |
+
"print(f'Starting Multi-Checkpoint Evaluation for {len(CHECKPOINT_STEPS)} stages...')\n",
|
| 222 |
+
"\n",
|
| 223 |
+
"for step in CHECKPOINT_STEPS:\n",
|
| 224 |
+
" ckpt_path = os.path.join(DRIVE_PATH, f'checkpoint-{step}')\n",
|
| 225 |
+
" is_remote = False\n",
|
| 226 |
+
" \n",
|
| 227 |
+
" # Fallback: Step 300 may be missing locally - pull from misnamed HF repo\n",
|
| 228 |
+
" if step == 300 and not os.path.exists(ckpt_path):\n",
|
| 229 |
+
" print('\\n\u2601\ufe0f Fetching 300-step weights from Hugging Face Hub (bawsi99/indicators-grpo-qwen1.5b)...')\n",
|
| 230 |
+
" ckpt_path = 'bawsi99/indicators-grpo-qwen1.5b'\n",
|
| 231 |
+
" is_remote = True\n",
|
| 232 |
+
" \n",
|
| 233 |
+
" if not is_remote and not os.path.exists(ckpt_path):\n",
|
| 234 |
+
" print(f'\\n\u26a0\ufe0f Skipping step {step}: Checkpoint not found at {ckpt_path}')\n",
|
| 235 |
+
" continue\n",
|
| 236 |
+
" \n",
|
| 237 |
+
" # Only run baseline on the very first checkpoint - skip for all subsequent ones\n",
|
| 238 |
+
" skip_baseline = '--skip_baseline' if len(results_map) > 0 else ''\n",
|
| 239 |
+
" source_label = '(Cloud Weights from HF)' if is_remote else ('(Skipping Baseline)' if skip_baseline else '(Full Eval)')\n",
|
| 240 |
+
" print(f'\\n--- Evaluating Checkpoint {step} {source_label} ---')\n",
|
| 241 |
+
" \n",
|
| 242 |
+
" !python evaluation/eval_finetuned.py \\\n",
|
| 243 |
+
" --adapter_path \"{ckpt_path}\" \\\n",
|
| 244 |
+
" --n_eval 50 \\\n",
|
| 245 |
+
" --term medium {skip_baseline}\n",
|
| 246 |
+
" \n",
|
| 247 |
+
" # Load results - for remote HF IDs, eval_finetuned.py resolves to local repo slug folder\n",
|
| 248 |
+
" res_dir = 'indicators-grpo-qwen1.5b' if is_remote else ckpt_path\n",
|
| 249 |
+
" metrics_file = os.path.join(res_dir, 'eval_results_full.json')\n",
|
| 250 |
+
" \n",
|
| 251 |
+
" if os.path.exists(metrics_file):\n",
|
| 252 |
+
" with open(metrics_file, 'r') as f:\n",
|
| 253 |
+
" results_map[step] = json.load(f)\n",
|
| 254 |
+
" print(f'\u2705 Successfully loaded metrics for step {step}')\n",
|
| 255 |
+
" else:\n",
|
| 256 |
+
" print(f'\u274c Failed to locate results at {metrics_file}')\n"
|
| 257 |
+
]
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"cell_type": "markdown",
|
| 261 |
+
"id": "step9",
|
| 262 |
+
"metadata": {},
|
| 263 |
+
"source": [
|
| 264 |
+
"## Step 9: Visualize Learning Progress"
|
| 265 |
+
]
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"cell_type": "code",
|
| 269 |
+
"execution_count": null,
|
| 270 |
+
"id": "step9_code",
|
| 271 |
+
"metadata": {},
|
| 272 |
+
"outputs": [],
|
| 273 |
+
"source": [
|
| 274 |
+
"import matplotlib.pyplot as plt\n",
|
| 275 |
+
"if \"results_map\" in globals() and results_map:\n",
|
| 276 |
+
" steps = sorted(results_map.keys())\n",
|
| 277 |
+
" accs = [results_map[s][\"finetuned_grpo\"][\"overall_accuracy\"] for s in steps]\n",
|
| 278 |
+
" plt.figure(figsize=(10, 4))\n",
|
| 279 |
+
" plt.plot(steps, accs, marker=\"o\", label=\"Fine-tuned (GRPO)\")\n",
|
| 280 |
+
" b = results_map[steps[0]].get(\"baseline_zeroshot\", {})\n",
|
| 281 |
+
" if \"overall_accuracy\" in b: plt.axhline(y=b[\"overall_accuracy\"], color=\"red\", linestyle=\"--\", label=\"Baseline\")\n",
|
| 282 |
+
" plt.title(\"Learning Curve\"); plt.legend(); \n",
|
| 283 |
+
" plot_path = os.path.join(DRIVE_PATH, \"grpo_learning_curve.png\")\n",
|
| 284 |
+
" plt.savefig(plot_path, dpi=150); plt.show()\n",
|
| 285 |
+
" print(f\"\u2705 Chart saved to: {plot_path}\")\n",
|
| 286 |
+
"else: print(\"No results to plot.\")"
|
| 287 |
+
]
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"cell_type": "markdown",
|
| 291 |
+
"id": "step10",
|
| 292 |
+
"metadata": {},
|
| 293 |
+
"source": [
|
| 294 |
+
"## Step 10: Consolidate All Results\n",
|
| 295 |
+
"Gather all individual JSONs from Drive into one master report."
|
| 296 |
+
]
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"cell_type": "code",
|
| 300 |
+
"execution_count": null,
|
| 301 |
+
"id": "step10_code",
|
| 302 |
+
"metadata": {},
|
| 303 |
+
"outputs": [],
|
| 304 |
+
"source": [
|
| 305 |
+
"import json, os\n",
|
| 306 |
+
"master_results = {}\n",
|
| 307 |
+
"for folder in sorted(os.listdir(DRIVE_PATH)):\n",
|
| 308 |
+
" if folder.startswith(\"checkpoint-\"):\n",
|
| 309 |
+
" f_path = os.path.join(DRIVE_PATH, folder, \"eval_results_full.json\")\n",
|
| 310 |
+
" if os.path.exists(f_path):\n",
|
| 311 |
+
" with open(f_path, \"r\") as f: master_results[folder.split(\"-\")[1]] = json.load(f)\n",
|
| 312 |
+
"out_path = os.path.join(DRIVE_PATH, \"consolidated_eval_report.json\")\n",
|
| 313 |
+
"with open(out_path, \"w\") as f: json.dump(master_results, f, indent=2)\n",
|
| 314 |
+
"print(f\"\u2705 Consolidated JSON saved to: {out_path}\")"
|
| 315 |
+
]
|
| 316 |
+
}
|
| 317 |
+
],
|
| 318 |
+
"metadata": {
|
| 319 |
+
"accelerator": "GPU",
|
| 320 |
+
"kernelspec": {
|
| 321 |
+
"display_name": "Python 3",
|
| 322 |
+
"language": "python",
|
| 323 |
+
"name": "python3"
|
| 324 |
+
}
|
| 325 |
+
},
|
| 326 |
+
"nbformat": 4,
|
| 327 |
+
"nbformat_minor": 5
|
| 328 |
+
}
|
openenv.yaml
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: IndicatorsEnv
|
| 2 |
+
version: 1.0.0
|
| 3 |
+
description: >
|
| 4 |
+
RL environment for NSE (Indian) stock market technical indicators prediction.
|
| 5 |
+
An AI agent receives a rich indicator snapshot (RSI, MACD, ADX, Bollinger Bands,
|
| 6 |
+
VWAP, MFI, CMF, pivot points) for a real NSE stock on a real historical date,
|
| 7 |
+
and must predict the directional price move over a configurable forward window.
|
| 8 |
+
Ground truth is computed from actual historical price data.
|
| 9 |
+
|
| 10 |
+
author: bawsi99
|
| 11 |
+
license: MIT
|
| 12 |
+
|
| 13 |
+
tags:
|
| 14 |
+
- finance
|
| 15 |
+
- stock-market
|
| 16 |
+
- technical-analysis
|
| 17 |
+
- reinforcement-learning
|
| 18 |
+
- openenv
|
| 19 |
+
|
| 20 |
+
tasks:
|
| 21 |
+
- id: short_term_direction
|
| 22 |
+
name: "Short-term Direction (Easy)"
|
| 23 |
+
description: >
|
| 24 |
+
Predict 5-day forward price direction from technical indicators.
|
| 25 |
+
Threshold: Β±1.0%. More Neutral outcomes expected.
|
| 26 |
+
difficulty: easy
|
| 27 |
+
term: short
|
| 28 |
+
|
| 29 |
+
- id: medium_term_direction
|
| 30 |
+
name: "Medium-term Direction (Medium)"
|
| 31 |
+
description: >
|
| 32 |
+
Predict 20-day forward price direction from technical indicators.
|
| 33 |
+
Threshold: Β±2.5%. Balanced Bullish/Bearish/Neutral distribution.
|
| 34 |
+
difficulty: medium
|
| 35 |
+
term: medium
|
| 36 |
+
|
| 37 |
+
- id: long_term_conviction
|
| 38 |
+
name: "Long-term Conviction (Hard)"
|
| 39 |
+
description: >
|
| 40 |
+
Predict 60-day forward price direction AND demonstrate calibrated conviction.
|
| 41 |
+
Threshold: Β±5.0%. Score requires both correct direction AND conviction >= 0.7.
|
| 42 |
+
Overconfident wrong predictions are penalized.
|
| 43 |
+
difficulty: hard
|
| 44 |
+
term: long
|
| 45 |
+
|
| 46 |
+
endpoints:
|
| 47 |
+
reset: "POST /reset"
|
| 48 |
+
step: "POST /step"
|
| 49 |
+
state: "GET /state"
|
| 50 |
+
tasks: "GET /tasks"
|
| 51 |
+
grader: "POST /grader"
|
| 52 |
+
baseline: "GET /baseline"
|
| 53 |
+
health: "GET /health"
|
| 54 |
+
|
| 55 |
+
action_space:
|
| 56 |
+
type: object
|
| 57 |
+
properties:
|
| 58 |
+
direction:
|
| 59 |
+
type: string
|
| 60 |
+
enum: [Bullish, Bearish, Neutral]
|
| 61 |
+
description: Predicted price direction over the forward window
|
| 62 |
+
conviction:
|
| 63 |
+
type: number
|
| 64 |
+
minimum: 0.0
|
| 65 |
+
maximum: 1.0
|
| 66 |
+
description: Agent confidence in the prediction (0=uncertain, 1=certain)
|
| 67 |
+
|
| 68 |
+
observation_space:
|
| 69 |
+
type: object
|
| 70 |
+
properties:
|
| 71 |
+
symbol:
|
| 72 |
+
type: string
|
| 73 |
+
description: NSE stock ticker (e.g. RELIANCE, TCS, INFY)
|
| 74 |
+
date:
|
| 75 |
+
type: string
|
| 76 |
+
format: date
|
| 77 |
+
description: Observation date (YYYY-MM-DD)
|
| 78 |
+
term:
|
| 79 |
+
type: string
|
| 80 |
+
enum: [SHORT, MEDIUM, LONG]
|
| 81 |
+
current_price:
|
| 82 |
+
type: number
|
| 83 |
+
description: Closing price on observation date
|
| 84 |
+
indicators:
|
| 85 |
+
type: object
|
| 86 |
+
description: >
|
| 87 |
+
Nested indicator dict with: moving_averages, rsi, macd,
|
| 88 |
+
bollinger_bands, adx, stochastic, volatility, enhanced_volume, pivot_points
|
| 89 |
+
|
| 90 |
+
reward:
|
| 91 |
+
min: -0.1
|
| 92 |
+
max: 1.1
|
| 93 |
+
description: >
|
| 94 |
+
1.0 for correct direction. Shaped upward for confident correct predictions
|
| 95 |
+
(conviction >= 0.6). Penalized (-0.1) for overconfident wrong predictions
|
| 96 |
+
(conviction >= 0.8). Hard task uses stricter grading.
|
requirements-server.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core Env Server
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn[standard]>=0.34.0
|
| 4 |
+
pydantic>=2.10.0
|
| 5 |
+
websockets>=14.0
|
| 6 |
+
|
| 7 |
+
# Data
|
| 8 |
+
yfinance>=0.2.51
|
| 9 |
+
pandas>=2.0.0,<2.3.0
|
| 10 |
+
numpy>=1.26.0,<2.0.0
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Evaluation & Metrics
|
| 14 |
+
openai>=1.0.0
|
| 15 |
+
httpx>=0.27.0
|
| 16 |
+
scikit-learn>=1.3.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core OpenEnv + RL
|
| 2 |
+
openenv-core>=0.1.0
|
| 3 |
+
trl>=0.12.0
|
| 4 |
+
transformers>=4.47.0
|
| 5 |
+
peft>=0.14.0
|
| 6 |
+
bitsandbytes>=0.45.0
|
| 7 |
+
accelerate>=1.3.0
|
| 8 |
+
|
| 9 |
+
# Data
|
| 10 |
+
yfinance>=0.2.51
|
| 11 |
+
pandas==2.2.2
|
| 12 |
+
numpy<2.2.0,>=1.26.0
|
| 13 |
+
pandas-ta>=0.3.14b
|
| 14 |
+
|
| 15 |
+
# Env server
|
| 16 |
+
fastapi>=0.115.0
|
| 17 |
+
uvicorn[standard]>=0.34.0
|
| 18 |
+
websockets>=14.0
|
| 19 |
+
pydantic>=2.10.0
|
| 20 |
+
|
| 21 |
+
# Training utils
|
| 22 |
+
torch>=2.5.0
|
| 23 |
+
datasets>=3.2.0
|
| 24 |
+
scipy>=1.13.0
|
| 25 |
+
|
| 26 |
+
# Evaluation
|
| 27 |
+
scikit-learn>=1.5.0
|
| 28 |
+
|
| 29 |
+
# Baseline inference
|
| 30 |
+
openai>=1.0.0
|
| 31 |
+
httpx>=0.27.0
|
submission_summary.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hackathon Project Report: IndicatorsEnv (OpenEnv)
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
## 1. Context: The Meta Γ PyTorch Hackathon
|
| 6 |
+
|
| 7 |
+
The **Meta Γ PyTorch Hackathon** challenges developers to build a real-world Reinforcement Learning (RL) environment using their new **OpenEnv** standard.
|
| 8 |
+
|
| 9 |
+
The goal is not to build a traditional app or a puzzle game, but to create a fully standardized, containerized API β one that exposes `step()`, `reset()`, and `state()` endpoints β that simulates a **genuine real-world task**. This allows AI agents (like Large Language Models) to interact with the environment, receive immediate feedback (rewards), and learn complex behaviours over thousands of episodes.
|
| 10 |
+
|
| 11 |
+
The submission requires: a fully deployed Hugging Face Space, a programmatic AI grader for three difficulty levels, a baseline evaluation script using the OpenAI API, and a working Dockerfile.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 2. My Project: IndicatorsEnv
|
| 16 |
+
|
| 17 |
+
Most participants are submitting toy logic problems or simple text-adventure games. I chose to tackle a high-value, complex problem: **quantitative financial technical analysis.**
|
| 18 |
+
|
| 19 |
+
I built **IndicatorsEnv**: an OpenEnv-compliant RL environment where an AI agent acts as a quantitative analyst. On each episode, the agent receives a structured snapshot of 25+ computed technical indicators for a **real Indian NSE stock on a real historical date** and must predict the future directional price move (Bullish / Bearish / Neutral).
|
| 20 |
+
|
| 21 |
+
### Why NSE India is a Deliberate Design Choice
|
| 22 |
+
|
| 23 |
+
NSE India is a semi-strong form efficient market at best β retail participation is high, institutional research coverage of mid/small cap stocks is thin, and technical patterns have demonstrated predictive power in emerging markets (documented in Malkiel (2005) "Reflections on the Efficient Market Hypothesis" and recent work on Asian equity markets). This makes NSE a significantly better RL training ground than a fully efficient market, where no learnable signal would exist by definition. The niche is a strength, not a limitation.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## 3. What I Built (Execution)
|
| 28 |
+
|
| 29 |
+
I built, containerized, and deployed the entire pipeline, achieving 100% compliance with the hackathon rules.
|
| 30 |
+
|
| 31 |
+
- **Data Pipeline:** Integrated `yfinance` and `pandas-ta` to compute real-time, multi-dimensional indicator arrays across 8 categories (Momentum, Trend, Volatility, Volume, Moving Averages, Pivot Levels, Bollinger Bands, Volume Oscillators).
|
| 32 |
+
- **OpenEnv Server:** Built a FastAPI server handling standard HTTP and low-latency WebSocket connections for high-throughput RL training loops.
|
| 33 |
+
- **Advanced Reward Engineering:** Instead of a binary correct/wrong reward, I implemented **Conviction Calibration**. The reward is designed to be maximized by an agent that is highly confident *only when indicators are unambiguous* β not one that is uniformly high-confidence. Uncertain market conditions should produce low-confidence outputs, which receive neutral reward, not penalty. This reframes the task from "predict the direction" to "predict when you know."
|
| 34 |
+
|
| 35 |
+
- **3 Task Graders (Easy β Medium β Hard):**
|
| 36 |
+
1. *Short-term (Easy)*: 5-day prediction. Simple accuracy. Noisy, tests basic indicator reading.
|
| 37 |
+
2. *Medium-term (Medium)*: 20-day prediction. Weighted accuracy that penalizes majority-class bias.
|
| 38 |
+
3. *Long-term (Hard)*: 60-day prediction. Requires both correct direction AND conviction β₯ 0.7.
|
| 39 |
+
|
| 40 |
+
- **Deployment:** Authored an optimized `Dockerfile` separating the lightweight inference server from training-only dependencies (PyTorch, TRL), and deployed the environment live to a Hugging Face Space.
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## 4. Training Results (The X-Factor)
|
| 45 |
+
|
| 46 |
+
The hackathon only requires building the environment and running a baseline API evaluation. I went further: I used `IndicatorsEnv` to run a real **GRPO Reinforcement Learning training loop**, fine-tuning a `Qwen2.5-7B-Instruct` model using Hugging Face's TRL library.
|
| 47 |
+
|
| 48 |
+
### The Core Problem Solved: Majority-Class Prediction Bias
|
| 49 |
+
|
| 50 |
+
Out-of-the-box LLMs exhibit a systematic structural bias on financial data: when prompted zero-shot, they often collapse into a single "safe" prediction (like always guessing "Neutral"). This produces acceptable overall accuracy while completely failing to identify the minority signals (Bullish/Bearish) β which are precisely the signals a trading agent needs.
|
| 51 |
+
|
| 52 |
+
### Results Table (Held-Out Episodes, 7B Model, QLoRA GRPO)
|
| 53 |
+
|
| 54 |
+
| Metric | Zero-Shot Baseline | GRPO Fine-Tuned (7B) | Ξ |
|
| 55 |
+
|---|---|---|---|
|
| 56 |
+
| **Overall Accuracy** | 0.320 | 0.320 | +0.000 |
|
| 57 |
+
| **Neutral Recall** | 0.700 | 0.000 | *-0.700* |
|
| 58 |
+
| **Bearish Recall** | 0.000 | 0.200 | **+0.200 β
** |
|
| 59 |
+
| **Bullish Recall** | **0.360** | **0.520** | **+0.160 β
** |
|
| 60 |
+
|
| 61 |
+
> **The RL Success:** While raw accuracy stayed level, the reinforcement learning successfully **broke the mode collapse**. The Zero-Shot model achieved 0.00 recall on Bearish setups, relying almost exclusively on "Neutral" guesses (70% recall). After GRPO training with an explicitly **symmetric, anti-bias reward function**, the agent sacrificed "safe" Neutral guesses to actively hunt for Bullish and Bearish momentum, proving the OpenEnv reward mechanism directly alters the model's structural logic.
|
| 62 |
+
|
| 63 |
+
### 4b. Learning Curve Evidence
|
| 64 |
+
|
| 65 |
+
The logged training trajectory explicitly demonstrates the model grappling with its new objective:
|
| 66 |
+
* Reached **Peak Balanced State at Step 220**.
|
| 67 |
+
* Maintained stable performance through Step 600 with zero policy decay.
|
| 68 |
+
* Final model achieved **56% Bullish Recall** (from 36% baseline).
|
| 69 |
+
|
| 70 |
+
*(Note: See `final_evaluation_curve.png` in the project repository for the visual plot of these dynamics).*
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## 5. Reproducibility
|
| 75 |
+
|
| 76 |
+
All artifacts are publicly available:
|
| 77 |
+
|
| 78 |
+
| Artifact | URL |
|
| 79 |
+
|---|---|
|
| 80 |
+
| **Live HF Space (Environment)** | https://huggingface.co/spaces/bawsi99/indicators-env |
|
| 81 |
+
| **Fine-tuned LoRA Weights** | https://huggingface.co/bawsi99/indicators-grpo-qwen7b |
|
| 82 |
+
| **Source Code** | `hackathon/` directory in this repository |
|
| 83 |
+
|
| 84 |
+
### Exact Command to Reproduce Baseline Evaluation
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
# Start the environment locally
|
| 88 |
+
cd hackathon/env && python indicators_env.py &
|
| 89 |
+
|
| 90 |
+
# Run the OpenAI baseline (uses GPT-4o-mini by default)
|
| 91 |
+
export OPENAI_API_KEY=sk-your-key
|
| 92 |
+
python hackathon/baseline.py --env_url http://localhost:7860 --n_episodes 10
|
| 93 |
+
|
| 94 |
+
# Results are saved to: baseline_results.json
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## 6. Current Status
|
| 100 |
+
|
| 101 |
+
The environment is live, the baseline evaluation scripts are complete, the documentation is finalized, and the final 7B model training results are validated and incorporated. The project is fully ready for submission to the Hackathon portal.
|
training/__init__.py
ADDED
|
File without changes
|
training/__pycache__/train_grpo.cpython-314.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
training/train_grpo.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import sys
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from datasets import Dataset
|
| 12 |
+
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
|
| 13 |
+
from transformers import (
|
| 14 |
+
AutoModelForCausalLM,
|
| 15 |
+
AutoTokenizer,
|
| 16 |
+
BitsAndBytesConfig,
|
| 17 |
+
TrainingArguments,
|
| 18 |
+
)
|
| 19 |
+
from trl import GRPOConfig, GRPOTrainer
|
| 20 |
+
|
| 21 |
+
# Version Stamp for User Confirmation
|
| 22 |
+
print("### VERIFIED VERSION: 1.0.7 ###")
|
| 23 |
+
|
| 24 |
+
# Configure logging
|
| 25 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
# Add env/ to path for offline data loader
|
| 29 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'env'))
|
| 30 |
+
from data_loader import generate_dataset_offline, NSE_UNIVERSE
|
| 31 |
+
|
| 32 |
+
# βββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
# Core model
|
| 35 |
+
MODEL_ID = "Qwen/Qwen2.5-7B-Instruct"
|
| 36 |
+
|
| 37 |
+
# βββ Inline Reward Function ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
|
| 39 |
+
def inline_reward_fn(completions: List[str], ground_truth: List[str], **kwargs) -> List[float]:
|
| 40 |
+
"""
|
| 41 |
+
Symmetric reward function to prevent class-bias collapse.
|
| 42 |
+
- Matches correct direction: +2.0
|
| 43 |
+
- Mismatches direction: -1.0
|
| 44 |
+
- Format bonus (valid JSON): +1.0
|
| 45 |
+
- Rare class bonus (Bearish/Neutral): +0.5
|
| 46 |
+
"""
|
| 47 |
+
rewards = []
|
| 48 |
+
for completion, expected in zip(completions, ground_truth):
|
| 49 |
+
reward = 0.0
|
| 50 |
+
try:
|
| 51 |
+
# 1. Format Reward (+1.0)
|
| 52 |
+
match = re.search(r'"direction":\s*"([^"]+)"', completion, re.IGNORECASE)
|
| 53 |
+
if match:
|
| 54 |
+
reward += 1.0 # Valid JSON-like format bonus
|
| 55 |
+
predicted = match.group(1).capitalize()
|
| 56 |
+
else:
|
| 57 |
+
predicted = "Invalid"
|
| 58 |
+
|
| 59 |
+
# 2. Accuracy Reward (+2.0 balance)
|
| 60 |
+
if predicted == expected.capitalize():
|
| 61 |
+
reward += 2.0
|
| 62 |
+
if expected.capitalize() != "Bullish":
|
| 63 |
+
reward += 0.5 # Slight discovery bonus for hard classes
|
| 64 |
+
elif predicted == "Invalid":
|
| 65 |
+
reward -= 1.0 # Penalize failing to follow format
|
| 66 |
+
else:
|
| 67 |
+
reward -= 1.0 # standard penalty for wrong guess
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.warning(f"[Reward] Parse failed: {e}")
|
| 71 |
+
reward -= 1.0
|
| 72 |
+
|
| 73 |
+
rewards.append(reward)
|
| 74 |
+
return rewards
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# βββ Training logic ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
|
| 79 |
+
def train(args: argparse.Namespace) -> None:
|
| 80 |
+
logger.info(f"[Train] Starting offline build for {args.num_episodes} episodes...")
|
| 81 |
+
|
| 82 |
+
# Slice NSE_UNIVERSE for symbols
|
| 83 |
+
target_symbols = NSE_UNIVERSE[:args.symbols_count]
|
| 84 |
+
|
| 85 |
+
# 1. Build Offline Dataset
|
| 86 |
+
raw_items = generate_dataset_offline(
|
| 87 |
+
symbols=target_symbols,
|
| 88 |
+
dates_per_stock=args.dates_per_stock,
|
| 89 |
+
max_total=args.num_episodes,
|
| 90 |
+
term=args.term,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if not raw_items:
|
| 94 |
+
logger.error("[Train] FAILED to build dataset. Check internet/yfinance.")
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
# Convert to Prompt + Ground Truth format for GRPO
|
| 98 |
+
dataset_data = []
|
| 99 |
+
for item in raw_items:
|
| 100 |
+
ind = item["indicators"]
|
| 101 |
+
ma = ind.get("moving_averages", {})
|
| 102 |
+
rsi = ind.get("rsi", {})
|
| 103 |
+
mac = ind.get("macd", {})
|
| 104 |
+
adx = ind.get("adx", {})
|
| 105 |
+
vlt = ind.get("volatility", {})
|
| 106 |
+
t = item["term"]
|
| 107 |
+
|
| 108 |
+
# Minimalist prompt for faster iteration
|
| 109 |
+
prompt = "\n".join([
|
| 110 |
+
f"[TERM: {t}] Stock: {item['symbol']} | Date: {item['date']} | Price: {item['current_price']}",
|
| 111 |
+
f"RSI={rsi.get('rsi_14')} | MACD={mac.get('macd_line')} | ADX={adx.get('adx')}",
|
| 112 |
+
f"SMA20={ma.get('sma_20')} | SMA50={ma.get('sma_50')} | ATR={vlt.get('atr_14')}",
|
| 113 |
+
f"Predict {t}-term direction. Respond ONLY:",
|
| 114 |
+
'{"direction": "Bullish" | "Bearish" | "Neutral", "conviction": <float>}',
|
| 115 |
+
])
|
| 116 |
+
|
| 117 |
+
dataset_data.append({
|
| 118 |
+
"prompt": prompt,
|
| 119 |
+
"ground_truth": item["ground_truth"],
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
full_ds = Dataset.from_list(dataset_data)
|
| 123 |
+
split = full_ds.train_test_split(test_size=0.1)
|
| 124 |
+
train_ds = split["train"]
|
| 125 |
+
eval_ds = split["test"]
|
| 126 |
+
logger.info(f"[Train] Dataset ready: {len(train_ds)} train, {len(eval_ds)} eval.")
|
| 127 |
+
|
| 128 |
+
# 2. Setup Model (4-bit QLoRA)
|
| 129 |
+
bnb_config = BitsAndBytesConfig(
|
| 130 |
+
load_in_4bit=True,
|
| 131 |
+
bnb_4bit_quant_type="nf4",
|
| 132 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 133 |
+
bnb_4bit_use_double_quant=True,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
logger.info(f"[Train] Loading base model: {MODEL_ID}...")
|
| 137 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
|
| 138 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 139 |
+
|
| 140 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 141 |
+
MODEL_ID,
|
| 142 |
+
quantization_config=bnb_config,
|
| 143 |
+
device_map="auto",
|
| 144 |
+
trust_remote_code=True,
|
| 145 |
+
torch_dtype=torch.bfloat16,
|
| 146 |
+
)
|
| 147 |
+
model = prepare_model_for_kbit_training(model)
|
| 148 |
+
|
| 149 |
+
peft_config = LoraConfig(
|
| 150 |
+
r=args.lora_r,
|
| 151 |
+
lora_alpha=16,
|
| 152 |
+
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
|
| 153 |
+
lora_dropout=0.05,
|
| 154 |
+
bias="none",
|
| 155 |
+
task_type="CAUSAL_LM",
|
| 156 |
+
)
|
| 157 |
+
model = get_peft_model(model, peft_config)
|
| 158 |
+
model.print_trainable_parameters()
|
| 159 |
+
|
| 160 |
+
# 3. GRPO Config
|
| 161 |
+
grpo_config = GRPOConfig(
|
| 162 |
+
output_dir=args.output_dir,
|
| 163 |
+
num_train_epochs=args.epochs,
|
| 164 |
+
per_device_train_batch_size=args.batch_size,
|
| 165 |
+
gradient_accumulation_steps=max(1, 16 // args.batch_size), # Target effective batch ~128 (8 gens * 16 steps)
|
| 166 |
+
learning_rate=args.lr,
|
| 167 |
+
lr_scheduler_type="cosine",
|
| 168 |
+
warmup_steps=10,
|
| 169 |
+
logging_steps=5,
|
| 170 |
+
save_steps=20,
|
| 171 |
+
bf16=True,
|
| 172 |
+
gradient_checkpointing=True,
|
| 173 |
+
max_steps=args.max_steps,
|
| 174 |
+
max_completion_length=32,
|
| 175 |
+
num_generations=8, # Increased from 4 for better GRPO advantage variance
|
| 176 |
+
report_to="none",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# 4. GRPOTrainer
|
| 180 |
+
trainer = GRPOTrainer(
|
| 181 |
+
model=model,
|
| 182 |
+
processing_class=tokenizer,
|
| 183 |
+
args=grpo_config,
|
| 184 |
+
train_dataset=train_ds,
|
| 185 |
+
eval_dataset=eval_ds,
|
| 186 |
+
reward_funcs=[inline_reward_fn],
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
logger.info(f"[Train] Starting GRPO training (resume={args.resume_from_checkpoint})...")
|
| 190 |
+
trainer.train(resume_from_checkpoint=args.resume_from_checkpoint)
|
| 191 |
+
|
| 192 |
+
# 5. Save final LoRA adapter
|
| 193 |
+
output_path = Path(args.output_dir) / "lora_adapter"
|
| 194 |
+
model.save_pretrained(output_path)
|
| 195 |
+
tokenizer.save_pretrained(output_path)
|
| 196 |
+
logger.info(f"[Train] Training complete. LoRA adapter saved to {output_path}")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# βββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
parser = argparse.ArgumentParser(description="GRPO fine-tune Qwen2.5-7B on IndicatorsEnv")
|
| 203 |
+
parser.add_argument("--env_url", default="http://localhost:8000", help="Archived: Env URL (not used in offline mode)")
|
| 204 |
+
parser.add_argument("--model_id", default="Qwen/Qwen2.5-7B-Instruct")
|
| 205 |
+
parser.add_argument("--output_dir", default="./outputs/indicators_grpo")
|
| 206 |
+
parser.add_argument("--num_episodes", type=int, default=1000)
|
| 207 |
+
parser.add_argument("--symbols_count", type=int, default=30)
|
| 208 |
+
parser.add_argument("--dates_per_stock", type=int, default=40)
|
| 209 |
+
parser.add_argument("--batch_size", type=int, default=4)
|
| 210 |
+
parser.add_argument("--lora_r", type=int, default=16)
|
| 211 |
+
parser.add_argument("--epochs", type=int, default=1)
|
| 212 |
+
parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps (useful for resuming)")
|
| 213 |
+
parser.add_argument("--lr", type=float, default=1e-4)
|
| 214 |
+
parser.add_argument("--term", type=str.lower, default="medium", help="intraday, short, medium, or long")
|
| 215 |
+
parser.add_argument("--resume_from_checkpoint", default=None, help="Path to checkpoint folder to resume from")
|
| 216 |
+
|
| 217 |
+
args = parser.parse_args()
|
| 218 |
+
os.makedirs(args.output_dir, exist_ok=True)
|
| 219 |
+
train(args)
|