RayMelius Claude Sonnet 4.6 commited on
Commit
e4043cb
·
1 Parent(s): 0d1f05c

Add AI Analyst service: Ollama (local) + HuggingFace (HF Spaces)

Browse files

New ai_analyst/ai_analyst.py service consumes trades/snapshots from
Kafka, builds a market-context prompt every 30 min, calls Ollama
(llama3.1:8b) locally or HuggingFace Inference API (Mistral-7B) as
fallback, and publishes insights to the ai_insights Kafka topic.

Dashboard consumes ai_insights and broadcasts via SSE. New full-width
AI Analyst panel displays timestamped insights with animated cards.

- ai_analyst/ai_analyst.py: new service (Ollama-first, HF fallback)
- ai_analyst/Dockerfile: minimal Python image for local compose
- shared/config.py: add AI_INSIGHTS_TOPIC
- dashboard/dashboard.py: consume ai_insights, SSE broadcast
- dashboard/templates/index.html: AI Analyst panel + JS handlers
- Dockerfile: copy ai_analyst.py into HF single-container image
- entrypoint.sh: create ai_insights topic, start ai_analyst.py
- docker-compose.yml: ai_analyst service with OLLAMA_HOST + HF_TOKEN

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

Dockerfile CHANGED
@@ -70,6 +70,9 @@ COPY fix-ui-client/fix-ui-client.py /app/fix_ui/fix_ui_client.py
70
  COPY fix-ui-client/templates/ /app/fix_ui/templates/
71
  COPY client_hf.cfg /app/fix_ui/client_hf.cfg
72
 
 
 
 
73
  # ── Kafka KRaft configuration ─────────────────────────────────────────────────
74
  COPY kafka-kraft.properties /opt/kafka/config/kraft/server.properties
75
 
 
70
  COPY fix-ui-client/templates/ /app/fix_ui/templates/
71
  COPY client_hf.cfg /app/fix_ui/client_hf.cfg
72
 
73
+ # AI Analyst service
74
+ COPY ai_analyst/ai_analyst.py /app/ai_analyst.py
75
+
76
  # ── Kafka KRaft configuration ─────────────────────────────────────────────────
77
  COPY kafka-kraft.properties /opt/kafka/config/kraft/server.properties
78
 
ai_analyst/Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN pip install --no-cache-dir kafka-python==2.0.2 requests==2.31.0
4
+
5
+ WORKDIR /app
6
+ COPY ai_analyst.py .
7
+
8
+ ENV PYTHONPATH=/app
9
+
10
+ CMD ["python", "-u", "ai_analyst.py"]
ai_analyst/ai_analyst.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ sys.path.insert(0, "/app")
3
+
4
+ import threading, time, os, json, datetime, requests
5
+ from collections import deque
6
+
7
+ from shared.config import Config
8
+ from shared.kafka_utils import create_producer, create_consumer
9
+
10
+ # ── Config ─────────────────────────────────────────────────────────────────────
11
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "") # e.g. http://host.docker.internal:11434
12
+ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
13
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
14
+ HF_MODEL = os.getenv("HF_MODEL", "mistralai/Mistral-7B-Instruct-v0.2")
15
+ ANALYSIS_INTERVAL = int(os.getenv("ANALYSIS_INTERVAL", "1800")) # 30 min default
16
+
17
+ # ── Rolling market data buffers ────────────────────────────────────────────────
18
+ recent_trades = deque(maxlen=200)
19
+ latest_snapshots = {} # symbol -> snapshot dict
20
+ lock = threading.Lock()
21
+
22
+ _running = False
23
+ _suspended = False
24
+
25
+ # ── LLM call ──────────────────────────────────────────────────────────────────
26
+
27
+ def call_llm(prompt: str) -> str | None:
28
+ """Try Ollama first, fall back to HuggingFace Inference API."""
29
+
30
+ # 1. Ollama (local)
31
+ if OLLAMA_HOST:
32
+ try:
33
+ resp = requests.post(
34
+ f"{OLLAMA_HOST}/api/chat",
35
+ json={
36
+ "model": OLLAMA_MODEL,
37
+ "messages": [{"role": "user", "content": prompt}],
38
+ "stream": False,
39
+ },
40
+ timeout=90,
41
+ )
42
+ if resp.status_code == 200:
43
+ text = resp.json().get("message", {}).get("content", "").strip()
44
+ if text:
45
+ print(f"[AI-Analyst] Insight via Ollama ({OLLAMA_MODEL})")
46
+ return text
47
+ else:
48
+ print(f"[AI-Analyst] Ollama HTTP {resp.status_code}: {resp.text[:200]}")
49
+ except Exception as e:
50
+ print(f"[AI-Analyst] Ollama unreachable: {e}")
51
+
52
+ # 2. HuggingFace Inference API
53
+ if HF_TOKEN:
54
+ try:
55
+ url = f"https://api-inference.huggingface.co/models/{HF_MODEL}/v1/chat/completions"
56
+ resp = requests.post(
57
+ url,
58
+ headers={"Authorization": f"Bearer {HF_TOKEN}"},
59
+ json={
60
+ "model": HF_MODEL,
61
+ "messages": [{"role": "user", "content": prompt}],
62
+ "max_tokens": 220,
63
+ "temperature": 0.7,
64
+ },
65
+ timeout=45,
66
+ )
67
+ if resp.status_code == 200:
68
+ text = resp.json()["choices"][0]["message"]["content"].strip()
69
+ if text:
70
+ print(f"[AI-Analyst] Insight via HuggingFace ({HF_MODEL})")
71
+ return text
72
+ else:
73
+ print(f"[AI-Analyst] HF HTTP {resp.status_code}: {resp.text[:300]}")
74
+ except Exception as e:
75
+ print(f"[AI-Analyst] HF API error: {e}")
76
+
77
+ return None
78
+
79
+
80
+ # ── Prompt builder ─────────────────────────────────────────────────────────────
81
+
82
+ def build_prompt() -> str:
83
+ now = datetime.datetime.now().strftime("%H:%M:%S")
84
+ cutoff = time.time() - ANALYSIS_INTERVAL
85
+
86
+ with lock:
87
+ trades_snap = list(recent_trades)
88
+ snaps_snap = dict(latest_snapshots)
89
+ session_str = ("ACTIVE" if _running and not _suspended
90
+ else "SUSPENDED" if _suspended else "IDLE")
91
+
92
+ # Recent trades summary per symbol
93
+ recent = [t for t in trades_snap if float(t.get("timestamp", 0)) >= cutoff]
94
+ interval_label = f"{ANALYSIS_INTERVAL // 60} min" if ANALYSIS_INTERVAL >= 60 else f"{ANALYSIS_INTERVAL}s"
95
+ if recent:
96
+ by_sym: dict = {}
97
+ for t in recent:
98
+ sym = t.get("symbol", "?")
99
+ by_sym.setdefault(sym, []).append(t)
100
+ trade_lines = []
101
+ for sym, ts in sorted(by_sym.items()):
102
+ prices = [float(t.get("price", 0)) for t in ts]
103
+ vol = sum(int(t.get("quantity") or t.get("qty") or 0) for t in ts)
104
+ trade_lines.append(
105
+ f" {sym}: {len(ts)} trade(s), "
106
+ f"range {min(prices):.2f}–{max(prices):.2f}, "
107
+ f"vol {vol}, last {prices[-1]:.2f}"
108
+ )
109
+ trades_block = "\n".join(trade_lines)
110
+ else:
111
+ trades_block = " No trades in the last interval"
112
+
113
+ # Order book snapshot
114
+ if snaps_snap:
115
+ book_lines = []
116
+ for sym, snap in sorted(snaps_snap.items()):
117
+ bid = snap.get("best_bid")
118
+ ask = snap.get("best_ask")
119
+ if bid and ask:
120
+ spread = float(ask) - float(bid)
121
+ book_lines.append(f" {sym}: Bid {bid} / Ask {ask} (spread {spread:.2f})")
122
+ else:
123
+ book_lines.append(f" {sym}: Bid {bid or '-'} / Ask {ask or '-'}")
124
+ book_block = "\n".join(book_lines)
125
+ else:
126
+ book_block = " No order book data yet"
127
+
128
+ return f"""You are a concise financial market analyst for a simulated stock exchange.
129
+ Time: {now} | Session: {session_str}
130
+
131
+ Trades in the last {interval_label}:
132
+ {trades_block}
133
+
134
+ Order book (best bid/ask):
135
+ {book_block}
136
+
137
+ In 3–4 sentences analyse: activity level, notable price moves or volume spikes, market sentiment.
138
+ Be specific and data-driven. No headers, no bullet points, plain prose only."""
139
+
140
+
141
+ # ── Kafka consumer (market data) ──────────────────────────────────────────────
142
+
143
+ def consume_market_data():
144
+ global _running, _suspended
145
+ consumer = create_consumer(
146
+ topics=[
147
+ Config.TRADES_TOPIC,
148
+ Config.SNAPSHOTS_TOPIC,
149
+ Config.CONTROL_TOPIC,
150
+ ],
151
+ group_id="ai-analyst",
152
+ component_name="AI-Analyst",
153
+ auto_offset_reset="latest",
154
+ )
155
+ for msg in consumer:
156
+ with lock:
157
+ if msg.topic == Config.TRADES_TOPIC:
158
+ recent_trades.append(msg.value)
159
+ elif msg.topic == Config.SNAPSHOTS_TOPIC:
160
+ snap = msg.value
161
+ sym = snap.get("symbol")
162
+ if sym:
163
+ latest_snapshots[sym] = snap
164
+ elif msg.topic == Config.CONTROL_TOPIC:
165
+ cmd = msg.value.get("command", "")
166
+ if cmd == "start":
167
+ _running = True
168
+ _suspended = False
169
+ elif cmd in ("end", "stop"):
170
+ _running = False
171
+ elif cmd == "suspend":
172
+ _suspended = True
173
+ elif cmd == "resume":
174
+ _suspended = False
175
+
176
+
177
+ # ── Analysis loop ──────────────────────────────────────────────────────────────
178
+
179
+ def analysis_loop(producer):
180
+ print(f"[AI-Analyst] Analysis loop started (interval={ANALYSIS_INTERVAL}s)")
181
+ if OLLAMA_HOST:
182
+ print(f"[AI-Analyst] Ollama: {OLLAMA_HOST} model: {OLLAMA_MODEL}")
183
+ if HF_TOKEN:
184
+ print(f"[AI-Analyst] HuggingFace fallback: model={HF_MODEL}")
185
+ if not OLLAMA_HOST and not HF_TOKEN:
186
+ print("[AI-Analyst] WARNING: neither OLLAMA_HOST nor HF_TOKEN configured — no insights will be generated")
187
+
188
+ while True:
189
+ time.sleep(ANALYSIS_INTERVAL)
190
+
191
+ with lock:
192
+ active = _running and not _suspended
193
+
194
+ if not active:
195
+ continue
196
+
197
+ prompt = build_prompt()
198
+ text = call_llm(prompt)
199
+
200
+ if text:
201
+ insight = {"text": text, "timestamp": time.time()}
202
+ producer.send(Config.AI_INSIGHTS_TOPIC, insight)
203
+ producer.flush()
204
+ print(f"[AI-Analyst] Published insight ({len(text)} chars)")
205
+
206
+
207
+ # ── Entry point ─────────────────────────────────────────────────────────────────
208
+
209
+ if __name__ == "__main__":
210
+ producer = create_producer(component_name="AI-Analyst")
211
+ threading.Thread(target=consume_market_data, daemon=True).start()
212
+ analysis_loop(producer)
dashboard/dashboard.py CHANGED
@@ -13,6 +13,7 @@ app.config["TEMPLATES_AUTO_RELOAD"] = True
13
 
14
  # Shared state
15
  orders, bbos, trades_cache = [], {}, []
 
16
  lock = threading.Lock()
17
 
18
  # SSE: list of queues for connected clients
@@ -128,7 +129,7 @@ def broadcast_event(event_type, data):
128
 
129
  def consume_kafka():
130
  consumer = create_consumer(
131
- topics=[Config.ORDERS_TOPIC, Config.SNAPSHOTS_TOPIC, Config.TRADES_TOPIC],
132
  group_id="dashboard",
133
  component_name="Dashboard",
134
  )
@@ -168,6 +169,12 @@ def consume_kafka():
168
  ts = float(trade.get("timestamp") or time.time())
169
  record_trade(sym, price, qty, ts)
170
 
 
 
 
 
 
 
171
 
172
  # Initialise DB then start consumer thread
173
  init_history_db()
@@ -511,6 +518,7 @@ def stream():
511
  f"event: init\ndata: "
512
  f"{json.dumps({'orders': list(orders), 'bbos': dict(bbos), 'trades': list(trades_cache)})}\n\n"
513
  )
 
514
  # Also send current session state
515
  if not session_state["active"]:
516
  _sess_status = "ended"
 
13
 
14
  # Shared state
15
  orders, bbos, trades_cache = [], {}, []
16
+ ai_insights_cache = []
17
  lock = threading.Lock()
18
 
19
  # SSE: list of queues for connected clients
 
129
 
130
  def consume_kafka():
131
  consumer = create_consumer(
132
+ topics=[Config.ORDERS_TOPIC, Config.SNAPSHOTS_TOPIC, Config.TRADES_TOPIC, Config.AI_INSIGHTS_TOPIC],
133
  group_id="dashboard",
134
  component_name="Dashboard",
135
  )
 
169
  ts = float(trade.get("timestamp") or time.time())
170
  record_trade(sym, price, qty, ts)
171
 
172
+ elif msg.topic == Config.AI_INSIGHTS_TOPIC:
173
+ insight = msg.value
174
+ ai_insights_cache.insert(0, insight)
175
+ ai_insights_cache[:] = ai_insights_cache[:10]
176
+ broadcast_event("ai_insight", insight)
177
+
178
 
179
  # Initialise DB then start consumer thread
180
  init_history_db()
 
518
  f"event: init\ndata: "
519
  f"{json.dumps({'orders': list(orders), 'bbos': dict(bbos), 'trades': list(trades_cache)})}\n\n"
520
  )
521
+ yield f"event: ai_insights_init\ndata: {json.dumps(list(ai_insights_cache))}\n\n"
522
  # Also send current session state
523
  if not session_state["active"]:
524
  _sess_status = "ended"
dashboard/templates/index.html CHANGED
@@ -48,6 +48,30 @@
48
  from { background: yellow; }
49
  to { background: transparent; }
50
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  /* Connection status indicator */
52
  .status {
53
  display: inline-flex;
@@ -352,6 +376,20 @@
352
 
353
  </div>
354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  <script>
356
  // State
357
  const state = {
@@ -390,6 +428,18 @@
390
  // Selected order state
391
  let selectedOrder = null;
392
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  function renderOrders() {
394
  const tbody = document.getElementById("orders-body");
395
  tbody.innerHTML = "";
@@ -1036,6 +1086,18 @@
1036
  updateModeBtn(data.mode);
1037
  });
1038
 
 
 
 
 
 
 
 
 
 
 
 
 
1039
  eventSource.onerror = () => {
1040
  setStatus("disconnected", "Disconnected");
1041
  state.connected = false;
 
48
  from { background: yellow; }
49
  to { background: transparent; }
50
  }
51
+
52
+ /* AI Insights panel */
53
+ .ai-panel {
54
+ background: #fff;
55
+ border-radius: 8px;
56
+ padding: 10px 14px;
57
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
58
+ margin-top: 20px;
59
+ }
60
+ .insight-card {
61
+ padding: 9px 12px;
62
+ border-left: 3px solid #5c6bc0;
63
+ margin-bottom: 8px;
64
+ background: #f8f9ff;
65
+ border-radius: 0 4px 4px 0;
66
+ font-size: 13px;
67
+ line-height: 1.6;
68
+ }
69
+ .insight-time { font-size: 11px; color: #999; margin-bottom: 3px; }
70
+ @keyframes fadeInDown {
71
+ from { opacity: 0; transform: translateY(-5px); }
72
+ to { opacity: 1; transform: translateY(0); }
73
+ }
74
+ .insight-new { animation: fadeInDown 0.4s ease; }
75
  /* Connection status indicator */
76
  .status {
77
  display: inline-flex;
 
376
 
377
  </div>
378
 
379
+ <!-- AI Analyst panel (full width) -->
380
+ <div class="ai-panel">
381
+ <h2 style="margin:0 0 8px; font-size:15px; display:flex; align-items:center; gap:10px;">
382
+ AI Analyst
383
+ <span id="ai-model-badge" style="font-size:10px; color:#fff; background:#5c6bc0; padding:2px 8px; border-radius:10px; font-weight:normal;"></span>
384
+ <span id="ai-status" style="font-size:11px; color:#999; font-weight:normal; margin-left:4px;">waiting for first insight…</span>
385
+ </h2>
386
+ <div id="ai-insights-list" style="max-height:220px; overflow-y:auto;">
387
+ <div class="insight-card" style="color:#bbb; border-left-color:#ddd; background:#fafafa;" id="ai-placeholder">
388
+ No insights yet — insights are generated every 30 min when the session is active.
389
+ </div>
390
+ </div>
391
+ </div>
392
+
393
  <script>
394
  // State
395
  const state = {
 
428
  // Selected order state
429
  let selectedOrder = null;
430
 
431
+ function addInsight(insight) {
432
+ const list = document.getElementById("ai-insights-list");
433
+ const ph = document.getElementById("ai-placeholder");
434
+ if (ph) ph.remove();
435
+ const div = document.createElement("div");
436
+ div.className = "insight-card insight-new";
437
+ const t = new Date(insight.timestamp * 1000).toLocaleTimeString();
438
+ div.innerHTML = `<div class="insight-time">${t}</div><div>${insight.text}</div>`;
439
+ list.prepend(div);
440
+ while (list.children.length > 10) list.removeChild(list.lastChild);
441
+ }
442
+
443
  function renderOrders() {
444
  const tbody = document.getElementById("orders-body");
445
  tbody.innerHTML = "";
 
1086
  updateModeBtn(data.mode);
1087
  });
1088
 
1089
+ eventSource.addEventListener("ai_insights_init", (e) => {
1090
+ const insights = JSON.parse(e.data);
1091
+ insights.forEach(addInsight);
1092
+ });
1093
+
1094
+ eventSource.addEventListener("ai_insight", (e) => {
1095
+ const insight = JSON.parse(e.data);
1096
+ addInsight(insight);
1097
+ document.getElementById("ai-status").textContent =
1098
+ "Last update: " + new Date().toLocaleTimeString();
1099
+ });
1100
+
1101
  eventSource.onerror = () => {
1102
  setStatus("disconnected", "Disconnected");
1103
  state.connected = false;
docker-compose.yml CHANGED
@@ -146,6 +146,23 @@ services:
146
  depends_on:
147
  - fix_oeg
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  dashboard:
150
  build:
151
  context: ./dashboard
 
146
  depends_on:
147
  - fix_oeg
148
 
149
+ ai_analyst:
150
+ build: ./ai_analyst
151
+ container_name: ai_analyst
152
+ depends_on:
153
+ - kafka
154
+ volumes:
155
+ - ./shared:/app/shared
156
+ environment:
157
+ - KAFKA_BOOTSTRAP=kafka:9092
158
+ - OLLAMA_HOST=http://host.docker.internal:11434
159
+ - OLLAMA_MODEL=llama3.1:8b
160
+ - HF_TOKEN=${HF_TOKEN:-}
161
+ - HF_MODEL=${HF_MODEL:-mistralai/Mistral-7B-Instruct-v0.2}
162
+ - ANALYSIS_INTERVAL=1800
163
+ extra_hosts:
164
+ - "host.docker.internal:host-gateway"
165
+
166
  dashboard:
167
  build:
168
  context: ./dashboard
entrypoint.sh CHANGED
@@ -39,7 +39,7 @@ done
39
  echo "[startup] Kafka ready."
40
 
41
  # Create topics
42
- for TOPIC in orders trades snapshots control; do
43
  $KAFKA_DIR/bin/kafka-topics.sh \
44
  --create --if-not-exists \
45
  --topic "$TOPIC" \
@@ -65,6 +65,10 @@ echo "[startup] Starting FIX UI Client on port 5002..."
65
  python3 /app/fix_ui/fix_ui_client.py &
66
  sleep 3
67
 
 
 
 
 
68
  echo "[startup] Starting Frontend on port 5003..."
69
  PORT=$FRONTEND_PORT TEMPLATE_FOLDER=/app/frontend_templates python3 /app/frontend.py &
70
  sleep 2
 
39
  echo "[startup] Kafka ready."
40
 
41
  # Create topics
42
+ for TOPIC in orders trades snapshots control ai_insights; do
43
  $KAFKA_DIR/bin/kafka-topics.sh \
44
  --create --if-not-exists \
45
  --topic "$TOPIC" \
 
65
  python3 /app/fix_ui/fix_ui_client.py &
66
  sleep 3
67
 
68
+ echo "[startup] Starting AI Analyst (interval=1800s)..."
69
+ python3 /app/ai_analyst.py &
70
+ sleep 1
71
+
72
  echo "[startup] Starting Frontend on port 5003..."
73
  PORT=$FRONTEND_PORT TEMPLATE_FOLDER=/app/frontend_templates python3 /app/frontend.py &
74
  sleep 2
shared/config.py CHANGED
@@ -29,6 +29,9 @@ class Config:
29
  # Control topic for start/end of day signals
30
  CONTROL_TOPIC: str = os.getenv("CONTROL_TOPIC", "control")
31
 
 
 
 
32
  # Trading simulation
33
  TICK_SIZE: float = float(os.getenv("TICK_SIZE", "0.05"))
34
  ORDERS_PER_MIN: int = int(os.getenv("ORDERS_PER_MIN", "8"))
 
29
  # Control topic for start/end of day signals
30
  CONTROL_TOPIC: str = os.getenv("CONTROL_TOPIC", "control")
31
 
32
+ # AI Analyst insights topic
33
+ AI_INSIGHTS_TOPIC: str = os.getenv("AI_INSIGHTS_TOPIC", "ai_insights")
34
+
35
  # Trading simulation
36
  TICK_SIZE: float = float(os.getenv("TICK_SIZE", "0.05"))
37
  ORDERS_PER_MIN: int = int(os.getenv("ORDERS_PER_MIN", "8"))