bawsi99 Claude Sonnet 4.6 commited on
Commit
1637bca
·
1 Parent(s): eb89118

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>

Files changed (2) hide show
  1. env/indicators_env.py +604 -198
  2. inference.py +328 -103
env/indicators_env.py CHANGED
@@ -1,19 +1,46 @@
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
@@ -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
- build_observation,
34
- compute_ground_truth,
35
- generate_scenario_pool,
 
 
 
 
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
- "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):
@@ -149,76 +261,123 @@ class GraderRequest(BaseModel):
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(..., gt=0.0, lt=1.0)
161
  num_episodes: int
162
- breakdown: Dict[str, Any]
163
 
164
 
165
- # ─── Grader Logic ─────────────────────────────────────────────────────────────
 
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
- """Deterministic grader for all 3 tasks. Returns a score in (0, 1) exclusive."""
 
 
 
 
 
 
174
  if not episode_results:
175
- return GraderResult(task_id=task_id, score=0.001, num_episodes=0, breakdown={})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- n = len(episode_results)
178
 
179
  if task_id == "short_term_direction":
180
- # Easy: simple accuracy
181
  correct = sum(
182
- 1 for r in episode_results
183
- if r.get("predicted", "").capitalize() == r.get("ground_truth", "").capitalize()
184
  )
185
- score = _clamp_score(correct / n)
186
- breakdown = {"correct": correct, "total": n, "metric": "accuracy"}
 
 
 
 
 
 
 
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 episode_results:
193
- gt = r.get("ground_truth", "").capitalize()
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
- score = _clamp_score(min(1.0, weighted_score / weighted_total) if weighted_total > 0 else 0.0)
200
- breakdown = {"weighted_score": round(weighted_score, 3), "weighted_total": round(weighted_total, 3), "metric": "weighted_accuracy"}
 
 
 
 
 
 
 
 
 
 
201
 
202
  elif task_id == "long_term_conviction":
203
- # Hard: direction + conviction calibration
204
- per_episode_scores = []
205
- for r in episode_results:
206
- gt = r.get("ground_truth", "").capitalize()
207
- pred = r.get("predicted", "").capitalize()
208
  conviction = float(r.get("conviction", 0.5))
209
- correct = pred == gt
210
  if correct and conviction >= 0.7:
211
- per_episode_scores.append(1.0)
212
  elif correct and conviction < 0.7:
213
- per_episode_scores.append(0.5)
214
  elif not correct and conviction >= 0.8:
215
- per_episode_scores.append(-0.1) # overconfident wrong
216
  else:
217
- per_episode_scores.append(0.0)
218
- raw = sum(per_episode_scores) / n
219
- # Normalize from [-0.1, 1.0] to (0, 1) exclusive
220
  score = _clamp_score((raw + 0.1) / 1.1)
221
- breakdown = {"per_episode_scores": per_episode_scores, "raw_mean": round(raw, 4), "metric": "conviction_calibrated_accuracy"}
 
 
 
 
 
 
 
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=n,
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 = session_id
239
- self.term = term.lower()
240
- self.current_obs: Optional[IndicatorsObservation] = None
241
- self.current_gt: Optional[str] = None
242
- self.episodes_completed = 0
243
- self.episode_history: List[Dict[str, Any]] = []
244
- self.scenario_pool: List[Dict[str, str]] = []
245
-
246
- def _get_scenario(self) -> Optional[Dict[str, str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  if not self.scenario_pool:
248
- self.scenario_pool = generate_scenario_pool(
249
- symbols=random.sample(NSE_UNIVERSE, min(20, len(NSE_UNIVERSE))),
250
- start_date="2020-01-01",
251
- end_date="2024-06-30",
252
- term=self.term,
253
- max_scenarios=2000,
 
 
 
 
 
 
 
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
- obs_dict = build_observation(sc["symbol"], sc["date"], self.term)
264
- gt = compute_ground_truth(sc["symbol"], sc["date"], self.term)
265
- if obs_dict is not None and gt is not None:
266
- self.current_obs = IndicatorsObservation(**obs_dict)
267
- self.current_gt = gt
 
 
 
 
 
 
 
 
268
  return ResetResult(
269
- observation=self.current_obs,
270
- info={"session_id": self.session_id, "term": self.term, "task_id": TERM_TO_TASK_ID.get(self.term)},
 
 
 
 
 
 
 
 
271
  )
272
  return None
273
 
274
- def step(self, action: IndicatorsAction) -> StepResult:
275
- if self.current_obs is None or self.current_gt is None:
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
- reward = round(max(-0.1, min(1.1, reward)), 4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
- info = {
292
- "ground_truth": self.current_gt,
293
- "predicted": action.direction,
294
- "correct": correct,
295
- "conviction": action.conviction,
296
- "session_id": self.session_id,
297
- "task_id": TERM_TO_TASK_ID.get(self.term),
298
- }
 
 
 
 
 
 
 
 
299
 
300
- # Store for grader
301
  self.episode_history.append({
302
- "ground_truth": self.current_gt,
303
- "predicted": action.direction.strip().capitalize(),
304
- "conviction": action.conviction,
305
- "reward": reward,
 
 
 
 
 
 
 
306
  })
307
 
308
- self.episodes_completed += 1
309
- prev_obs = self.current_obs
310
- self.current_obs = None
311
- self.current_gt = None
312
- return StepResult(observation=prev_obs, reward=reward, done=True, info=info)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {"status": "ok", "env": "IndicatorsEnv", "version": "2.0.0", "tasks": len(TASKS)}
 
 
 
 
 
 
 
 
 
 
340
 
341
 
342
  @app.get("/tasks")
343
  def get_tasks():
344
- """Returns all task definitions and action schemas."""
345
  return {
346
- "tasks": TASKS,
347
- "total": len(TASKS),
348
  "action_schema": {
349
- "direction": {"type": "string", "enum": ["Bullish", "Bearish", "Neutral"]},
350
- "conviction": {"type": "number", "minimum": 0.0, "maximum": 1.0},
 
351
  },
 
 
352
  }
353
 
354
 
355
  @app.post("/reset", response_model=ResetResult)
356
  def reset(session_id: Optional[str] = None, term: str = "medium"):
357
- sess = _get_or_create(session_id, term=term)
358
  result = sess.reset()
359
  if result is None:
360
- raise HTTPException(status_code=503, detail="Could not fetch data. Retry.")
 
 
 
361
  return result
362
 
363
 
364
  @app.post("/step", response_model=StepResult)
365
- def step(action: IndicatorsAction, session_id: Optional[str] = None):
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
- Provide episode_results as list of {ground_truth, predicted, conviction}.
381
- Returns a 0.01.0 score.
382
  """
383
  if request.task_id not in TASK_BY_ID:
384
- raise HTTPException(status_code=404, detail=f"Unknown task_id: {request.task_id}. Valid: {list(TASK_BY_ID.keys())}")
 
 
 
 
385
  return grade_task(request.task_id, request.episode_results)
386
 
387
 
388
  @app.get("/baseline")
389
  def baseline():
390
  """
391
- Trigger the built-in random baseline agent across all 3 tasks (10 episodes each).
392
- Returns reproducible baseline scores.
 
 
 
393
  """
394
  results = {}
395
- random.seed(42) # Deterministic
396
 
397
  for task in TASKS:
398
- term = task["term"]
399
  task_id = task["id"]
400
- session_id = f"baseline-{task_id}"
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
- # Random agent
411
- action = IndicatorsAction(
412
- direction=random.choice(["Bullish", "Bearish", "Neutral"]),
413
- conviction=round(random.uniform(0.3, 0.9), 2),
414
- )
415
- step_result = sess.step(action)
416
- episode_results.append({
417
- "ground_truth": step_result.info.get("ground_truth", ""),
418
- "predicted": action.direction,
419
- "conviction": action.conviction,
420
- })
 
 
 
 
 
 
 
 
 
421
 
422
  grader_result = grade_task(task_id, episode_results)
423
  results[task_id] = {
424
- "task_name": task["name"],
425
- "difficulty": task["difficulty"],
426
- "score": grader_result.score,
427
  "num_episodes": grader_result.num_episodes,
428
- "breakdown": grader_result.breakdown,
429
  }
430
 
431
  return {
432
- "agent": "random_baseline",
433
- "seed": 42,
434
- "tasks": results,
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 = IndicatorsAction(**msg.get("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
- uvicorn.run("indicators_env:app", host="0.0.0.0", port=7860, reload=False, workers=2)
 
 
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+conviction0.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. 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
 
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
- # --- Configuration (Mandatory per Hackathon Spec) ---
19
- API_BASE_URL = os.environ.get("API_BASE_URL", "https://api-inference.huggingface.co/v1/")
20
- MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-7B-Instruct")
21
- HF_TOKEN = os.environ.get("HF_TOKEN")
 
 
 
 
 
 
 
 
22
 
23
- TASKS = ["short_term_direction", "medium_term_direction", "long_term_conviction"]
24
- ENV_TERMS = {"short_term_direction": "short", "medium_term_direction": "medium", "long_term_conviction": "long"}
25
 
 
 
 
 
 
 
26
 
27
- def _parse_direction_and_conviction(text: str):
28
- """Extract direction and conviction from JSON-formatted LLM response."""
29
- # Clean possible markdown artifacts
 
 
 
 
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
- d = str(obj.get("direction", "Neutral")).strip().capitalize()
37
- c = float(obj.get("conviction", 0.5))
38
- if d not in ("Bullish", "Bearish", "Neutral"): d = "Neutral"
39
- return d, c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  except Exception:
41
  pass
42
-
43
- # Simple fallback: keyword search
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
- def run_evaluation(env_url, n_episodes):
51
- # Initialize OpenAI Client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Environment (use params, not json body)
64
- reset_resp = requests.post(f"{env_url}/reset", params={"term": term}, timeout=30)
 
 
 
 
65
  if reset_resp.status_code != 200:
 
66
  continue
67
-
68
- data = reset_resp.json()
69
- obs = data.get("observation", {})
70
- state = obs
71
  session_id = data.get("info", {}).get("session_id", "")
72
- symbol = obs.get("symbol", "N/A")
73
- date = obs.get("date", "N/A")
74
-
75
- # 2. Build Prompt
76
- system_prompt = "You are a quantitative analyst. Given technical indicators, predict the directional price move as Bullish, Bearish, or Neutral. Provide reasoning then output a JSON with 'reasoning', 'direction', and 'conviction' (0.0-1.0)."
77
- user_prompt = f"Technical Indicators for {symbol} on {date}: {state}\n\nPredict the directional move."
78
-
79
- # 3. Call LLM (using mandatory OpenAI Client)
80
- chat_completion = client.chat.completions.create(
81
- model=MODEL_NAME,
82
- messages=[
83
- {"role": "system", "content": system_prompt},
84
- {"role": "user", "content": user_prompt},
85
- ],
86
- max_tokens=256,
87
- temperature=0.7
88
- )
89
- response = chat_completion.choices[0].message.content
90
- direction, conviction = _parse_direction_and_conviction(response)
91
-
92
- # 4. Step Environment (use params for session_id, json body for action)
93
- step_resp = requests.post(
94
- f"{env_url}/step",
95
- params={"session_id": session_id},
96
- json={"direction": direction, "conviction": conviction},
97
- timeout=30
98
- )
99
- if step_resp.status_code != 200:
100
- continue
101
-
102
- step_data = step_resp.json()
103
- reward = step_data.get("reward", 0.0)
104
- ground_truth = step_data.get("info", {}).get("ground_truth", "N/A")
105
-
106
- # Track result for grader
107
- episode_results.append({
108
- "episode": i + 1,
109
- "direction": direction,
110
- "conviction": conviction,
111
- "reward": reward,
112
- "ground_truth": ground_truth
113
- })
114
-
115
- # [STEP] Log
116
- print(f"[STEP] step={i+1} action={direction} reward={reward:.4f} done=True error=None", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  except Exception as e:
119
- # Never crash the entire run
120
  continue
121
 
122
- # 5. Finalize Task (Call Grader)
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
- final_score = grader_resp.json().get("score", 0.0) if grader_resp.status_code == 200 else 0.0
132
-
133
- # [END] Log
134
- rewards_list = [ep["reward"] for ep in episode_results]
135
- steps_taken = len(episode_results)
136
- success = final_score > 0.0
137
- print(f"[END] success={success} steps={steps_taken} score={final_score:.4f} rewards={rewards_list}", flush=True)
138
- except:
139
- rewards_list = [ep["reward"] for ep in episode_results]
140
- steps_taken = len(episode_results)
141
- print(f"[END] success=False steps={steps_taken} score=0.0000 rewards={rewards_list}", flush=True)
 
142
 
143
 
144
  if __name__ == "__main__":
145
- parser = argparse.ArgumentParser(description="Strict Compliance OpenEnv Inference Agent")
146
- parser.add_argument("--env_url", default="http://localhost:7860", help="URL of the IndicatorsEnv server")
147
- parser.add_argument("--n_episodes", type=int, default=5, help="Number of episodes per task (Total 15)")
148
-
149
  args = parser.parse_args()
150
-
151
- # Mandatory Check: Environment Variables
152
  if not HF_TOKEN:
153
- print("Error: HF_TOKEN must be set as an environment variable.")
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)