# CLAUDE.md ## Source-control rules (non-negotiable) - **Never `git commit` or `git push` without an explicit, in-message instruction from the user.** "Iterate the Dockerfile until green," "fix the bug," etc. do NOT authorize commits. Make the edits, run tests, and stop. - Show the user `git status --short` and `git diff --stat` so they can review before committing manually. - Auto mode does NOT relax this rule — code changes are fine, but writing to the git index or pushing to remotes always requires explicit user say-so. ## Project Overview **Stocker** — multi-agent council RL environment for stock trading on OpenEnv. Seven specialist LLM analysts vote each step; a moderator LLM merges votes into a `(side, quantity)` trade. The moderator is GRPO-trained via TRL on top of `google/gemma-4-E4B-it`. ## Stack - **Language:** Python 3.10+ (Docker image runs 3.13) - **Framework:** FastAPI + Uvicorn - **Models:** Pydantic v2 + pydantic-settings - **OpenEnv:** `openenv-core>=0.2.0` - **Package manager:** `uv` (deps grouped into optional extras: `data`, `eval`, `serve`, `train`) - **Tests:** pytest + FastAPI TestClient (`MockLLMClient` for council tests — no GPU/API needed) - **Serving:** vLLM (OpenAI-compatible at :8000) — see [scripts/serve_vllm.sh](scripts/serve_vllm.sh) - **Training:** TRL `GRPOTrainer` + PEFT LoRA — see [training/train_grpo.py](training/train_grpo.py) ## Layout ``` . ├── app/ │ ├── api/ # HTTP routers (health, meta, env, state, frontend) │ ├── council/ # 7 specialists + moderator + asyncio runner │ │ ├── llm.py # OpenAILLMClient, MockLLMClient, parse_json_object │ │ ├── specialists.py # ChartPattern / Seasonal / Indicator / News / Forum / Peer / Geo │ │ ├── moderator.py # merges votes → TradeAction (extra_body for LoRA) │ │ └── runner.py # Council.run / run_async + .cache/council/* on-disk cache │ ├── core/ # environment.py, graders.py, tasks.py │ ├── data/loader.py # parquet lookups + chart_path │ ├── config.py │ ├── main.py │ └── models.py # Pydantic schemas (the public OpenEnv contract) ├── data/ # bundled by scripts/build_dataset.py │ ├── *.parquet # the 3-task bundle (committed) │ ├── charts/ # 768x768 candlestick PNGs (cache/ on-the-fly, gitignored) │ ├── sources/ # curated news / forums / macro JSON │ └── corpus/ # 15-ticker × 20y corpus (built by scripts/build_corpus.py, gitignored) ├── server/app.py # OpenEnv entry point (`server.app:main`) ├── inference.py # council-driven OpenEnv inference loop (root) ├── client.py ├── frontend/ # Vite + React 19 + Tailwind v4 SPA (built artefact mounted by FastAPI) ├── scripts/ # build_dataset, render_charts, serve_vllm, validate_tasks ├── training/ # eval_rollout, train_grpo, runs/ ├── tests/ ├── Dockerfile ├── openenv.yaml └── pyproject.toml ``` ## Frontend - Source lives in [frontend/](frontend/) — Vite + React 19 + TypeScript + Tailwind v4. Single `App.tsx` with six tabs (Terminal, Council, Training, Gallery, Portfolio, Intelligence). - Dev: `cd frontend && npm install && npm run dev` (HMR on :3000). - Production: `cd frontend && npm run build` emits `frontend/dist/`. FastAPI serves `frontend/dist/index.html` at `/` and `/web`, and mounts `frontend/dist/assets/` at `/assets` (see [app/main.py](app/main.py) and [app/api/frontend.py](app/api/frontend.py)). - Builds are **manual**. Don't add auto-build to CI, hooks, or `npm` scripts triggered from Python. - When `frontend/dist/` is absent, FastAPI falls back to the inline `FRONTEND_HTML` string in [app/api/frontend.py](app/api/frontend.py). That fallback is the lowest-common-denominator UI for headless dev/tests and must keep working without Node. ## Conventions - All Pydantic schemas live in [app/models.py](app/models.py). Don't move them. Names: `MarketObservation`, `TradeAction`, `SpecialistVote`, `CouncilDecision`, `RewardResult`, `StepResult`, `ResetResult`, `EnvironmentState`. - `StockerEnv` exposes `reset() / step() / state() / load_snapshot()`. - The OpenEnv contract is **single-agent**. Multi-agent council lives in `app/council/` and `inference.py` — never inside `step()`. - `inference.py` MUST keep the `[START] / [STEP] / [END]` log format and also emit `[COUNCIL]` per step — graders parse all four. - Reward is clipped to `[-1.0, 1.0]` at the env boundary ([app/core/graders.py](app/core/graders.py)). - Specialists return JSON `{"signal": float, "confidence": float, "rationale": str}` — the parser in [app/council/llm.py](app/council/llm.py) tolerates fenced code blocks and trailing prose. - The moderator response goes with `extra_body={"lora_request": {"name": ...}}` when a LoRA is configured, otherwise plain base. - Cache layout: `.cache/council//base/__.json` for specialists, `.cache/council/moderator//____.json` for the moderator (votes hash differentiates). - Tests use `MockLLMClient` — never `HF_TOKEN` in CI. The mock routes by system-prompt keyword, so changing role keywords requires updating `MockLLMClient.SIGNAL_BIAS`. - Two task tiers: the 3 stable IDs (`task_easy/medium/hard`) come from `data/*.parquet`; corpus tasks (prefixed `corpus_`) come from `data/corpus/episodes.parquet`. Both flow through the same `app.core.tasks.get_task_definition`. Corpus is *optional* — `list_task_ids()` silently returns just the 3 if `data/corpus/` is missing. Build it with `python scripts/build_corpus.py` (use `--quick` for a 3-ticker × 5y smoke). - Charts are rendered on-the-fly from corpus prices when a (ticker, date) isn't in the pre-rendered bundle — see [app/data/corpus.py](app/data/corpus.py) `render_chart_cached`. Cache lives in `data/charts/cache/` (gitignored). ## Don't - Don't bake live API calls into specialists. Council inputs come from the bundled parquet dataset (`app/data/loader.py`). Live sources are the data builder's job. - Don't pull large model weights into the Docker image; serve via vLLM externally. - Don't break the per-step `[COUNCIL]` log line format — the writeup + trainer's reward replay both depend on it. - Don't make specialists rely on the moderator's LoRA. Specialists must remain frozen so their cached votes are reusable across GRPO steps. - Don't auto-build the React frontend from Python or CI. Builds are run by hand with `npm run build`. Don't delete the inline `FRONTEND_HTML` fallback in [app/api/frontend.py](app/api/frontend.py) — tests and Node-less dev rely on it.