Add AI Analyst service: Ollama (local) + HuggingFace (HF Spaces)
Browse filesNew 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 +3 -0
- ai_analyst/Dockerfile +10 -0
- ai_analyst/ai_analyst.py +212 -0
- dashboard/dashboard.py +9 -1
- dashboard/templates/index.html +62 -0
- docker-compose.yml +17 -0
- entrypoint.sh +5 -1
- shared/config.py +3 -0
|
@@ -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 |
|
|
@@ -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"]
|
|
@@ -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)
|
|
@@ -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"
|
|
@@ -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;
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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"))
|