RayMelius Claude Opus 4.6 commited on
Commit
4937757
Β·
1 Parent(s): b32ac07

Per-member AI model switching: right-click Type badge to change LLM/NN1/NN2

Browse files

Replace global strategy system with per-member assignment map.
Default: USR01-04 LLM, USR05-07 NN1, USR08-10 NN2.
Right-click any member's Type badge in the leaderboard to change
their individual AI model. Human members (logged in) show Human
badge and skip the context menu.

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

clearing_house/app.py CHANGED
@@ -401,25 +401,7 @@ def api_member_detail(member_id):
401
  def _member_ai_type(member_id: str) -> str:
402
  if ai_trader.is_human_active(member_id):
403
  return "Human"
404
- strategy = ai_trader.get_strategy()
405
- if strategy in ("nn1", "nn2"):
406
- return strategy.upper()
407
- if strategy == "llm":
408
- return "LLM"
409
- member_num = int(member_id[-2:])
410
- if strategy == "hybrid":
411
- # USR01-04 LLM, USR05-07 NN1, USR08-10 NN2
412
- if member_num <= 4:
413
- return "LLM"
414
- elif member_num <= 7:
415
- return "NN1"
416
- else:
417
- return "NN2"
418
- # hybrid-nn1 or hybrid-nn2
419
- if strategy.startswith("hybrid-"):
420
- nn_slot = strategy.split("-", 1)[1].upper()
421
- return nn_slot if member_num <= 5 else "LLM"
422
- return "LLM"
423
 
424
 
425
  @app.route("/ch/api/market")
@@ -430,7 +412,7 @@ def api_market():
430
  @app.route("/ch/api/config")
431
  def api_config():
432
  result = {
433
- "strategy": ai_trader.get_strategy(),
434
  "obligation": db.CH_DAILY_OBLIGATION,
435
  "ai_interval": int(os.getenv("CH_AI_INTERVAL", "45")),
436
  }
@@ -442,13 +424,15 @@ def api_config():
442
  return jsonify(result)
443
 
444
 
445
- @app.route("/ch/api/strategy", methods=["POST"])
446
- def api_set_strategy():
 
 
447
  data = request.get_json(force=True)
448
  strategy = data.get("strategy", "")
449
- result = ai_trader.set_strategy(strategy)
450
- _broadcast("config", {"strategy": result})
451
- return jsonify({"status": "ok", "strategy": result})
452
 
453
 
454
  # ── SSE ────────────────────────────────────────────────────────────────────────
 
401
  def _member_ai_type(member_id: str) -> str:
402
  if ai_trader.is_human_active(member_id):
403
  return "Human"
404
+ return ai_trader.get_member_strategy(member_id).upper()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
 
406
 
407
  @app.route("/ch/api/market")
 
412
  @app.route("/ch/api/config")
413
  def api_config():
414
  result = {
415
+ "member_strategies": ai_trader.get_all_member_strategies(),
416
  "obligation": db.CH_DAILY_OBLIGATION,
417
  "ai_interval": int(os.getenv("CH_AI_INTERVAL", "45")),
418
  }
 
424
  return jsonify(result)
425
 
426
 
427
+ @app.route("/ch/api/member/<member_id>/strategy", methods=["POST"])
428
+ def api_set_member_strategy(member_id):
429
+ """Set the AI model type for a specific member."""
430
+ member_id = member_id.upper().strip()
431
  data = request.get_json(force=True)
432
  strategy = data.get("strategy", "")
433
+ result = ai_trader.set_member_strategy(member_id, strategy)
434
+ _broadcast("config", {"member_id": member_id, "strategy": result})
435
+ return jsonify({"status": "ok", "member_id": member_id, "strategy": result})
436
 
437
 
438
  # ── SSE ────────────────────────────────────────────────────────────────────────
clearing_house/ch_ai_trader.py CHANGED
@@ -7,14 +7,9 @@ Three background threads:
7
  for each unoccupied member using the configured strategy.
8
  3. _control_listener_thread – listens for session start/stop/suspend/resume.
9
 
10
- Strategy is selected via CH_AI_STRATEGY env var:
11
- "hybrid" – USR01-04 LLM, USR05-07 NN1, USR08-10 NN2 (default)
12
- "hybrid-nn1" – USR01-05 NN1, USR06-10 LLM
13
- "hybrid-nn2" – USR01-05 NN2, USR06-10 LLM
14
- "llm" – all members use LLM
15
- "nn1" – all members use NN1 (Adilbai/stock-trading-rl-agent)
16
- "nn2" – all members use NN2 (RayMelius/stockex-nn-agent)
17
- Legacy alias: "rl" β†’ "nn1"
18
 
19
  Call start() once from app.py after init_db().
20
  Call set_human_active(member_id) / set_human_inactive(member_id) on login/logout.
@@ -48,11 +43,7 @@ except ImportError:
48
 
49
  # ── Config ─────────────────────────────────────────────────────────────────────
50
  CH_AI_INTERVAL = int(os.getenv("CH_AI_INTERVAL", "45")) # seconds between AI cycles
51
- # Normalize legacy strategy names on startup
52
- _raw_strategy = os.getenv("CH_AI_STRATEGY", "hybrid")
53
- _STRATEGY_ALIASES = {"rl": "nn1", "hybrid-nn1": "hybrid-nn1", "hybrid-nn2": "hybrid-nn2"}
54
- CH_AI_STRATEGY = _STRATEGY_ALIASES.get(_raw_strategy, _raw_strategy)
55
- VALID_STRATEGIES = {"llm", "nn1", "nn2", "hybrid", "hybrid-nn1", "hybrid-nn2"}
56
  CH_SOURCE = "CLRH"
57
 
58
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
@@ -76,6 +67,29 @@ _seq_lock = threading.Lock()
76
  _producer = None
77
  _producer_lock = threading.Lock()
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  # ── Public API ─────────────────────────────────────────────────────────────────
81
 
@@ -94,31 +108,30 @@ def is_human_active(member_id: str) -> bool:
94
  return member_id in _active_humans
95
 
96
 
97
- def get_strategy() -> str:
98
- return CH_AI_STRATEGY
 
 
99
 
100
 
101
- def set_strategy(strategy: str) -> str:
102
- """Dynamically switch AI strategy. Returns the active strategy."""
103
- global CH_AI_STRATEGY
104
  strategy = strategy.lower().strip()
105
- # Support legacy aliases
106
- strategy = _STRATEGY_ALIASES.get(strategy, strategy)
107
- if strategy not in VALID_STRATEGIES:
108
- return CH_AI_STRATEGY
109
  if strategy != "llm" and not _rl_available:
110
- print(f"[CH-AI] Cannot switch to {strategy}: RL deps not installed")
111
- return CH_AI_STRATEGY
112
- CH_AI_STRATEGY = strategy
113
- # Tell RL trader which model slot to use
114
- if _rl_available and strategy in ("nn1", "nn2"):
115
- rl_trader.set_active_model(strategy)
116
- elif _rl_available and strategy.startswith("hybrid-"):
117
- nn_slot = strategy.split("-", 1)[1]
118
- rl_trader.set_active_model(nn_slot)
119
- # "hybrid" uses both nn1 and nn2, no single active model to set
120
- print(f"[CH-AI] Strategy switched to: {strategy}")
121
- return CH_AI_STRATEGY
122
 
123
 
124
  def start() -> None:
@@ -128,11 +141,14 @@ def start() -> None:
128
  threading.Thread(target=_trade_consumer_thread, daemon=True, name="ch-trade-consumer").start()
129
  threading.Thread(target=_simulation_thread, daemon=True, name="ch-ai-sim").start()
130
  threading.Thread(target=_control_listener_thread, daemon=True, name="ch-control").start()
131
- strategy = CH_AI_STRATEGY
132
- if strategy != "llm" and not _rl_available:
133
- strategy = "llm"
134
- print("[CH-AI] WARNING: NN requested but deps missing, falling back to LLM")
135
- print(f"[CH-AI] Background threads started (strategy={strategy})")
 
 
 
136
 
137
 
138
  # ── Kafka helpers ──────────────────────────────────────────────────────────────
@@ -275,25 +291,8 @@ def _run_simulation_cycle():
275
 
276
 
277
  def _decide_order(member_id, capital, holdings, daily_trades, bbos, obligation_remaining):
278
- """Dispatch to the configured strategy."""
279
- strategy = CH_AI_STRATEGY
280
-
281
- # Hybrid modes: split members between strategies
282
- member_num = int(member_id[-2:])
283
- if strategy == "hybrid" and _rl_available:
284
- # Default hybrid: USR01-04 LLM, USR05-07 NN1, USR08-10 NN2
285
- if member_num <= 4:
286
- strategy = "llm"
287
- elif member_num <= 7:
288
- strategy = "nn1"
289
- else:
290
- strategy = "nn2"
291
- elif strategy.startswith("hybrid-") and _rl_available:
292
- nn_slot = strategy.split("-", 1)[1] # "nn1" or "nn2"
293
- if member_num <= 5:
294
- strategy = nn_slot
295
- else:
296
- strategy = "llm"
297
 
298
  if strategy in ("nn1", "nn2") and _rl_available:
299
  try:
 
7
  for each unoccupied member using the configured strategy.
8
  3. _control_listener_thread – listens for session start/stop/suspend/resume.
9
 
10
+ Each member has an individually assignable AI model type: "llm", "nn1", or "nn2".
11
+ Default: USR01-04 LLM, USR05-07 NN1, USR08-10 NN2.
12
+ Per-member assignment can be changed at runtime via set_member_strategy().
 
 
 
 
 
13
 
14
  Call start() once from app.py after init_db().
15
  Call set_human_active(member_id) / set_human_inactive(member_id) on login/logout.
 
43
 
44
  # ── Config ─────────────────────────────────────────────────────────────────────
45
  CH_AI_INTERVAL = int(os.getenv("CH_AI_INTERVAL", "45")) # seconds between AI cycles
46
+ VALID_MEMBER_STRATEGIES = {"llm", "nn1", "nn2"}
 
 
 
 
47
  CH_SOURCE = "CLRH"
48
 
49
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
 
67
  _producer = None
68
  _producer_lock = threading.Lock()
69
 
70
+ # Per-member AI model assignment: member_id β†’ "llm" | "nn1" | "nn2"
71
+ # Default: USR01-04 LLM, USR05-07 NN1, USR08-10 NN2
72
+ _member_strategies: dict[str, str] = {}
73
+ _strategies_lock = threading.Lock()
74
+
75
+ def _default_member_strategy(member_id: str) -> str:
76
+ """Default strategy based on member number."""
77
+ num = int(member_id[-2:])
78
+ if num <= 4:
79
+ return "llm"
80
+ elif num <= 7:
81
+ return "nn1"
82
+ return "nn2"
83
+
84
+ def _init_member_strategies():
85
+ """Initialize default per-member strategies."""
86
+ with _strategies_lock:
87
+ for i in range(1, 11):
88
+ mid = f"USR{i:02d}"
89
+ _member_strategies[mid] = _default_member_strategy(mid)
90
+
91
+ _init_member_strategies()
92
+
93
 
94
  # ── Public API ─────────────────────────────────────────────────────────────────
95
 
 
108
  return member_id in _active_humans
109
 
110
 
111
+ def get_member_strategy(member_id: str) -> str:
112
+ """Get the AI model type for a specific member."""
113
+ with _strategies_lock:
114
+ return _member_strategies.get(member_id, _default_member_strategy(member_id))
115
 
116
 
117
+ def set_member_strategy(member_id: str, strategy: str) -> str:
118
+ """Set the AI model type for a specific member. Returns the active strategy."""
 
119
  strategy = strategy.lower().strip()
120
+ if strategy not in VALID_MEMBER_STRATEGIES:
121
+ return get_member_strategy(member_id)
 
 
122
  if strategy != "llm" and not _rl_available:
123
+ print(f"[CH-AI] Cannot set {member_id} to {strategy}: RL deps not installed")
124
+ return get_member_strategy(member_id)
125
+ with _strategies_lock:
126
+ _member_strategies[member_id] = strategy
127
+ print(f"[CH-AI] {member_id} strategy β†’ {strategy}")
128
+ return strategy
129
+
130
+
131
+ def get_all_member_strategies() -> dict[str, str]:
132
+ """Return a copy of all per-member strategy assignments."""
133
+ with _strategies_lock:
134
+ return dict(_member_strategies)
135
 
136
 
137
  def start() -> None:
 
141
  threading.Thread(target=_trade_consumer_thread, daemon=True, name="ch-trade-consumer").start()
142
  threading.Thread(target=_simulation_thread, daemon=True, name="ch-ai-sim").start()
143
  threading.Thread(target=_control_listener_thread, daemon=True, name="ch-control").start()
144
+ if not _rl_available:
145
+ # Force all members to LLM if RL deps missing
146
+ with _strategies_lock:
147
+ for mid in _member_strategies:
148
+ _member_strategies[mid] = "llm"
149
+ print("[CH-AI] WARNING: NN deps missing, all members forced to LLM")
150
+ strategies = get_all_member_strategies()
151
+ print(f"[CH-AI] Background threads started (strategies={strategies})")
152
 
153
 
154
  # ── Kafka helpers ──────────────────────────────────────────────────────────────
 
291
 
292
 
293
  def _decide_order(member_id, capital, holdings, daily_trades, bbos, obligation_remaining):
294
+ """Dispatch to the per-member strategy."""
295
+ strategy = get_member_strategy(member_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
  if strategy in ("nn1", "nn2") and _rl_available:
298
  try:
clearing_house/templates/dashboard.html CHANGED
@@ -84,7 +84,7 @@
84
  <p style="color:var(--muted); font-size:11px; margin-top:8px;">
85
  Daily obligation: each member must trade at least {{ obligation }} securities.
86
  Holdings value is calculated at current market mid-price.
87
- Click a row for full member detail. Right-click table to switch AI model (LLM / NN1 / NN2). Refreshes every 10 seconds.
88
  </p>
89
 
90
  <!-- Member detail slide-out panel -->
@@ -170,6 +170,7 @@ function bindRowClicks() {
170
  tr.style.cursor = 'pointer';
171
  tr.onclick = () => openDetail(tr.dataset.member);
172
  });
 
173
  }
174
  bindRowClicks();
175
 
@@ -313,75 +314,72 @@ async function openDetail(memberId) {
313
  }
314
  }
315
 
316
- // ── Right-click context menu: switch AI strategy ───────────────────
317
  const ctxMenu = document.createElement('div');
318
  ctxMenu.id = 'strategy-menu';
319
  ctxMenu.style.cssText = `
320
  display:none; position:fixed; z-index:2000;
321
  background:var(--card-bg, #1e1e2e); border:1px solid var(--border, #444);
322
  border-radius:8px; padding:6px 0; box-shadow:0 4px 16px rgba(0,0,0,0.4);
323
- min-width:180px; font-size:13px;
324
  `;
325
  ctxMenu.innerHTML = `
326
- <div style="padding:6px 14px; color:var(--muted); font-size:11px; font-weight:bold;">AI Strategy</div>
327
- <div class="ctx-item" data-strategy="hybrid" style="padding:8px 14px; cursor:pointer;">Hybrid (LLM + NN1 + NN2)</div>
328
- <div class="ctx-item" data-strategy="hybrid-nn1" style="padding:8px 14px; cursor:pointer;">Hybrid (NN1 + LLM)</div>
329
- <div class="ctx-item" data-strategy="hybrid-nn2" style="padding:8px 14px; cursor:pointer;">Hybrid (NN2 + LLM)</div>
330
- <div style="border-top:1px solid var(--border, #333); margin:4px 0;"></div>
331
- <div class="ctx-item" data-strategy="nn1" style="padding:8px 14px; cursor:pointer;">All NN1</div>
332
- <div class="ctx-item" data-strategy="nn2" style="padding:8px 14px; cursor:pointer;">All NN2</div>
333
- <div class="ctx-item" data-strategy="llm" style="padding:8px 14px; cursor:pointer;">All LLM</div>
334
  `;
335
  document.body.appendChild(ctxMenu);
336
 
 
 
337
  // Style hover
338
  ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
339
  item.addEventListener('mouseenter', () => item.style.background = 'rgba(255,255,255,0.08)');
340
  item.addEventListener('mouseleave', () => item.style.background = 'none');
341
  });
342
 
343
- // Show on right-click anywhere on the leaderboard table
344
- document.getElementById('lb-table').addEventListener('contextmenu', (e) => {
345
- e.preventDefault();
346
- ctxMenu.style.display = 'block';
347
- const x = Math.min(e.clientX, window.innerWidth - 200);
348
- const y = Math.min(e.clientY, window.innerHeight - 140);
349
- ctxMenu.style.left = x + 'px';
350
- ctxMenu.style.top = y + 'px';
351
- highlightCurrentStrategy();
352
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
 
354
  // Hide on click elsewhere
355
  document.addEventListener('click', () => ctxMenu.style.display = 'none');
356
 
357
- const STRATEGY_LABELS = {
358
- 'hybrid': 'Hybrid (LLM + NN1 + NN2)',
359
- 'hybrid-nn1': 'Hybrid (NN1 + LLM)',
360
- 'hybrid-nn2': 'Hybrid (NN2 + LLM)',
361
- 'nn1': 'All NN1',
362
- 'nn2': 'All NN2',
363
- 'llm': 'All LLM',
364
- };
365
-
366
- async function highlightCurrentStrategy() {
367
- try {
368
- const resp = await fetch('/ch/api/config');
369
- const cfg = await resp.json();
370
- ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
371
- const isCurrent = item.dataset.strategy === cfg.strategy;
372
- item.style.fontWeight = isCurrent ? 'bold' : 'normal';
373
- item.style.color = isCurrent ? 'var(--accent, #42a5f5)' : 'var(--text, #ccc)';
374
- const base = STRATEGY_LABELS[item.dataset.strategy] || item.dataset.strategy;
375
- item.textContent = isCurrent ? base + ' \u2713' : base;
376
- });
377
- } catch(e) {}
378
- }
379
-
380
  ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
381
  item.addEventListener('click', async () => {
382
  ctxMenu.style.display = 'none';
 
383
  try {
384
- const resp = await fetch('/ch/api/strategy', {
385
  method: 'POST',
386
  headers: {'Content-Type': 'application/json'},
387
  body: JSON.stringify({strategy: item.dataset.strategy}),
 
84
  <p style="color:var(--muted); font-size:11px; margin-top:8px;">
85
  Daily obligation: each member must trade at least {{ obligation }} securities.
86
  Holdings value is calculated at current market mid-price.
87
+ Click a row for full member detail. Right-click a member's Type badge to switch their AI model. Refreshes every 10 seconds.
88
  </p>
89
 
90
  <!-- Member detail slide-out panel -->
 
170
  tr.style.cursor = 'pointer';
171
  tr.onclick = () => openDetail(tr.dataset.member);
172
  });
173
+ bindContextMenus();
174
  }
175
  bindRowClicks();
176
 
 
314
  }
315
  }
316
 
317
+ // ── Right-click context menu: per-member AI model switch ────────────
318
  const ctxMenu = document.createElement('div');
319
  ctxMenu.id = 'strategy-menu';
320
  ctxMenu.style.cssText = `
321
  display:none; position:fixed; z-index:2000;
322
  background:var(--card-bg, #1e1e2e); border:1px solid var(--border, #444);
323
  border-radius:8px; padding:6px 0; box-shadow:0 4px 16px rgba(0,0,0,0.4);
324
+ min-width:160px; font-size:13px;
325
  `;
326
  ctxMenu.innerHTML = `
327
+ <div id="ctx-title" style="padding:6px 14px; color:var(--muted); font-size:11px; font-weight:bold;">Switch AI Model</div>
328
+ <div class="ctx-item" data-strategy="llm" style="padding:8px 14px; cursor:pointer;">LLM</div>
329
+ <div class="ctx-item" data-strategy="nn1" style="padding:8px 14px; cursor:pointer;">NN1</div>
330
+ <div class="ctx-item" data-strategy="nn2" style="padding:8px 14px; cursor:pointer;">NN2</div>
 
 
 
 
331
  `;
332
  document.body.appendChild(ctxMenu);
333
 
334
+ let ctxMemberId = null; // which member the menu is open for
335
+
336
  // Style hover
337
  ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
338
  item.addEventListener('mouseenter', () => item.style.background = 'rgba(255,255,255,0.08)');
339
  item.addEventListener('mouseleave', () => item.style.background = 'none');
340
  });
341
 
342
+ function bindContextMenus() {
343
+ document.querySelectorAll('#lb-table tbody tr[data-member]').forEach(tr => {
344
+ // Right-click on the Type cell (3rd column)
345
+ const typeCell = tr.children[2];
346
+ if (!typeCell) return;
347
+ typeCell.addEventListener('contextmenu', (e) => {
348
+ e.preventDefault();
349
+ e.stopPropagation();
350
+ ctxMemberId = tr.dataset.member;
351
+ // If member is human, don't show menu
352
+ const badge = typeCell.querySelector('.badge-human');
353
+ if (badge) return;
354
+ ctxMenu.style.display = 'block';
355
+ const x = Math.min(e.clientX, window.innerWidth - 180);
356
+ const y = Math.min(e.clientY, window.innerHeight - 120);
357
+ ctxMenu.style.left = x + 'px';
358
+ ctxMenu.style.top = y + 'px';
359
+ document.getElementById('ctx-title').textContent = ctxMemberId + ' β€” AI Model';
360
+ // Highlight current strategy
361
+ const currentType = typeCell.textContent.trim().toLowerCase();
362
+ ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
363
+ const isCurrent = item.dataset.strategy === currentType;
364
+ item.style.fontWeight = isCurrent ? 'bold' : 'normal';
365
+ item.style.color = isCurrent ? 'var(--accent, #42a5f5)' : 'var(--text, #ccc)';
366
+ const label = item.dataset.strategy.toUpperCase();
367
+ item.textContent = isCurrent ? label + ' \u2713' : label;
368
+ });
369
+ });
370
+ });
371
+ }
372
+ bindContextMenus();
373
 
374
  // Hide on click elsewhere
375
  document.addEventListener('click', () => ctxMenu.style.display = 'none');
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  ctxMenu.querySelectorAll('.ctx-item').forEach(item => {
378
  item.addEventListener('click', async () => {
379
  ctxMenu.style.display = 'none';
380
+ if (!ctxMemberId) return;
381
  try {
382
+ const resp = await fetch(`/ch/api/member/${ctxMemberId}/strategy`, {
383
  method: 'POST',
384
  headers: {'Content-Type': 'application/json'},
385
  body: JSON.stringify({strategy: item.dataset.strategy}),
docker-compose.yml CHANGED
@@ -203,7 +203,6 @@ services:
203
  - GROQ_API_KEY=${GROQ_API_KEY:-}
204
  - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
205
  - OLLAMA_HOST=${OLLAMA_HOST:-}
206
- - CH_AI_STRATEGY=${CH_AI_STRATEGY:-hybrid}
207
  - CH_RL_MODEL_REPO_NN1=${CH_RL_MODEL_REPO_NN1:-Adilbai/stock-trading-rl-agent}
208
  - CH_RL_MODEL_REPO_NN2=${CH_RL_MODEL_REPO_NN2:-RayMelius/stockex-nn-agent}
209
  extra_hosts:
 
203
  - GROQ_API_KEY=${GROQ_API_KEY:-}
204
  - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
205
  - OLLAMA_HOST=${OLLAMA_HOST:-}
 
206
  - CH_RL_MODEL_REPO_NN1=${CH_RL_MODEL_REPO_NN1:-Adilbai/stock-trading-rl-agent}
207
  - CH_RL_MODEL_REPO_NN2=${CH_RL_MODEL_REPO_NN2:-RayMelius/stockex-nn-agent}
208
  extra_hosts: