Hydr473 commited on
Commit
28ce45d
Β·
1 Parent(s): b83fd38

Enhance training logic: Adjust reward shaping and implement lookahead bonus for improved agent performance

Browse files
CLAUDE.md CHANGED
@@ -1,5 +1,15 @@
1
  # CLAUDE.md
2
 
 
 
 
 
 
 
 
 
 
 
3
  ## Project Overview
4
  **Stocker** β€” multi-agent council RL environment for stock trading on
5
  OpenEnv. Seven specialist LLM analysts vote each step; a moderator LLM
 
1
  # CLAUDE.md
2
 
3
+ ## Source-control rules (non-negotiable)
4
+
5
+ - **Never `git commit` or `git push` without an explicit, in-message instruction
6
+ from the user.** "Iterate the Dockerfile until green," "fix the bug," etc. do
7
+ NOT authorize commits. Make the edits, run tests, and stop.
8
+ - Show the user `git status --short` and `git diff --stat` so they can review
9
+ before committing manually.
10
+ - Auto mode does NOT relax this rule β€” code changes are fine, but writing to
11
+ the git index or pushing to remotes always requires explicit user say-so.
12
+
13
  ## Project Overview
14
  **Stocker** β€” multi-agent council RL environment for stock trading on
15
  OpenEnv. Seven specialist LLM analysts vote each step; a moderator LLM
app/config.py CHANGED
@@ -8,10 +8,24 @@ class Settings(BaseSettings):
8
  allow_origins: list[str] = ["*"]
9
  port: int = 7860
10
 
 
 
 
11
  transaction_cost_rate: float = 0.001
12
- annual_inflation_rate: float = 0.05
13
- reward_weight_performance: float = 0.7
14
- reward_weight_inflation: float = 0.3
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  model_config = {"env_prefix": "STOCKER_", "case_sensitive": False}
17
 
 
8
  allow_origins: list[str] = ["*"]
9
  port: int = 7860
10
 
11
+ # Reward shaping. Bumped weights + inflation rate to push the moderator
12
+ # toward actually trading instead of converging to "always hold" β€” see
13
+ # docstring in app/core/graders.py for the full design.
14
  transaction_cost_rate: float = 0.001
15
+ annual_inflation_rate: float = 0.10 # was 0.05 β€” sharper "cash decays" signal
16
+ reward_weight_performance: float = 0.8 # was 0.7
17
+ reward_weight_inflation: float = 0.5 # was 0.3 β€” aggressively penalize sitting in cash
18
+
19
+ # Lookahead bonus: at training step N, reward proportional to the price
20
+ # change between step N and N+lookahead_steps, times the agent's net
21
+ # exposure delta. Gives the single-step reward a directional signal.
22
+ lookahead_steps: int = 5
23
+ lookahead_weight: float = 0.5 # 0 disables the term entirely
24
+
25
+ # Multi-step rollout horizon used by training/train_grpo.reward_for_completion.
26
+ # After applying the model's action, the env is rolled forward this many
27
+ # steps with `hold` and the cumulative reward is what the agent sees.
28
+ rollout_horizon: int = 5
29
 
30
  model_config = {"env_prefix": "STOCKER_", "case_sensitive": False}
31
 
app/core/environment.py CHANGED
@@ -99,6 +99,8 @@ class StockerEnv:
99
  ideal_pnl_pct_series=self._task.get("ideal_pnl_pct_series", []),
100
  ideal_pnl_pct_total=float(self._task.get("ideal_pnl_pct_total", 0.0)),
101
  settings=settings,
 
 
102
  )
103
  reward = result.score
104
 
 
99
  ideal_pnl_pct_series=self._task.get("ideal_pnl_pct_series", []),
100
  ideal_pnl_pct_total=float(self._task.get("ideal_pnl_pct_total", 0.0)),
101
  settings=settings,
102
+ prices=self._prices,
103
+ position_after=self._position,
104
  )
105
  reward = result.score
106
 
app/core/graders.py CHANGED
@@ -39,6 +39,8 @@ def compute_step_reward(
39
  ideal_pnl_pct_series: list[float],
40
  ideal_pnl_pct_total: float,
41
  settings: Settings,
 
 
42
  ) -> RewardResult:
43
  breakdown: dict[str, float] = {}
44
 
@@ -75,8 +77,10 @@ def compute_step_reward(
75
  # Close to ideal β€” high reward decaying linearly toward 0.
76
  performance_factor = 1.0 - gap / scale
77
  else:
78
- # Far behind ideal β€” punishment down to -1.0.
79
- performance_factor = -min(1.0, (gap - scale) / scale)
 
 
80
 
81
  breakdown["ideal_pnl_pct_at_step"] = round(ideal_at_step, 6)
82
  breakdown["gap"] = round(gap, 6)
@@ -92,6 +96,38 @@ def compute_step_reward(
92
 
93
  score = weighted_perf + weighted_inf
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  if invalid:
96
  score -= INVALID_ACTION_PENALTY
97
  breakdown["invalid_action_penalty"] = -INVALID_ACTION_PENALTY
 
39
  ideal_pnl_pct_series: list[float],
40
  ideal_pnl_pct_total: float,
41
  settings: Settings,
42
+ prices: list[float] | None = None,
43
+ position_after: int = 0,
44
  ) -> RewardResult:
45
  breakdown: dict[str, float] = {}
46
 
 
77
  # Close to ideal β€” high reward decaying linearly toward 0.
78
  performance_factor = 1.0 - gap / scale
79
  else:
80
+ # Far behind ideal β€” sharper ramp into punishment so the agent
81
+ # actually feels under-performance (was -min(1, (gap-scale)/scale),
82
+ # now -min(1, 2*(gap-scale)/scale) β†’ reaches -1.0 at half the gap).
83
+ performance_factor = -min(1.0, 2.0 * (gap - scale) / scale)
84
 
85
  breakdown["ideal_pnl_pct_at_step"] = round(ideal_at_step, 6)
86
  breakdown["gap"] = round(gap, 6)
 
96
 
97
  score = weighted_perf + weighted_inf
98
 
99
+ # ── Lookahead bonus ──────────────────────────────────────────────────
100
+ # Single-step reward is nearly invariant to action when the trade is at
101
+ # fair market value. The lookahead term gives a directional signal:
102
+ # "would buying here have been profitable over the next K days?"
103
+ # Active only when prices are passed (training path).
104
+ lookahead_bonus = 0.0
105
+ K = getattr(settings, "lookahead_steps", 0)
106
+ W = getattr(settings, "lookahead_weight", 0.0)
107
+ if prices and K > 0 and W > 0.0 and step_index + K < len(prices):
108
+ cur_price = prices[step_index]
109
+ future_price = prices[step_index + K]
110
+ if cur_price > 0:
111
+ future_pct = (future_price - cur_price) / cur_price
112
+ # Net exposure delta: position the trade ADDED. buy(q) = +q,
113
+ # sell(q) = -q (relative to starting). Hold = 0 β†’ no bonus.
114
+ if action.side == "buy":
115
+ exposure = action.quantity
116
+ elif action.side == "sell":
117
+ exposure = -action.quantity
118
+ else:
119
+ exposure = 0
120
+ # Normalize by starting_cash / cur_price to keep magnitude sane
121
+ # across tickers / cash sizes.
122
+ shares_per_unit = max(starting_cash / max(cur_price, 1e-9), 1.0)
123
+ lookahead_bonus = W * future_pct * (exposure / shares_per_unit)
124
+ # Clip the lookahead bonus to [-1, 1] so it doesn't dominate.
125
+ lookahead_bonus = max(-1.0, min(1.0, lookahead_bonus))
126
+ breakdown["lookahead_pct"] = round(future_pct, 6)
127
+ breakdown["lookahead_exposure"] = exposure
128
+ breakdown["lookahead_bonus"] = round(lookahead_bonus, 6)
129
+ score += lookahead_bonus
130
+
131
  if invalid:
132
  score -= INVALID_ACTION_PENALTY
133
  breakdown["invalid_action_penalty"] = -INVALID_ACTION_PENALTY
spaces/train/Dockerfile CHANGED
@@ -57,7 +57,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
57
  "torch==2.10.0" "torchvision==0.25.0" "torchaudio==2.10.0" \
58
  "transformers==5.6.2" "peft==0.19.1" "trl==1.2.0" \
59
  "accelerate==1.13.0" "bitsandbytes==0.49.2" "datasets==4.8.4" \
60
- "tensorboard==2.20.0" "torchao==0.17.0" "huggingface_hub==1.12.0"
 
61
 
62
  # ─── Layer 4: build-time smoke test ────────────────────────────────────
63
  # Fails the build immediately if any pin combination is broken.
 
57
  "torch==2.10.0" "torchvision==0.25.0" "torchaudio==2.10.0" \
58
  "transformers==5.6.2" "peft==0.19.1" "trl==1.2.0" \
59
  "accelerate==1.13.0" "bitsandbytes==0.49.2" "datasets==4.8.4" \
60
+ "tensorboard==2.20.0" "torchao==0.17.0" "huggingface_hub==1.12.0" \
61
+ "gradio==5.50.0"
62
 
63
  # ─── Layer 4: build-time smoke test ────────────────────────────────────
64
  # Fails the build immediately if any pin combination is broken.
spaces/train/app.py CHANGED
@@ -1,14 +1,14 @@
1
- """Training Space UI β€” minimal FastAPI + HTML.
2
 
3
- Avoids Gradio because gradio<5 pulls gradio-client==1.3.0 which constrains
4
- websockets<13, conflicting with openenv-core's websockets>=15.0.1.
5
 
6
  Required Space secrets (Settings β†’ Variables and secrets):
7
  HF_TOKEN β€” write-access token for uploading results
8
  API_BASE_URL β€” https://<endpoint>.endpoints.huggingface.cloud/v1
9
  MODEL_NAME β€” ggml-org/gemma-4-26B-A4B-it-GGUF
10
- RESULTS_REPO β€” Hydr473/stocker-results (defaults to this if unset)
11
- USE_MOCK_SPECIALISTS=1 β€” fall back to MockLLMClient (skip endpoint calls)
12
  """
13
  from __future__ import annotations
14
 
@@ -19,9 +19,7 @@ import sys
19
  import threading
20
  from pathlib import Path
21
 
22
- import uvicorn
23
- from fastapi import FastAPI
24
- from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
25
  from huggingface_hub import HfApi
26
 
27
  WORKDIR = Path(__file__).resolve().parent.parent.parent
@@ -40,6 +38,7 @@ def _stream_proc(cmd: list[str], env_extra: dict | None = None) -> None:
40
  text=True,
41
  cwd=str(WORKDIR),
42
  env=env,
 
43
  )
44
  with open(LOG_PATH, "a") as lf:
45
  assert proc.stdout
@@ -85,7 +84,8 @@ def _run_pipeline() -> None:
85
  "--batch-size", "4",
86
  "--grad-accum", "1",
87
  "--lora-rank", "16",
88
- "--lr", "5e-6"],
 
89
  env_extra=endpoint_env,
90
  )
91
 
@@ -160,104 +160,25 @@ PHASE_LABELS = {
160
  }
161
 
162
 
163
- HTML_PAGE = """<!DOCTYPE html>
164
- <html lang="en">
165
- <head>
166
- <meta charset="UTF-8">
167
- <title>Stocker β€” GRPO Training</title>
168
- <meta name="viewport" content="width=device-width, initial-scale=1">
169
- <style>
170
- body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 24px; max-width: 1100px; margin: 0 auto; }
171
- h1 { color: #38bdf8; }
172
- h2 { color: #38bdf8; border-top: 1px solid #334155; padding-top: 12px; margin-top: 24px; }
173
- .status { background: #1e293b; padding: 14px 18px; border-radius: 8px; margin: 12px 0; font-size: 1.1em; border: 1px solid #334155; }
174
- .btn { background: #2563eb; color: white; padding: 12px 28px; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; font-weight: 600; }
175
- .btn:hover { background: #1d4ed8; }
176
- .btn:disabled { background: #475569; cursor: not-allowed; }
177
- .logs { background: #020617; padding: 14px; border-radius: 8px; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; max-height: 500px; overflow-y: auto; white-space: pre-wrap; border: 1px solid #1e293b; }
178
- .plots img { max-width: 48%; border-radius: 8px; margin: 8px 1%; border: 1px solid #334155; }
179
- .meta { color: #94a3b8; font-size: 0.9em; margin: 8px 0; }
180
- </style>
181
- </head>
182
- <body>
183
- <h1>⚑ Stocker β€” GRPO Training</h1>
184
- <p class="meta">L4 GPU Β· pre-cache 26B specialist votes β†’ baseline eval β†’ GRPO E4B β†’ post eval β†’ compile β†’ upload</p>
185
-
186
- <div class="status" id="status">Loading status…</div>
187
- <button class="btn" id="launchBtn" onclick="launch()">πŸš€ Launch Pipeline</button>
188
-
189
- <h2>Live logs</h2>
190
- <div class="logs" id="logs">No logs yet.</div>
191
-
192
- <h2>Plots</h2>
193
- <div class="plots" id="plots">Plots appear here once training completes.</div>
194
-
195
- <script>
196
- async function launch() {
197
- const r = await fetch('/launch', { method: 'POST' });
198
- const d = await r.json();
199
- if (d.status === 'started') document.getElementById('launchBtn').disabled = true;
200
- }
201
- async function poll() {
202
- try {
203
- const s = await (await fetch('/status')).json();
204
- document.getElementById('status').textContent = s.label;
205
- if (s.phase !== 'idle' && s.phase !== 'done') {
206
- document.getElementById('launchBtn').disabled = true;
207
- } else {
208
- document.getElementById('launchBtn').disabled = false;
209
- }
210
- const txt = await (await fetch('/logs')).text();
211
- const logsEl = document.getElementById('logs');
212
- const wasNearBottom = logsEl.scrollHeight - logsEl.scrollTop - logsEl.clientHeight < 50;
213
- logsEl.textContent = txt.slice(-12000) || 'No logs yet.';
214
- if (wasNearBottom) logsEl.scrollTop = logsEl.scrollHeight;
215
- const ps = await (await fetch('/plots')).json();
216
- if (ps.length) {
217
- document.getElementById('plots').innerHTML = ps.map(u => `<img src="${u}" />`).join('');
218
- }
219
- } catch (e) { /* ignore poll errors */ }
220
- }
221
- setInterval(poll, 3000);
222
- poll();
223
- </script>
224
- </body>
225
- </html>
226
- """
227
-
228
-
229
- app = FastAPI(title="Stocker Training")
230
-
231
-
232
- @app.get("/", response_class=HTMLResponse)
233
- def index() -> str:
234
- return HTML_PAGE
235
-
236
-
237
- @app.post("/launch")
238
- def launch():
239
- if _state["phase"] in ("idle", "done"):
240
- LOG_PATH.write_text("")
241
- threading.Thread(target=_run_pipeline, daemon=True).start()
242
- return {"status": "started"}
243
- return {"status": "already_running", "phase": _state["phase"]}
244
 
245
 
246
- @app.get("/status")
247
- def status():
248
- phase = _state["phase"]
249
- return {"phase": phase, "label": PHASE_LABELS.get(phase, phase)}
250
 
251
 
252
- @app.get("/logs", response_class=PlainTextResponse)
253
- def logs():
254
- if LOG_PATH.exists():
255
- return LOG_PATH.read_text()
256
- return ""
257
 
258
 
259
- @app.get("/plots")
260
- def plots():
261
  patterns = [
262
  "training/runs/grpo_*/*.png",
263
  "training/runs/eval_pre/*.png",
@@ -266,15 +187,57 @@ def plots():
266
  images = []
267
  for p in patterns:
268
  images.extend(sorted(glob.glob(str(WORKDIR / p))))
269
- return [f"/plot?p={i}" for i in images]
270
 
271
 
272
- @app.get("/plot")
273
- def plot(p: str):
274
- if not p.startswith(str(WORKDIR)):
275
- return PlainTextResponse("forbidden", status_code=403)
276
- return FileResponse(p)
 
 
 
 
 
 
 
 
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
  if __name__ == "__main__":
280
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
 
 
1
+ """Training Space UI β€” Gradio (5.x).
2
 
3
+ Compatible with openenv-core because gradio>=5 dropped the websockets<13
4
+ pin (gradio-client 1.14+ uses websockets>=13 / >=15 family).
5
 
6
  Required Space secrets (Settings β†’ Variables and secrets):
7
  HF_TOKEN β€” write-access token for uploading results
8
  API_BASE_URL β€” https://<endpoint>.endpoints.huggingface.cloud/v1
9
  MODEL_NAME β€” ggml-org/gemma-4-26B-A4B-it-GGUF
10
+ RESULTS_REPO β€” Hydr473/stocker-results (defaults if unset)
11
+ USE_MOCK_SPECIALISTS=1 β€” fall back to MockLLMClient (skip endpoint)
12
  """
13
  from __future__ import annotations
14
 
 
19
  import threading
20
  from pathlib import Path
21
 
22
+ import gradio as gr
 
 
23
  from huggingface_hub import HfApi
24
 
25
  WORKDIR = Path(__file__).resolve().parent.parent.parent
 
38
  text=True,
39
  cwd=str(WORKDIR),
40
  env=env,
41
+ bufsize=1,
42
  )
43
  with open(LOG_PATH, "a") as lf:
44
  assert proc.stdout
 
84
  "--batch-size", "4",
85
  "--grad-accum", "1",
86
  "--lora-rank", "16",
87
+ "--lr", "5e-6",
88
+ "--imitation-warmstart"],
89
  env_extra=endpoint_env,
90
  )
91
 
 
160
  }
161
 
162
 
163
+ def launch_pipeline() -> str:
164
+ if _state["phase"] not in ("idle", "done"):
165
+ return PHASE_LABELS.get(_state["phase"], _state["phase"])
166
+ threading.Thread(target=_run_pipeline, daemon=True).start()
167
+ return PHASE_LABELS["precaching"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
 
170
+ def poll_status() -> str:
171
+ return PHASE_LABELS.get(_state["phase"], _state["phase"])
 
 
172
 
173
 
174
+ def poll_logs() -> str:
175
+ if not LOG_PATH.exists():
176
+ return "No logs yet."
177
+ text = LOG_PATH.read_text()
178
+ return text[-12000:] if len(text) > 12000 else text
179
 
180
 
181
+ def poll_plots() -> list[str]:
 
182
  patterns = [
183
  "training/runs/grpo_*/*.png",
184
  "training/runs/eval_pre/*.png",
 
187
  images = []
188
  for p in patterns:
189
  images.extend(sorted(glob.glob(str(WORKDIR / p))))
190
+ return images
191
 
192
 
193
+ with gr.Blocks(title="Stocker β€” GRPO Training") as demo:
194
+ gr.Markdown(
195
+ "# ⚑ Stocker β€” GRPO Training\n"
196
+ "L4 GPU Β· pre-cache 26B specialist votes β†’ baseline eval β†’ "
197
+ "GRPO E4B β†’ post eval β†’ compile β†’ upload"
198
+ )
199
+
200
+ status_box = gr.Textbox(
201
+ label="Status",
202
+ value=PHASE_LABELS["idle"],
203
+ interactive=False,
204
+ lines=1,
205
+ )
206
 
207
+ with gr.Row():
208
+ launch_btn = gr.Button(
209
+ "πŸš€ Launch Pipeline",
210
+ variant="primary",
211
+ scale=2,
212
+ )
213
+
214
+ log_box = gr.Textbox(
215
+ label="Live logs",
216
+ lines=24,
217
+ max_lines=40,
218
+ interactive=False,
219
+ autoscroll=True,
220
+ )
221
+
222
+ gallery = gr.Gallery(
223
+ label="Plots (training curves + eval rollouts)",
224
+ show_label=True,
225
+ columns=2,
226
+ height="auto",
227
+ object_fit="contain",
228
+ )
229
+
230
+ launch_btn.click(fn=launch_pipeline, outputs=status_box)
231
+
232
+ timer = gr.Timer(2.0)
233
+ timer.tick(fn=poll_status, outputs=status_box)
234
+ timer.tick(fn=poll_logs, outputs=log_box)
235
+ timer.tick(fn=poll_plots, outputs=gallery)
236
 
237
  if __name__ == "__main__":
238
+ # theme= moved to launch() in Gradio 6+; harmless on 5 too.
239
+ demo.launch(
240
+ server_name="0.0.0.0",
241
+ server_port=7860,
242
+ show_api=False,
243
+ )
training/train_grpo.py CHANGED
@@ -91,14 +91,111 @@ def build_prompt_dataset(
91
  return rows
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def reward_for_completion(completion_text: str, snapshot: dict) -> float:
95
- """Replay env to the given step, apply the completion's action, and
96
- return the env reward."""
 
 
 
 
 
 
 
97
  from app.council.llm import parse_json_object
98
  from app.core.environment import StockerEnv
99
  from app.models import EnvironmentState, TradeAction
100
 
101
  parsed = parse_json_object(completion_text)
 
102
  side = str(parsed.get("side", "hold")).lower()
103
  if side not in ("buy", "sell", "hold"):
104
  side = "hold"
@@ -111,20 +208,67 @@ def reward_for_completion(completion_text: str, snapshot: dict) -> float:
111
  env.reset()
112
  env.load_snapshot(EnvironmentState(**snapshot["env_state"]))
113
  result = env.step(TradeAction(side=side, quantity=qty))
114
- return float(result.reward)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  # ---------------------------------------------------------------------------
118
  def run_grpo(args, run_dir: Path, dataset):
119
  import torch
120
  from datasets import Dataset
121
- from peft import LoraConfig
122
- from transformers import AutoTokenizer
123
  from trl import GRPOConfig, GRPOTrainer
124
 
125
  model_id = args.model
126
  tokenizer = AutoTokenizer.from_pretrained(model_id)
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # Flatten chat messages -> single prompt string the way TRL expects
129
  def _row_to_prompt(row):
130
  msgs = row["messages"]
@@ -133,6 +277,13 @@ def run_grpo(args, run_dir: Path, dataset):
133
  train_rows = [{"prompt": _row_to_prompt(r), "snapshot": r} for r in dataset]
134
  hf_ds = Dataset.from_list(train_rows)
135
 
 
 
 
 
 
 
 
136
  def reward_fn(completions, **kwargs):
137
  # `completions` is a list of decoded strings; `kwargs` contains the
138
  # original row fields including "snapshot".
@@ -167,8 +318,6 @@ def run_grpo(args, run_dir: Path, dataset):
167
  f"{args.batch_size * args.grad_accum}."
168
  )
169
 
170
- # max_prompt_length / max_completion_length were renamed/removed in
171
- # newer TRL β€” let TRL pick its own defaults instead of hard-coding.
172
  grpo_cfg = GRPOConfig(
173
  output_dir=str(run_dir),
174
  per_device_train_batch_size=args.batch_size,
@@ -181,11 +330,16 @@ def run_grpo(args, run_dir: Path, dataset):
181
  save_strategy="epoch",
182
  bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
183
  fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
 
 
 
 
184
  seed=args.seed,
185
  )
186
 
187
  trainer = GRPOTrainer(
188
- model=model_id,
 
189
  reward_funcs=[reward_fn],
190
  args=grpo_cfg,
191
  train_dataset=hf_ds,
@@ -251,6 +405,10 @@ def main():
251
  help="Use MockLLMClient for the 7 specialists (testing only)")
252
  p.add_argument("--tasks", default="all",
253
  help="Comma-separated task IDs to train on, or 'all' (default)")
 
 
 
 
254
  p.add_argument("--out", default=None)
255
  args = p.parse_args()
256
 
 
91
  return rows
92
 
93
 
94
+ PARSE_FAILURE_PENALTY = 0.1 # explicit penalty for malformed JSON output
95
+
96
+
97
+ def _derive_ideal_action(snapshot: dict) -> dict:
98
+ """Greedy-hindsight ideal action for a snapshot. Looks at the price
99
+ `lookahead_steps` ahead and returns buy when price will rise, sell
100
+ when price will fall, hold otherwise. Used for imitation pretrain only.
101
+ """
102
+ from app.config import settings as global_settings
103
+ from app.core.tasks import get_task_definition
104
+
105
+ task = get_task_definition(snapshot["task_id"])
106
+ prices = task["prices"]
107
+ step = snapshot["step_index"]
108
+ K = max(1, int(getattr(global_settings, "lookahead_steps", 5)))
109
+ if step + K >= len(prices):
110
+ return {"side": "hold", "quantity": 0,
111
+ "rationale": "End of episode β€” hold."}
112
+
113
+ cur, fut = prices[step], prices[step + K]
114
+ pct = (fut - cur) / max(cur, 1e-9)
115
+ state = snapshot["env_state"]
116
+ cash = state.get("cash", 10000)
117
+ pos = state.get("position", 0)
118
+ max_buy = int(cash // max(cur, 1e-9))
119
+
120
+ if pct > 0.005 and max_buy > 0:
121
+ qty = max(1, max_buy // 2)
122
+ return {"side": "buy", "quantity": qty,
123
+ "rationale": f"+{pct:.2%} forecast over next {K} bars β€” buy."}
124
+ if pct < -0.005 and pos > 0:
125
+ return {"side": "sell", "quantity": pos,
126
+ "rationale": f"{pct:.2%} forecast over next {K} bars β€” exit."}
127
+ return {"side": "hold", "quantity": 0,
128
+ "rationale": "Flat forecast β€” hold."}
129
+
130
+
131
+ def _imitation_pretrain(base_model, tokenizer, dataset, run_dir, args) -> None:
132
+ """One-pass SFT on (prompt β†’ ideal-action JSON) pairs. Cheap warmstart
133
+ so GRPO starts with at least valid JSON output and a sensible prior."""
134
+ import json as _json
135
+ from datasets import Dataset
136
+ from peft import LoraConfig, get_peft_model
137
+ from trl import SFTConfig, SFTTrainer
138
+
139
+ # IMPORTANT: must attach a LoRA adapter BEFORE SFT, or the 4-bit base
140
+ # weights are frozen and there's nothing to train. Same lora_cfg shape
141
+ # GRPO will use later; trainer.save_model preserves it.
142
+ sft_lora = LoraConfig(
143
+ r=args.lora_rank, lora_alpha=args.lora_rank * 2,
144
+ target_modules="all-linear", bias="none", task_type="CAUSAL_LM",
145
+ )
146
+
147
+ rows = []
148
+ for r in dataset:
149
+ prompt = tokenizer.apply_chat_template(
150
+ r["messages"], tokenize=False, add_generation_prompt=True
151
+ )
152
+ ideal = _derive_ideal_action(r)
153
+ completion = _json.dumps(ideal)
154
+ rows.append({"text": prompt + completion})
155
+ sft_ds = Dataset.from_list(rows)
156
+ print(f"[grpo] Imitation pretrain on {len(rows)} (prompt β†’ ideal-action) pairs ...")
157
+
158
+ sft_cfg = SFTConfig(
159
+ output_dir=str(run_dir / "sft_warmstart"),
160
+ per_device_train_batch_size=args.batch_size,
161
+ gradient_accumulation_steps=args.grad_accum,
162
+ learning_rate=args.lr * 4, # warmstart can use a higher LR
163
+ num_train_epochs=1,
164
+ logging_dir=str(run_dir / "tensorboard_sft"),
165
+ report_to=["tensorboard"],
166
+ save_strategy="no",
167
+ bf16=base_model.dtype == __import__("torch").bfloat16,
168
+ gradient_checkpointing=True,
169
+ gradient_checkpointing_kwargs={"use_reentrant": False},
170
+ seed=args.seed,
171
+ )
172
+ sft_trainer = SFTTrainer(
173
+ model=base_model,
174
+ processing_class=tokenizer,
175
+ args=sft_cfg,
176
+ train_dataset=sft_ds,
177
+ peft_config=sft_lora,
178
+ )
179
+ sft_trainer.train()
180
+ print("[grpo] Imitation pretrain done. Continuing to GRPO ...")
181
+
182
+
183
  def reward_for_completion(completion_text: str, snapshot: dict) -> float:
184
+ """Replay env to the snapshot, apply the completion's action, then roll
185
+ forward `rollout_horizon` steps with `hold` so the consequences of the
186
+ action (price moves while holding the new position) accumulate into the
187
+ reward. Without this multi-step rollout, single-step reward is nearly
188
+ invariant to action and GRPO has no advantage signal.
189
+
190
+ Also applies an explicit parse-failure penalty so the model learns to
191
+ output valid JSON instead of getting silently defaulted to hold(0)."""
192
+ from app.config import settings as global_settings
193
  from app.council.llm import parse_json_object
194
  from app.core.environment import StockerEnv
195
  from app.models import EnvironmentState, TradeAction
196
 
197
  parsed = parse_json_object(completion_text)
198
+ parse_failed = not parsed # empty dict from parse_json_object means malformed JSON
199
  side = str(parsed.get("side", "hold")).lower()
200
  if side not in ("buy", "sell", "hold"):
201
  side = "hold"
 
208
  env.reset()
209
  env.load_snapshot(EnvironmentState(**snapshot["env_state"]))
210
  result = env.step(TradeAction(side=side, quantity=qty))
211
+ cum_reward = float(result.reward)
212
+
213
+ # Roll forward with `hold` so the position taken is actually marked-to-
214
+ # market over future bars. This is what gives the agent a real signal
215
+ # that buying at low prices / selling at high prices is good.
216
+ horizon = int(getattr(global_settings, "rollout_horizon", 5))
217
+ steps_done = 0
218
+ while steps_done < horizon and not result.done:
219
+ result = env.step(TradeAction(side="hold", quantity=0))
220
+ cum_reward += float(result.reward)
221
+ steps_done += 1
222
+
223
+ if parse_failed:
224
+ cum_reward -= PARSE_FAILURE_PENALTY
225
+
226
+ return cum_reward
227
 
228
 
229
  # ---------------------------------------------------------------------------
230
  def run_grpo(args, run_dir: Path, dataset):
231
  import torch
232
  from datasets import Dataset
233
+ from peft import LoraConfig, prepare_model_for_kbit_training
234
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
235
  from trl import GRPOConfig, GRPOTrainer
236
 
237
  model_id = args.model
238
  tokenizer = AutoTokenizer.from_pretrained(model_id)
239
 
240
+ # GPU path: load in 4-bit so base + LoRA + reference + activations all
241
+ # fit in 24 GB on the L4. Without this the bf16 model alone is ~8 GB and
242
+ # GRPO's reference copy + KV cache + activations OOMs.
243
+ # CPU path: skip BnB (CUDA-only), load in fp32. Used only for end-to-end
244
+ # code-path tests with a tiny model β€” actual training needs GPU.
245
+ if torch.cuda.is_available():
246
+ bnb_compute_dtype = (
247
+ torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
248
+ )
249
+ bnb_config = BitsAndBytesConfig(
250
+ load_in_4bit=True,
251
+ bnb_4bit_compute_dtype=bnb_compute_dtype,
252
+ bnb_4bit_use_double_quant=True,
253
+ bnb_4bit_quant_type="nf4",
254
+ )
255
+ print(f"[grpo] Loading {model_id} in 4-bit (compute dtype={bnb_compute_dtype}) ...")
256
+ base_model = AutoModelForCausalLM.from_pretrained(
257
+ model_id,
258
+ quantization_config=bnb_config,
259
+ device_map={"": 0},
260
+ torch_dtype=bnb_compute_dtype,
261
+ )
262
+ base_model = prepare_model_for_kbit_training(
263
+ base_model, use_gradient_checkpointing=True
264
+ )
265
+ else:
266
+ print(f"[grpo] CUDA unavailable β€” loading {model_id} in fp32 on CPU "
267
+ "(end-to-end code-path test only; not a real training run).")
268
+ base_model = AutoModelForCausalLM.from_pretrained(
269
+ model_id, torch_dtype=torch.float32
270
+ )
271
+
272
  # Flatten chat messages -> single prompt string the way TRL expects
273
  def _row_to_prompt(row):
274
  msgs = row["messages"]
 
277
  train_rows = [{"prompt": _row_to_prompt(r), "snapshot": r} for r in dataset]
278
  hf_ds = Dataset.from_list(train_rows)
279
 
280
+ # ── Imitation pretrain (option C) ─────────────────────────────────────
281
+ # Brief SFT pass on derived ideal actions to warmstart the LoRA before
282
+ # GRPO. Without it, GRPO starts from random LoRA noise and the agent
283
+ # may need many steps to discover even basic JSON formatting.
284
+ if getattr(args, "imitation_warmstart", False):
285
+ _imitation_pretrain(base_model, tokenizer, dataset, run_dir, args)
286
+
287
  def reward_fn(completions, **kwargs):
288
  # `completions` is a list of decoded strings; `kwargs` contains the
289
  # original row fields including "snapshot".
 
318
  f"{args.batch_size * args.grad_accum}."
319
  )
320
 
 
 
321
  grpo_cfg = GRPOConfig(
322
  output_dir=str(run_dir),
323
  per_device_train_batch_size=args.batch_size,
 
330
  save_strategy="epoch",
331
  bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
332
  fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
333
+ # Gradient checkpointing trades a little compute for ~30% activation
334
+ # memory savings. Critical on the L4 24 GB.
335
+ gradient_checkpointing=True,
336
+ gradient_checkpointing_kwargs={"use_reentrant": False},
337
  seed=args.seed,
338
  )
339
 
340
  trainer = GRPOTrainer(
341
+ model=base_model,
342
+ processing_class=tokenizer,
343
  reward_funcs=[reward_fn],
344
  args=grpo_cfg,
345
  train_dataset=hf_ds,
 
405
  help="Use MockLLMClient for the 7 specialists (testing only)")
406
  p.add_argument("--tasks", default="all",
407
  help="Comma-separated task IDs to train on, or 'all' (default)")
408
+ p.add_argument("--imitation-warmstart", action="store_true",
409
+ help="One-pass SFT on derived ideal actions before GRPO. "
410
+ "Gives the model a sensible prior so GRPO doesn't "
411
+ "start from random LoRA noise.")
412
  p.add_argument("--out", default=None)
413
  args = p.parse_args()
414