--- title: Stocker - OpenEnv colorFrom: blue colorTo: green sdk: docker pinned: false app_port: 7860 tags: - openenv - multi-agent - rl - finance - grpo --- # Stocker β€” Multi-Agent Council RL for Stock Trading > **[πŸ€— Environment Space](https://huggingface.co/spaces/Hydr473/stocker-env)** Β· > **[🎯 Training Space](https://huggingface.co/spaces/Hydr473/stocker-train)** Β· > **[πŸ““ Training Notebook](training/train_grpo.ipynb)** Β· > **[πŸ“ Blog](BLOG.md)** Β· > **[πŸ’» GitHub](https://github.com/CRIMSONHydra/stocker)** **Problem:** LLMs can reason about markets from multiple angles simultaneously β€” charts, news, macro, technicals, sentiment. Can we train a *moderator* LLM to synthesize seven specialist perspectives into profitable trades, purely via RL? **Approach:** Seven frozen specialist LLMs vote each step; a trainable moderator merges their votes. The moderator is fine-tuned with **GRPO** (Group Relative Policy Optimization) using the environment's own reward as the training signal. No human labels, no supervised data β€” reward comes from the env. **Results:** See [training/runs/RESULTS.md](training/runs/RESULTS.md) for the pre-training vs post-training comparison table and plots. A long-term stock-trading RL environment built on **OpenEnv**. Every step, seven specialist analyst agents examine the market through different lenses and emit a vote; a **moderator LLM** merges the seven votes into a single `(side, quantity)` trade. The moderator is fine-tuned with **GRPO** (TRL) using the environment's own reward as the training signal. Base model: **[`google/gemma-4-E4B-it`](https://huggingface.co/google/gemma-4-E4B-it)** β€” multimodal (text + image), 4-billion parameters, Apache 2.0. --- ## Council architecture ``` OpenEnv interface (single-agent contract) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ POST /reset β†’ MarketObservation(ticker, date, …) β”‚ β”‚ POST /step β†’ TradeAction(side, quantity) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ inference.py orchestrates: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ per step β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ 7 specialists run in PARALLEL β”‚ β”‚ β”‚ β”‚ ChartPattern (vision: 60-day candlestick PNG) β”‚ β”‚ β”‚ β”‚ SeasonalTrend (long-term + cycle context) β”‚ β”‚ β”‚ β”‚ Indicator (RSI / MACD / SMA / BB / ATR) β”‚ β”‚ β”‚ β”‚ News (curated headlines, ~7-day window) β”‚ β”‚ β”‚ β”‚ ForumSentiment (Reddit excerpts) β”‚ β”‚ β”‚ β”‚ PeerCommodity (peer stocks + gold/oil correlation) β”‚ β”‚ β”‚ β”‚ Geopolitics (CPI / FOMC / sanctions / fiscal) β”‚ β”‚ β”‚ β”‚ β†’ SpecialistVote(signal ∈ [-1,1], confidence, why) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ Moderator (Gemma 4 E4B IT + LoRA) β”‚ β”‚ sees 7 votes β†’ outputs TradeAction + rationale β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό env.step(action) β†’ reward (performance vs ideal trajectory βˆ’ inflation drag) ``` **Key invariant:** the OpenEnv contract stays single-agent. Council orchestration lives in `inference.py` + `app/council/`, not inside `step()`. `openenv validate` passes and graders can re-run inference exactly. --- ## Methodology ### Dataset Three deterministic episodes built from real OHLCV data (yfinance): | Task | Ticker | Window | Regime | |------|--------|--------|--------| | `task_easy` | AAPL | Aug–Sep 2023 | Steady uptrend into iPhone 15 launch | | `task_medium` | INTC | Jan–Feb 2024 | Choppy / sideways post-earnings | | `task_hard` | META | Sep–Oct 2022 | Drawdown then snap-back after Q3 print | Each episode is 41–43 trading days. All OHLCV, indicators, curated headlines, forum excerpts, macro events, and 768Γ—768 candlestick PNGs are bundled in `data/` and versioned in git (6.2 MB). An optional **corpus** (15 tickers Γ— 20 years, ~3 GB) can be built with `python scripts/build_corpus.py` and exposes 4 000+ additional episodes via `corpus_*` task IDs. ### Specialists (frozen) Each specialist has a fixed system prompt and calls the same LLM (Gemma 4 E4B IT in 4-bit, or any OpenAI-compatible endpoint). Their outputs are **cached by `(role, ticker, date)`** after the first call, so they are never re-rolled during GRPO β€” only the moderator is trained. | Specialist | Input | Dimension | |-----------|-------|-----------| | `ChartPattern` | candlestick PNG (multimodal) | visual patterns | | `SeasonalTrend` | price history + fundamentals | macro cycle | | `Indicator` | RSI, MACD, SMA, BB, ATR | technical signals | | `News` | curated headlines | sentiment | | `ForumSentiment` | Reddit excerpts | crowd sentiment | | `PeerCommodity` | peer stocks + gold/oil | correlation | | `Geopolitics` | CPI, FOMC, macro events | macro risk | Each returns `{"signal": float ∈ [-1,1], "confidence": float ∈ [0,1], "rationale": str}`. ### Reward function Per-step reward is a weighted combination of two components: ``` reward_step = W_PERF Γ— performance_factor(gap) + W_INFL Γ— inflation_factor βˆ’ invalid_action_penalty (0.01 if action invalid) ``` **Performance factor** β€” asymmetric piecewise-linear function of the gap between the pre-computed ideal PnL trajectory and the model's actual inflation-adjusted PnL at this step: ``` gap = ideal_pnl_pct[step] βˆ’ real_pnl_pct if gap < 0: performance_factor = 1 + min(1, |gap| / scale) # outperformance bonus elif gap ≀ scale: performance_factor = 1 βˆ’ gap / scale # close to ideal else: performance_factor = βˆ’min(1, (gap βˆ’ scale) / scale) # far behind ``` where `scale = max(0.05, 0.5 Γ— |ideal_pnl_pct_total|)`. The **ideal PnL trajectory** is pre-computed by `scripts/build_ideal_profit.py` using the optimal hindsight strategy (greedy day-by-day). **Inflation factor** β€” penalty for the share of nominal gain eaten by inflation: ``` real_pnl_pct = (1 + nominal_pnl_pct) / (1 + inflation_growth) βˆ’ 1 inflation_factor = real_pnl_pct βˆ’ nominal_pnl_pct (≀ 0) ``` **Trajectory bonus** (added to the last step): ``` alpha_bonus = min(0.10, max(0, (final_portfolio / buy_and_hold_portfolio) βˆ’ 1)) dd_penalty = min(0.10, max(0, max_drawdown βˆ’ 0.05)) ``` **Default weights:** `W_PERF = 0.7`, `W_INFL = 0.3`, `annual_inflation_rate = 0.05`, `transaction_cost_rate = 0.001`. All are configurable via `STOCKER_*` env vars (see `app/config.py`) and sweepable with the `tune_easy_gemma4.ipynb` replay grader. All rewards are clipped to `[-1.0, 1.0]` at the env boundary. ### Training (GRPO) The 7 specialists are **frozen**. Only the **moderator** is fine-tuned via `trl.GRPOTrainer` with a LoRA adapter (rank 16) on Gemma 4 E4B IT. **Dataset:** `task_easy` (43 moderator prompts, each containing 7 specialist votes and the current market state). **Settings** (L4-tuned for the HF training Space): | Parameter | Value | |-----------|-------| | Base model | `google/gemma-4-E4B-it` (4-bit BnB, ~3 GB) | | Inference (specialists + production) | `ggml-org/gemma-4-26B-A4B-it-GGUF` via HF endpoint (llama.cpp) | | LoRA rank / alpha | 16 / 32 | | Epochs | 3 | | `num_generations` | 4 | | `per_device_train_batch_size` | 4 | | Learning rate | 5e-6 | | Compute | HF Space, Nvidia L4 24 GB (~$0.80 / training run) | Each GRPO step: sample 4 moderator completions per prompt β†’ parse each into `TradeAction` β†’ simulate env step β†’ compare rewards β†’ update LoRA. Specialist votes come from cache (no LLM calls); only the moderator is re-rolled. ### Reward-weight tuning `training/tune_easy_gemma4.ipynb` runs a full episode, captures per-step reward breakdowns, then **re-grades the saved trace** under a sweep of weight combinations (pure CPU, sub-second per combo). This lets us iterate on the reward function without re-running the LLM: ``` swept 180 combos over (W_PERF, W_INFL, inflation_rate, transaction_cost) best: W_PERF=1.0, W_INFL=0.0, tc=0.0 β†’ total=+0.5725 default: β†’ total=+0.3801 ``` --- ## Results > Training run results are compiled automatically by `scripts/compile_results.py` > after training. See `training/runs/RESULTS.md` for the latest numbers and plots. --- ## Quick start ### A. Hit the running env Space (zero-setup, recommended for graders) The OpenEnv contract is live at the HF Space β€” interact with `/reset`, `/step`, `/state` directly: ```bash # Reset to task_easy curl -X POST https://hydr473-stocker-env.hf.space/reset \ -H "Content-Type: application/json" \ -d '{"task_id": "task_easy"}' # Submit a trade curl -X POST https://hydr473-stocker-env.hf.space/step \ -H "Content-Type: application/json" \ -d '{"side": "buy", "quantity": 10}' ``` Or open `https://hydr473-stocker-env.hf.space/web` for the interactive React UI (six tabs: Terminal, Council, Training, Gallery, Portfolio, Intelligence). ### B. Run GRPO training (one click on the L4 GPU Space) Open [Hydr473/stocker-train](https://huggingface.co/spaces/Hydr473/stocker-train) and click **πŸš€ Launch Pipeline**. The Gradio UI streams the full 6-phase run (precache β†’ eval_pre β†’ GRPO β†’ eval_post β†’ compile β†’ upload) and uploads the final plots + LoRA adapter to [Hydr473/stocker-results](https://huggingface.co/datasets/Hydr473/stocker-results). Cost: ~$0.80 / run on the L4. ### C. Local development (mock client, no GPU) ```bash pip install uv uv pip install -e ".[dev,data,eval]" python scripts/build_dataset.py python scripts/validate_tasks.py # Deterministic offline smoke (no LLM calls) python inference.py --task all --mock --no-cache # Local server + React UI ./run.sh # http://localhost:7860/web pytest tests/ -q ``` To run the council against your own endpoint, set `API_BASE_URL`, `MODEL_NAME`, and `HF_TOKEN` (see [.env.example](.env.example)). ### Council vote cache Each `(role, ticker, date)` triple is computed once and cached as JSON. The env Space pulls a pre-warmed cache from the HF dataset [Hydr473/stocker-cache](https://huggingface.co/datasets/Hydr473/stocker-cache) on startup so judges' UI clicks are instant. To re-warm: ```bash # 1. Compute votes via your endpoint (writes .cache/council//base/...) python scripts/precache_endpoint.py --tasks task_easy,task_medium,task_hard # 2. Upload to the dataset repo python scripts/upload_cache.py # uses $STOCKER_CACHE_REPO or default # 3. Restart the env Space β€” its lifespan hook downloads the new cache. ``` The startup hook is no-op (and never crashes) when `STOCKER_CACHE_REPO` is unset, so local dev still works without HF Hub access. --- ## Evaluation ```bash # Baseline (no LoRA) python -m training.eval_rollout --tasks task_easy --out training/runs/eval_pre # After GRPO python -m training.eval_rollout --tasks task_easy --moderator-lora moderator \ --out training/runs/eval_post # Compile artifacts + diff table python scripts/compile_results.py ``` --- ## API | Endpoint | Method | Description | |----------|--------|-------------| | `/web` | GET | Interactive React frontend | | `/health` | GET | Health check | | `/meta` | GET | Environment metadata | | `/reset` | POST | Reset (`{"task_id": "task_easy"}`) | | `/step` | POST | Submit `{"side": "buy", "quantity": 10}` | | `/state` | GET/POST | Export / restore env snapshot | | `/docs` | GET | Swagger UI | --- ## Layout ``` . β”œβ”€β”€ app/ β”‚ β”œβ”€β”€ api/ # FastAPI routers β”‚ β”œβ”€β”€ council/ β”‚ β”‚ β”œβ”€β”€ llm.py # TransformersLLMClient, OpenAILLMClient, MockLLMClient β”‚ β”‚ β”œβ”€β”€ specialists.py # 7 role-specific prompted agents β”‚ β”‚ β”œβ”€β”€ moderator.py # merges votes β†’ TradeAction (+ optional LoRA) β”‚ β”‚ └── runner.py # ThreadPoolExecutor + on-disk cache β”‚ β”œβ”€β”€ core/ β”‚ β”‚ β”œβ”€β”€ environment.py # StockerEnv: reset / step / state / load_snapshot β”‚ β”‚ β”œβ”€β”€ graders.py # compute_step_reward, compute_trajectory_bonus β”‚ β”‚ └── tasks.py # get_task_definition + corpus integration β”‚ β”œβ”€β”€ data/ β”‚ β”‚ β”œβ”€β”€ loader.py # parquet + chart lookups + corpus fallback β”‚ β”‚ └── corpus.py # optional large corpus (15 tickers Γ— 20 y) β”‚ └── models.py # all Pydantic schemas β”œβ”€β”€ data/ β”‚ β”œβ”€β”€ *.parquet # prices, indicators, news, peers, macro β”‚ β”œβ”€β”€ ideal_profits/ # hindsight-optimal PnL sidecars β”‚ β”œβ”€β”€ charts/ # 768Γ—768 candlestick PNGs β”‚ └── sources/ # curated JSON (news, forums, macro) β”œβ”€β”€ scripts/ β”‚ β”œβ”€β”€ build_dataset.py # yfinance β†’ parquet + charts β”‚ β”œβ”€β”€ build_ideal_profit.py # per-task optimal PnL trajectory β”‚ β”œβ”€β”€ build_corpus.py # large corpus (optional, gitignored) β”‚ β”œβ”€β”€ compile_results.py # compile training artifacts β†’ RESULTS.md β”‚ β”œβ”€β”€ precache_endpoint.py # standalone specialist pre-cache via endpoint β”‚ └── validate_tasks.py β”œβ”€β”€ training/ β”‚ β”œβ”€β”€ train_grpo.py # GRPO trainer (CLI, --tasks filter) β”‚ β”œβ”€β”€ train_grpo.ipynb # end-to-end Colab/Jupyter notebook β”‚ β”œβ”€β”€ tune_easy_gemma4.ipynb # reward-weight tuning bench β”‚ β”œβ”€β”€ eval_rollout.py # offline backtest β†’ reward/portfolio curves β”‚ └── runs/ # gitignored (except .gitkeep) β”œβ”€β”€ spaces/ β”‚ └── train/ # Gradio app for the L4 GPU training Space β”‚ β”œβ”€β”€ Dockerfile # CUDA + PyTorch + git clone of this repo β”‚ └── app.py # 6-phase pipeline: precache β†’ eval β†’ GRPO β†’ upload β”œβ”€β”€ .github/workflows/ β”‚ └── deploy_spaces.yml # CI: auto-deploy both Spaces on push to main β”œβ”€β”€ inference.py # council-driven OpenEnv inference loop β”œβ”€β”€ tests/ β”œβ”€β”€ Dockerfile # multi-stage: build React UI β†’ Python runtime └── pyproject.toml ``` --- ## Credits - Built on [OpenEnv](https://github.com/huggingface/openenv) following the submission spec. - Real OHLCV: yfinance (Yahoo Finance public endpoints). - Model: [google/gemma-4-E4B-it](https://huggingface.co/google/gemma-4-E4B-it) (Apache 2.0). - Curated headlines + forum excerpts: `data/sources/` (drop-in replaceable).