Spaces:
Sleeping
Sleeping
feat: v4.1 portfolio MDP — capital tracking + tx_cost on all tasks
Browse files- Add current_holding, capital, drawdown to MultiStockObservation
- Track virtual capital for ALL tasks (was only Task 3)
- Apply 0.1% transaction cost when switching stock holdings
- Per-task drawdown limits: short=5%, medium=10%, long=15%
- Expose portfolio state in inference.py prompt
- Version bump to 4.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- env/indicators_env.py +604 -198
- inference.py +328 -103
env/indicators_env.py
CHANGED
|
@@ -1,19 +1,46 @@
|
|
| 1 |
"""
|
| 2 |
-
indicators_env.py —
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
@@ -21,18 +48,33 @@ from __future__ import annotations
|
|
| 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 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -41,107 +83,177 @@ logger = logging.getLogger(__name__)
|
|
| 41 |
app = FastAPI(
|
| 42 |
title="IndicatorsEnv",
|
| 43 |
description=(
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
|
|
|
|
|
|
|
|
|
| 47 |
"Built for the Meta × PyTorch Hackathon."
|
| 48 |
),
|
| 49 |
-
version="
|
| 50 |
)
|
| 51 |
|
| 52 |
-
# ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
TASKS = [
|
| 55 |
{
|
| 56 |
"id": "short_term_direction",
|
| 57 |
-
"name": "Short-term
|
| 58 |
"description": (
|
| 59 |
-
"
|
| 60 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
),
|
| 62 |
"difficulty": "easy",
|
| 63 |
"term": "short",
|
| 64 |
-
"
|
|
|
|
|
|
|
| 65 |
"action_schema": {
|
| 66 |
-
"
|
| 67 |
-
"
|
|
|
|
| 68 |
},
|
| 69 |
},
|
| 70 |
{
|
| 71 |
"id": "medium_term_direction",
|
| 72 |
-
"name": "Medium-term
|
| 73 |
"description": (
|
| 74 |
-
"
|
| 75 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
),
|
| 77 |
"difficulty": "medium",
|
| 78 |
"term": "medium",
|
|
|
|
|
|
|
| 79 |
"grader_note": (
|
| 80 |
-
"
|
| 81 |
-
"
|
|
|
|
| 82 |
),
|
| 83 |
"action_schema": {
|
| 84 |
-
"
|
| 85 |
-
"
|
|
|
|
| 86 |
},
|
| 87 |
},
|
| 88 |
{
|
| 89 |
"id": "long_term_conviction",
|
| 90 |
-
"name": "Long-term
|
| 91 |
"description": (
|
| 92 |
-
"
|
| 93 |
-
"
|
| 94 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
),
|
| 96 |
"difficulty": "hard",
|
| 97 |
"term": "long",
|
|
|
|
|
|
|
| 98 |
"grader_note": (
|
| 99 |
-
"
|
| 100 |
-
"
|
| 101 |
-
"
|
|
|
|
| 102 |
),
|
| 103 |
"action_schema": {
|
| 104 |
-
"
|
| 105 |
-
"
|
|
|
|
| 106 |
},
|
| 107 |
},
|
| 108 |
]
|
| 109 |
|
| 110 |
-
TASK_BY_ID
|
| 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 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
class StepResult(BaseModel):
|
| 129 |
-
observation: Optional[
|
| 130 |
-
reward:
|
| 131 |
-
done:
|
| 132 |
-
info:
|
| 133 |
|
| 134 |
|
| 135 |
class ResetResult(BaseModel):
|
| 136 |
-
observation:
|
| 137 |
-
info:
|
| 138 |
|
| 139 |
|
| 140 |
class StateResult(BaseModel):
|
| 141 |
-
session_id:
|
| 142 |
-
current_observation: Optional[
|
| 143 |
-
episodes_completed:
|
| 144 |
-
current_task:
|
| 145 |
|
| 146 |
|
| 147 |
class GraderRequest(BaseModel):
|
|
@@ -149,76 +261,123 @@ class GraderRequest(BaseModel):
|
|
| 149 |
episode_results: List[Dict[str, Any]] = Field(
|
| 150 |
...,
|
| 151 |
description=(
|
| 152 |
-
"List of
|
| 153 |
-
"'
|
|
|
|
|
|
|
| 154 |
),
|
| 155 |
)
|
| 156 |
|
| 157 |
|
| 158 |
class GraderResult(BaseModel):
|
| 159 |
-
task_id:
|
| 160 |
-
score:
|
| 161 |
num_episodes: int
|
| 162 |
-
breakdown:
|
| 163 |
|
| 164 |
|
| 165 |
-
# ─── Grader
|
|
|
|
| 166 |
|
| 167 |
def _clamp_score(score: float) -> float:
|
| 168 |
-
"""Clamp to strictly open interval (0, 1) as required by the validator."""
|
| 169 |
return round(max(0.001, min(0.999, score)), 4)
|
| 170 |
|
| 171 |
|
| 172 |
def grade_task(task_id: str, episode_results: List[Dict[str, Any]]) -> GraderResult:
|
| 173 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
if not episode_results:
|
| 175 |
-
return GraderResult(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
|
| 178 |
|
| 179 |
if task_id == "short_term_direction":
|
| 180 |
-
# Easy: simple accuracy
|
| 181 |
correct = sum(
|
| 182 |
-
1 for r in
|
| 183 |
-
if r.get("predicted",
|
| 184 |
)
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
elif task_id == "medium_term_direction":
|
| 189 |
-
# Medium: weighted accuracy (minority classes worth more)
|
| 190 |
weighted_score = 0.0
|
| 191 |
weighted_total = 0.0
|
| 192 |
-
for r in
|
| 193 |
-
gt
|
| 194 |
-
pred = r.get("predicted", "").capitalize()
|
| 195 |
w = 1.5 if gt in ("Bearish", "Neutral") else 1.0
|
| 196 |
weighted_total += w
|
| 197 |
if pred == gt:
|
| 198 |
weighted_score += w
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
elif task_id == "long_term_conviction":
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
pred = r.get("predicted", "").capitalize()
|
| 208 |
conviction = float(r.get("conviction", 0.5))
|
| 209 |
-
correct
|
| 210 |
if correct and conviction >= 0.7:
|
| 211 |
-
|
| 212 |
elif correct and conviction < 0.7:
|
| 213 |
-
|
| 214 |
elif not correct and conviction >= 0.8:
|
| 215 |
-
|
| 216 |
else:
|
| 217 |
-
|
| 218 |
-
raw = sum(
|
| 219 |
-
# Normalize
|
| 220 |
score = _clamp_score((raw + 0.1) / 1.1)
|
| 221 |
-
breakdown = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
else:
|
| 224 |
raise HTTPException(status_code=404, detail=f"Unknown task_id: {task_id}")
|
|
@@ -226,97 +385,310 @@ def grade_task(task_id: str, episode_results: List[Dict[str, Any]]) -> GraderRes
|
|
| 226 |
return GraderResult(
|
| 227 |
task_id=task_id,
|
| 228 |
score=round(score, 4),
|
| 229 |
-
num_episodes=
|
| 230 |
breakdown=breakdown,
|
| 231 |
)
|
| 232 |
|
| 233 |
|
| 234 |
# ─── Session state ────────────────────────────────────────────────────────────
|
| 235 |
|
|
|
|
| 236 |
class EnvSession:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
def __init__(self, session_id: str, term: str = "medium"):
|
| 238 |
-
self.session_id
|
| 239 |
-
self.term
|
| 240 |
-
self.
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
self.
|
| 244 |
-
self.
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
if not self.scenario_pool:
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
random.shuffle(self.scenario_pool)
|
|
|
|
| 256 |
return self.scenario_pool.pop() if self.scenario_pool else None
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
def reset(self) -> Optional[ResetResult]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
for _ in range(10):
|
| 260 |
sc = self._get_scenario()
|
| 261 |
if sc is None:
|
| 262 |
return None
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
return ResetResult(
|
| 269 |
-
observation=self.current_obs,
|
| 270 |
-
info=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
)
|
| 272 |
return None
|
| 273 |
|
| 274 |
-
def step(self, action:
|
| 275 |
-
if self.
|
| 276 |
return StepResult(
|
| 277 |
observation=None, reward=0.0, done=True,
|
| 278 |
-
info={"error": "Call reset() first"}
|
| 279 |
)
|
| 280 |
-
correct = action.direction.strip().capitalize() == self.current_gt
|
| 281 |
-
reward = 1.0 if correct else 0.0
|
| 282 |
-
|
| 283 |
-
# Shaped reward: conviction calibration
|
| 284 |
-
if correct and action.conviction >= 0.6:
|
| 285 |
-
reward = 1.0 + 0.1 * (action.conviction - 0.6)
|
| 286 |
-
elif not correct and action.conviction >= 0.8:
|
| 287 |
-
reward = -0.1
|
| 288 |
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
"
|
| 294 |
-
"
|
| 295 |
-
"
|
| 296 |
-
"
|
| 297 |
-
"
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
-
# Store for grader
|
| 301 |
self.episode_history.append({
|
| 302 |
-
"
|
| 303 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
})
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
def state(self) -> StateResult:
|
| 315 |
return StateResult(
|
| 316 |
-
session_id=self.session_id,
|
| 317 |
-
current_observation=self.current_obs,
|
| 318 |
-
episodes_completed=self.episodes_completed,
|
| 319 |
-
current_task=TERM_TO_TASK_ID.get(self.term),
|
| 320 |
)
|
| 321 |
|
| 322 |
|
|
@@ -324,6 +696,7 @@ class EnvSession:
|
|
| 324 |
|
| 325 |
_sessions: Dict[str, EnvSession] = {}
|
| 326 |
|
|
|
|
| 327 |
def _get_or_create(session_id: Optional[str] = None, term: str = "medium") -> EnvSession:
|
| 328 |
if session_id is None:
|
| 329 |
session_id = str(uuid.uuid4())
|
|
@@ -334,35 +707,52 @@ def _get_or_create(session_id: Optional[str] = None, term: str = "medium") -> En
|
|
| 334 |
|
| 335 |
# ─── HTTP endpoints (OpenEnv standard) ───────────────────────────────────────
|
| 336 |
|
|
|
|
| 337 |
@app.get("/health")
|
| 338 |
def health():
|
| 339 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
|
| 342 |
@app.get("/tasks")
|
| 343 |
def get_tasks():
|
| 344 |
-
"""Returns all task definitions
|
| 345 |
return {
|
| 346 |
-
"tasks":
|
| 347 |
-
"total":
|
| 348 |
"action_schema": {
|
| 349 |
-
"
|
| 350 |
-
"
|
|
|
|
| 351 |
},
|
|
|
|
|
|
|
| 352 |
}
|
| 353 |
|
| 354 |
|
| 355 |
@app.post("/reset", response_model=ResetResult)
|
| 356 |
def reset(session_id: Optional[str] = None, term: str = "medium"):
|
| 357 |
-
sess
|
| 358 |
result = sess.reset()
|
| 359 |
if result is None:
|
| 360 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
| 361 |
return result
|
| 362 |
|
| 363 |
|
| 364 |
@app.post("/step", response_model=StepResult)
|
| 365 |
-
def step(action:
|
| 366 |
sess = _get_or_create(session_id)
|
| 367 |
return sess.step(action)
|
| 368 |
|
|
@@ -377,67 +767,81 @@ def state(session_id: Optional[str] = None):
|
|
| 377 |
def grader(request: GraderRequest):
|
| 378 |
"""
|
| 379 |
Score a completed episode set against a specific task grader.
|
| 380 |
-
|
| 381 |
-
Returns
|
| 382 |
"""
|
| 383 |
if request.task_id not in TASK_BY_ID:
|
| 384 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
return grade_task(request.task_id, request.episode_results)
|
| 386 |
|
| 387 |
|
| 388 |
@app.get("/baseline")
|
| 389 |
def baseline():
|
| 390 |
"""
|
| 391 |
-
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
| 393 |
"""
|
| 394 |
results = {}
|
| 395 |
-
random.seed(42)
|
| 396 |
|
| 397 |
for task in TASKS:
|
| 398 |
-
term
|
| 399 |
task_id = task["id"]
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
# Fresh session
|
| 403 |
-
sess = EnvSession(session_id=session_id, term=term)
|
| 404 |
-
episode_results = []
|
| 405 |
|
| 406 |
for _ in range(10):
|
| 407 |
reset_result = sess.reset()
|
| 408 |
if reset_result is None:
|
| 409 |
continue
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
"
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
|
| 422 |
grader_result = grade_task(task_id, episode_results)
|
| 423 |
results[task_id] = {
|
| 424 |
-
"task_name":
|
| 425 |
-
"difficulty":
|
| 426 |
-
"score":
|
| 427 |
"num_episodes": grader_result.num_episodes,
|
| 428 |
-
"breakdown":
|
| 429 |
}
|
| 430 |
|
| 431 |
return {
|
| 432 |
-
"agent":
|
| 433 |
-
"seed":
|
| 434 |
-
"tasks":
|
| 435 |
"overall_mean": round(sum(r["score"] for r in results.values()) / len(results), 4),
|
| 436 |
}
|
| 437 |
|
| 438 |
|
| 439 |
# ─── WebSocket endpoint (OpenEnv /ws) ────────────────────────────────────────
|
| 440 |
|
|
|
|
| 441 |
@app.websocket("/ws")
|
| 442 |
async def websocket_env(ws: WebSocket, session_id: Optional[str] = None, term: str = "medium"):
|
| 443 |
await ws.accept()
|
|
@@ -457,7 +861,7 @@ async def websocket_env(ws: WebSocket, session_id: Optional[str] = None, term: s
|
|
| 457 |
await ws.send_text(result.model_dump_json())
|
| 458 |
|
| 459 |
elif method == "step":
|
| 460 |
-
action =
|
| 461 |
result = sess.step(action)
|
| 462 |
await ws.send_text(result.model_dump_json())
|
| 463 |
|
|
@@ -477,5 +881,7 @@ async def websocket_env(ws: WebSocket, session_id: Optional[str] = None, term: s
|
|
| 477 |
|
| 478 |
# ─── Entry point ─────────────────────────────────────────────────────────────
|
| 479 |
|
|
|
|
| 480 |
if __name__ == "__main__":
|
| 481 |
-
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
indicators_env.py — IndicatorsEnv v4.1: Multi-stock Portfolio MDP
|
| 3 |
+
|
| 4 |
+
OpenEnv-compatible RL environment for NSE equity analysis.
|
| 5 |
+
|
| 6 |
+
Full OpenEnv interface:
|
| 7 |
+
HTTP : GET /health /tasks
|
| 8 |
+
POST /reset /step /grader
|
| 9 |
+
GET /state /baseline
|
| 10 |
+
WebSocket: /ws
|
| 11 |
+
|
| 12 |
+
Env design (v4.1) — Multi-stock Portfolio MDP:
|
| 13 |
+
At each step the agent observes 3 stocks from the same NSE sector.
|
| 14 |
+
It picks ONE stock (or passes with NONE) and declares a direction.
|
| 15 |
+
|
| 16 |
+
Observation:
|
| 17 |
+
- 3 stocks from the same sector (same stocks for the whole episode)
|
| 18 |
+
- Full technical indicator snapshot per stock
|
| 19 |
+
- RSI + price-momentum signal history (accumulated across steps)
|
| 20 |
+
- Macro context in Task 3 (NIFTY50 trend, market regime)
|
| 21 |
+
|
| 22 |
+
Action:
|
| 23 |
+
{"stock": "HDFCBANK", "direction": "Bullish"|"Bearish"|"NONE", "conviction": 0.8}
|
| 24 |
+
NONE = skip this step (preserve selective participation)
|
| 25 |
+
|
| 26 |
+
Reward (market-neutral alpha):
|
| 27 |
+
alpha = chosen_stock_period_return − sector_avg_period_return
|
| 28 |
+
reward = alpha × direction_sign × conviction × REWARD_SCALE
|
| 29 |
+
→ random policy earns ~0 expected reward (sector avg cancels market beta)
|
| 30 |
+
→ skilled policy earns consistently positive reward (correct stock selection)
|
| 31 |
+
→ Kelly conviction semantics: fraction of virtual wealth wagered per step
|
| 32 |
+
|
| 33 |
+
Step spacing:
|
| 34 |
+
short : 1 trading day per step → GT = 1-day return (5 steps = 1 week)
|
| 35 |
+
medium : 5 trading days per step → GT = 5-day return (10 steps = 10 weeks)
|
| 36 |
+
long : 20 trading days per step → GT = 20-day return (15 steps = 15 months)
|
| 37 |
+
GT window = step spacing → zero overlap, reward and GT measure identical period.
|
| 38 |
+
|
| 39 |
+
Task 3 extras:
|
| 40 |
+
- Drawdown limit: virtual capital terminates at 10% drawdown
|
| 41 |
+
- Macro context (NIFTY50) injected into each observation
|
| 42 |
+
|
| 43 |
+
Built for the Meta × PyTorch Hackathon.
|
| 44 |
"""
|
| 45 |
|
| 46 |
from __future__ import annotations
|
|
|
|
| 48 |
import json
|
| 49 |
import logging
|
| 50 |
import random
|
| 51 |
+
import sys
|
| 52 |
import uuid
|
| 53 |
+
from statistics import mean
|
| 54 |
from typing import Any, Dict, List, Optional
|
| 55 |
+
from datetime import datetime, timedelta
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
import yf_patch
|
| 59 |
+
yf_patch.patch_yfinance_globally()
|
| 60 |
+
except ImportError:
|
| 61 |
+
pass
|
| 62 |
|
| 63 |
+
import pandas as pd
|
| 64 |
import uvicorn
|
| 65 |
+
import numpy as np
|
| 66 |
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 67 |
from pydantic import BaseModel, Field
|
| 68 |
|
| 69 |
from data_loader import (
|
| 70 |
+
NSE_UNIVERSE,
|
| 71 |
+
SECTOR_GROUPS,
|
| 72 |
+
TERM_WINDOWS,
|
| 73 |
+
TERM_THRESHOLDS,
|
| 74 |
+
STEP_SPACING,
|
| 75 |
+
PERIOD_THRESHOLDS,
|
| 76 |
+
build_multi_stock_episode,
|
| 77 |
+
fetch_macro_context
|
| 78 |
)
|
| 79 |
|
| 80 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 83 |
app = FastAPI(
|
| 84 |
title="IndicatorsEnv",
|
| 85 |
description=(
|
| 86 |
+
"IndicatorsEnv v4.1 — Multi-stock Portfolio MDP for NSE equity analysis. "
|
| 87 |
+
"At each step the agent observes 3 stocks from the same NSE sector and picks one "
|
| 88 |
+
"(or passes). Reward = (chosen stock return − sector average) × direction × conviction. "
|
| 89 |
+
"Market-neutral alpha with portfolio capital tracking: holding a position avoids "
|
| 90 |
+
"transaction cost (0.1%); switching incurs it. Capital causally tracks cumulative alpha P&L. "
|
| 91 |
+
"Drawdown limits apply to all tasks. Three tasks of increasing difficulty. "
|
| 92 |
"Built for the Meta × PyTorch Hackathon."
|
| 93 |
),
|
| 94 |
+
version="4.1.0",
|
| 95 |
)
|
| 96 |
|
| 97 |
+
# ─── Environment constants ────────────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
TASK_MAX_STEPS: Dict[str, int] = {
|
| 100 |
+
"short": 5, # 5 × 1-day = 1 trading week
|
| 101 |
+
"medium": 10, # 10 × 5-day = 10 trading weeks
|
| 102 |
+
"long": 15, # 15 × 20-day = 15 months
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
REWARD_SCALE = 50.0 # ×50: 2% alpha × conviction 0.7 × 50 ≈ 0.7 reward
|
| 106 |
+
TRANSACTION_COST = 0.001 # 0.1% of capital on holding switch (exploration cost)
|
| 107 |
+
|
| 108 |
+
DRAWDOWN_LIMITS: Dict[str, float] = {
|
| 109 |
+
"short": 0.05, # 5% — 5 steps, tight risk
|
| 110 |
+
"medium": 0.10, # 10% — 10 steps
|
| 111 |
+
"long": 0.15, # 15% — 15 steps, more recovery time
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# ─── Task definitions ─────────────────────────────────────────────────────────
|
| 115 |
|
| 116 |
TASKS = [
|
| 117 |
{
|
| 118 |
"id": "short_term_direction",
|
| 119 |
+
"name": "Short-term Relative Alpha (Easy)",
|
| 120 |
"description": (
|
| 121 |
+
"5 steps, 1 trading day apart (= 1 week of daily observations). "
|
| 122 |
+
"At each step observe 3 stocks from the same NSE sector. "
|
| 123 |
+
"Pick one stock and declare Bullish (long), Bearish (short), or NONE (pass). "
|
| 124 |
+
"Reward = (chosen_return − sector_avg) × direction × conviction × 50. "
|
| 125 |
+
"Market-neutral: sector beta cancels — the agent profits from within-sector "
|
| 126 |
+
"relative momentum, not from broad market moves. "
|
| 127 |
+
"GT = 1-day forward return (±0.3% threshold). "
|
| 128 |
+
"Virtual capital tracked across steps; 0.1% transaction cost when switching holdings; "
|
| 129 |
+
"episode ends early at 5% drawdown."
|
| 130 |
),
|
| 131 |
"difficulty": "easy",
|
| 132 |
"term": "short",
|
| 133 |
+
"episode_steps": TASK_MAX_STEPS["short"],
|
| 134 |
+
"step_spacing_days": STEP_SPACING["short"],
|
| 135 |
+
"grader_note": "Directional accuracy on active (non-NONE) steps.",
|
| 136 |
"action_schema": {
|
| 137 |
+
"stock": {"type": "string", "description": "NSE symbol or 'NONE' to skip"},
|
| 138 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "NONE"]},
|
| 139 |
+
"conviction":{"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 140 |
},
|
| 141 |
},
|
| 142 |
{
|
| 143 |
"id": "medium_term_direction",
|
| 144 |
+
"name": "Medium-term Relative Alpha (Medium)",
|
| 145 |
"description": (
|
| 146 |
+
"10 steps, 5 trading days apart (= 10 weeks, one observation per week). "
|
| 147 |
+
"Same 3-stock multi-stock structure. "
|
| 148 |
+
"Signal history accumulates across steps — RSI trends and price momentum "
|
| 149 |
+
"are visible for all 3 stocks at each step, enabling sequential inference. "
|
| 150 |
+
"Reward = (chosen_return − sector_avg) × direction × conviction × 50. "
|
| 151 |
+
"GT = 5-day forward return (±1.5% threshold). "
|
| 152 |
+
"Grader gives extra weight to Bearish/Neutral correct calls (anti-majority-bias). "
|
| 153 |
+
"Virtual capital tracked; 0.1% switching cost; 10% drawdown terminates episode early."
|
| 154 |
),
|
| 155 |
"difficulty": "medium",
|
| 156 |
"term": "medium",
|
| 157 |
+
"episode_steps": TASK_MAX_STEPS["medium"],
|
| 158 |
+
"step_spacing_days": STEP_SPACING["medium"],
|
| 159 |
"grader_note": (
|
| 160 |
+
"Weighted accuracy on active steps: "
|
| 161 |
+
"Bearish/Neutral correct = 1.5× weight. "
|
| 162 |
+
"Participation bonus: score × (0.9 + 0.1 × active_rate)."
|
| 163 |
),
|
| 164 |
"action_schema": {
|
| 165 |
+
"stock": {"type": "string", "description": "NSE symbol or 'NONE' to skip"},
|
| 166 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "NONE"]},
|
| 167 |
+
"conviction":{"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 168 |
},
|
| 169 |
},
|
| 170 |
{
|
| 171 |
"id": "long_term_conviction",
|
| 172 |
+
"name": "Long-term Risk-Constrained Alpha (Hard)",
|
| 173 |
"description": (
|
| 174 |
+
"15 steps, 20 trading days apart (= 15 months, one observation per month). "
|
| 175 |
+
"Spans multiple market regimes — signal history essential for detecting "
|
| 176 |
+
"multi-month relative momentum shifts within the sector. "
|
| 177 |
+
"Macro context (NIFTY50 trend, market regime) added to each observation. "
|
| 178 |
+
"Capital-tracked risk management: 0.1% switching cost + 15% drawdown limit. "
|
| 179 |
+
"The agent must manage risk across months as well as generate alpha. "
|
| 180 |
+
"Correct calls with conviction ≥ 0.7 score highest; "
|
| 181 |
+
"overconfident wrong predictions penalized (conviction ≥ 0.8 on wrong). "
|
| 182 |
+
"GT = 20-day forward return (±2.5% threshold)."
|
| 183 |
),
|
| 184 |
"difficulty": "hard",
|
| 185 |
"term": "long",
|
| 186 |
+
"episode_steps": TASK_MAX_STEPS["long"],
|
| 187 |
+
"step_spacing_days": STEP_SPACING["long"],
|
| 188 |
"grader_note": (
|
| 189 |
+
"Conviction-calibrated accuracy on active steps: "
|
| 190 |
+
"correct+conviction≥0.7 = 1.0, correct+conviction<0.7 = 0.5, "
|
| 191 |
+
"wrong = 0.0, overconfident wrong (conviction≥0.8) = −0.1. "
|
| 192 |
+
"Normalized to (0, 1)."
|
| 193 |
),
|
| 194 |
"action_schema": {
|
| 195 |
+
"stock": {"type": "string", "description": "NSE symbol or 'NONE' to skip"},
|
| 196 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "NONE"]},
|
| 197 |
+
"conviction":{"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 198 |
},
|
| 199 |
},
|
| 200 |
]
|
| 201 |
|
| 202 |
+
TASK_BY_ID = {t["id"]: t for t in TASKS}
|
| 203 |
TERM_TO_TASK_ID = {t["term"]: t["id"] for t in TASKS}
|
| 204 |
|
| 205 |
# ─── Pydantic schemas ─────────────────────────────────────────────────────────
|
| 206 |
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
+
class StockState(BaseModel):
|
| 209 |
+
"""Per-stock snapshot at one episode step."""
|
| 210 |
+
symbol: str
|
| 211 |
+
current_price: float
|
| 212 |
+
rsi_14: float
|
| 213 |
+
rsi_trend: str # "up" | "down" | "flat"
|
| 214 |
+
price_momentum_pct: float # cumulative return vs episode start (%)
|
| 215 |
+
indicators: Dict[str, Any]
|
| 216 |
|
| 217 |
+
|
| 218 |
+
class MultiStockObservation(BaseModel):
|
| 219 |
+
"""Full observation returned by reset() and step()."""
|
| 220 |
+
step: int # 1-indexed current step
|
| 221 |
+
max_steps: int
|
| 222 |
+
term: str # SHORT | MEDIUM | LONG
|
| 223 |
+
sector: str # e.g. "banking"
|
| 224 |
+
available_stocks: List[str] # 3 symbols
|
| 225 |
+
stocks: Dict[str, StockState] # symbol → full state
|
| 226 |
+
signal_history: List[Dict[str, Any]] # chronological pick log
|
| 227 |
+
macro: Optional[Dict[str, Any]] = None # Task 3 only
|
| 228 |
+
# v4.1 portfolio fields (all tasks)
|
| 229 |
+
current_holding: str = "NONE" # stock held from last active step, or "NONE"
|
| 230 |
+
capital: float = 1.0 # virtual capital (starts 1.0, tracks cumulative alpha P&L)
|
| 231 |
+
drawdown: float = 0.0 # current drawdown from peak capital
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class MultiStockAction(BaseModel):
|
| 235 |
+
stock: str = Field(..., description="NSE symbol to pick, or 'NONE' to skip")
|
| 236 |
+
direction: str = Field(..., description="Bullish | Bearish | NONE")
|
| 237 |
+
conviction: float = Field(0.5, ge=0.0, le=1.0, description="Confidence 0–1")
|
| 238 |
|
| 239 |
|
| 240 |
class StepResult(BaseModel):
|
| 241 |
+
observation: Optional[MultiStockObservation]
|
| 242 |
+
reward: float
|
| 243 |
+
done: bool
|
| 244 |
+
info: Dict[str, Any]
|
| 245 |
|
| 246 |
|
| 247 |
class ResetResult(BaseModel):
|
| 248 |
+
observation: MultiStockObservation
|
| 249 |
+
info: Dict[str, Any]
|
| 250 |
|
| 251 |
|
| 252 |
class StateResult(BaseModel):
|
| 253 |
+
session_id: str
|
| 254 |
+
current_observation: Optional[MultiStockObservation]
|
| 255 |
+
episodes_completed: int
|
| 256 |
+
current_task: Optional[str]
|
| 257 |
|
| 258 |
|
| 259 |
class GraderRequest(BaseModel):
|
|
|
|
| 261 |
episode_results: List[Dict[str, Any]] = Field(
|
| 262 |
...,
|
| 263 |
description=(
|
| 264 |
+
"List of step dicts, each with keys: "
|
| 265 |
+
"'predicted' (str: Bullish|Bearish|NONE), "
|
| 266 |
+
"'ground_truth' (str: Bullish|Bearish|Neutral|N/A), "
|
| 267 |
+
"'conviction' (float)"
|
| 268 |
),
|
| 269 |
)
|
| 270 |
|
| 271 |
|
| 272 |
class GraderResult(BaseModel):
|
| 273 |
+
task_id: str
|
| 274 |
+
score: float = Field(..., gt=0.0, lt=1.0)
|
| 275 |
num_episodes: int
|
| 276 |
+
breakdown: Dict[str, Any]
|
| 277 |
|
| 278 |
|
| 279 |
+
# ─── Grader logic ─────────────────────────────────────────────────────────────
|
| 280 |
+
|
| 281 |
|
| 282 |
def _clamp_score(score: float) -> float:
|
| 283 |
+
"""Clamp to strictly open interval (0, 1) as required by the OpenEnv validator."""
|
| 284 |
return round(max(0.001, min(0.999, score)), 4)
|
| 285 |
|
| 286 |
|
| 287 |
def grade_task(task_id: str, episode_results: List[Dict[str, Any]]) -> GraderResult:
|
| 288 |
+
"""
|
| 289 |
+
Score episode results for a given task.
|
| 290 |
+
|
| 291 |
+
Active steps = steps where predicted != 'NONE'.
|
| 292 |
+
Grader evaluates only active steps; passing silently does not harm the score
|
| 293 |
+
but forfeits the opportunity to score points.
|
| 294 |
+
"""
|
| 295 |
if not episode_results:
|
| 296 |
+
return GraderResult(
|
| 297 |
+
task_id=task_id, score=0.001, num_episodes=0,
|
| 298 |
+
breakdown={"note": "No results submitted"}
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
n_total = len(episode_results)
|
| 302 |
+
active = [
|
| 303 |
+
r for r in episode_results
|
| 304 |
+
if str(r.get("predicted", "NONE")).capitalize() != "None"
|
| 305 |
+
and str(r.get("predicted", "NONE")).upper() != "NONE"
|
| 306 |
+
]
|
| 307 |
+
n_active = len(active)
|
| 308 |
+
|
| 309 |
+
if n_active == 0:
|
| 310 |
+
return GraderResult(
|
| 311 |
+
task_id=task_id, score=0.001, num_episodes=n_total,
|
| 312 |
+
breakdown={"note": "All steps passed (NONE)", "active_steps": 0}
|
| 313 |
+
)
|
| 314 |
|
| 315 |
+
participation_rate = n_active / n_total
|
| 316 |
|
| 317 |
if task_id == "short_term_direction":
|
|
|
|
| 318 |
correct = sum(
|
| 319 |
+
1 for r in active
|
| 320 |
+
if str(r.get("predicted","")).capitalize() == str(r.get("ground_truth","")).capitalize()
|
| 321 |
)
|
| 322 |
+
raw_score = correct / n_active
|
| 323 |
+
score = _clamp_score(raw_score)
|
| 324 |
+
breakdown = {
|
| 325 |
+
"correct": correct,
|
| 326 |
+
"active_steps": n_active,
|
| 327 |
+
"total_steps": n_total,
|
| 328 |
+
"participation_rate": round(participation_rate, 3),
|
| 329 |
+
"metric": "directional_accuracy",
|
| 330 |
+
}
|
| 331 |
|
| 332 |
elif task_id == "medium_term_direction":
|
|
|
|
| 333 |
weighted_score = 0.0
|
| 334 |
weighted_total = 0.0
|
| 335 |
+
for r in active:
|
| 336 |
+
gt = str(r.get("ground_truth", "")).capitalize()
|
| 337 |
+
pred = str(r.get("predicted", "")).capitalize()
|
| 338 |
w = 1.5 if gt in ("Bearish", "Neutral") else 1.0
|
| 339 |
weighted_total += w
|
| 340 |
if pred == gt:
|
| 341 |
weighted_score += w
|
| 342 |
+
base = (weighted_score / weighted_total) if weighted_total > 0 else 0.0
|
| 343 |
+
# Participation bonus: agents that selectively engage vs. pass every step
|
| 344 |
+
adjusted = base * (0.9 + 0.1 * participation_rate)
|
| 345 |
+
score = _clamp_score(adjusted)
|
| 346 |
+
breakdown = {
|
| 347 |
+
"weighted_score": round(weighted_score, 3),
|
| 348 |
+
"weighted_total": round(weighted_total, 3),
|
| 349 |
+
"participation_rate": round(participation_rate, 3),
|
| 350 |
+
"active_steps": n_active,
|
| 351 |
+
"total_steps": n_total,
|
| 352 |
+
"metric": "weighted_accuracy_with_participation",
|
| 353 |
+
}
|
| 354 |
|
| 355 |
elif task_id == "long_term_conviction":
|
| 356 |
+
per_step: List[float] = []
|
| 357 |
+
for r in active:
|
| 358 |
+
gt = str(r.get("ground_truth", "")).capitalize()
|
| 359 |
+
pred = str(r.get("predicted", "")).capitalize()
|
|
|
|
| 360 |
conviction = float(r.get("conviction", 0.5))
|
| 361 |
+
correct = (pred == gt)
|
| 362 |
if correct and conviction >= 0.7:
|
| 363 |
+
per_step.append(1.0)
|
| 364 |
elif correct and conviction < 0.7:
|
| 365 |
+
per_step.append(0.5)
|
| 366 |
elif not correct and conviction >= 0.8:
|
| 367 |
+
per_step.append(-0.1) # overconfident wrong
|
| 368 |
else:
|
| 369 |
+
per_step.append(0.0)
|
| 370 |
+
raw = sum(per_step) / n_active
|
| 371 |
+
# Normalize [-0.1, 1.0] → (0, 1) exclusive
|
| 372 |
score = _clamp_score((raw + 0.1) / 1.1)
|
| 373 |
+
breakdown = {
|
| 374 |
+
"per_step_scores": per_step,
|
| 375 |
+
"raw_mean": round(raw, 4),
|
| 376 |
+
"active_steps": n_active,
|
| 377 |
+
"total_steps": n_total,
|
| 378 |
+
"participation_rate": round(participation_rate, 3),
|
| 379 |
+
"metric": "conviction_calibrated_accuracy",
|
| 380 |
+
}
|
| 381 |
|
| 382 |
else:
|
| 383 |
raise HTTPException(status_code=404, detail=f"Unknown task_id: {task_id}")
|
|
|
|
| 385 |
return GraderResult(
|
| 386 |
task_id=task_id,
|
| 387 |
score=round(score, 4),
|
| 388 |
+
num_episodes=n_total,
|
| 389 |
breakdown=breakdown,
|
| 390 |
)
|
| 391 |
|
| 392 |
|
| 393 |
# ─── Session state ────────────────────────────────────────────────────────────
|
| 394 |
|
| 395 |
+
|
| 396 |
class EnvSession:
|
| 397 |
+
"""
|
| 398 |
+
Manages a single agent session across multi-stock episodes.
|
| 399 |
+
|
| 400 |
+
State per episode:
|
| 401 |
+
episode_steps : list of n_steps dicts from build_multi_stock_episode
|
| 402 |
+
current_step_idx : 0-indexed pointer into episode_steps
|
| 403 |
+
signal_history : accumulated RSI/momentum log shown in each observation
|
| 404 |
+
virtual_capital : starts 1.0; updated by scaled alpha reward (Task 3 drawdown)
|
| 405 |
+
|
| 406 |
+
Reward (per step):
|
| 407 |
+
alpha = chosen_stock_period_return − mean(all_3_stocks_period_return)
|
| 408 |
+
reward = alpha × direction_sign × conviction × REWARD_SCALE
|
| 409 |
+
NONE action → reward = 0.0
|
| 410 |
+
"""
|
| 411 |
+
|
| 412 |
def __init__(self, session_id: str, term: str = "medium"):
|
| 413 |
+
self.session_id = session_id
|
| 414 |
+
self.term = term.lower()
|
| 415 |
+
self.MAX_STEPS = TASK_MAX_STEPS.get(self.term, 5)
|
| 416 |
+
|
| 417 |
+
# Episode data: list of step dicts from build_multi_stock_episode
|
| 418 |
+
self.episode_steps: List[Dict[str, Any]] = []
|
| 419 |
+
self.current_step_idx: int = 0
|
| 420 |
+
self.current_obs: Optional[MultiStockObservation] = None
|
| 421 |
+
self.sector: str = ""
|
| 422 |
+
self.symbols: List[str] = []
|
| 423 |
+
|
| 424 |
+
self.episodes_completed: int = 0
|
| 425 |
+
self.episode_history: List[Dict[str, Any]] = []
|
| 426 |
+
self.signal_history: List[Dict[str, Any]] = []
|
| 427 |
+
|
| 428 |
+
# Portfolio: virtual capital and holding tracked across all tasks
|
| 429 |
+
self.virtual_capital: float = 1.0
|
| 430 |
+
self.peak_capital: float = 1.0
|
| 431 |
+
self.current_holding: str = "NONE"
|
| 432 |
+
|
| 433 |
+
# Lazy scenario pool
|
| 434 |
+
self.scenario_pool: List[Dict[str, Any]] = []
|
| 435 |
+
|
| 436 |
+
# ── helpers ──────────────────────────────────────────────────────────────
|
| 437 |
+
|
| 438 |
+
def _reset_episode_state(self) -> None:
|
| 439 |
+
self.episode_steps = []
|
| 440 |
+
self.current_step_idx = 0
|
| 441 |
+
self.signal_history = []
|
| 442 |
+
self.virtual_capital = 1.0
|
| 443 |
+
self.peak_capital = 1.0
|
| 444 |
+
self.current_holding = "NONE"
|
| 445 |
+
|
| 446 |
+
def _get_scenario(self) -> Optional[Dict[str, Any]]:
|
| 447 |
+
"""Pop a (sector, symbols, date) scenario from the pool, refilling if needed."""
|
| 448 |
if not self.scenario_pool:
|
| 449 |
+
sectors = list(SECTOR_GROUPS.keys())
|
| 450 |
+
# Date range safely within available yfinance data
|
| 451 |
+
# For long term: leave 15×20=300 days of future data → cap at 2022-12-31
|
| 452 |
+
end_date = {
|
| 453 |
+
"short": "2024-06-30",
|
| 454 |
+
"medium": "2023-12-31",
|
| 455 |
+
"long": "2022-06-30",
|
| 456 |
+
}.get(self.term, "2023-12-31")
|
| 457 |
+
|
| 458 |
+
dates = (
|
| 459 |
+
pd.bdate_range("2020-01-01", end_date, freq="15B")
|
| 460 |
+
.strftime("%Y-%m-%d")
|
| 461 |
+
.tolist()
|
| 462 |
)
|
| 463 |
+
for sector in sectors:
|
| 464 |
+
syms = SECTOR_GROUPS[sector]
|
| 465 |
+
sampled_dates = random.sample(dates, min(60, len(dates)))
|
| 466 |
+
for date in sampled_dates:
|
| 467 |
+
self.scenario_pool.append({
|
| 468 |
+
"sector": sector,
|
| 469 |
+
"symbols": random.sample(syms, min(3, len(syms))),
|
| 470 |
+
"date": date,
|
| 471 |
+
})
|
| 472 |
random.shuffle(self.scenario_pool)
|
| 473 |
+
|
| 474 |
return self.scenario_pool.pop() if self.scenario_pool else None
|
| 475 |
|
| 476 |
+
def _build_obs(self, step_idx: int) -> MultiStockObservation:
|
| 477 |
+
"""Construct MultiStockObservation for the current step."""
|
| 478 |
+
raw = self.episode_steps[step_idx]
|
| 479 |
+
stocks_dict: Dict[str, StockState] = {}
|
| 480 |
+
for s in raw["stocks"]:
|
| 481 |
+
od = s["obs_dict"]
|
| 482 |
+
stocks_dict[s["symbol"]] = StockState(
|
| 483 |
+
symbol = od["symbol"],
|
| 484 |
+
current_price = od["current_price"],
|
| 485 |
+
rsi_14 = od["rsi_14"],
|
| 486 |
+
rsi_trend = od["rsi_trend"],
|
| 487 |
+
price_momentum_pct = od["price_momentum_pct"],
|
| 488 |
+
indicators = od["indicators"],
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
drawdown = (
|
| 492 |
+
(self.peak_capital - self.virtual_capital) / self.peak_capital
|
| 493 |
+
if self.peak_capital > 0 else 0.0
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
return MultiStockObservation(
|
| 497 |
+
step = step_idx + 1, # 1-indexed
|
| 498 |
+
max_steps = self.MAX_STEPS,
|
| 499 |
+
term = self.term.upper(),
|
| 500 |
+
sector = self.sector,
|
| 501 |
+
available_stocks = self.symbols,
|
| 502 |
+
stocks = stocks_dict,
|
| 503 |
+
signal_history = list(self.signal_history),
|
| 504 |
+
macro = raw.get("macro"),
|
| 505 |
+
current_holding = self.current_holding,
|
| 506 |
+
capital = round(self.virtual_capital, 6),
|
| 507 |
+
drawdown = round(drawdown, 4),
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
# ── core interface ────────────────────────────────────────────────────────
|
| 511 |
+
|
| 512 |
def reset(self) -> Optional[ResetResult]:
|
| 513 |
+
"""
|
| 514 |
+
Start a new episode. Picks a sector + 3 stocks, fetches data in 3 yfinance
|
| 515 |
+
calls (one per stock), builds the full episode in one pass.
|
| 516 |
+
"""
|
| 517 |
+
self._reset_episode_state()
|
| 518 |
+
|
| 519 |
for _ in range(10):
|
| 520 |
sc = self._get_scenario()
|
| 521 |
if sc is None:
|
| 522 |
return None
|
| 523 |
+
|
| 524 |
+
steps = build_multi_stock_episode(
|
| 525 |
+
symbols = sc["symbols"],
|
| 526 |
+
start_date = sc["date"],
|
| 527 |
+
n_steps = self.MAX_STEPS,
|
| 528 |
+
term = self.term,
|
| 529 |
+
include_macro= (self.term == "long"),
|
| 530 |
+
)
|
| 531 |
+
if steps is not None:
|
| 532 |
+
self.episode_steps = steps
|
| 533 |
+
self.sector = sc["sector"]
|
| 534 |
+
self.symbols = sc["symbols"]
|
| 535 |
+
self.current_obs = self._build_obs(0)
|
| 536 |
return ResetResult(
|
| 537 |
+
observation = self.current_obs,
|
| 538 |
+
info = {
|
| 539 |
+
"session_id": self.session_id,
|
| 540 |
+
"term": self.term,
|
| 541 |
+
"task_id": TERM_TO_TASK_ID.get(self.term),
|
| 542 |
+
"sector": self.sector,
|
| 543 |
+
"symbols": self.symbols,
|
| 544 |
+
"step": 0,
|
| 545 |
+
"max_steps": self.MAX_STEPS,
|
| 546 |
+
},
|
| 547 |
)
|
| 548 |
return None
|
| 549 |
|
| 550 |
+
def step(self, action: MultiStockAction) -> StepResult:
|
| 551 |
+
if not self.episode_steps or self.current_step_idx >= len(self.episode_steps):
|
| 552 |
return StepResult(
|
| 553 |
observation=None, reward=0.0, done=True,
|
| 554 |
+
info={"error": "Call reset() first"},
|
| 555 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
+
raw = self.episode_steps[self.current_step_idx]
|
| 558 |
+
stock_rows = {s["symbol"]: s for s in raw["stocks"]}
|
| 559 |
+
|
| 560 |
+
# ── Parse action ──────────────────────────────────────────────────────
|
| 561 |
+
stock = action.stock.strip().upper()
|
| 562 |
+
direction = action.direction.strip().capitalize()
|
| 563 |
+
if direction not in ("Bullish", "Bearish"):
|
| 564 |
+
direction = "NONE"
|
| 565 |
+
if stock == "NONE" or direction == "NONE":
|
| 566 |
+
stock, direction = "NONE", "NONE"
|
| 567 |
+
elif stock not in stock_rows:
|
| 568 |
+
# Invalid symbol → treat as NONE
|
| 569 |
+
stock, direction = "NONE", "NONE"
|
| 570 |
+
|
| 571 |
+
conviction = action.conviction
|
| 572 |
+
|
| 573 |
+
# ── Reward: market-neutral alpha + portfolio dynamics ─────────────────
|
| 574 |
+
all_returns = [s["actual_period_return"] for s in raw["stocks"]]
|
| 575 |
+
sector_avg = mean(all_returns) if all_returns else 0.0
|
| 576 |
+
|
| 577 |
+
if direction == "NONE":
|
| 578 |
+
reward = 0.0
|
| 579 |
+
alpha = 0.0
|
| 580 |
+
chosen_return = 0.0
|
| 581 |
+
chosen_gt = "N/A"
|
| 582 |
+
tx_cost = 0.0
|
| 583 |
+
# NONE pass: keep existing holding, no capital change
|
| 584 |
+
else:
|
| 585 |
+
chosen_return = stock_rows[stock]["actual_period_return"]
|
| 586 |
+
alpha = chosen_return - sector_avg
|
| 587 |
+
direction_sign = 1 if direction == "Bullish" else -1
|
| 588 |
+
raw_reward = alpha * direction_sign * conviction * REWARD_SCALE
|
| 589 |
+
chosen_gt = stock_rows[stock]["gt"]
|
| 590 |
+
|
| 591 |
+
# Transaction cost: 0.1% of capital when switching holdings (all tasks)
|
| 592 |
+
tx_cost = 0.0
|
| 593 |
+
if (self.current_holding != "NONE"
|
| 594 |
+
and stock != self.current_holding):
|
| 595 |
+
tx_cost = TRANSACTION_COST * self.virtual_capital
|
| 596 |
+
|
| 597 |
+
reward = round(max(-1.5, min(1.5, raw_reward)) - tx_cost, 4)
|
| 598 |
+
|
| 599 |
+
# Update capital — ALL tasks (not just long)
|
| 600 |
+
pnl_fraction = alpha * direction_sign * conviction
|
| 601 |
+
self.virtual_capital = max(0.0, self.virtual_capital + pnl_fraction)
|
| 602 |
+
self.peak_capital = max(self.peak_capital, self.virtual_capital)
|
| 603 |
+
|
| 604 |
+
# Update holding
|
| 605 |
+
self.current_holding = stock
|
| 606 |
+
|
| 607 |
+
correct = (direction != "NONE") and (direction == chosen_gt)
|
| 608 |
+
drawdown_limit = DRAWDOWN_LIMITS.get(self.term, 0.10)
|
| 609 |
+
drawdown = (
|
| 610 |
+
(self.peak_capital - self.virtual_capital) / self.peak_capital
|
| 611 |
+
if self.peak_capital > 0 else 0.0
|
| 612 |
+
)
|
| 613 |
|
| 614 |
+
# ── Update signal history ─────────────────────────────────────────────
|
| 615 |
+
self.signal_history.append({
|
| 616 |
+
"step": self.current_step_idx + 1,
|
| 617 |
+
"picked_stock": stock,
|
| 618 |
+
"direction": direction,
|
| 619 |
+
"conviction": conviction,
|
| 620 |
+
"ground_truth": chosen_gt,
|
| 621 |
+
"correct": correct if direction != "NONE" else None,
|
| 622 |
+
"alpha_pct": round(alpha * 100, 3),
|
| 623 |
+
"reward": reward,
|
| 624 |
+
# RSI snapshot of all stocks at this step
|
| 625 |
+
"rsi_snapshot": {
|
| 626 |
+
s["symbol"]: round(s["obs_dict"]["rsi_14"], 1)
|
| 627 |
+
for s in raw["stocks"]
|
| 628 |
+
},
|
| 629 |
+
})
|
| 630 |
|
|
|
|
| 631 |
self.episode_history.append({
|
| 632 |
+
"step": self.current_step_idx + 1,
|
| 633 |
+
"stock": stock,
|
| 634 |
+
"direction": direction,
|
| 635 |
+
"ground_truth": chosen_gt,
|
| 636 |
+
"correct": correct,
|
| 637 |
+
"conviction": conviction,
|
| 638 |
+
"reward": reward,
|
| 639 |
+
"tx_cost": round(tx_cost, 6),
|
| 640 |
+
"alpha_pct": round(alpha * 100, 3),
|
| 641 |
+
"chosen_return_pct": round(chosen_return * 100, 4),
|
| 642 |
+
"sector_avg_pct": round(sector_avg * 100, 4),
|
| 643 |
})
|
| 644 |
|
| 645 |
+
# ── Advance step ──────────────────────────────────────────────────────
|
| 646 |
+
self.current_step_idx += 1
|
| 647 |
+
|
| 648 |
+
early_stop = drawdown > drawdown_limit
|
| 649 |
+
done = (self.current_step_idx >= self.MAX_STEPS) or early_stop
|
| 650 |
+
|
| 651 |
+
info = {
|
| 652 |
+
"step": self.current_step_idx,
|
| 653 |
+
"max_steps": self.MAX_STEPS,
|
| 654 |
+
"session_id": self.session_id,
|
| 655 |
+
"task_id": TERM_TO_TASK_ID.get(self.term),
|
| 656 |
+
"sector": self.sector,
|
| 657 |
+
"chosen_stock": stock,
|
| 658 |
+
"direction": direction,
|
| 659 |
+
"ground_truth": chosen_gt,
|
| 660 |
+
"correct": correct,
|
| 661 |
+
"conviction": conviction,
|
| 662 |
+
"tx_cost": round(tx_cost, 6),
|
| 663 |
+
"current_holding": self.current_holding,
|
| 664 |
+
"alpha_pct": round(alpha * 100, 3),
|
| 665 |
+
"chosen_return_pct": round(chosen_return * 100, 4),
|
| 666 |
+
"sector_avg_pct": round(sector_avg * 100, 4),
|
| 667 |
+
"virtual_capital": round(self.virtual_capital, 6),
|
| 668 |
+
"drawdown": round(drawdown, 4),
|
| 669 |
+
"drawdown_limit": drawdown_limit,
|
| 670 |
+
"early_stop": early_stop,
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
if not done:
|
| 674 |
+
self.current_obs = self._build_obs(self.current_step_idx)
|
| 675 |
+
else:
|
| 676 |
+
self.episodes_completed += 1
|
| 677 |
+
self.current_obs = None
|
| 678 |
+
|
| 679 |
+
return StepResult(
|
| 680 |
+
observation = self.current_obs,
|
| 681 |
+
reward = reward,
|
| 682 |
+
done = done,
|
| 683 |
+
info = info,
|
| 684 |
+
)
|
| 685 |
|
| 686 |
def state(self) -> StateResult:
|
| 687 |
return StateResult(
|
| 688 |
+
session_id = self.session_id,
|
| 689 |
+
current_observation = self.current_obs,
|
| 690 |
+
episodes_completed = self.episodes_completed,
|
| 691 |
+
current_task = TERM_TO_TASK_ID.get(self.term),
|
| 692 |
)
|
| 693 |
|
| 694 |
|
|
|
|
| 696 |
|
| 697 |
_sessions: Dict[str, EnvSession] = {}
|
| 698 |
|
| 699 |
+
|
| 700 |
def _get_or_create(session_id: Optional[str] = None, term: str = "medium") -> EnvSession:
|
| 701 |
if session_id is None:
|
| 702 |
session_id = str(uuid.uuid4())
|
|
|
|
| 707 |
|
| 708 |
# ─── HTTP endpoints (OpenEnv standard) ───────────────────────────────────────
|
| 709 |
|
| 710 |
+
|
| 711 |
@app.get("/health")
|
| 712 |
def health():
|
| 713 |
+
return {
|
| 714 |
+
"status": "ok",
|
| 715 |
+
"env": "IndicatorsEnv",
|
| 716 |
+
"version": "4.1.0",
|
| 717 |
+
"tasks": len(TASKS),
|
| 718 |
+
"episode_steps": TASK_MAX_STEPS,
|
| 719 |
+
"step_spacing": STEP_SPACING,
|
| 720 |
+
"sectors": list(SECTOR_GROUPS.keys()),
|
| 721 |
+
"drawdown_limits": DRAWDOWN_LIMITS,
|
| 722 |
+
"transaction_cost": TRANSACTION_COST,
|
| 723 |
+
}
|
| 724 |
|
| 725 |
|
| 726 |
@app.get("/tasks")
|
| 727 |
def get_tasks():
|
| 728 |
+
"""Returns all task definitions, action schemas, and episode lengths."""
|
| 729 |
return {
|
| 730 |
+
"tasks": TASKS,
|
| 731 |
+
"total": len(TASKS),
|
| 732 |
"action_schema": {
|
| 733 |
+
"stock": {"type": "string", "description": "NSE symbol or 'NONE' to skip"},
|
| 734 |
+
"direction": {"type": "string", "enum": ["Bullish", "Bearish", "NONE"]},
|
| 735 |
+
"conviction":{"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 736 |
},
|
| 737 |
+
"episode_steps": TASK_MAX_STEPS,
|
| 738 |
+
"step_spacing": STEP_SPACING,
|
| 739 |
}
|
| 740 |
|
| 741 |
|
| 742 |
@app.post("/reset", response_model=ResetResult)
|
| 743 |
def reset(session_id: Optional[str] = None, term: str = "medium"):
|
| 744 |
+
sess = _get_or_create(session_id, term=term)
|
| 745 |
result = sess.reset()
|
| 746 |
if result is None:
|
| 747 |
+
raise HTTPException(
|
| 748 |
+
status_code=503,
|
| 749 |
+
detail="Could not fetch market data. Retry or try a different term.",
|
| 750 |
+
)
|
| 751 |
return result
|
| 752 |
|
| 753 |
|
| 754 |
@app.post("/step", response_model=StepResult)
|
| 755 |
+
def step(action: MultiStockAction, session_id: Optional[str] = None):
|
| 756 |
sess = _get_or_create(session_id)
|
| 757 |
return sess.step(action)
|
| 758 |
|
|
|
|
| 767 |
def grader(request: GraderRequest):
|
| 768 |
"""
|
| 769 |
Score a completed episode set against a specific task grader.
|
| 770 |
+
episode_results: list of {predicted, ground_truth, conviction}.
|
| 771 |
+
Returns score in (0.0, 1.0).
|
| 772 |
"""
|
| 773 |
if request.task_id not in TASK_BY_ID:
|
| 774 |
+
raise HTTPException(
|
| 775 |
+
status_code=404,
|
| 776 |
+
detail=f"Unknown task_id: {request.task_id}. "
|
| 777 |
+
f"Valid: {list(TASK_BY_ID.keys())}",
|
| 778 |
+
)
|
| 779 |
return grade_task(request.task_id, request.episode_results)
|
| 780 |
|
| 781 |
|
| 782 |
@app.get("/baseline")
|
| 783 |
def baseline():
|
| 784 |
"""
|
| 785 |
+
Run the built-in random baseline across all 3 tasks (10 episodes each).
|
| 786 |
+
Expected: overall_mean ≈ 0.33 (random agent on 3-class directional problem).
|
| 787 |
+
short : 5×10=50 steps
|
| 788 |
+
medium : 10×10=100 steps
|
| 789 |
+
long : ≤15×10=150 steps (may be fewer with early termination)
|
| 790 |
"""
|
| 791 |
results = {}
|
| 792 |
+
random.seed(42)
|
| 793 |
|
| 794 |
for task in TASKS:
|
| 795 |
+
term = task["term"]
|
| 796 |
task_id = task["id"]
|
| 797 |
+
sess = EnvSession(session_id=f"baseline-{task_id}", term=term)
|
| 798 |
+
episode_results: List[Dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
| 799 |
|
| 800 |
for _ in range(10):
|
| 801 |
reset_result = sess.reset()
|
| 802 |
if reset_result is None:
|
| 803 |
continue
|
| 804 |
+
done = False
|
| 805 |
+
while not done:
|
| 806 |
+
# Random agent: random stock, random direction (including NONE)
|
| 807 |
+
available = reset_result.observation.available_stocks
|
| 808 |
+
if hasattr(sess.current_obs, "available_stocks") and sess.current_obs:
|
| 809 |
+
available = sess.current_obs.available_stocks
|
| 810 |
+
chosen_stock = random.choice(available + ["NONE"])
|
| 811 |
+
direction = "NONE" if chosen_stock == "NONE" else random.choice(["Bullish", "Bearish"])
|
| 812 |
+
action = MultiStockAction(
|
| 813 |
+
stock = chosen_stock,
|
| 814 |
+
direction = direction,
|
| 815 |
+
conviction = round(random.uniform(0.3, 0.9), 2),
|
| 816 |
+
)
|
| 817 |
+
step_result = sess.step(action)
|
| 818 |
+
episode_results.append({
|
| 819 |
+
"predicted": direction,
|
| 820 |
+
"ground_truth": step_result.info.get("ground_truth", "N/A"),
|
| 821 |
+
"conviction": action.conviction,
|
| 822 |
+
})
|
| 823 |
+
done = step_result.done
|
| 824 |
|
| 825 |
grader_result = grade_task(task_id, episode_results)
|
| 826 |
results[task_id] = {
|
| 827 |
+
"task_name": task["name"],
|
| 828 |
+
"difficulty": task["difficulty"],
|
| 829 |
+
"score": grader_result.score,
|
| 830 |
"num_episodes": grader_result.num_episodes,
|
| 831 |
+
"breakdown": grader_result.breakdown,
|
| 832 |
}
|
| 833 |
|
| 834 |
return {
|
| 835 |
+
"agent": "random_baseline",
|
| 836 |
+
"seed": 42,
|
| 837 |
+
"tasks": results,
|
| 838 |
"overall_mean": round(sum(r["score"] for r in results.values()) / len(results), 4),
|
| 839 |
}
|
| 840 |
|
| 841 |
|
| 842 |
# ─── WebSocket endpoint (OpenEnv /ws) ────────────────────────────────────────
|
| 843 |
|
| 844 |
+
|
| 845 |
@app.websocket("/ws")
|
| 846 |
async def websocket_env(ws: WebSocket, session_id: Optional[str] = None, term: str = "medium"):
|
| 847 |
await ws.accept()
|
|
|
|
| 861 |
await ws.send_text(result.model_dump_json())
|
| 862 |
|
| 863 |
elif method == "step":
|
| 864 |
+
action = MultiStockAction(**msg.get("action", {}))
|
| 865 |
result = sess.step(action)
|
| 866 |
await ws.send_text(result.model_dump_json())
|
| 867 |
|
|
|
|
| 881 |
|
| 882 |
# ─── Entry point ─────────────────────────────────────────────────────────────
|
| 883 |
|
| 884 |
+
|
| 885 |
if __name__ == "__main__":
|
| 886 |
+
# workers=1 required: _sessions is an in-memory dict, not shared across processes
|
| 887 |
+
uvicorn.run("indicators_env:app", host="0.0.0.0", port=7860, reload=False, workers=1)
|
inference.py
CHANGED
|
@@ -2,10 +2,17 @@
|
|
| 2 |
inference.py — Mandatory entry point for the Meta × PyTorch Hackathon automated evaluation.
|
| 3 |
|
| 4 |
Requirements:
|
| 5 |
-
1.
|
| 6 |
-
2.
|
| 7 |
-
3.
|
| 8 |
-
4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import os
|
|
@@ -13,144 +20,362 @@ import re
|
|
| 13 |
import json
|
| 14 |
import requests
|
| 15 |
import argparse
|
|
|
|
| 16 |
from openai import OpenAI
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
API_BASE_URL = os.environ.get("API_BASE_URL", "https://
|
| 20 |
-
MODEL_NAME
|
| 21 |
-
HF_TOKEN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
ENV_TERMS = {"short_term_direction": "short", "medium_term_direction": "medium", "long_term_conviction": "long"}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip()
|
|
|
|
| 31 |
try:
|
| 32 |
-
# Look for JSON object
|
| 33 |
match = re.search(r"\{[^}]+\}", text, re.DOTALL)
|
| 34 |
if match:
|
| 35 |
obj = json.loads(match.group(0))
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
except Exception:
|
| 41 |
pass
|
| 42 |
-
|
| 43 |
-
#
|
| 44 |
lower = text.lower()
|
| 45 |
-
if "bullish" in lower: return "Bullish", 0.7
|
| 46 |
-
if "bearish" in lower: return "Bearish", 0.7
|
| 47 |
-
return "Neutral", 0.5
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 53 |
|
| 54 |
for task_id in TASKS:
|
| 55 |
term = ENV_TERMS[task_id]
|
| 56 |
-
episode_results = []
|
| 57 |
-
|
| 58 |
-
# [START] Log
|
| 59 |
print(f"[START] task={task_id} env=IndicatorsEnv model={MODEL_NAME}", flush=True)
|
| 60 |
|
| 61 |
for i in range(n_episodes):
|
| 62 |
try:
|
| 63 |
-
# 1. Reset
|
| 64 |
-
reset_resp = requests.post(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
if reset_resp.status_code != 200:
|
|
|
|
| 66 |
continue
|
| 67 |
-
|
| 68 |
-
data
|
| 69 |
-
obs
|
| 70 |
-
state = obs
|
| 71 |
session_id = data.get("info", {}).get("session_id", "")
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
except Exception as e:
|
| 119 |
-
|
| 120 |
continue
|
| 121 |
|
| 122 |
-
# 5. Finalize
|
| 123 |
try:
|
| 124 |
-
# Note: We provide the grader with the episode results for that task
|
| 125 |
-
# In OpenEnv, the grader is usually a separate endpoint
|
| 126 |
grader_resp = requests.post(
|
| 127 |
-
f"{env_url}/grader",
|
| 128 |
json={"task_id": task_id, "episode_results": episode_results},
|
| 129 |
-
timeout=60
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
if __name__ == "__main__":
|
| 145 |
-
parser = argparse.ArgumentParser(description="
|
| 146 |
-
parser.add_argument("--env_url",
|
| 147 |
-
parser.add_argument("--n_episodes", type=int, default=5,
|
| 148 |
-
|
| 149 |
args = parser.parse_args()
|
| 150 |
-
|
| 151 |
-
# Mandatory Check: Environment Variables
|
| 152 |
if not HF_TOKEN:
|
| 153 |
-
print("
|
| 154 |
exit(1)
|
| 155 |
|
| 156 |
run_evaluation(args.env_url, args.n_episodes)
|
|
|
|
| 2 |
inference.py — Mandatory entry point for the Meta × PyTorch Hackathon automated evaluation.
|
| 3 |
|
| 4 |
Requirements:
|
| 5 |
+
1. Reads API_BASE_URL, MODEL_NAME, and HF_TOKEN from environment variables.
|
| 6 |
+
2. Uses the OpenAI Client for all LLM calls.
|
| 7 |
+
3. Stdout follows strict [START], [STEP], and [END] logging format.
|
| 8 |
+
4. Runtime < 20 min on 2 vCPU / 8 GB RAM.
|
| 9 |
+
|
| 10 |
+
IndicatorsEnv v4.1 — Multi-stock Portfolio MDP:
|
| 11 |
+
At each step the agent observes 3 stocks from the same NSE sector.
|
| 12 |
+
It picks ONE stock and declares Bullish/Bearish, or passes with NONE.
|
| 13 |
+
Reward = (chosen_stock_return − sector_avg) × direction × conviction × 50
|
| 14 |
+
Market-neutral: random policy earns ~0; skilled policy earns positive alpha.
|
| 15 |
+
Tasks: short (5 steps, daily), medium (10 steps, weekly), long (15 steps, monthly).
|
| 16 |
"""
|
| 17 |
|
| 18 |
import os
|
|
|
|
| 20 |
import json
|
| 21 |
import requests
|
| 22 |
import argparse
|
| 23 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 24 |
from openai import OpenAI
|
| 25 |
|
| 26 |
+
# ── Configuration (Mandatory per Hackathon Spec) ────────────────────────────
|
| 27 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1/")
|
| 28 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "meta-llama/Llama-3.2-1B-Instruct")
|
| 29 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 30 |
+
|
| 31 |
+
TASKS = ["short_term_direction", "medium_term_direction", "long_term_conviction"]
|
| 32 |
+
ENV_TERMS = {
|
| 33 |
+
"short_term_direction": "short",
|
| 34 |
+
"medium_term_direction": "medium",
|
| 35 |
+
"long_term_conviction": "long",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
|
| 39 |
+
# ── Action parser ────────────────────────────────────────────────────────────
|
|
|
|
| 40 |
|
| 41 |
+
def _parse_action(
|
| 42 |
+
text: str,
|
| 43 |
+
available_stocks: List[str],
|
| 44 |
+
) -> Tuple[str, str, float]:
|
| 45 |
+
"""
|
| 46 |
+
Parse LLM response into (stock, direction, conviction).
|
| 47 |
|
| 48 |
+
Expected JSON:
|
| 49 |
+
{"stock": "HDFCBANK", "direction": "Bullish", "conviction": 0.8}
|
| 50 |
+
or to pass:
|
| 51 |
+
{"stock": "NONE", "direction": "NONE", "conviction": 0.0}
|
| 52 |
+
|
| 53 |
+
Falls back gracefully on malformed output.
|
| 54 |
+
"""
|
| 55 |
text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip()
|
| 56 |
+
|
| 57 |
try:
|
|
|
|
| 58 |
match = re.search(r"\{[^}]+\}", text, re.DOTALL)
|
| 59 |
if match:
|
| 60 |
obj = json.loads(match.group(0))
|
| 61 |
+
stock = str(obj.get("stock", "NONE")).strip().upper()
|
| 62 |
+
direction = str(obj.get("direction", "NONE")).strip().capitalize()
|
| 63 |
+
conviction = float(obj.get("conviction", 0.5))
|
| 64 |
+
conviction = max(0.0, min(1.0, conviction))
|
| 65 |
+
|
| 66 |
+
# Validate stock
|
| 67 |
+
if stock not in available_stocks and stock != "NONE":
|
| 68 |
+
# Try case-insensitive match
|
| 69 |
+
upper_map = {s.upper(): s for s in available_stocks}
|
| 70 |
+
stock = upper_map.get(stock, "NONE")
|
| 71 |
+
|
| 72 |
+
# Validate direction
|
| 73 |
+
if direction not in ("Bullish", "Bearish", "None"):
|
| 74 |
+
direction = "NONE"
|
| 75 |
+
if stock == "NONE":
|
| 76 |
+
direction = "NONE"
|
| 77 |
+
|
| 78 |
+
return stock, direction, conviction
|
| 79 |
except Exception:
|
| 80 |
pass
|
| 81 |
+
|
| 82 |
+
# ── Fallback: keyword search ─────────────────────────────────────────────
|
| 83 |
lower = text.lower()
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
+
# Look for stock mention
|
| 86 |
+
found_stock = "NONE"
|
| 87 |
+
for sym in available_stocks:
|
| 88 |
+
if sym.lower() in lower:
|
| 89 |
+
found_stock = sym
|
| 90 |
+
break
|
| 91 |
+
|
| 92 |
+
# Look for direction
|
| 93 |
+
if "bullish" in lower:
|
| 94 |
+
direction = "Bullish"
|
| 95 |
+
elif "bearish" in lower:
|
| 96 |
+
direction = "Bearish"
|
| 97 |
+
elif "none" in lower or "pass" in lower or "skip" in lower:
|
| 98 |
+
direction = "NONE"
|
| 99 |
+
found_stock = "NONE"
|
| 100 |
+
else:
|
| 101 |
+
direction = "NONE"
|
| 102 |
+
found_stock = "NONE"
|
| 103 |
+
|
| 104 |
+
return found_stock, direction, 0.6
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── Prompt builder ───────────────────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
def _build_prompt(
|
| 110 |
+
obs: Dict[str, Any],
|
| 111 |
+
step_num: int,
|
| 112 |
+
max_steps: int,
|
| 113 |
+
term: str,
|
| 114 |
+
task_id: str,
|
| 115 |
+
signal_history: List[Dict[str, Any]],
|
| 116 |
+
prev_info: Optional[Dict[str, Any]] = None,
|
| 117 |
+
) -> Tuple[str, str]:
|
| 118 |
+
"""
|
| 119 |
+
Build system + user prompt for multi-stock relative alpha prediction.
|
| 120 |
+
"""
|
| 121 |
+
system_prompt = (
|
| 122 |
+
"You are a quantitative portfolio manager specializing in NSE (India) equities. "
|
| 123 |
+
"At each step you observe 3 stocks from the SAME sector. "
|
| 124 |
+
"Your job: identify which stock has the strongest relative momentum signal "
|
| 125 |
+
"and bet on its direction vs. the sector average. "
|
| 126 |
+
"Reward = (your stock's return − sector average) × direction × conviction × 50. "
|
| 127 |
+
"Market-neutral: picking randomly earns ~0 reward — you profit ONLY by selecting "
|
| 128 |
+
"the outperformer correctly. "
|
| 129 |
+
"Switching your held position to a different stock costs 0.1% of capital in transaction cost. "
|
| 130 |
+
"If you are already in a strong position, staying in the same stock avoids switching cost. "
|
| 131 |
+
"Pass with NONE to hold your position without incurring a switch cost. "
|
| 132 |
+
'Respond ONLY with JSON: {"stock": "<SYMBOL>", "direction": "Bullish"|"Bearish", '
|
| 133 |
+
'"conviction": <0.0-1.0>} or {"stock": "NONE", "direction": "NONE", "conviction": 0.0}'
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
sector = obs.get("sector", "unknown")
|
| 137 |
+
available = obs.get("available_stocks", [])
|
| 138 |
+
stocks_data = obs.get("stocks", {})
|
| 139 |
+
macro = obs.get("macro")
|
| 140 |
+
|
| 141 |
+
# ── Format stock snapshots ────────────────────────────────────────────────
|
| 142 |
+
stock_lines = []
|
| 143 |
+
for sym in available:
|
| 144 |
+
s = stocks_data.get(sym, {})
|
| 145 |
+
rsi = s.get("rsi_14", "N/A")
|
| 146 |
+
rsi_trend = s.get("rsi_trend", "?")
|
| 147 |
+
momentum = s.get("price_momentum_pct", 0.0)
|
| 148 |
+
price = s.get("current_price", "N/A")
|
| 149 |
+
# Key indicator signals
|
| 150 |
+
inds = s.get("indicators", {})
|
| 151 |
+
macd_sig = inds.get("macd", {}).get("signal", "?")
|
| 152 |
+
ma_sig = inds.get("moving_averages", {}).get("signal", "?")
|
| 153 |
+
adx_str = inds.get("adx", {}).get("trend_strength", "?")
|
| 154 |
+
|
| 155 |
+
stock_lines.append(
|
| 156 |
+
f" {sym}: price={price} RSI={rsi}({rsi_trend}) "
|
| 157 |
+
f"momentum={momentum:+.1f}% MACD={macd_sig} MA={ma_sig} ADX={adx_str}"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
stocks_str = "\n".join(stock_lines)
|
| 161 |
+
|
| 162 |
+
# ── Signal history (last 3 steps for context) ────────────────────────────
|
| 163 |
+
history_str = ""
|
| 164 |
+
if signal_history:
|
| 165 |
+
recent = signal_history[-3:]
|
| 166 |
+
lines = []
|
| 167 |
+
for h in recent:
|
| 168 |
+
picked = h.get("picked_stock", "NONE")
|
| 169 |
+
dirn = h.get("direction", "NONE")
|
| 170 |
+
gt = h.get("ground_truth", "?")
|
| 171 |
+
alpha = h.get("alpha_pct", 0.0)
|
| 172 |
+
rew = h.get("reward", 0.0)
|
| 173 |
+
if dirn != "NONE":
|
| 174 |
+
correct_str = "✓" if h.get("correct") else "✗"
|
| 175 |
+
lines.append(
|
| 176 |
+
f" Step {h['step']}: picked {picked} → {dirn} "
|
| 177 |
+
f"({correct_str} GT={gt} alpha={alpha:+.2f}% reward={rew:.3f})"
|
| 178 |
+
)
|
| 179 |
+
else:
|
| 180 |
+
lines.append(f" Step {h['step']}: NONE (passed)")
|
| 181 |
+
history_str = "Signal history:\n" + "\n".join(lines) + "\n"
|
| 182 |
+
|
| 183 |
+
# ── Macro context (Task 3) ────────────────────────────────────────────────
|
| 184 |
+
macro_str = ""
|
| 185 |
+
if macro:
|
| 186 |
+
macro_str = (
|
| 187 |
+
f"Macro: NIFTY50={macro.get('nifty_trend','?')} "
|
| 188 |
+
f"(20d return: {macro.get('nifty_return_20d',0):.1f}%) "
|
| 189 |
+
f"regime={macro.get('market_regime','?')}\n"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# ── Previous step feedback ────────────────────────────────────────────────
|
| 193 |
+
prev_str = ""
|
| 194 |
+
if prev_info and prev_info.get("chosen_stock") != "N/A":
|
| 195 |
+
prev_str = (
|
| 196 |
+
f"Last step: picked {prev_info.get('chosen_stock','?')} → "
|
| 197 |
+
f"{prev_info.get('direction','?')} "
|
| 198 |
+
f"(GT={prev_info.get('ground_truth','?')} "
|
| 199 |
+
f"alpha={prev_info.get('alpha_pct',0):+.2f}% "
|
| 200 |
+
f"reward={prev_info.get('reward',0):.3f})\n"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# ── Portfolio context (v4.1) ──────────────────────────────────────────────
|
| 204 |
+
holding = obs.get("current_holding", "NONE") if isinstance(obs, dict) else "NONE"
|
| 205 |
+
capital = obs.get("capital", 1.0) if isinstance(obs, dict) else 1.0
|
| 206 |
+
drawdown = obs.get("drawdown", 0.0) if isinstance(obs, dict) else 0.0
|
| 207 |
+
if holding != "NONE":
|
| 208 |
+
tx_note = f"(switching from {holding} costs ~{capital * 0.001:.4f} tx_cost)"
|
| 209 |
+
else:
|
| 210 |
+
tx_note = "(no current holding — first pick is free)"
|
| 211 |
+
portfolio_str = (
|
| 212 |
+
f"Portfolio: capital={capital:.4f} holding={holding} "
|
| 213 |
+
f"drawdown={drawdown:.1%} {tx_note}\n"
|
| 214 |
+
)
|
| 215 |
|
| 216 |
+
user_prompt = (
|
| 217 |
+
f"[Step {step_num}/{max_steps}] Sector: {sector.upper()} | Task: {task_id} | "
|
| 218 |
+
f"Term: {term.upper()}\n\n"
|
| 219 |
+
f"Stocks (same sector):\n{stocks_str}\n\n"
|
| 220 |
+
f"{history_str}"
|
| 221 |
+
f"{macro_str}"
|
| 222 |
+
f"{portfolio_str}"
|
| 223 |
+
f"{prev_str}"
|
| 224 |
+
f"Pick the stock with the strongest relative momentum signal.\n"
|
| 225 |
+
f"Available: {available} or NONE to skip.\n\n"
|
| 226 |
+
f"Which stock has the best setup? Respond with JSON."
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
return system_prompt, user_prompt
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ── Main evaluation loop ────────────────────────────────────────────────────
|
| 233 |
+
|
| 234 |
+
def run_evaluation(env_url: str, n_episodes: int) -> None:
|
| 235 |
client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 236 |
|
| 237 |
for task_id in TASKS:
|
| 238 |
term = ENV_TERMS[task_id]
|
| 239 |
+
episode_results: List[Dict[str, Any]] = []
|
| 240 |
+
|
|
|
|
| 241 |
print(f"[START] task={task_id} env=IndicatorsEnv model={MODEL_NAME}", flush=True)
|
| 242 |
|
| 243 |
for i in range(n_episodes):
|
| 244 |
try:
|
| 245 |
+
# 1. Reset — 90s timeout (multi-stock fetches 3 yfinance calls)
|
| 246 |
+
reset_resp = requests.post(
|
| 247 |
+
f"{env_url}/reset",
|
| 248 |
+
params={"term": term},
|
| 249 |
+
timeout=90,
|
| 250 |
+
)
|
| 251 |
if reset_resp.status_code != 200:
|
| 252 |
+
print(f"[ERR] episode={i+1} reset HTTP {reset_resp.status_code}", flush=True)
|
| 253 |
continue
|
| 254 |
+
|
| 255 |
+
data = reset_resp.json()
|
| 256 |
+
obs = data.get("observation", {})
|
|
|
|
| 257 |
session_id = data.get("info", {}).get("session_id", "")
|
| 258 |
+
max_steps = data.get("info", {}).get("max_steps", 5)
|
| 259 |
+
available = obs.get("available_stocks", [])
|
| 260 |
+
done = False
|
| 261 |
+
step_num = 0
|
| 262 |
+
prev_info: Optional[Dict[str, Any]] = None
|
| 263 |
+
|
| 264 |
+
# 2. Multi-step loop — one episode = max_steps steps
|
| 265 |
+
while not done:
|
| 266 |
+
step_num += 1
|
| 267 |
+
|
| 268 |
+
# Signal history from observation (accumulated across steps)
|
| 269 |
+
signal_history = obs.get("signal_history", []) if isinstance(obs, dict) else []
|
| 270 |
+
available = obs.get("available_stocks", available) if isinstance(obs, dict) else available
|
| 271 |
+
|
| 272 |
+
# Build prompts
|
| 273 |
+
system_prompt, user_prompt = _build_prompt(
|
| 274 |
+
obs = obs if isinstance(obs, dict) else {},
|
| 275 |
+
step_num = step_num,
|
| 276 |
+
max_steps = max_steps,
|
| 277 |
+
term = term,
|
| 278 |
+
task_id = task_id,
|
| 279 |
+
signal_history= signal_history,
|
| 280 |
+
prev_info = prev_info,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# 3. LLM call
|
| 284 |
+
chat_completion = client.chat.completions.create(
|
| 285 |
+
model = MODEL_NAME,
|
| 286 |
+
messages = [
|
| 287 |
+
{"role": "system", "content": system_prompt},
|
| 288 |
+
{"role": "user", "content": user_prompt},
|
| 289 |
+
],
|
| 290 |
+
max_tokens = 128,
|
| 291 |
+
temperature = 0.7,
|
| 292 |
+
)
|
| 293 |
+
response = chat_completion.choices[0].message.content or ""
|
| 294 |
+
stock, direction, conviction = _parse_action(response, available)
|
| 295 |
+
|
| 296 |
+
# 4. Step environment
|
| 297 |
+
step_resp = requests.post(
|
| 298 |
+
f"{env_url}/step",
|
| 299 |
+
params={"session_id": session_id},
|
| 300 |
+
json={
|
| 301 |
+
"stock": stock,
|
| 302 |
+
"direction": direction,
|
| 303 |
+
"conviction": conviction,
|
| 304 |
+
},
|
| 305 |
+
timeout=30,
|
| 306 |
+
)
|
| 307 |
+
if step_resp.status_code != 200:
|
| 308 |
+
print(f"[ERR] episode={i+1} step={step_num} HTTP {step_resp.status_code}", flush=True)
|
| 309 |
+
break
|
| 310 |
+
|
| 311 |
+
step_data = step_resp.json()
|
| 312 |
+
reward = step_data.get("reward", 0.0)
|
| 313 |
+
done = step_data.get("done", True)
|
| 314 |
+
step_info = step_data.get("info", {})
|
| 315 |
+
ground_truth = step_info.get("ground_truth", "N/A")
|
| 316 |
+
obs = step_data.get("observation") or obs
|
| 317 |
+
|
| 318 |
+
# Store info for next step's prompt
|
| 319 |
+
prev_info = {
|
| 320 |
+
"chosen_stock": step_info.get("chosen_stock", stock),
|
| 321 |
+
"direction": direction,
|
| 322 |
+
"ground_truth": ground_truth,
|
| 323 |
+
"alpha_pct": step_info.get("alpha_pct", 0.0),
|
| 324 |
+
"reward": reward,
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
# Track for grader — key MUST be "predicted"
|
| 328 |
+
episode_results.append({
|
| 329 |
+
"predicted": direction,
|
| 330 |
+
"conviction": conviction,
|
| 331 |
+
"reward": reward,
|
| 332 |
+
"ground_truth": ground_truth,
|
| 333 |
+
})
|
| 334 |
+
|
| 335 |
+
# [STEP] log
|
| 336 |
+
print(
|
| 337 |
+
f"[STEP] step={step_num} stock={stock} action={direction} "
|
| 338 |
+
f"reward={reward:.4f} done={done} error=None",
|
| 339 |
+
flush=True,
|
| 340 |
+
)
|
| 341 |
|
| 342 |
except Exception as e:
|
| 343 |
+
print(f"[ERR] episode={i+1} {type(e).__name__}: {e}", flush=True)
|
| 344 |
continue
|
| 345 |
|
| 346 |
+
# 5. Finalize task — call grader
|
| 347 |
try:
|
|
|
|
|
|
|
| 348 |
grader_resp = requests.post(
|
| 349 |
+
f"{env_url}/grader",
|
| 350 |
json={"task_id": task_id, "episode_results": episode_results},
|
| 351 |
+
timeout=60,
|
| 352 |
+
)
|
| 353 |
+
final_score = (
|
| 354 |
+
grader_resp.json().get("score", 0.0)
|
| 355 |
+
if grader_resp.status_code == 200 else 0.0
|
| 356 |
)
|
| 357 |
+
except Exception as e:
|
| 358 |
+
print(f"[ERR] grader: {type(e).__name__}: {e}", flush=True)
|
| 359 |
+
final_score = 0.0
|
| 360 |
+
|
| 361 |
+
rewards_list = [ep["reward"] for ep in episode_results]
|
| 362 |
+
steps_taken = len(episode_results)
|
| 363 |
+
success = final_score > 0.0
|
| 364 |
+
print(
|
| 365 |
+
f"[END] success={success} steps={steps_taken} score={final_score:.4f} "
|
| 366 |
+
f"rewards={rewards_list}",
|
| 367 |
+
flush=True,
|
| 368 |
+
)
|
| 369 |
|
| 370 |
|
| 371 |
if __name__ == "__main__":
|
| 372 |
+
parser = argparse.ArgumentParser(description="IndicatorsEnv v4.0 Inference Agent")
|
| 373 |
+
parser.add_argument("--env_url", default="http://localhost:7860", help="IndicatorsEnv server URL")
|
| 374 |
+
parser.add_argument("--n_episodes", type=int, default=5, help="Episodes per task")
|
|
|
|
| 375 |
args = parser.parse_args()
|
| 376 |
+
|
|
|
|
| 377 |
if not HF_TOKEN:
|
| 378 |
+
print("Error: HF_TOKEN must be set as an environment variable.")
|
| 379 |
exit(1)
|
| 380 |
|
| 381 |
run_evaluation(args.env_url, args.n_episodes)
|