naverdo commited on
Commit
53503f4
·
0 Parent(s):
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ HF_TOKEN=hf_xxxxxxxxxxxxx
2
+ API_BASE_URL=https://router.huggingface.co/v1
3
+ MODEL_NAME=Qwen/Qwen2.5-7B-Instruct
.github/workflows/ci.yml ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI - Submission Validation
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ env:
10
+ PYTHONPATH: ${{ github.workspace }}
11
+
12
+ jobs:
13
+ validate:
14
+ name: Tests & Validation
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.13"
21
+
22
+ - name: Install dependencies
23
+ run: |
24
+ pip install uv
25
+ uv venv .venv
26
+ source .venv/bin/activate
27
+ uv pip install -e .
28
+ uv pip install pytest pyyaml
29
+
30
+ - name: Run unit tests
31
+ run: |
32
+ source .venv/bin/activate
33
+ pytest tests/ -v
34
+
35
+ - name: Validate openenv.yaml
36
+ run: |
37
+ source .venv/bin/activate
38
+ python -c "
39
+ import yaml
40
+ with open('openenv.yaml') as f: spec = yaml.safe_load(f)
41
+ assert spec['name'] and spec['version'] and spec['entrypoint']
42
+ assert len(spec['tasks']) >= 3
43
+ assert 'observation_space' in spec and 'action_space' in spec
44
+ print(f'openenv.yaml OK: {spec[\"name\"]} v{spec[\"version\"]}')
45
+ "
46
+
47
+ - name: Validate Pydantic models
48
+ run: |
49
+ source .venv/bin/activate
50
+ python -c "
51
+ from app.models import (MarketObservation, TradeAction, StepResult,
52
+ ResetResult, EnvironmentState)
53
+ assert 'side' in TradeAction.model_fields
54
+ assert 'quantity' in TradeAction.model_fields
55
+ for f in ['ticker','date','price','price_history','cash','position','portfolio_value','task_id','step_number','total_steps']:
56
+ assert f in MarketObservation.model_fields, f'Missing: {f}'
57
+ print('models OK')
58
+ "
59
+
60
+ - name: Validate endpoints
61
+ run: |
62
+ source .venv/bin/activate
63
+ python -c "
64
+ from fastapi.testclient import TestClient
65
+ from app.main import app
66
+ c = TestClient(app)
67
+ assert c.post('/reset', json={}).status_code == 200
68
+ r = c.post('/step', json={'side': 'hold', 'quantity': 0})
69
+ assert r.status_code == 200 and -1.0 <= r.json()['reward'] <= 1.0
70
+ assert c.get('/state').status_code == 200
71
+ print('endpoints OK')
72
+ "
73
+
74
+ - name: Validate tasks
75
+ run: |
76
+ source .venv/bin/activate
77
+ python scripts/validate_tasks.py
78
+
79
+ - name: Verify inference.py structure
80
+ run: |
81
+ test -f inference.py
82
+ grep -q "from openai import OpenAI" inference.py
83
+ grep -q "API_BASE_URL" inference.py
84
+ grep -q "MODEL_NAME" inference.py
85
+ grep -q "HF_TOKEN" inference.py
86
+ grep -q '\[START\]' inference.py
87
+ grep -q '\[STEP\]' inference.py
88
+ grep -q '\[END\]' inference.py
89
+
90
+ - name: Verify inference.py imports
91
+ run: |
92
+ source .venv/bin/activate
93
+ python inference.py --help
94
+
95
+ docker-build:
96
+ name: Docker Build
97
+ runs-on: ubuntu-latest
98
+ timeout-minutes: 20
99
+ steps:
100
+ - uses: actions/checkout@v4
101
+ - name: Build image
102
+ run: docker build -t stocker:ci .
103
+ - name: Container /reset returns 200
104
+ run: |
105
+ docker run -d --name stocker-test -p 7860:7860 stocker:ci
106
+ for i in $(seq 1 30); do
107
+ if curl -sf http://localhost:7860/health >/dev/null 2>&1; then break; fi
108
+ sleep 2
109
+ done
110
+ CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
111
+ -H "Content-Type: application/json" -d '{}' http://localhost:7860/reset)
112
+ [ "$CODE" = "200" ] || exit 1
113
+ docker stop stocker-test && docker rm stocker-test
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .env
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .hypothesis/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ *.log
13
+ .venv/
14
+ results.json
CLAUDE.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ ## Project Overview
4
+ **Stocker** — a stock-trading RL environment built on top of OpenEnv. The agent
5
+ sees daily market observations and must produce a `(side, quantity)` trade.
6
+
7
+ ## Stack
8
+ - **Language:** Python 3.10+ (Docker image runs 3.13)
9
+ - **Framework:** FastAPI + Uvicorn
10
+ - **Models:** Pydantic v2 + pydantic-settings
11
+ - **OpenEnv:** `openenv-core>=0.2.0`
12
+ - **Package manager:** `uv`
13
+ - **Tests:** pytest + FastAPI TestClient
14
+
15
+ ## Layout
16
+ ```
17
+ .
18
+ ├── app/ # FastAPI application
19
+ │ ├── api/ # HTTP routers (health, meta, env, state, frontend)
20
+ │ ├── core/ # environment.py, graders.py, tasks.py
21
+ │ ├── config.py
22
+ │ ├── main.py
23
+ │ └── models.py # Pydantic schemas
24
+ ├── server/app.py # OpenEnv entry point (server.app:main)
25
+ ├── tasks/ # JSON task definitions (loaded at import)
26
+ ├── tests/ # pytest suite
27
+ ├── scripts/ # helper CLIs
28
+ ├── inference.py # LLM rollout script (root, OpenEnv requirement)
29
+ ├── client.py # Python HTTP client
30
+ ├── Dockerfile # builds via uv
31
+ ├── openenv.yaml # OpenEnv spec
32
+ └── run.sh # local dev server
33
+ ```
34
+
35
+ ## Conventions
36
+ - All Pydantic models live in [app/models.py](app/models.py) — keep them
37
+ named: `MarketObservation`, `TradeAction`, `RewardResult`, `StepResult`,
38
+ `ResetResult`, `EnvironmentState`.
39
+ - `StockerEnv` exposes `reset() / step() / state() / load_snapshot()`.
40
+ - `inference.py` MUST keep the `[START] / [STEP] / [END]` stdout format —
41
+ graders parse it.
42
+ - Tasks are inline in [app/core/tasks.py](app/core/tasks.py) plus optional
43
+ JSON files in [tasks/](tasks/) (auto-loaded at import).
44
+ - Reward is always clipped to `[-1.0, 1.0]` at the boundary.
45
+ - The server runs on port `7860` (HF Spaces convention).
46
+
47
+ ## Don't
48
+ - Don't introduce a frontend build step — the HTML lives inline in
49
+ [app/api/frontend.py](app/api/frontend.py).
50
+ - Don't bundle large data files in the Docker image; pull at runtime if
51
+ needed.
52
+ - Don't break the OpenAI-client pattern in `inference.py` — judges rerun it.
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1 \
3
+ PYTHONUNBUFFERED=1 \
4
+ PYTHONPATH=/app
5
+ WORKDIR /app
6
+
7
+ RUN pip install --no-cache-dir uv
8
+
9
+ COPY pyproject.toml ./
10
+ RUN if [ -f uv.lock ]; then \
11
+ uv export --no-dev --no-hashes > requirements.txt; \
12
+ else \
13
+ uv pip compile pyproject.toml -o requirements.txt; \
14
+ fi && \
15
+ pip install --no-cache-dir -r requirements.txt
16
+
17
+ COPY . .
18
+
19
+ RUN adduser --disabled-password --gecos "" appuser && \
20
+ chown -R appuser:appuser /app
21
+ USER appuser
22
+ EXPOSE 7860
23
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
24
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" || exit 1
25
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Stocker - OpenEnv
3
+ colorFrom: blue
4
+ colorTo: green
5
+ sdk: docker
6
+ pinned: false
7
+ app_port: 7860
8
+ base_path: /web
9
+ tags:
10
+ - openenv
11
+ ---
12
+
13
+ # Stocker - OpenEnv Environment
14
+
15
+ An RL environment where an AI agent makes stock-trading decisions (buy / sell / hold)
16
+ over a sequence of daily market observations and is rewarded for portfolio P&L.
17
+
18
+ ## Tasks
19
+
20
+ | Task | Difficulty | Steps | Description |
21
+ |------|-----------|-------|-------------|
22
+ | `task_easy` | Easy | 10 | Steady uptrend |
23
+ | `task_medium` | Medium | 10 | Volatile sideways market |
24
+ | `task_hard` | Hard | 10 | Bull-then-bear reversal |
25
+
26
+ ## Action Space
27
+
28
+ | Field | Type | Description |
29
+ |-------|------|-------------|
30
+ | `side` | `buy \| sell \| hold` | Trade direction |
31
+ | `quantity` | `int (>= 0)` | Number of shares (ignored if `hold`) |
32
+
33
+ ## Observation Space
34
+
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `ticker` | `string` | Stock ticker symbol |
38
+ | `date` | `string` | Day label (e.g. `day_3`) |
39
+ | `price` | `float` | Current price |
40
+ | `price_history` | `list[float]` | Prices observed so far |
41
+ | `fundamentals` | `dict` | Static facts about the company |
42
+ | `cash` | `float` | Current cash balance |
43
+ | `position` | `int` | Current number of shares held |
44
+ | `portfolio_value` | `float` | `cash + position * price` |
45
+ | `task_id` | `string` | Active task |
46
+ | `step_number` | `int` | 1-indexed step |
47
+ | `total_steps` | `int` | Total steps in the episode |
48
+
49
+ ## Environment Variables
50
+
51
+ | Variable | Required | Description |
52
+ |----------|----------|-------------|
53
+ | `API_BASE_URL` | inference only | LLM endpoint (default: HuggingFace Router) |
54
+ | `MODEL_NAME` | inference only | Model identifier |
55
+ | `HF_TOKEN` | inference only | HuggingFace API key |
56
+
57
+ The server itself requires no API keys.
58
+
59
+ ## Quick Start
60
+
61
+ ```bash
62
+ # Install dependencies
63
+ pip install uv
64
+ uv sync
65
+
66
+ # Run the server
67
+ ./run.sh
68
+ # Server: http://localhost:7860
69
+ # Frontend: http://localhost:7860/web
70
+ # Swagger: http://localhost:7860/docs
71
+
72
+ # Smoke test
73
+ curl http://localhost:7860/health
74
+ curl -X POST http://localhost:7860/reset -H "Content-Type: application/json" -d '{}'
75
+ curl -X POST http://localhost:7860/step -H "Content-Type: application/json" \
76
+ -d '{"side": "buy", "quantity": 10}'
77
+
78
+ # Run inference
79
+ export HF_TOKEN=hf_xxx
80
+ export API_BASE_URL=https://router.huggingface.co/v1
81
+ export MODEL_NAME=Qwen/Qwen2.5-7B-Instruct
82
+ python inference.py --task all
83
+ ```
84
+
85
+ ## Inference Script
86
+
87
+ `inference.py` lives in the project root, uses the OpenAI client, and emits
88
+ `[START]`, `[STEP]`, `[END]` log lines required by OpenEnv.
89
+
90
+ ```
91
+ [START] task=<task_name> env=stocker model=<model_name>
92
+ [STEP] step=<n> action=<side(qty)> reward=<x.xx> done=<true|false> error=<msg|null>
93
+ [END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...>
94
+ ```
95
+
96
+ ## Reward
97
+
98
+ For each step:
99
+
100
+ ```
101
+ reward = (new_portfolio - prev_portfolio) / prev_portfolio - invalid_action_penalty
102
+ ```
103
+
104
+ A small bonus (`+0.05`) is added on the final step if the agent ends the episode
105
+ with at least 1.05× starting capital. Reward is clipped to `[-1.0, 1.0]`.
106
+
107
+ ## Docker
108
+
109
+ ```bash
110
+ docker build -t stocker .
111
+ docker run -p 7860:7860 stocker
112
+ ```
113
+
114
+ ## API Endpoints
115
+
116
+ | Endpoint | Method | Description |
117
+ |----------|--------|-------------|
118
+ | `/web` | GET | Interactive frontend |
119
+ | `/health` | GET | Health check |
120
+ | `/meta` | GET | Environment metadata |
121
+ | `/reset` | POST | Reset environment |
122
+ | `/step` | POST | Submit trade action |
123
+ | `/state` | GET / POST | Export / restore state |
124
+ | `/docs` | GET | Swagger UI |
125
+
126
+ ## Validation
127
+
128
+ ```bash
129
+ ./validate-submission.sh https://your-space.hf.space .
130
+ python scripts/validate_tasks.py
131
+ ```
132
+
133
+ ## TODO (scaffolding hand-off)
134
+
135
+ - Replace inline `prices` with realistic OHLCV data (CSV or HF datasets).
136
+ - Add more tasks under `tasks/*.json`.
137
+ - Tune the reward shaping (transaction costs, risk penalty, Sharpe ratio).
138
+ - Wire HF Spaces deployment in `.env` + `push-hf.sh`.
139
+ - Add training script (Unsloth / TRL) per OpenEnv submission requirements.
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/env.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core RL environment endpoints: reset and step."""
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from app.core.environment import StockerEnv
9
+ from app.models import ResetResult, StepResult, TradeAction
10
+
11
+ router = APIRouter(tags=["environment"])
12
+
13
+ current_env = StockerEnv(task_id="task_easy")
14
+
15
+
16
+ class ResetRequest(BaseModel):
17
+ task_id: str = "task_easy"
18
+
19
+
20
+ @router.post("/reset", response_model=ResetResult)
21
+ async def reset(body: Optional[ResetRequest] = None) -> ResetResult:
22
+ global current_env
23
+ task_id = body.task_id if body else "task_easy"
24
+ try:
25
+ current_env = StockerEnv(task_id=task_id)
26
+ return current_env.reset()
27
+ except KeyError as e:
28
+ raise HTTPException(status_code=400, detail=str(e))
29
+
30
+
31
+ @router.post("/step", response_model=StepResult)
32
+ async def step(action: TradeAction) -> StepResult:
33
+ try:
34
+ return current_env.step(action)
35
+ except Exception as e:
36
+ raise HTTPException(status_code=400, detail=str(e))
app/api/frontend.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serves the embedded HTML frontend."""
2
+
3
+ from fastapi import APIRouter
4
+ from fastapi.responses import HTMLResponse
5
+
6
+ router = APIRouter(tags=["frontend"])
7
+
8
+ FRONTEND_HTML = """<!DOCTYPE html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="UTF-8">
12
+ <title>Stocker - OpenEnv</title>
13
+ <style>
14
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; }
15
+ .container { max-width: 900px; margin: 0 auto; }
16
+ h1 { color: #38bdf8; }
17
+ .card { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 16px; border: 1px solid #334155; }
18
+ label { display: block; color: #94a3b8; font-size: 0.85em; margin: 8px 0 4px; }
19
+ select, input { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #475569; background: #0f172a; color: #e2e8f0; }
20
+ .btn { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: 600; margin-top: 10px; margin-right: 8px; }
21
+ .btn-primary { background: #2563eb; color: white; }
22
+ .btn-success { background: #059669; color: white; }
23
+ .row { display: flex; gap: 12px; }
24
+ .row > * { flex: 1; }
25
+ .log { background: #0f172a; border-radius: 8px; padding: 12px; font-family: monospace; font-size: 0.85em; max-height: 280px; overflow-y: auto; white-space: pre-wrap; color: #94a3b8; }
26
+ .stat { color: #94a3b8; }
27
+ .stat strong { color: #e2e8f0; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div class="container">
32
+ <h1>Stocker</h1>
33
+ <p>OpenEnv RL environment for stock-trading decisions.</p>
34
+
35
+ <div class="card">
36
+ <div class="row">
37
+ <div>
38
+ <label>Task</label>
39
+ <select id="taskSelect"><option>Loading...</option></select>
40
+ </div>
41
+ <div style="display:flex;align-items:flex-end;">
42
+ <button class="btn btn-primary" onclick="resetEnv()">Start</button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="card" id="obsCard" style="display:none;">
48
+ <p class="stat">Ticker: <strong id="ticker"></strong> | Date: <strong id="date"></strong> | Price: <strong id="price"></strong></p>
49
+ <p class="stat">Cash: <strong id="cash"></strong> | Position: <strong id="position"></strong> | Portfolio: <strong id="pv"></strong></p>
50
+ <p class="stat">Step <strong id="step"></strong> of <strong id="total"></strong></p>
51
+ </div>
52
+
53
+ <div class="card" id="actCard" style="display:none;">
54
+ <div class="row">
55
+ <div>
56
+ <label>Side</label>
57
+ <select id="side"><option>buy</option><option>sell</option><option selected>hold</option></select>
58
+ </div>
59
+ <div>
60
+ <label>Quantity</label>
61
+ <input type="number" id="qty" min="0" value="0">
62
+ </div>
63
+ </div>
64
+ <button class="btn btn-success" onclick="submit()">Submit</button>
65
+ </div>
66
+
67
+ <div class="card">
68
+ <div class="log" id="log">Ready.</div>
69
+ </div>
70
+ </div>
71
+
72
+ <script>
73
+ function log(m) { const el = document.getElementById('log'); el.textContent += '\\n' + m; el.scrollTop = el.scrollHeight; }
74
+
75
+ async function loadTasks() {
76
+ const r = await fetch('/meta');
77
+ const d = await r.json();
78
+ const sel = document.getElementById('taskSelect');
79
+ sel.innerHTML = '';
80
+ for (const t of d.tasks) {
81
+ const o = document.createElement('option');
82
+ o.value = t; o.textContent = t;
83
+ sel.appendChild(o);
84
+ }
85
+ }
86
+ loadTasks();
87
+
88
+ function showObs(o) {
89
+ document.getElementById('obsCard').style.display = 'block';
90
+ document.getElementById('actCard').style.display = 'block';
91
+ document.getElementById('ticker').textContent = o.ticker;
92
+ document.getElementById('date').textContent = o.date;
93
+ document.getElementById('price').textContent = o.price.toFixed(2);
94
+ document.getElementById('cash').textContent = o.cash.toFixed(2);
95
+ document.getElementById('position').textContent = o.position;
96
+ document.getElementById('pv').textContent = o.portfolio_value.toFixed(2);
97
+ document.getElementById('step').textContent = o.step_number;
98
+ document.getElementById('total').textContent = o.total_steps;
99
+ }
100
+
101
+ async function resetEnv() {
102
+ const tid = document.getElementById('taskSelect').value;
103
+ const r = await fetch('/reset', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({task_id: tid}) });
104
+ const d = await r.json();
105
+ log('--- reset: ' + tid + ' ---');
106
+ showObs(d.observation);
107
+ }
108
+
109
+ async function submit() {
110
+ const a = { side: document.getElementById('side').value, quantity: parseInt(document.getElementById('qty').value || '0') };
111
+ log('action: ' + JSON.stringify(a));
112
+ const r = await fetch('/step', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(a) });
113
+ const d = await r.json();
114
+ log('reward: ' + d.reward.toFixed(4) + (d.done ? ' [DONE]' : ''));
115
+ showObs(d.observation);
116
+ }
117
+ </script>
118
+ </body>
119
+ </html>"""
120
+
121
+
122
+ @router.get("/", response_class=HTMLResponse)
123
+ @router.get("/web", response_class=HTMLResponse)
124
+ async def index() -> HTMLResponse:
125
+ return HTMLResponse(content=FRONTEND_HTML)
app/api/health.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health check endpoint."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from fastapi import APIRouter
6
+ from pydantic import BaseModel
7
+
8
+ router = APIRouter(tags=["health"])
9
+
10
+
11
+ class HealthResponse(BaseModel):
12
+ status: str
13
+ timestamp: str
14
+ service: str
15
+
16
+
17
+ @router.get("/health", response_model=HealthResponse)
18
+ async def health() -> HealthResponse:
19
+ return HealthResponse(
20
+ status="healthy",
21
+ timestamp=datetime.now(timezone.utc).isoformat(),
22
+ service="stocker",
23
+ )
app/api/meta.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment metadata endpoint."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.core.tasks import list_task_ids
6
+
7
+ router = APIRouter(tags=["meta"])
8
+
9
+
10
+ @router.get("/meta")
11
+ async def meta() -> dict:
12
+ return {
13
+ "name": "stocker",
14
+ "version": "0.1.0",
15
+ "description": "RL environment for stock trading decisions.",
16
+ "tasks": list_task_ids(),
17
+ "action_space": {
18
+ "side": "literal[buy, sell, hold]",
19
+ "quantity": "int (>=0)",
20
+ },
21
+ "observation_space": {
22
+ "ticker": "string",
23
+ "date": "string (ISO date)",
24
+ "price": "float",
25
+ "price_history": "list[float]",
26
+ "fundamentals": "dict",
27
+ "cash": "float",
28
+ "position": "int",
29
+ "portfolio_value": "float",
30
+ },
31
+ }
app/api/router.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aggregate API router."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.api import health, meta, env, state
6
+
7
+ api_router = APIRouter()
8
+ api_router.include_router(health.router)
9
+ api_router.include_router(meta.router)
10
+ api_router.include_router(env.router)
11
+ api_router.include_router(state.router)
app/api/state.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """State export and restore endpoints."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from app.models import EnvironmentState
6
+
7
+ router = APIRouter(tags=["state"])
8
+
9
+
10
+ @router.get("/state", response_model=EnvironmentState)
11
+ async def get_state() -> EnvironmentState:
12
+ import app.api.env as env_module
13
+ try:
14
+ return env_module.current_env.state()
15
+ except Exception as e:
16
+ raise HTTPException(status_code=400, detail=str(e))
17
+
18
+
19
+ @router.post("/state")
20
+ async def restore_state(snapshot: EnvironmentState) -> dict:
21
+ import app.api.env as env_module
22
+ from app.core.environment import StockerEnv
23
+
24
+ try:
25
+ new_env = StockerEnv(task_id=snapshot.task_id)
26
+ new_env.load_snapshot(snapshot)
27
+ env_module.current_env = new_env
28
+ return {"status": "restored", "task_id": snapshot.task_id}
29
+ except KeyError as e:
30
+ raise HTTPException(status_code=400, detail=str(e))
app/config.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application settings using pydantic-settings."""
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ project_name: str = "stocker"
8
+ allow_origins: list[str] = ["*"]
9
+ port: int = 7860
10
+
11
+ model_config = {"env_prefix": "STOCKER_", "case_sensitive": False}
12
+
13
+
14
+ settings = Settings()
app/core/__init__.py ADDED
File without changes
app/core/environment.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """StockerEnv — the Stocker OpenEnv environment."""
2
+
3
+ import uuid
4
+
5
+ from app.core.graders import compute_step_reward, compute_trajectory_bonus
6
+ from app.core.tasks import get_task_definition
7
+ from app.models import (
8
+ EnvironmentState,
9
+ MarketObservation,
10
+ ResetResult,
11
+ StepResult,
12
+ TradeAction,
13
+ )
14
+
15
+
16
+ class StockerEnv:
17
+ """Stock-trading RL environment."""
18
+
19
+ def __init__(self, task_id: str = "task_easy"):
20
+ self.task_id = task_id
21
+ self._task: dict = {}
22
+ self._prices: list[float] = []
23
+ self._current_index: int = 0
24
+ self._done: bool = True
25
+ self._cash: float = 0.0
26
+ self._position: int = 0
27
+ self._action_history: list[dict] = []
28
+ self._reward_history: list[float] = []
29
+ self._episode_id: str = ""
30
+
31
+ # ------------------------------------------------------------------ reset
32
+ def reset(self, task_id: str | None = None) -> ResetResult:
33
+ if task_id is not None:
34
+ self.task_id = task_id
35
+
36
+ self._task = get_task_definition(self.task_id)
37
+ self._prices = list(self._task["prices"])
38
+ self._current_index = 0
39
+ self._done = False
40
+ self._cash = float(self._task.get("starting_cash", 10000.0))
41
+ self._position = 0
42
+ self._action_history = []
43
+ self._reward_history = []
44
+ self._episode_id = str(uuid.uuid4())[:8]
45
+
46
+ return ResetResult(
47
+ observation=self._build_observation(0),
48
+ info={
49
+ "task_id": self.task_id,
50
+ "episode_id": self._episode_id,
51
+ "total_steps": len(self._prices),
52
+ "description": self._task.get("description", ""),
53
+ "starting_cash": self._cash,
54
+ },
55
+ )
56
+
57
+ # ------------------------------------------------------------------- step
58
+ def step(self, action: TradeAction | dict) -> StepResult:
59
+ if isinstance(action, dict):
60
+ try:
61
+ action = TradeAction.model_validate(action)
62
+ except Exception as e:
63
+ return StepResult(
64
+ observation=self._build_observation(self._current_index),
65
+ reward=0.0,
66
+ done=self._done,
67
+ info={"error": f"Invalid action: {e}"},
68
+ )
69
+
70
+ if self._done:
71
+ return StepResult(
72
+ observation=self._terminal_observation(),
73
+ reward=0.0,
74
+ done=True,
75
+ info={"message": "Episode already finished. Call reset()."},
76
+ )
77
+
78
+ price = self._prices[self._current_index]
79
+ prev_portfolio = self._cash + self._position * price
80
+ invalid = self._apply_action(action, price)
81
+ new_portfolio = self._cash + self._position * price
82
+
83
+ result = compute_step_reward(
84
+ action=action,
85
+ prev_portfolio=prev_portfolio,
86
+ new_portfolio=new_portfolio,
87
+ starting_cash=float(self._task.get("starting_cash", 10000.0)),
88
+ invalid=invalid,
89
+ )
90
+ reward = result.score
91
+
92
+ self._action_history.append(action.model_dump())
93
+ self._reward_history.append(reward)
94
+
95
+ self._current_index += 1
96
+ if self._current_index >= len(self._prices):
97
+ self._done = True
98
+ final_price = self._prices[-1]
99
+ final_portfolio = self._cash + self._position * final_price
100
+ reward += compute_trajectory_bonus(
101
+ final_portfolio, float(self._task.get("starting_cash", 10000.0))
102
+ )
103
+
104
+ reward = max(-1.0, min(1.0, reward))
105
+
106
+ next_obs = (
107
+ self._terminal_observation() if self._done
108
+ else self._build_observation(self._current_index)
109
+ )
110
+
111
+ return StepResult(
112
+ observation=next_obs,
113
+ reward=round(reward, 5),
114
+ done=self._done,
115
+ info={
116
+ "trade_feedback": result.feedback,
117
+ "reward_breakdown": result.breakdown,
118
+ "portfolio_value": round(new_portfolio, 4),
119
+ "cash": round(self._cash, 4),
120
+ "position": self._position,
121
+ },
122
+ )
123
+
124
+ # ------------------------------------------------------------------ state
125
+ def state(self) -> EnvironmentState:
126
+ price = (
127
+ self._prices[self._current_index]
128
+ if self._current_index < len(self._prices)
129
+ else (self._prices[-1] if self._prices else 0.0)
130
+ )
131
+ return EnvironmentState(
132
+ task_id=self.task_id,
133
+ current_step=self._current_index,
134
+ total_steps=len(self._prices),
135
+ done=self._done,
136
+ cash=round(self._cash, 4),
137
+ position=self._position,
138
+ portfolio_value=round(self._cash + self._position * price, 4),
139
+ action_history=self._action_history,
140
+ reward_history=self._reward_history,
141
+ )
142
+
143
+ def load_snapshot(self, snapshot: EnvironmentState) -> None:
144
+ self._task = get_task_definition(snapshot.task_id)
145
+ self._prices = list(self._task["prices"])
146
+ self._current_index = snapshot.current_step
147
+ self._done = snapshot.done
148
+ self._cash = snapshot.cash
149
+ self._position = snapshot.position
150
+ self._action_history = snapshot.action_history
151
+ self._reward_history = snapshot.reward_history
152
+
153
+ # ---------------------------------------------------------------- helpers
154
+ def _apply_action(self, action: TradeAction, price: float) -> bool:
155
+ """Apply trade. Returns True if the action was invalid (insufficient
156
+ cash/position) — the action is then treated as a hold."""
157
+ if action.side == "hold" or action.quantity <= 0:
158
+ return False
159
+
160
+ if action.side == "buy":
161
+ cost = action.quantity * price
162
+ if cost > self._cash:
163
+ return True
164
+ self._cash -= cost
165
+ self._position += action.quantity
166
+ return False
167
+
168
+ if action.side == "sell":
169
+ if action.quantity > self._position:
170
+ return True
171
+ self._cash += action.quantity * price
172
+ self._position -= action.quantity
173
+ return False
174
+
175
+ return True
176
+
177
+ def _build_observation(self, index: int) -> MarketObservation:
178
+ price = self._prices[index]
179
+ return MarketObservation(
180
+ ticker=self._task["ticker"],
181
+ date=f"day_{index + 1}",
182
+ price=price,
183
+ price_history=self._prices[: index + 1],
184
+ fundamentals=self._task.get("fundamentals", {}),
185
+ cash=round(self._cash, 4),
186
+ position=self._position,
187
+ portfolio_value=round(self._cash + self._position * price, 4),
188
+ task_id=self.task_id,
189
+ step_number=index + 1,
190
+ total_steps=len(self._prices),
191
+ )
192
+
193
+ def _terminal_observation(self) -> MarketObservation:
194
+ final_price = self._prices[-1] if self._prices else 0.0
195
+ portfolio = self._cash + self._position * final_price
196
+ return MarketObservation(
197
+ ticker=self._task.get("ticker", ""),
198
+ date="[EPISODE COMPLETE]",
199
+ price=final_price,
200
+ price_history=self._prices,
201
+ fundamentals=self._task.get("fundamentals", {}),
202
+ cash=round(self._cash, 4),
203
+ position=self._position,
204
+ portfolio_value=round(portfolio, 4),
205
+ task_id=self.task_id,
206
+ step_number=len(self._prices),
207
+ total_steps=len(self._prices),
208
+ )
app/core/graders.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reward and shaping functions for the Stocker environment."""
2
+
3
+ from app.models import RewardResult, TradeAction
4
+
5
+
6
+ def compute_step_reward(
7
+ action: TradeAction,
8
+ prev_portfolio: float,
9
+ new_portfolio: float,
10
+ starting_cash: float,
11
+ invalid: bool,
12
+ ) -> RewardResult:
13
+ """Reward = pct change in portfolio value, with a small penalty for invalid actions."""
14
+ breakdown: dict[str, float] = {}
15
+
16
+ pnl_pct = (new_portfolio - prev_portfolio) / max(prev_portfolio, 1e-9)
17
+ breakdown["pnl_pct"] = round(pnl_pct, 5)
18
+
19
+ penalty = 0.0
20
+ if invalid:
21
+ penalty = 0.01
22
+ breakdown["invalid_action_penalty"] = -penalty
23
+
24
+ score = pnl_pct - penalty
25
+
26
+ side = action.side
27
+ feedback = f"{side}({action.quantity}) -> portfolio {new_portfolio:.2f}"
28
+ if invalid:
29
+ feedback += " [invalid: insufficient cash/position]"
30
+
31
+ return RewardResult(score=round(score, 5), breakdown=breakdown, feedback=feedback)
32
+
33
+
34
+ def compute_trajectory_bonus(
35
+ final_portfolio: float, starting_cash: float, threshold: float = 1.05
36
+ ) -> float:
37
+ """Bonus if the agent finishes with >threshold of starting capital."""
38
+ ratio = final_portfolio / max(starting_cash, 1e-9)
39
+ if ratio >= threshold:
40
+ return 0.05
41
+ return 0.0
app/core/tasks.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trading scenario dataset for the Stocker environment.
2
+
3
+ Each task is a sequence of daily market observations for a single ticker, with
4
+ a known ground-truth optimal trajectory used only for reward shaping (not
5
+ shown to the agent).
6
+
7
+ Tasks are loaded from:
8
+ 1. Inline definitions below
9
+ 2. JSON files in tasks/ directory
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Inline task definitions
20
+ # ---------------------------------------------------------------------------
21
+
22
+ TASK_EASY = {
23
+ "task_id": "task_easy",
24
+ "description": "Steady uptrend: a clearly bullish 10-day sequence.",
25
+ "ticker": "ACME",
26
+ "starting_cash": 10000.0,
27
+ "fundamentals": {"sector": "tech", "pe_ratio": 22.0, "market_cap": 5e9},
28
+ "prices": [100.0, 101.5, 103.0, 104.2, 106.0, 107.5, 109.0, 110.5, 112.0, 114.0],
29
+ }
30
+
31
+ TASK_MEDIUM = {
32
+ "task_id": "task_medium",
33
+ "description": "Volatile sideways market: noisy mean-reverting prices.",
34
+ "ticker": "VOLT",
35
+ "starting_cash": 10000.0,
36
+ "fundamentals": {"sector": "energy", "pe_ratio": 14.5, "market_cap": 1.2e9},
37
+ "prices": [50.0, 52.0, 49.5, 51.0, 48.5, 50.5, 52.5, 49.0, 51.5, 50.0],
38
+ }
39
+
40
+ TASK_HARD = {
41
+ "task_id": "task_hard",
42
+ "description": "Bull-then-bear reversal: agent must time the exit.",
43
+ "ticker": "FLIP",
44
+ "starting_cash": 10000.0,
45
+ "fundamentals": {"sector": "biotech", "pe_ratio": 35.0, "market_cap": 800e6},
46
+ "prices": [40.0, 42.5, 45.0, 47.0, 49.5, 50.0, 47.0, 43.5, 39.0, 35.0],
47
+ }
48
+
49
+ _INLINE_TASKS = {
50
+ "task_easy": TASK_EASY,
51
+ "task_medium": TASK_MEDIUM,
52
+ "task_hard": TASK_HARD,
53
+ }
54
+
55
+ _TASKS_DIR = Path(__file__).resolve().parent.parent.parent / "tasks"
56
+
57
+
58
+ def _load_tasks_from_dir(tasks_dir: Path) -> dict[str, dict]:
59
+ loaded: dict[str, dict] = {}
60
+ if not tasks_dir.is_dir():
61
+ return loaded
62
+ for json_file in sorted(tasks_dir.glob("*.json")):
63
+ try:
64
+ with open(json_file) as f:
65
+ task = json.load(f)
66
+ task_id = task.get("task_id", json_file.stem)
67
+ loaded[task_id] = task
68
+ logger.debug("Loaded task '%s' from %s", task_id, json_file.name)
69
+ except (json.JSONDecodeError, KeyError) as e:
70
+ logger.warning("Failed to load task from %s: %s", json_file, e)
71
+ return loaded
72
+
73
+
74
+ TASKS_BY_ID: dict[str, dict] = {}
75
+ TASKS_BY_ID.update(_INLINE_TASKS)
76
+ TASKS_BY_ID.update(_load_tasks_from_dir(_TASKS_DIR))
77
+
78
+
79
+ def get_task_definition(task_id: str) -> dict:
80
+ if task_id not in TASKS_BY_ID:
81
+ raise KeyError(
82
+ f"Unknown task_id: {task_id}. Available: {list(TASKS_BY_ID.keys())}"
83
+ )
84
+ return TASKS_BY_ID[task_id]
85
+
86
+
87
+ def list_task_ids() -> list[str]:
88
+ return list(TASKS_BY_ID.keys())
app/main.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application factory and setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import time
8
+ import traceback
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import JSONResponse
13
+
14
+ from app.api.router import api_router
15
+ from app.api.frontend import router as frontend_router
16
+ from app.config import settings
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(levelname)s:\t%(name)s - %(message)s",
21
+ handlers=[logging.StreamHandler(sys.stderr)],
22
+ )
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def create_app() -> FastAPI:
27
+ app = FastAPI(
28
+ title="Stocker - OpenEnv",
29
+ version="0.1.0",
30
+ description="RL environment where an AI agent makes stock-trading decisions.",
31
+ openapi_url="/openapi.json",
32
+ docs_url="/docs",
33
+ redoc_url="/redoc",
34
+ )
35
+
36
+ @app.exception_handler(Exception)
37
+ async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
38
+ logger.error("Unhandled exception: %s", exc)
39
+ logger.error(traceback.format_exc())
40
+ return JSONResponse(status_code=500, content={"detail": str(exc)})
41
+
42
+ @app.middleware("http")
43
+ async def log_requests(request: Request, call_next):
44
+ start = time.time()
45
+ response = await call_next(request)
46
+ logger.info(
47
+ "%s %s - %s - %.3fs",
48
+ request.method, request.url.path, response.status_code, time.time() - start,
49
+ )
50
+ return response
51
+
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=settings.allow_origins,
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ allow_credentials=True,
58
+ )
59
+
60
+ app.include_router(api_router)
61
+ app.include_router(frontend_router)
62
+ return app
63
+
64
+
65
+ app = create_app()
66
+
67
+
68
+ def run() -> None:
69
+ import uvicorn
70
+
71
+ uvicorn.run(app, host="0.0.0.0", port=settings.port)
app/models.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic data models for the Stocker OpenEnv environment."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class MarketObservation(BaseModel):
9
+ """What the agent sees: market state plus its current portfolio."""
10
+ ticker: str
11
+ date: str
12
+ price: float
13
+ price_history: list[float]
14
+ fundamentals: dict
15
+ cash: float
16
+ position: int
17
+ portfolio_value: float
18
+ task_id: str
19
+ step_number: int
20
+ total_steps: int
21
+
22
+
23
+ class TradeAction(BaseModel):
24
+ """What the agent does: buy / sell / hold a quantity of shares."""
25
+ side: Literal["buy", "sell", "hold"]
26
+ quantity: int = Field(ge=0, default=0)
27
+
28
+
29
+ class RewardResult(BaseModel):
30
+ """Internal reward computation result."""
31
+ score: float
32
+ breakdown: dict[str, float]
33
+ feedback: str
34
+
35
+
36
+ class EnvironmentState(BaseModel):
37
+ """Snapshot of environment state."""
38
+ task_id: str
39
+ current_step: int
40
+ total_steps: int
41
+ done: bool
42
+ cash: float
43
+ position: int
44
+ portfolio_value: float
45
+ action_history: list[dict]
46
+ reward_history: list[float]
47
+
48
+
49
+ class StepResult(BaseModel):
50
+ observation: MarketObservation
51
+ reward: float
52
+ done: bool
53
+ info: dict
54
+
55
+
56
+ class ResetResult(BaseModel):
57
+ observation: MarketObservation
58
+ info: dict
client.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP client for the Stocker OpenEnv environment."""
2
+
3
+ import requests
4
+
5
+
6
+ class StockerClient:
7
+ def __init__(self, base_url: str = "http://localhost:7860"):
8
+ self.base_url = base_url.rstrip("/")
9
+
10
+ def reset(self, task_id: str = "task_easy") -> dict:
11
+ r = requests.post(f"{self.base_url}/reset", json={"task_id": task_id})
12
+ r.raise_for_status()
13
+ return r.json()
14
+
15
+ def step(self, side: str, quantity: int = 0) -> dict:
16
+ r = requests.post(
17
+ f"{self.base_url}/step",
18
+ json={"side": side, "quantity": quantity},
19
+ )
20
+ r.raise_for_status()
21
+ return r.json()
22
+
23
+ def get_state(self) -> dict:
24
+ r = requests.get(f"{self.base_url}/state")
25
+ r.raise_for_status()
26
+ return r.json()
27
+
28
+ def restore_state(self, state: dict) -> dict:
29
+ r = requests.post(f"{self.base_url}/state", json=state)
30
+ r.raise_for_status()
31
+ return r.json()
32
+
33
+ def health(self) -> dict:
34
+ r = requests.get(f"{self.base_url}/health")
35
+ r.raise_for_status()
36
+ return r.json()
inference.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference Script — Stocker OpenEnv
3
+ ==================================
4
+ MANDATORY
5
+ - Environment variables:
6
+ API_BASE_URL The API endpoint for the LLM (default: HuggingFace Router)
7
+ MODEL_NAME The model identifier (default: Qwen/Qwen2.5-7B-Instruct)
8
+ HF_TOKEN Your HuggingFace / API key
9
+ API_KEY Alternative to HF_TOKEN
10
+
11
+ - Place inference.py in the project root. Uses OpenAI Client for LLM calls.
12
+
13
+ STDOUT FORMAT
14
+ [START] task=<task_name> env=<benchmark> model=<model_name>
15
+ [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
16
+ [END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import textwrap
25
+ from typing import Optional
26
+
27
+ from openai import OpenAI
28
+
29
+ from app.core.environment import StockerEnv
30
+ from app.core.tasks import list_task_ids
31
+
32
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY")
33
+ API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
34
+ MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-7B-Instruct"
35
+ BENCHMARK = "stocker"
36
+
37
+ MAX_STEPS = 50
38
+ TEMPERATURE = 0.2
39
+ MAX_TOKENS = 200
40
+ SUCCESS_SCORE_THRESHOLD = 0.0 # any non-negative net P&L counts as success
41
+
42
+ SYSTEM_PROMPT = textwrap.dedent("""\
43
+ You are a disciplined stock trader. At each step you observe the current
44
+ price, recent price history, your cash, and your share position. You must
45
+ decide: buy, sell, or hold — and if buying or selling, how many shares.
46
+
47
+ Rules:
48
+ - You cannot buy more than your cash allows.
49
+ - You cannot sell more shares than you own.
50
+ - Aim to maximize the final portfolio value.
51
+
52
+ Respond with ONLY a valid JSON object (no markdown, no extra text):
53
+ {"side": "buy|sell|hold", "quantity": <int>}""")
54
+
55
+
56
+ def log_start(task: str, env: str, model: str) -> None:
57
+ print(f"[START] task={task} env={env} model={model}", flush=True)
58
+
59
+
60
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
61
+ print(
62
+ f"[STEP] step={step} action={action} reward={reward:.4f} done={str(done).lower()} "
63
+ f"error={error if error else 'null'}",
64
+ flush=True,
65
+ )
66
+
67
+
68
+ def log_end(success: bool, steps: int, score: float, rewards: list[float]) -> None:
69
+ rewards_str = ",".join(f"{r:.4f}" for r in rewards)
70
+ print(
71
+ f"[END] success={str(success).lower()} steps={steps} score={score:.4f} rewards={rewards_str}",
72
+ flush=True,
73
+ )
74
+
75
+
76
+ def build_user_prompt(observation: dict) -> str:
77
+ return f"""Trade decision for {observation['ticker']} on {observation['date']}.
78
+
79
+ Current price: {observation['price']:.2f}
80
+ Recent prices: {observation['price_history']}
81
+ Fundamentals: {observation['fundamentals']}
82
+ Cash: {observation['cash']:.2f}
83
+ Position: {observation['position']} shares
84
+ Portfolio value: {observation['portfolio_value']:.2f}
85
+ Step {observation['step_number']} of {observation['total_steps']}"""
86
+
87
+
88
+ def parse_response(text: str) -> dict:
89
+ try:
90
+ cleaned = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`")
91
+ start = cleaned.find("{")
92
+ if start >= 0:
93
+ depth = 0
94
+ for i in range(start, len(cleaned)):
95
+ if cleaned[i] == "{":
96
+ depth += 1
97
+ elif cleaned[i] == "}":
98
+ depth -= 1
99
+ if depth == 0:
100
+ parsed = json.loads(cleaned[start : i + 1])
101
+ side = str(parsed.get("side", "hold")).lower()
102
+ if side not in ("buy", "sell", "hold"):
103
+ side = "hold"
104
+ qty = int(parsed.get("quantity", 0))
105
+ return {"side": side, "quantity": max(0, qty)}
106
+ except (json.JSONDecodeError, ValueError, TypeError):
107
+ pass
108
+
109
+ side_match = re.search(r"side[\"']?\s*[:=]\s*[\"']?(buy|sell|hold)", text, re.I)
110
+ qty_match = re.search(r"quantity[\"']?\s*[:=]\s*[\"']?(-?\d+)", text, re.I)
111
+ if side_match:
112
+ return {
113
+ "side": side_match.group(1).lower(),
114
+ "quantity": max(0, int(qty_match.group(1))) if qty_match else 0,
115
+ }
116
+
117
+ return {"side": "hold", "quantity": 0}
118
+
119
+
120
+ def run_episode(
121
+ client: OpenAI, model: str, task_id: str, use_json_mode: bool = True
122
+ ) -> dict:
123
+ env = StockerEnv(task_id=task_id)
124
+ reset_result = env.reset()
125
+ obs = reset_result.observation.model_dump()
126
+
127
+ rewards: list[float] = []
128
+ step_details: list[dict] = []
129
+ steps_taken = 0
130
+
131
+ log_start(task=task_id, env=BENCHMARK, model=model)
132
+
133
+ try:
134
+ for step in range(1, MAX_STEPS + 1):
135
+ messages = [
136
+ {"role": "system", "content": SYSTEM_PROMPT},
137
+ {"role": "user", "content": build_user_prompt(obs)},
138
+ ]
139
+ error: Optional[str] = None
140
+
141
+ try:
142
+ kwargs = {
143
+ "model": model,
144
+ "messages": messages,
145
+ "temperature": TEMPERATURE,
146
+ "max_tokens": MAX_TOKENS,
147
+ }
148
+ if use_json_mode:
149
+ kwargs["response_format"] = {"type": "json_object"}
150
+ response = client.chat.completions.create(**kwargs)
151
+ llm_text = (response.choices[0].message.content or "").strip()
152
+ except Exception as e:
153
+ err = str(e)
154
+ if use_json_mode and ("response_format" in err or "json" in err.lower()):
155
+ use_json_mode = False
156
+ try:
157
+ response = client.chat.completions.create(
158
+ model=model, messages=messages,
159
+ temperature=TEMPERATURE, max_tokens=MAX_TOKENS,
160
+ )
161
+ llm_text = (response.choices[0].message.content or "").strip()
162
+ except Exception as e2:
163
+ error = str(e2)
164
+ llm_text = '{"side": "hold", "quantity": 0}'
165
+ else:
166
+ error = err
167
+ llm_text = '{"side": "hold", "quantity": 0}'
168
+
169
+ action_dict = parse_response(llm_text)
170
+ step_result = env.step(action_dict)
171
+
172
+ reward = step_result.reward
173
+ done = step_result.done
174
+ rewards.append(reward)
175
+ steps_taken = step
176
+
177
+ action_str = f"{action_dict['side']}({action_dict['quantity']})"
178
+ log_step(step, action_str, reward, done, error)
179
+
180
+ step_details.append({
181
+ "step": step,
182
+ "side": action_dict["side"],
183
+ "quantity": action_dict["quantity"],
184
+ "reward": reward,
185
+ "info": step_result.info,
186
+ "done": done,
187
+ })
188
+
189
+ if done:
190
+ break
191
+ obs = step_result.observation.model_dump()
192
+
193
+ score = sum(rewards)
194
+ success = score >= SUCCESS_SCORE_THRESHOLD
195
+ finally:
196
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
197
+
198
+ return {
199
+ "task_id": task_id,
200
+ "score": round(score, 4),
201
+ "total_reward": round(sum(rewards), 4),
202
+ "steps": steps_taken,
203
+ "success": success,
204
+ "step_details": step_details,
205
+ }
206
+
207
+
208
+ def main():
209
+ parser = argparse.ArgumentParser(description="Run LLM inference on Stocker")
210
+ parser.add_argument("--task", default="all", help="Task name or 'all'")
211
+ parser.add_argument("--model", default=None, help=f"Model (default: {MODEL_NAME})")
212
+ parser.add_argument("--output", default=None, help="Path to write JSON results")
213
+ parser.add_argument(
214
+ "--no-json-mode", action="store_true",
215
+ help="Disable JSON response mode for models that don't support it",
216
+ )
217
+ args = parser.parse_args()
218
+
219
+ if not API_KEY:
220
+ print("ERROR: No API key. Set HF_TOKEN, API_KEY, or OPENAI_API_KEY.")
221
+ raise SystemExit(1)
222
+
223
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
224
+ model = args.model or MODEL_NAME
225
+
226
+ tasks = list_task_ids() if args.task == "all" else [args.task]
227
+
228
+ results = []
229
+ for task_id in tasks:
230
+ results.append(run_episode(client, model, task_id, use_json_mode=not args.no_json_mode))
231
+
232
+ print(f"\n{'='*56}", file=sys.stderr)
233
+ print(f"{'Task':<18} {'Score':>10} {'Reward':>10} {'Steps':>6} {'Pass':>6}", file=sys.stderr)
234
+ for r in results:
235
+ print(
236
+ f"{r['task_id']:<18} {r['score']:>10.4f} {r['total_reward']:>10.4f} "
237
+ f"{r['steps']:>6} {'yes' if r['success'] else 'no':>6}",
238
+ file=sys.stderr,
239
+ )
240
+
241
+ if args.output:
242
+ with open(args.output, "w") as f:
243
+ json.dump({
244
+ "model": model,
245
+ "api_base_url": API_BASE_URL,
246
+ "benchmark": BENCHMARK,
247
+ "tasks": results,
248
+ "total_score": round(sum(r["score"] for r in results) / max(len(results), 1), 4),
249
+ }, f, indent=2)
250
+ print(f"\nResults written to {args.output}", file=sys.stderr)
251
+
252
+
253
+ if __name__ == "__main__":
254
+ main()
openenv.yaml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: stocker
2
+ version: 0.1.0
3
+ description: >
4
+ An RL environment where an AI agent makes stock-trading decisions
5
+ (buy / sell / hold) over a sequence of market observations and is
6
+ rewarded for portfolio profit and risk-adjusted return.
7
+
8
+ tasks:
9
+ - task_easy
10
+ - task_medium
11
+ - task_hard
12
+
13
+ observation_space:
14
+ ticker: str
15
+ date: str
16
+ price: float
17
+ price_history: "list[float]"
18
+ fundamentals: dict
19
+ cash: float
20
+ position: int
21
+ portfolio_value: float
22
+ task_id: str
23
+ step_number: int
24
+ total_steps: int
25
+
26
+ action_space:
27
+ side: "literal[buy, sell, hold]"
28
+ quantity: int
29
+
30
+ entrypoint: app.core.environment:StockerEnv
31
+
32
+ tags:
33
+ - openenv
34
+ - finance
35
+ - trading
36
+ - rl
push-hf.sh ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Push the project to a HuggingFace Space.
3
+ #
4
+ # Expects the following in .env (see .env.example):
5
+ # HF_USERNAME your HF username/org
6
+ # HF_TOKEN a write-scope token
7
+ # HF_SPACE the space name (e.g. Stocker)
8
+ set -euo pipefail
9
+
10
+ if [ -f .env ]; then
11
+ # shellcheck disable=SC1091
12
+ source .env
13
+ fi
14
+
15
+ : "${HF_USERNAME:?Set HF_USERNAME in .env}"
16
+ : "${HF_TOKEN:?Set HF_TOKEN in .env}"
17
+ : "${HF_SPACE:?Set HF_SPACE in .env}"
18
+
19
+ git push "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${HF_USERNAME}/${HF_SPACE}" main
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "openenv-stocker"
3
+ version = "0.1.0"
4
+ description = "Stocker — a stock trading RL environment for OpenEnv"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "fastapi>=0.115.0",
8
+ "uvicorn[standard]>=0.30.0",
9
+ "pydantic>=2.0.0",
10
+ "pydantic-settings>=2.6.0",
11
+ "openai>=1.0.0",
12
+ "openenv-core>=0.2.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ server = "server.app:main"
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8.0.0"]
20
+
21
+ [tool.uv]
22
+ package = false
run.sh ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload --reload-dir app
scripts/validate_tasks.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Validate every Stocker task definition."""
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
8
+
9
+ from app.core.tasks import TASKS_BY_ID # noqa: E402
10
+
11
+
12
+ def validate() -> int:
13
+ errors: list[str] = []
14
+
15
+ for task_id, task in sorted(TASKS_BY_ID.items()):
16
+ for field in ("ticker", "starting_cash", "prices"):
17
+ if field not in task:
18
+ errors.append(f"{task_id}: missing '{field}'")
19
+
20
+ prices = task.get("prices", [])
21
+ if len(prices) < 3:
22
+ errors.append(f"{task_id}: needs >=3 prices, got {len(prices)}")
23
+ if any(p <= 0 for p in prices):
24
+ errors.append(f"{task_id}: non-positive price found")
25
+
26
+ cash = task.get("starting_cash", 0)
27
+ if cash <= 0:
28
+ errors.append(f"{task_id}: starting_cash must be > 0")
29
+
30
+ print(f"{task_id:<18} ticker={task.get('ticker'):<6} "
31
+ f"steps={len(prices)} cash={cash}")
32
+
33
+ if errors:
34
+ print("\nERRORS:")
35
+ for e in errors:
36
+ print(" -", e)
37
+ return 1
38
+ print(f"\nAll {len(TASKS_BY_ID)} tasks valid.")
39
+ return 0
40
+
41
+
42
+ if __name__ == "__main__":
43
+ sys.exit(validate())
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server entry point and ASGI application export.
2
+
3
+ The actual application lives in app/main.py. This module preserves
4
+ `server.app:app` as an ASGI target and adds a callable `main()`
5
+ entry point required by OpenEnv validation.
6
+ """
7
+
8
+ from app.main import app, run # noqa: F401
9
+
10
+ __all__ = ["app", "main"]
11
+
12
+
13
+ def main() -> None:
14
+ """CLI/server entry point required by OpenEnv validation."""
15
+ run()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
tests/__init__.py ADDED
File without changes
tests/test_api.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP-level smoke tests for the FastAPI app."""
2
+
3
+ from fastapi.testclient import TestClient
4
+
5
+ from app.main import app
6
+
7
+ client = TestClient(app)
8
+
9
+
10
+ def test_health():
11
+ r = client.get("/health")
12
+ assert r.status_code == 200
13
+ assert r.json()["status"] == "healthy"
14
+
15
+
16
+ def test_meta_lists_tasks():
17
+ r = client.get("/meta")
18
+ assert r.status_code == 200
19
+ assert len(r.json()["tasks"]) >= 3
20
+
21
+
22
+ def test_reset_and_step():
23
+ r = client.post("/reset", json={})
24
+ assert r.status_code == 200
25
+ assert "observation" in r.json()
26
+
27
+ r = client.post("/step", json={"side": "hold", "quantity": 0})
28
+ assert r.status_code == 200
29
+ assert -1.0 <= r.json()["reward"] <= 1.0
30
+
31
+
32
+ def test_state_roundtrip():
33
+ client.post("/reset", json={"task_id": "task_easy"})
34
+ s = client.get("/state").json()
35
+ r = client.post("/state", json=s)
36
+ assert r.status_code == 200
tests/test_environment.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests for StockerEnv."""
2
+
3
+ from app.core.environment import StockerEnv
4
+ from app.core.tasks import list_task_ids
5
+
6
+
7
+ def test_env_reset_and_step_each_task():
8
+ for task_id in list_task_ids():
9
+ env = StockerEnv(task_id=task_id)
10
+ reset = env.reset()
11
+ assert reset.observation.task_id == task_id
12
+ assert reset.observation.step_number == 1
13
+
14
+ result = env.step({"side": "hold", "quantity": 0})
15
+ assert -1.0 <= result.reward <= 1.0
16
+ assert result.observation.step_number >= 2 or result.done
17
+
18
+
19
+ def test_env_buy_then_hold_runs_to_completion():
20
+ env = StockerEnv(task_id="task_easy")
21
+ env.reset()
22
+ env.step({"side": "buy", "quantity": 10})
23
+ done = False
24
+ steps = 0
25
+ while not done and steps < 50:
26
+ r = env.step({"side": "hold", "quantity": 0})
27
+ done = r.done
28
+ steps += 1
29
+ assert done
30
+
31
+
32
+ def test_invalid_buy_does_not_change_position():
33
+ env = StockerEnv(task_id="task_easy")
34
+ env.reset()
35
+ r = env.step({"side": "buy", "quantity": 10**9})
36
+ assert r.info["position"] == 0
validate-submission.sh ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Validate that the HF Space is live, the Docker image builds, and openenv validate passes.
3
+ #
4
+ # Usage: ./validate-submission.sh <ping_url> [repo_dir]
5
+
6
+ set -uo pipefail
7
+
8
+ DOCKER_BUILD_TIMEOUT=600
9
+ if [ -t 1 ]; then
10
+ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m'
11
+ else
12
+ RED=''; GREEN=''; YELLOW=''; BOLD=''; NC=''
13
+ fi
14
+
15
+ PING_URL="${1:-}"
16
+ REPO_DIR="${2:-.}"
17
+
18
+ if [ -z "$PING_URL" ]; then
19
+ printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
20
+ exit 1
21
+ fi
22
+
23
+ REPO_DIR="$(cd "$REPO_DIR" && pwd)"
24
+ PING_URL="${PING_URL%/}"
25
+ PASS=0
26
+
27
+ log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
28
+ pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS+1)); }
29
+ fail() { log "${RED}FAILED${NC} -- $1"; }
30
+ hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
31
+ stop_at() { printf "\n${RED}${BOLD}Stopped at %s.${NC}\n" "$1"; exit 1; }
32
+
33
+ printf "${BOLD}========================================${NC}\n"
34
+ printf "${BOLD} Stocker Submission Validator${NC}\n"
35
+ printf "${BOLD}========================================${NC}\n"
36
+ log "Repo: $REPO_DIR"
37
+ log "Ping URL: $PING_URL"
38
+
39
+ log "${BOLD}Step 1/3:${NC} Pinging HF Space ($PING_URL/reset) ..."
40
+ HTTP_CODE=$(curl -s -o /tmp/stocker_resp -w "%{http_code}" -X POST \
41
+ -H "Content-Type: application/json" -d '{}' \
42
+ "$PING_URL/reset" --max-time 30 || echo "000")
43
+ if [ "$HTTP_CODE" = "200" ]; then
44
+ pass "HF Space is live"
45
+ else
46
+ fail "HF Space returned $HTTP_CODE"
47
+ hint "Check the Space is running at $PING_URL"
48
+ stop_at "Step 1"
49
+ fi
50
+
51
+ log "${BOLD}Step 2/3:${NC} docker build ..."
52
+ if ! command -v docker &>/dev/null; then
53
+ fail "docker not found"; stop_at "Step 2"
54
+ fi
55
+ if ! docker build "$REPO_DIR" >/tmp/stocker_build.log 2>&1; then
56
+ fail "docker build failed"
57
+ tail -20 /tmp/stocker_build.log
58
+ stop_at "Step 2"
59
+ fi
60
+ pass "docker build succeeded"
61
+
62
+ log "${BOLD}Step 3/3:${NC} openenv validate ..."
63
+ if ! command -v openenv &>/dev/null; then
64
+ fail "openenv not found"; hint "pip install openenv-core"; stop_at "Step 3"
65
+ fi
66
+ if ! ( cd "$REPO_DIR" && openenv validate ); then
67
+ fail "openenv validate failed"; stop_at "Step 3"
68
+ fi
69
+ pass "openenv validate"
70
+
71
+ printf "\n${GREEN}${BOLD}All 3/3 checks passed.${NC}\n"