Spaces:
Sleeping
Sleeping
naverdo commited on
Commit ·
53503f4
0
Parent(s):
init
Browse files- .env.example +3 -0
- .github/workflows/ci.yml +113 -0
- .gitignore +14 -0
- CLAUDE.md +52 -0
- Dockerfile +25 -0
- README.md +139 -0
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/env.py +36 -0
- app/api/frontend.py +125 -0
- app/api/health.py +23 -0
- app/api/meta.py +31 -0
- app/api/router.py +11 -0
- app/api/state.py +30 -0
- app/config.py +14 -0
- app/core/__init__.py +0 -0
- app/core/environment.py +208 -0
- app/core/graders.py +41 -0
- app/core/tasks.py +88 -0
- app/main.py +71 -0
- app/models.py +58 -0
- client.py +36 -0
- inference.py +254 -0
- openenv.yaml +36 -0
- push-hf.sh +19 -0
- pyproject.toml +22 -0
- run.sh +3 -0
- scripts/validate_tasks.py +43 -0
- server/__init__.py +0 -0
- server/app.py +19 -0
- tests/__init__.py +0 -0
- tests/test_api.py +36 -0
- tests/test_environment.py +36 -0
- validate-submission.sh +71 -0
.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"
|