RayMelius Claude Opus 4.6 commited on
Commit
de2b29d
Β·
1 Parent(s): 698087c

Add CH trader fine-tuning notebook and update default model

Browse files

- notebooks/ch_trader_finetune.ipynb: QLoRA fine-tunes Qwen2.5-7B-Instruct
on 2500 synthetic clearing house trading scenarios (Colab A100-ready)
- Generates diverse examples: varied capital, holdings, obligation levels
- Trains with SFTTrainer + chat template formatting, merges and pushes
the model to RayMelius/stockex-ch-trader on HuggingFace Hub
- ch_ai_trader.py + docker-compose: default HF_MODEL updated to
RayMelius/stockex-ch-trader (override via HF_MODEL env var)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

clearing_house/ch_ai_trader.py CHANGED
@@ -35,7 +35,7 @@ CH_SOURCE = "CLEARINGHOUSE"
35
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
36
  OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
37
  HF_TOKEN = os.getenv("HF_TOKEN", "")
38
- HF_MODEL = os.getenv("HF_MODEL", "RayMelius/stockex-analyst")
39
  GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
40
  GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
41
  GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
 
35
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
36
  OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
37
  HF_TOKEN = os.getenv("HF_TOKEN", "")
38
+ HF_MODEL = os.getenv("HF_MODEL", "RayMelius/stockex-ch-trader")
39
  GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
40
  GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
41
  GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
docker-compose.yml CHANGED
@@ -204,7 +204,7 @@ services:
204
  - CH_DB_PATH=/app/data/clearing_house.db
205
  - CH_PORT=5004
206
  - HF_TOKEN=${HF_TOKEN:-}
207
- - HF_MODEL=${HF_MODEL:-RayMelius/stockex-analyst}
208
  - GROQ_API_KEY=${GROQ_API_KEY:-}
209
  - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
210
  - OLLAMA_HOST=${OLLAMA_HOST:-}
 
204
  - CH_DB_PATH=/app/data/clearing_house.db
205
  - CH_PORT=5004
206
  - HF_TOKEN=${HF_TOKEN:-}
207
+ - HF_MODEL=${HF_MODEL:-RayMelius/stockex-ch-trader}
208
  - GROQ_API_KEY=${GROQ_API_KEY:-}
209
  - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
210
  - OLLAMA_HOST=${OLLAMA_HOST:-}
notebooks/ch_trader_finetune.ipynb ADDED
@@ -0,0 +1,681 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nbformat": 4,
3
+ "nbformat_minor": 5,
4
+ "metadata": {
5
+ "kernelspec": {
6
+ "display_name": "Python 3",
7
+ "language": "python",
8
+ "name": "python3"
9
+ },
10
+ "language_info": {
11
+ "name": "python",
12
+ "version": "3.10.0"
13
+ },
14
+ "accelerator": "GPU",
15
+ "colab": {
16
+ "gpuType": "A100",
17
+ "provenance": []
18
+ }
19
+ },
20
+ "cells": [
21
+ {
22
+ "cell_type": "markdown",
23
+ "id": "title",
24
+ "metadata": {},
25
+ "source": [
26
+ "# StockEx Clearing House β€” LLM Fine-Tuning\n",
27
+ "\n",
28
+ "Fine-tunes **Qwen/Qwen2.5-7B-Instruct** with QLoRA to act as a clearing house trading agent.\n",
29
+ "\n",
30
+ "Given a member's capital, holdings, and live market BBO, the model outputs a valid JSON trading decision.\n",
31
+ "\n",
32
+ "**Output model:** `RayMelius/stockex-ch-trader` on HuggingFace Hub\n",
33
+ "\n",
34
+ "---\n",
35
+ "**Runtime:** GPU β†’ A100 recommended (fits on T4 with batch_size=1)\n",
36
+ "\n",
37
+ "**Required secret:** `HF_TOKEN` with write access to `RayMelius/`"
38
+ ]
39
+ },
40
+ {
41
+ "cell_type": "code",
42
+ "execution_count": null,
43
+ "id": "install",
44
+ "metadata": {},
45
+ "outputs": [],
46
+ "source": [
47
+ "# ── Install dependencies ───────────────────────────────────────────────────────\n",
48
+ "!pip install -q \\\n",
49
+ " transformers==4.46.3 \\\n",
50
+ " peft==0.13.2 \\\n",
51
+ " trl==0.12.1 \\\n",
52
+ " datasets==3.1.0 \\\n",
53
+ " accelerate==1.1.1 \\\n",
54
+ " bitsandbytes==0.44.1 \\\n",
55
+ " huggingface_hub"
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "code",
60
+ "execution_count": null,
61
+ "id": "imports",
62
+ "metadata": {},
63
+ "outputs": [],
64
+ "source": [
65
+ "import os, json, random, torch\n",
66
+ "from datasets import Dataset\n",
67
+ "from transformers import (\n",
68
+ " AutoTokenizer, AutoModelForCausalLM,\n",
69
+ " BitsAndBytesConfig, TrainingArguments,\n",
70
+ ")\n",
71
+ "from peft import LoraConfig, get_peft_model, TaskType\n",
72
+ "from trl import SFTTrainer, SFTConfig\n",
73
+ "from huggingface_hub import login\n",
74
+ "\n",
75
+ "print(f\"CUDA available: {torch.cuda.is_available()}\")\n",
76
+ "if torch.cuda.is_available():\n",
77
+ " print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n",
78
+ " print(f\"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\")"
79
+ ]
80
+ },
81
+ {
82
+ "cell_type": "code",
83
+ "execution_count": null,
84
+ "id": "config",
85
+ "metadata": {},
86
+ "outputs": [],
87
+ "source": [
88
+ "# ── Configuration ─────────────────────────────────────────────────────────────\n",
89
+ "BASE_MODEL = \"Qwen/Qwen2.5-7B-Instruct\"\n",
90
+ "OUTPUT_REPO = \"RayMelius/stockex-ch-trader\"\n",
91
+ "OUTPUT_DIR = \"./stockex-ch-trader\"\n",
92
+ "\n",
93
+ "# Lora\n",
94
+ "LORA_R = 16\n",
95
+ "LORA_ALPHA = 32\n",
96
+ "LORA_DROPOUT = 0.05\n",
97
+ "\n",
98
+ "# Training\n",
99
+ "NUM_EPOCHS = 3\n",
100
+ "BATCH_SIZE = 4 # reduce to 1 on T4\n",
101
+ "GRAD_ACCUM = 4 # effective batch = BATCH_SIZE * GRAD_ACCUM\n",
102
+ "LR = 2e-4\n",
103
+ "MAX_SEQ_LEN = 512\n",
104
+ "DATASET_SIZE = 2500 # synthetic training examples\n",
105
+ "\n",
106
+ "# HuggingFace login\n",
107
+ "HF_TOKEN = os.getenv(\"HF_TOKEN\") or input(\"Enter your HF token: \")\n",
108
+ "login(token=HF_TOKEN)\n",
109
+ "print(\"Logged in to HuggingFace Hub\")"
110
+ ]
111
+ },
112
+ {
113
+ "cell_type": "markdown",
114
+ "id": "dataset-header",
115
+ "metadata": {},
116
+ "source": [
117
+ "## 1. Synthetic Dataset Generation\n",
118
+ "\n",
119
+ "Each training example is a realistic clearing house trading scenario:\n",
120
+ "- Member state: capital, holdings, obligation remaining\n",
121
+ "- Market: BBO for each security\n",
122
+ "- Target: a valid JSON trading decision that respects all constraints"
123
+ ]
124
+ },
125
+ {
126
+ "cell_type": "code",
127
+ "execution_count": null,
128
+ "id": "dataset-gen",
129
+ "metadata": {},
130
+ "outputs": [],
131
+ "source": [
132
+ "# Securities traded on StockEx\n",
133
+ "SECURITIES = [\n",
134
+ " {\"symbol\": \"ALPHA\", \"base\": 6.00},\n",
135
+ " {\"symbol\": \"PEIR\", \"base\": 8.20},\n",
136
+ " {\"symbol\": \"EXAE\", \"base\": 6.90},\n",
137
+ " {\"symbol\": \"OPAP\", \"base\": 14.50},\n",
138
+ " {\"symbol\": \"MYTIL\", \"base\": 9.80},\n",
139
+ " {\"symbol\": \"ADMIE\", \"base\": 2.45},\n",
140
+ " {\"symbol\": \"ELPE\", \"base\": 7.60},\n",
141
+ " {\"symbol\": \"MOTOR\", \"base\": 22.30},\n",
142
+ " {\"symbol\": \"OTE\", \"base\": 15.10},\n",
143
+ " {\"symbol\": \"TPEIR\", \"base\": 1.75},\n",
144
+ "]\n",
145
+ "\n",
146
+ "STARTING_CAPITAL = 100_000.0\n",
147
+ "DAILY_OBLIGATION = 10\n",
148
+ "\n",
149
+ "\n",
150
+ "def gen_bbo(base_price: float) -> dict:\n",
151
+ " \"\"\"Generate a realistic bid/ask spread around a base price.\"\"\"\n",
152
+ " drift = random.uniform(-0.05, 0.05)\n",
153
+ " mid = round(base_price * (1 + drift), 2)\n",
154
+ " spread = round(random.choice([0.05, 0.10, 0.15]), 2)\n",
155
+ " best_bid = round(mid - spread / 2, 2)\n",
156
+ " best_ask = round(mid + spread / 2, 2)\n",
157
+ " return {\"best_bid\": best_bid, \"best_ask\": best_ask, \"mid\": mid}\n",
158
+ "\n",
159
+ "\n",
160
+ "def gen_holdings(bbos: dict) -> list:\n",
161
+ " \"\"\"Randomly generate some holdings for a member.\"\"\"\n",
162
+ " holdings = []\n",
163
+ " n = random.randint(0, 4) # 0–4 positions\n",
164
+ " for sym in random.sample(list(bbos.keys()), min(n, len(bbos))):\n",
165
+ " qty = random.randint(50, 500)\n",
166
+ " mid = bbos[sym][\"mid\"]\n",
167
+ " avg_cost = round(mid * random.uniform(0.92, 1.08), 2)\n",
168
+ " holdings.append({\"symbol\": sym, \"quantity\": qty, \"avg_cost\": avg_cost})\n",
169
+ " return holdings\n",
170
+ "\n",
171
+ "\n",
172
+ "def build_prompt(member_id: str, capital: float, holdings: list,\n",
173
+ " obligation_remaining: int, bbos: dict) -> str:\n",
174
+ " market_lines = [\n",
175
+ " f\" {sym}: Bid {bbo['best_bid']:.2f} / Ask {bbo['best_ask']:.2f}\"\n",
176
+ " for sym, bbo in sorted(bbos.items())\n",
177
+ " ]\n",
178
+ " holding_lines = (\n",
179
+ " [f\" {h['symbol']}: {h['quantity']} shares @ avg cost {h['avg_cost']:.2f}\"\n",
180
+ " for h in holdings]\n",
181
+ " if holdings else [\" None\"]\n",
182
+ " )\n",
183
+ " return (\n",
184
+ " f\"You are simulating clearing house member {member_id} making ONE trading decision.\\n\\n\"\n",
185
+ " f\"Member state:\\n\"\n",
186
+ " f\" Available capital: EUR {capital:,.2f}\\n\"\n",
187
+ " f\" Securities obligation remaining today: {obligation_remaining} more to trade\\n\"\n",
188
+ " f\" Current holdings:\\n\" + \"\\n\".join(holding_lines) + \"\\n\\n\"\n",
189
+ " f\"Current market (Bid/Ask):\\n\" + \"\\n\".join(market_lines) + \"\\n\\n\"\n",
190
+ " f\"Rules:\\n\"\n",
191
+ " f\"- Do not spend more than your available capital\\n\"\n",
192
+ " f\"- Do not sell more shares than you hold\\n\"\n",
193
+ " f\"- If you have no holdings, you must BUY\\n\"\n",
194
+ " f\"- Choose a realistic price close to the BBO mid-price\\n\"\n",
195
+ " f\"- Quantity should be between 10 and 200\\n\\n\"\n",
196
+ " f\"Respond ONLY with valid JSON, no other text:\\n\"\n",
197
+ " f'Example: {{\"symbol\": \"ALPHA\", \"side\": \"BUY\", \"quantity\": 50, \"price\": 5.95}}'\n",
198
+ " )\n",
199
+ "\n",
200
+ "\n",
201
+ "def gen_decision(capital: float, holdings: list, bbos: dict) -> dict:\n",
202
+ " \"\"\"Generate a rule-valid trading decision for the given state.\"\"\"\n",
203
+ " has_holdings = len(holdings) > 0\n",
204
+ "\n",
205
+ " # Decide side: BUY if no holdings or randomly; SELL if heavy positions\n",
206
+ " holdings_value = sum(\n",
207
+ " h[\"quantity\"] * bbos.get(h[\"symbol\"], {}).get(\"mid\", h[\"avg_cost\"])\n",
208
+ " for h in holdings\n",
209
+ " )\n",
210
+ " net_worth = capital + holdings_value\n",
211
+ " holdings_ratio = holdings_value / net_worth if net_worth > 0 else 0\n",
212
+ "\n",
213
+ " if not has_holdings:\n",
214
+ " side = \"BUY\"\n",
215
+ " elif holdings_ratio > 0.6:\n",
216
+ " side = random.choices([\"SELL\", \"BUY\"], weights=[0.7, 0.3])[0]\n",
217
+ " else:\n",
218
+ " side = random.choices([\"BUY\", \"SELL\"], weights=[0.55, 0.45])[0]\n",
219
+ "\n",
220
+ " if side == \"BUY\":\n",
221
+ " # Pick a random affordable symbol\n",
222
+ " affordable = [\n",
223
+ " sym for sym, bbo in bbos.items()\n",
224
+ " if 10 * bbo[\"best_ask\"] <= capital\n",
225
+ " ]\n",
226
+ " if not affordable:\n",
227
+ " # Fall back to cheapest\n",
228
+ " sym = min(bbos, key=lambda s: bbos[s][\"best_ask\"])\n",
229
+ " else:\n",
230
+ " # Weight toward securities we already hold (adding to position)\n",
231
+ " held_syms = [h[\"symbol\"] for h in holdings]\n",
232
+ " weights = [3 if s in held_syms else 1 for s in affordable]\n",
233
+ " sym = random.choices(affordable, weights=weights)[0]\n",
234
+ " ask = bbos[sym][\"best_ask\"]\n",
235
+ " max_qty = min(200, int(capital / ask))\n",
236
+ " qty = random.randint(10, max(10, max_qty))\n",
237
+ " price = round(bbos[sym][\"mid\"] + random.uniform(-0.05, 0.05), 2)\n",
238
+ " price = max(bbos[sym][\"best_bid\"], min(price, ask))\n",
239
+ " return {\"symbol\": sym, \"side\": \"BUY\", \"quantity\": qty, \"price\": round(price, 2)}\n",
240
+ " else:\n",
241
+ " # Sell from existing holdings\n",
242
+ " h = random.choice(holdings)\n",
243
+ " sym = h[\"symbol\"]\n",
244
+ " bbo = bbos[sym]\n",
245
+ " qty = random.randint(10, min(200, h[\"quantity\"]))\n",
246
+ " price = round(bbo[\"mid\"] + random.uniform(-0.05, 0.05), 2)\n",
247
+ " price = max(bbo[\"best_bid\"] - 0.05, min(price, bbo[\"best_ask\"]))\n",
248
+ " return {\"symbol\": sym, \"side\": \"SELL\", \"quantity\": qty, \"price\": round(price, 2)}\n",
249
+ "\n",
250
+ "\n",
251
+ "def generate_dataset(n: int) -> list:\n",
252
+ " examples = []\n",
253
+ " member_ids = [f\"USR{i:02d}\" for i in range(1, 11)]\n",
254
+ "\n",
255
+ " scenarios = [\n",
256
+ " # (capital_range, obligation_range, description)\n",
257
+ " ((80_000, 100_000), (5, 10), \"fresh_member\"), # new, must trade a lot\n",
258
+ " ((50_000, 80_000), (0, 5), \"active_member\"), # mid-session, nearly done\n",
259
+ " ((20_000, 50_000), (0, 2), \"low_capital\"), # low cash, mostly holdings\n",
260
+ " ((5_000, 20_000), (0, 10), \"very_low_capital\"), # near margin, careful\n",
261
+ " ((90_000, 100_000), (10, 10),\"start_of_day\"), # just started\n",
262
+ " ]\n",
263
+ "\n",
264
+ " for _ in range(n):\n",
265
+ " cap_range, obl_range, _ = random.choice(scenarios)\n",
266
+ " capital = round(random.uniform(*cap_range), 2)\n",
267
+ " obligation = random.randint(*obl_range)\n",
268
+ " member_id = random.choice(member_ids)\n",
269
+ "\n",
270
+ " # Generate market state\n",
271
+ " bbos = {s[\"symbol\"]: gen_bbo(s[\"base\"]) for s in SECURITIES}\n",
272
+ "\n",
273
+ " # Generate holdings consistent with remaining capital\n",
274
+ " holdings = gen_holdings(bbos)\n",
275
+ "\n",
276
+ " # Ensure capital consistency: if holdings are expensive, reduce capital\n",
277
+ " holdings_cost = sum(h[\"quantity\"] * h[\"avg_cost\"] for h in holdings)\n",
278
+ " if holdings_cost > STARTING_CAPITAL - capital:\n",
279
+ " # Scale down holdings to fit\n",
280
+ " scale = (STARTING_CAPITAL - capital) / max(holdings_cost, 1)\n",
281
+ " for h in holdings:\n",
282
+ " h[\"quantity\"] = max(10, int(h[\"quantity\"] * scale))\n",
283
+ "\n",
284
+ " prompt = build_prompt(member_id, capital, holdings, obligation, bbos)\n",
285
+ " decision = gen_decision(capital, holdings, bbos)\n",
286
+ "\n",
287
+ " examples.append({\n",
288
+ " \"prompt\": prompt,\n",
289
+ " \"completion\": json.dumps(decision),\n",
290
+ " })\n",
291
+ "\n",
292
+ " return examples\n",
293
+ "\n",
294
+ "\n",
295
+ "print(f\"Generating {DATASET_SIZE} training examples...\")\n",
296
+ "raw_data = generate_dataset(DATASET_SIZE)\n",
297
+ "print(f\"Done. Example:\")\n",
298
+ "print(\"PROMPT:\\n\", raw_data[0][\"prompt\"])\n",
299
+ "print(\"\\nCOMPLETION:\", raw_data[0][\"completion\"])"
300
+ ]
301
+ },
302
+ {
303
+ "cell_type": "code",
304
+ "execution_count": null,
305
+ "id": "dataset-split",
306
+ "metadata": {},
307
+ "outputs": [],
308
+ "source": [
309
+ "# Train/val split (90/10)\n",
310
+ "random.shuffle(raw_data)\n",
311
+ "split = int(len(raw_data) * 0.9)\n",
312
+ "train_data = raw_data[:split]\n",
313
+ "val_data = raw_data[split:]\n",
314
+ "\n",
315
+ "train_dataset = Dataset.from_list(train_data)\n",
316
+ "val_dataset = Dataset.from_list(val_data)\n",
317
+ "print(f\"Train: {len(train_dataset)} | Val: {len(val_dataset)}\")"
318
+ ]
319
+ },
320
+ {
321
+ "cell_type": "markdown",
322
+ "id": "model-header",
323
+ "metadata": {},
324
+ "source": [
325
+ "## 2. Load Base Model (4-bit QLoRA)"
326
+ ]
327
+ },
328
+ {
329
+ "cell_type": "code",
330
+ "execution_count": null,
331
+ "id": "load-tokenizer",
332
+ "metadata": {},
333
+ "outputs": [],
334
+ "source": [
335
+ "print(f\"Loading tokenizer: {BASE_MODEL}\")\n",
336
+ "tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)\n",
337
+ "tokenizer.pad_token = tokenizer.eos_token\n",
338
+ "tokenizer.padding_side = \"right\"\n",
339
+ "print(\"Tokenizer loaded\")"
340
+ ]
341
+ },
342
+ {
343
+ "cell_type": "code",
344
+ "execution_count": null,
345
+ "id": "format-dataset",
346
+ "metadata": {},
347
+ "outputs": [],
348
+ "source": [
349
+ "SYSTEM_PROMPT = (\n",
350
+ " \"You are a StockEx clearing house trading agent. \"\n",
351
+ " \"Given a member's financial state and live market data, \"\n",
352
+ " \"you output a single valid JSON trading decision that respects all capital and holdings constraints. \"\n",
353
+ " \"Never output anything other than the JSON object.\"\n",
354
+ ")\n",
355
+ "\n",
356
+ "\n",
357
+ "def format_chat(example):\n",
358
+ " \"\"\"Apply the model's chat template to produce a training string.\"\"\"\n",
359
+ " messages = [\n",
360
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
361
+ " {\"role\": \"user\", \"content\": example[\"prompt\"]},\n",
362
+ " {\"role\": \"assistant\", \"content\": example[\"completion\"]},\n",
363
+ " ]\n",
364
+ " text = tokenizer.apply_chat_template(\n",
365
+ " messages,\n",
366
+ " tokenize=False,\n",
367
+ " add_generation_prompt=False,\n",
368
+ " )\n",
369
+ " return {\"text\": text}\n",
370
+ "\n",
371
+ "\n",
372
+ "train_dataset = train_dataset.map(format_chat)\n",
373
+ "val_dataset = val_dataset.map(format_chat)\n",
374
+ "\n",
375
+ "print(\"Sample formatted text:\")\n",
376
+ "print(train_dataset[0][\"text\"][:600], \"...\")"
377
+ ]
378
+ },
379
+ {
380
+ "cell_type": "code",
381
+ "execution_count": null,
382
+ "id": "load-model",
383
+ "metadata": {},
384
+ "outputs": [],
385
+ "source": [
386
+ "# 4-bit quantization config\n",
387
+ "bnb_config = BitsAndBytesConfig(\n",
388
+ " load_in_4bit=True,\n",
389
+ " bnb_4bit_quant_type=\"nf4\",\n",
390
+ " bnb_4bit_compute_dtype=torch.bfloat16,\n",
391
+ " bnb_4bit_use_double_quant=True,\n",
392
+ ")\n",
393
+ "\n",
394
+ "print(f\"Loading model: {BASE_MODEL} (4-bit)\")\n",
395
+ "model = AutoModelForCausalLM.from_pretrained(\n",
396
+ " BASE_MODEL,\n",
397
+ " quantization_config=bnb_config,\n",
398
+ " device_map=\"auto\",\n",
399
+ " trust_remote_code=True,\n",
400
+ " torch_dtype=torch.bfloat16,\n",
401
+ ")\n",
402
+ "model.config.use_cache = False\n",
403
+ "model.config.pretraining_tp = 1\n",
404
+ "print(f\"Model loaded. Parameters: {model.num_parameters()/1e9:.2f}B\")"
405
+ ]
406
+ },
407
+ {
408
+ "cell_type": "markdown",
409
+ "id": "lora-header",
410
+ "metadata": {},
411
+ "source": [
412
+ "## 3. LoRA Configuration"
413
+ ]
414
+ },
415
+ {
416
+ "cell_type": "code",
417
+ "execution_count": null,
418
+ "id": "lora-config",
419
+ "metadata": {},
420
+ "outputs": [],
421
+ "source": [
422
+ "lora_config = LoraConfig(\n",
423
+ " r=LORA_R,\n",
424
+ " lora_alpha=LORA_ALPHA,\n",
425
+ " target_modules=[\n",
426
+ " \"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",\n",
427
+ " \"gate_proj\", \"up_proj\", \"down_proj\",\n",
428
+ " ],\n",
429
+ " lora_dropout=LORA_DROPOUT,\n",
430
+ " bias=\"none\",\n",
431
+ " task_type=TaskType.CAUSAL_LM,\n",
432
+ ")\n",
433
+ "\n",
434
+ "trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\n",
435
+ "total = sum(p.numel() for p in model.parameters())\n",
436
+ "print(f\"Trainable parameters: {trainable/1e6:.1f}M / {total/1e6:.0f}M ({100*trainable/total:.2f}%)\")"
437
+ ]
438
+ },
439
+ {
440
+ "cell_type": "markdown",
441
+ "id": "training-header",
442
+ "metadata": {},
443
+ "source": [
444
+ "## 4. Train"
445
+ ]
446
+ },
447
+ {
448
+ "cell_type": "code",
449
+ "execution_count": null,
450
+ "id": "train",
451
+ "metadata": {},
452
+ "outputs": [],
453
+ "source": [
454
+ "sft_config = SFTConfig(\n",
455
+ " output_dir=OUTPUT_DIR,\n",
456
+ " num_train_epochs=NUM_EPOCHS,\n",
457
+ " per_device_train_batch_size=BATCH_SIZE,\n",
458
+ " per_device_eval_batch_size=BATCH_SIZE,\n",
459
+ " gradient_accumulation_steps=GRAD_ACCUM,\n",
460
+ " gradient_checkpointing=True,\n",
461
+ " optim=\"paged_adamw_32bit\",\n",
462
+ " learning_rate=LR,\n",
463
+ " lr_scheduler_type=\"cosine\",\n",
464
+ " warmup_ratio=0.05,\n",
465
+ " max_seq_length=MAX_SEQ_LEN,\n",
466
+ " fp16=not torch.cuda.is_bf16_supported(),\n",
467
+ " bf16=torch.cuda.is_bf16_supported(),\n",
468
+ " logging_steps=25,\n",
469
+ " eval_strategy=\"steps\",\n",
470
+ " eval_steps=100,\n",
471
+ " save_strategy=\"steps\",\n",
472
+ " save_steps=100,\n",
473
+ " load_best_model_at_end=True,\n",
474
+ " metric_for_best_model=\"eval_loss\",\n",
475
+ " greater_is_better=False,\n",
476
+ " report_to=\"none\",\n",
477
+ " dataset_text_field=\"text\",\n",
478
+ " packing=False,\n",
479
+ ")\n",
480
+ "\n",
481
+ "trainer = SFTTrainer(\n",
482
+ " model=model,\n",
483
+ " args=sft_config,\n",
484
+ " train_dataset=train_dataset,\n",
485
+ " eval_dataset=val_dataset,\n",
486
+ " peft_config=lora_config,\n",
487
+ " processing_class=tokenizer,\n",
488
+ ")\n",
489
+ "\n",
490
+ "print(\"Starting training...\")\n",
491
+ "trainer.train()\n",
492
+ "print(\"Training complete.\")"
493
+ ]
494
+ },
495
+ {
496
+ "cell_type": "markdown",
497
+ "id": "save-header",
498
+ "metadata": {},
499
+ "source": [
500
+ "## 5. Save & Push to HuggingFace Hub\n",
501
+ "\n",
502
+ "Merges LoRA adapters into the base model weights and pushes the full model."
503
+ ]
504
+ },
505
+ {
506
+ "cell_type": "code",
507
+ "execution_count": null,
508
+ "id": "save-model",
509
+ "metadata": {},
510
+ "outputs": [],
511
+ "source": [
512
+ "from peft import PeftModel\n",
513
+ "\n",
514
+ "# Save best adapter checkpoint locally\n",
515
+ "trainer.model.save_pretrained(OUTPUT_DIR)\n",
516
+ "tokenizer.save_pretrained(OUTPUT_DIR)\n",
517
+ "print(f\"Adapter saved to {OUTPUT_DIR}\")\n",
518
+ "\n",
519
+ "# Reload base model in fp16 for merging (can't merge with 4-bit)\n",
520
+ "print(\"Reloading base model in fp16 for adapter merge...\")\n",
521
+ "del model\n",
522
+ "torch.cuda.empty_cache()\n",
523
+ "\n",
524
+ "base_model = AutoModelForCausalLM.from_pretrained(\n",
525
+ " BASE_MODEL,\n",
526
+ " torch_dtype=torch.float16,\n",
527
+ " device_map=\"auto\",\n",
528
+ " trust_remote_code=True,\n",
529
+ ")\n",
530
+ "merged_model = PeftModel.from_pretrained(base_model, OUTPUT_DIR)\n",
531
+ "merged_model = merged_model.merge_and_unload()\n",
532
+ "print(\"Adapters merged.\")"
533
+ ]
534
+ },
535
+ {
536
+ "cell_type": "code",
537
+ "execution_count": null,
538
+ "id": "push-hub",
539
+ "metadata": {},
540
+ "outputs": [],
541
+ "source": [
542
+ "print(f\"Pushing merged model to: {OUTPUT_REPO}\")\n",
543
+ "merged_model.push_to_hub(\n",
544
+ " OUTPUT_REPO,\n",
545
+ " token=HF_TOKEN,\n",
546
+ " commit_message=\"StockEx CH Trader: QLoRA fine-tuned Qwen2.5-7B-Instruct\",\n",
547
+ ")\n",
548
+ "tokenizer.push_to_hub(\n",
549
+ " OUTPUT_REPO,\n",
550
+ " token=HF_TOKEN,\n",
551
+ " commit_message=\"Tokenizer for StockEx CH Trader\",\n",
552
+ ")\n",
553
+ "print(f\"Model pushed to https://huggingface.co/{OUTPUT_REPO}\")"
554
+ ]
555
+ },
556
+ {
557
+ "cell_type": "markdown",
558
+ "id": "test-header",
559
+ "metadata": {},
560
+ "source": [
561
+ "## 6. Inference Test\n",
562
+ "\n",
563
+ "Verify the model generates valid JSON trading decisions."
564
+ ]
565
+ },
566
+ {
567
+ "cell_type": "code",
568
+ "execution_count": null,
569
+ "id": "inference-test",
570
+ "metadata": {},
571
+ "outputs": [],
572
+ "source": [
573
+ "import re\n",
574
+ "from transformers import pipeline\n",
575
+ "\n",
576
+ "pipe = pipeline(\n",
577
+ " \"text-generation\",\n",
578
+ " model=merged_model,\n",
579
+ " tokenizer=tokenizer,\n",
580
+ " device_map=\"auto\",\n",
581
+ ")\n",
582
+ "\n",
583
+ "# Test scenarios\n",
584
+ "test_cases = [\n",
585
+ " {\n",
586
+ " \"desc\": \"New member, no holdings, must trade\",\n",
587
+ " \"capital\": 100_000.0,\n",
588
+ " \"holdings\": [],\n",
589
+ " \"obligation\": 10,\n",
590
+ " },\n",
591
+ " {\n",
592
+ " \"desc\": \"Experienced member with holdings, low obligation\",\n",
593
+ " \"capital\": 65_000.0,\n",
594
+ " \"holdings\": [\n",
595
+ " {\"symbol\": \"ALPHA\", \"quantity\": 300, \"avg_cost\": 5.90},\n",
596
+ " {\"symbol\": \"OPAP\", \"quantity\": 150, \"avg_cost\": 14.20},\n",
597
+ " ],\n",
598
+ " \"obligation\": 2,\n",
599
+ " },\n",
600
+ " {\n",
601
+ " \"desc\": \"Low capital, large holdings\",\n",
602
+ " \"capital\": 8_000.0,\n",
603
+ " \"holdings\": [\n",
604
+ " {\"symbol\": \"PEIR\", \"quantity\": 500, \"avg_cost\": 8.10},\n",
605
+ " {\"symbol\": \"MYTIL\", \"quantity\": 200, \"avg_cost\": 9.50},\n",
606
+ " ],\n",
607
+ " \"obligation\": 5,\n",
608
+ " },\n",
609
+ "]\n",
610
+ "\n",
611
+ "test_bbos = {s[\"symbol\"]: gen_bbo(s[\"base\"]) for s in SECURITIES}\n",
612
+ "\n",
613
+ "print(\"=\" * 70)\n",
614
+ "for tc in test_cases:\n",
615
+ " print(f\"\\nSCENARIO: {tc['desc']}\")\n",
616
+ " prompt = build_prompt(\n",
617
+ " \"USR01\", tc[\"capital\"], tc[\"holdings\"], tc[\"obligation\"], test_bbos\n",
618
+ " )\n",
619
+ " messages = [\n",
620
+ " {\"role\": \"system\",\"content\": SYSTEM_PROMPT},\n",
621
+ " {\"role\": \"user\", \"content\": prompt},\n",
622
+ " ]\n",
623
+ " output = pipe(\n",
624
+ " messages,\n",
625
+ " max_new_tokens=60,\n",
626
+ " temperature=0.3,\n",
627
+ " do_sample=True,\n",
628
+ " pad_token_id=tokenizer.eos_token_id,\n",
629
+ " )\n",
630
+ " response = output[0][\"generated_text\"][-1][\"content\"].strip()\n",
631
+ " print(f\"RESPONSE: {response}\")\n",
632
+ "\n",
633
+ " # Validate JSON\n",
634
+ " try:\n",
635
+ " m = re.search(r\"\\{[^}]+\\}\", response)\n",
636
+ " if m:\n",
637
+ " d = json.loads(m.group())\n",
638
+ " assert d[\"side\"] in (\"BUY\", \"SELL\")\n",
639
+ " assert d[\"symbol\"] in [s[\"symbol\"] for s in SECURITIES]\n",
640
+ " assert d[\"quantity\"] > 0\n",
641
+ " assert d[\"price\"] > 0\n",
642
+ " print(f\"βœ“ Valid JSON: {d}\")\n",
643
+ " else:\n",
644
+ " print(\"βœ— No JSON found in response\")\n",
645
+ " except Exception as e:\n",
646
+ " print(f\"βœ— Invalid: {e}\")\n",
647
+ " print(\"-\" * 70)"
648
+ ]
649
+ },
650
+ {
651
+ "cell_type": "markdown",
652
+ "id": "usage-header",
653
+ "metadata": {},
654
+ "source": [
655
+ "## 7. Activate in StockEx\n",
656
+ "\n",
657
+ "The clearing house already uses `RayMelius/stockex-ch-trader` as default.\n",
658
+ "\n",
659
+ "To switch to this model in a running StockEx instance:\n",
660
+ "\n",
661
+ "**HuggingFace Spaces** β€” add to secrets:\n",
662
+ "```\n",
663
+ "HF_MODEL = RayMelius/stockex-ch-trader\n",
664
+ "HF_TOKEN = <your token>\n",
665
+ "```\n",
666
+ "\n",
667
+ "**Docker Compose** β€” already set in `docker-compose.yml`:\n",
668
+ "```yaml\n",
669
+ "environment:\n",
670
+ " - HF_MODEL=RayMelius/stockex-ch-trader\n",
671
+ " - HF_TOKEN=<your token>\n",
672
+ "```\n",
673
+ "\n",
674
+ "To use a future CH-specific model later:\n",
675
+ "```\n",
676
+ "HF_MODEL = RayMelius/<new-ch-model>\n",
677
+ "```"
678
+ ]
679
+ }
680
+ ]
681
+ }