RayMelius Claude Opus 4.6 commited on
Commit
8f253b3
·
1 Parent(s): ddfaae2

Add clearing house simulation with 10 members (USR01-USR10)

Browse files

- New clearing_house/ Flask service (port 5004) with dedicated dark-themed portal
- SQLite persistence: member capital, holdings, daily trade counters, settlements
- Members start with €100,000; must trade ≥10 securities per trading day
- Password = member ID (e.g. USR01 logs in with password USR01)
- Real users log in to trade manually; AI (LLM via Groq→HF→Ollama) simulates unoccupied members
- Trade attribution via Kafka trades consumer — detects USRxx- cl_ord_id prefix
- EOD settlement triggered by dashboard session end; calculates unrealized P&L
- Leaderboard at /ch/ with real-time auto-refresh and Human/AI badges
- Member portfolio view: holdings, order entry, trade history, settlement history
- Integrated into nginx (/ch/), entrypoint.sh, docker-compose, and HF Dockerfile

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

Dockerfile CHANGED
@@ -73,6 +73,9 @@ COPY client_hf.cfg /app/fix_ui/client_hf.cfg
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
 
 
73
  # AI Analyst service
74
  COPY ai_analyst/ai_analyst.py /app/ai_analyst.py
75
 
76
+ # Clearing House service
77
+ COPY clearing_house/ /app/clearing_house/
78
+
79
  # ── Kafka KRaft configuration ─────────────────────────────────────────────────
80
  COPY kafka-kraft.properties /opt/kafka/config/kraft/server.properties
81
 
clearing_house/Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN pip install --no-cache-dir flask kafka-python requests
4
+
5
+ WORKDIR /app
6
+
7
+ # shared/ is mounted as /app/shared at runtime (via docker-compose volume)
8
+ # Copy clearing house service files
9
+ COPY clearing_house/ /app/clearing_house/
10
+
11
+ RUN mkdir -p /app/data
12
+
13
+ WORKDIR /app/clearing_house
14
+
15
+ ENV PYTHONPATH=/app
16
+
17
+ CMD ["python", "app.py"]
clearing_house/__init__.py ADDED
File without changes
clearing_house/app.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clearing House portal — Flask service on port 5004.
2
+
3
+ Routes are mounted at /ch/ so nginx can proxy /ch/ → this service
4
+ without stripping the prefix (proxy_pass http://...:5004).
5
+ """
6
+
7
+ import sys
8
+ sys.path.insert(0, "/app")
9
+
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ import datetime
15
+ from queue import Empty, Queue
16
+
17
+ import requests
18
+ from flask import (
19
+ Flask, Response, jsonify, redirect, render_template,
20
+ request, session, stream_with_context, url_for,
21
+ )
22
+
23
+ from shared.config import Config
24
+ from shared.kafka_utils import create_producer
25
+
26
+ import ch_database as db
27
+ import ch_ai_trader as ai_trader
28
+
29
+ # ── App setup ──────────────────────────────────────────────────────────────────
30
+ app = Flask(__name__, template_folder="templates")
31
+ app.secret_key = os.getenv("CH_SECRET_KEY", "ch-stockex-secret-2024")
32
+ app.config["TEMPLATES_AUTO_RELOAD"] = True
33
+
34
+
35
+ @app.template_filter("ts")
36
+ def ts_filter(ts):
37
+ """Format a Unix timestamp as HH:MM:SS."""
38
+ return datetime.datetime.fromtimestamp(float(ts)).strftime("%H:%M:%S")
39
+
40
+ MATCHER_URL = os.getenv("MATCHER_URL", Config.MATCHER_URL)
41
+ SECURITIES_FILE = os.getenv("SECURITIES_FILE", "/app/data/securities.txt")
42
+
43
+ # SSE clients
44
+ _sse_clients: list[Queue] = []
45
+ _sse_lock = threading.Lock()
46
+
47
+ _producer = None
48
+
49
+
50
+ def get_producer():
51
+ global _producer
52
+ if _producer is None:
53
+ _producer = create_producer(component_name="CH-App")
54
+ return _producer
55
+
56
+
57
+ # ── Helpers ────────────────────────────────────────────────────────────────────
58
+
59
+ def _load_symbols() -> list[str]:
60
+ symbols = []
61
+ try:
62
+ with open(SECURITIES_FILE) as f:
63
+ for line in f:
64
+ line = line.strip()
65
+ if line and not line.startswith("#"):
66
+ parts = line.split()
67
+ if parts:
68
+ symbols.append(parts[0])
69
+ except Exception:
70
+ pass
71
+ return symbols
72
+
73
+
74
+ def _get_bbos() -> dict:
75
+ symbols = _load_symbols()
76
+ bbos = {}
77
+ for sym in symbols:
78
+ try:
79
+ r = requests.get(f"{MATCHER_URL}/orderbook/{sym}", timeout=2)
80
+ if r.status_code == 200:
81
+ book = r.json()
82
+ bids = book.get("bids", [])
83
+ asks = book.get("asks", [])
84
+ best_bid = max((b["price"] for b in bids), default=None)
85
+ best_ask = min((a["price"] for a in asks), default=None)
86
+ mid = round((best_bid + best_ask) / 2, 2) if best_bid and best_ask else None
87
+ bbos[sym] = {"best_bid": best_bid, "best_ask": best_ask, "mid": mid}
88
+ except Exception:
89
+ pass
90
+ return bbos
91
+
92
+
93
+ def _broadcast(event_type: str, data: dict):
94
+ msg = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
95
+ with _sse_lock:
96
+ for q in _sse_clients:
97
+ try:
98
+ q.put_nowait(msg)
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ def _build_leaderboard(bbos: dict) -> list[dict]:
104
+ rows = db.get_leaderboard()
105
+ for row in rows:
106
+ holdings_value = sum(
107
+ h["quantity"] * (bbos.get(h["symbol"], {}).get("mid") or h["avg_cost"])
108
+ for h in row["holdings"]
109
+ )
110
+ row["holdings_value"] = round(holdings_value, 2)
111
+ row["total_value"] = round(row["capital"] + holdings_value, 2)
112
+ row["pnl"] = round(row["total_value"] - db.CH_STARTING_CAPITAL, 2)
113
+ row["is_human"] = ai_trader.is_human_active(row["member_id"])
114
+ # Sort by total_value descending
115
+ rows.sort(key=lambda r: r["total_value"], reverse=True)
116
+ for i, row in enumerate(rows):
117
+ row["rank"] = i + 1
118
+ return rows
119
+
120
+
121
+ # ── Auth helpers ───────────────────────────────────────────────────────────────
122
+
123
+ def current_member() -> str | None:
124
+ return session.get("member_id")
125
+
126
+
127
+ def require_login():
128
+ if not current_member():
129
+ return redirect(url_for("login_page"))
130
+ return None
131
+
132
+
133
+ # ── Routes ─────────────────────────────────────────────────────────────────────
134
+
135
+ @app.route("/ch/")
136
+ def index():
137
+ bbos = _get_bbos()
138
+ leaderboard = _build_leaderboard(bbos)
139
+ member = current_member()
140
+ return render_template(
141
+ "dashboard.html",
142
+ leaderboard=leaderboard,
143
+ symbols=list(bbos.keys()),
144
+ member=member,
145
+ obligation=db.CH_DAILY_OBLIGATION,
146
+ )
147
+
148
+
149
+ @app.route("/ch/login", methods=["GET"])
150
+ def login_page():
151
+ if current_member():
152
+ return redirect(url_for("portfolio"))
153
+ return render_template("login.html", members=db.CH_MEMBERS)
154
+
155
+
156
+ @app.route("/ch/login", methods=["POST"])
157
+ def login_post():
158
+ member_id = request.form.get("member_id", "").upper().strip()
159
+ password = request.form.get("password", "").strip()
160
+
161
+ if not db.verify_password(member_id, password):
162
+ return render_template("login.html", members=db.CH_MEMBERS, error="Invalid credentials.")
163
+
164
+ session["member_id"] = member_id
165
+ ai_trader.set_human_active(member_id)
166
+ _broadcast("member_login", {"member_id": member_id})
167
+ return redirect(url_for("portfolio"))
168
+
169
+
170
+ @app.route("/ch/logout", methods=["POST"])
171
+ def logout():
172
+ mid = current_member()
173
+ if mid:
174
+ ai_trader.set_human_inactive(mid)
175
+ session.pop("member_id", None)
176
+ _broadcast("member_logout", {"member_id": mid})
177
+ return redirect(url_for("index"))
178
+
179
+
180
+ @app.route("/ch/portfolio")
181
+ def portfolio():
182
+ redir = require_login()
183
+ if redir:
184
+ return redir
185
+ mid = current_member()
186
+ member = db.get_member(mid)
187
+ holdings = db.get_holdings(mid)
188
+ daily = db.get_daily_trades(mid)
189
+ trades = db.get_trade_log(mid)
190
+ settlements = db.get_settlements(mid, limit=10)
191
+ bbos = _get_bbos()
192
+
193
+ # Enrich holdings with current prices and P&L
194
+ for h in holdings:
195
+ bbo = bbos.get(h["symbol"], {})
196
+ current_price = bbo.get("mid") or h["avg_cost"]
197
+ h["current_price"] = round(current_price, 2)
198
+ h["unrealized_pnl"] = round((current_price - h["avg_cost"]) * h["quantity"], 2)
199
+ h["value"] = round(current_price * h["quantity"], 2)
200
+
201
+ total_holdings_value = sum(h["value"] for h in holdings)
202
+ total_value = round(member["capital"] + total_holdings_value, 2)
203
+ total_pnl = round(total_value - db.CH_STARTING_CAPITAL, 2)
204
+
205
+ return render_template(
206
+ "portfolio.html",
207
+ member=mid,
208
+ capital=round(member["capital"], 2),
209
+ holdings=holdings,
210
+ daily=daily,
211
+ trades=trades,
212
+ settlements=settlements,
213
+ symbols=list(bbos.keys()),
214
+ obligation=db.CH_DAILY_OBLIGATION,
215
+ total_value=total_value,
216
+ total_pnl=total_pnl,
217
+ )
218
+
219
+
220
+ @app.route("/ch/order", methods=["POST"])
221
+ def submit_order():
222
+ redir = require_login()
223
+ if redir:
224
+ return jsonify({"error": "Not logged in"}), 401
225
+
226
+ mid = current_member()
227
+ data = request.get_json(force=True)
228
+
229
+ symbol = str(data.get("symbol", "")).upper()
230
+ side = str(data.get("side", "")).upper()
231
+ quantity = int(data.get("quantity", 0))
232
+ price = float(data.get("price", 0))
233
+
234
+ if side not in ("BUY", "SELL") or quantity <= 0 or price <= 0 or not symbol:
235
+ return jsonify({"error": "Invalid order parameters"}), 400
236
+
237
+ member = db.get_member(mid)
238
+ if not member:
239
+ return jsonify({"error": "Member not found"}), 404
240
+
241
+ # Capital check
242
+ if side == "BUY" and quantity * price > member["capital"]:
243
+ return jsonify({"error": f"Insufficient capital (have €{member['capital']:.2f})"}), 400
244
+
245
+ # Holdings check
246
+ if side == "SELL":
247
+ holding = db.get_holding(mid, symbol)
248
+ if holding["quantity"] < quantity:
249
+ return jsonify({"error": f"Insufficient holdings ({holding['quantity']} shares held)"}), 400
250
+
251
+ cl_ord_id = f"{mid}-{int(time.time()*1000)}-H"
252
+ msg = {
253
+ "cl_ord_id": cl_ord_id,
254
+ "symbol": symbol,
255
+ "side": side,
256
+ "quantity": quantity,
257
+ "price": price,
258
+ "ord_type": "LIMIT",
259
+ "time_in_force": "DAY",
260
+ "timestamp": time.time(),
261
+ "source": "CLEARINGHOUSE",
262
+ }
263
+ get_producer().send(Config.ORDERS_TOPIC, msg)
264
+ return jsonify({"status": "ok", "cl_ord_id": cl_ord_id})
265
+
266
+
267
+ @app.route("/ch/eod", methods=["POST"])
268
+ def eod_settlement():
269
+ """Called by dashboard at end-of-day to settle all CH members."""
270
+ bbos = _get_bbos()
271
+ today = db.today_str()
272
+ results = []
273
+
274
+ members = db.get_all_members()
275
+ for member in members:
276
+ mid = member["member_id"]
277
+ capital = member["capital"]
278
+ holdings = db.get_holdings(mid)
279
+ daily = db.get_daily_trades(mid, today)
280
+
281
+ # Unrealized P&L: current market value vs avg cost
282
+ unrealized_pnl = 0.0
283
+ for h in holdings:
284
+ bbo = bbos.get(h["symbol"], {})
285
+ current_price = bbo.get("mid") or h["avg_cost"]
286
+ unrealized_pnl += (current_price - h["avg_cost"]) * h["quantity"]
287
+
288
+ # Realized P&L approximation: capital change from starting capital
289
+ # minus current holdings cost basis
290
+ cost_basis = sum(h["quantity"] * h["avg_cost"] for h in holdings)
291
+ realized_pnl = round(capital - db.CH_STARTING_CAPITAL + cost_basis, 2)
292
+
293
+ obligation_met = daily["total_securities"] >= db.CH_DAILY_OBLIGATION
294
+
295
+ db.record_settlement(
296
+ member_id=mid,
297
+ trading_date=today,
298
+ opening_capital=db.CH_STARTING_CAPITAL,
299
+ closing_capital=round(capital, 2),
300
+ realized_pnl=round(realized_pnl, 2),
301
+ unrealized_pnl=round(unrealized_pnl, 2),
302
+ obligation_met=obligation_met,
303
+ )
304
+
305
+ results.append({
306
+ "member_id": mid,
307
+ "capital": round(capital, 2),
308
+ "unrealized_pnl": round(unrealized_pnl, 2),
309
+ "obligation_met": obligation_met,
310
+ })
311
+
312
+ if not obligation_met:
313
+ print(f"[CH-EOD] {mid} did NOT meet daily obligation "
314
+ f"({daily['total_securities']}/{db.CH_DAILY_OBLIGATION})")
315
+
316
+ _broadcast("eod_settlement", {"date": today, "count": len(results)})
317
+ print(f"[CH-EOD] Settlement complete for {len(results)} members on {today}")
318
+ return jsonify({"status": "ok", "settlements": results})
319
+
320
+
321
+ # ── JSON API ───────────────────────────────────────────────────────────────────
322
+
323
+ @app.route("/ch/api/leaderboard")
324
+ def api_leaderboard():
325
+ bbos = _get_bbos()
326
+ return jsonify(_build_leaderboard(bbos))
327
+
328
+
329
+ @app.route("/ch/api/portfolio")
330
+ def api_portfolio():
331
+ mid = current_member()
332
+ if not mid:
333
+ return jsonify({"error": "Not logged in"}), 401
334
+ bbos = _get_bbos()
335
+ member = db.get_member(mid)
336
+ holdings = db.get_holdings(mid)
337
+ daily = db.get_daily_trades(mid)
338
+ for h in holdings:
339
+ bbo = bbos.get(h["symbol"], {})
340
+ h["current_price"] = bbo.get("mid") or h["avg_cost"]
341
+ h["unrealized_pnl"] = round((h["current_price"] - h["avg_cost"]) * h["quantity"], 2)
342
+ return jsonify({
343
+ "member_id": mid,
344
+ "capital": round(member["capital"], 2),
345
+ "holdings": holdings,
346
+ "daily": daily,
347
+ })
348
+
349
+
350
+ @app.route("/ch/api/market")
351
+ def api_market():
352
+ return jsonify(_get_bbos())
353
+
354
+
355
+ # ── SSE ────────────────────────────────────────────────────────────────────────
356
+
357
+ @app.route("/ch/stream")
358
+ def sse_stream():
359
+ q: Queue = Queue(maxsize=50)
360
+ with _sse_lock:
361
+ _sse_clients.append(q)
362
+
363
+ def generate():
364
+ try:
365
+ yield "data: {\"type\":\"connected\"}\n\n"
366
+ while True:
367
+ try:
368
+ msg = q.get(timeout=30)
369
+ yield msg
370
+ except Empty:
371
+ yield ": heartbeat\n\n"
372
+ finally:
373
+ with _sse_lock:
374
+ try:
375
+ _sse_clients.remove(q)
376
+ except ValueError:
377
+ pass
378
+
379
+ return Response(
380
+ stream_with_context(generate()),
381
+ mimetype="text/event-stream",
382
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
383
+ )
384
+
385
+
386
+ # ── Startup ────────────────────────────────────────────────────────────────────
387
+
388
+ if __name__ == "__main__":
389
+ db.init_db()
390
+ ai_trader.start()
391
+ port = int(os.getenv("CH_PORT", "5004"))
392
+ print(f"[CH] Clearing House service starting on port {port}")
393
+ app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False)
clearing_house/ch_ai_trader.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI-driven simulation of CH members not currently controlled by a real user.
2
+
3
+ Two background threads:
4
+ 1. _trade_consumer_thread – Kafka consumer on 'trades' topic; attributes
5
+ trades back to CH members whose cl_ord_id starts with USRxx-.
6
+ 2. _simulation_thread – every CH_AI_INTERVAL seconds, picks an order
7
+ for each unoccupied member using the LLM (Groq → HF → Ollama fallback).
8
+
9
+ Call start() once from app.py after init_db().
10
+ Call set_human_active(member_id) / set_human_inactive(member_id) on login/logout.
11
+ """
12
+
13
+ import sys
14
+ sys.path.insert(0, "/app")
15
+
16
+ import json
17
+ import os
18
+ import random
19
+ import re
20
+ import threading
21
+ import time
22
+ from typing import Optional
23
+
24
+ import requests
25
+
26
+ from shared.config import Config
27
+ from shared.kafka_utils import create_consumer, create_producer
28
+
29
+ import ch_database as db
30
+
31
+ # ── Config ─────────────────────────────────────────────────────────────────────
32
+ CH_AI_INTERVAL = int(os.getenv("CH_AI_INTERVAL", "45")) # seconds between AI cycles
33
+ CH_SOURCE = "CLEARINGHOUSE"
34
+
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", "Qwen/Qwen2.5-7B-Instruct")
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"
42
+
43
+ # ── Shared state ───────────────────────────────────────────────────────────────
44
+ _active_humans: set[str] = set() # member_ids currently logged-in as humans
45
+ _humans_lock = threading.Lock()
46
+
47
+ _running = False
48
+ _suspended = False
49
+
50
+ _order_seq = 0
51
+ _seq_lock = threading.Lock()
52
+
53
+ _producer = None
54
+ _producer_lock = threading.Lock()
55
+
56
+
57
+ # ── Public API ─────────────────────────────────────────────────────────────────
58
+
59
+ def set_human_active(member_id: str) -> None:
60
+ with _humans_lock:
61
+ _active_humans.add(member_id)
62
+
63
+
64
+ def set_human_inactive(member_id: str) -> None:
65
+ with _humans_lock:
66
+ _active_humans.discard(member_id)
67
+
68
+
69
+ def is_human_active(member_id: str) -> bool:
70
+ with _humans_lock:
71
+ return member_id in _active_humans
72
+
73
+
74
+ def start() -> None:
75
+ """Start background threads. Call once after app startup."""
76
+ global _running
77
+ _running = True
78
+ threading.Thread(target=_trade_consumer_thread, daemon=True, name="ch-trade-consumer").start()
79
+ threading.Thread(target=_simulation_thread, daemon=True, name="ch-ai-sim").start()
80
+ threading.Thread(target=_control_listener_thread, daemon=True, name="ch-control").start()
81
+ print("[CH-AI] Background threads started")
82
+
83
+
84
+ # ── Kafka helpers ──────────────────────────────────────────────────────────────
85
+
86
+ def _get_producer():
87
+ global _producer
88
+ with _producer_lock:
89
+ if _producer is None:
90
+ _producer = create_producer(component_name="CH-AI")
91
+ return _producer
92
+
93
+
94
+ def _next_cl_ord_id(member_id: str) -> str:
95
+ global _order_seq
96
+ with _seq_lock:
97
+ _order_seq += 1
98
+ return f"{member_id}-{int(time.time() * 1000)}-{_order_seq}"
99
+
100
+
101
+ # ── Thread 1: Trade consumer (attribution) ─────────────────────────────────────
102
+
103
+ def _trade_consumer_thread():
104
+ """Consume 'trades' topic and attribute CH member trades to their accounts."""
105
+ try:
106
+ consumer = create_consumer(
107
+ Config.TRADES_TOPIC,
108
+ group_id="clearing-house-trades",
109
+ auto_offset_reset="latest",
110
+ component_name="CH-TradeConsumer",
111
+ )
112
+ except Exception as e:
113
+ print(f"[CH-AI] Trade consumer failed to start: {e}")
114
+ return
115
+
116
+ for msg in consumer:
117
+ if not _running:
118
+ break
119
+ try:
120
+ trade = msg.value
121
+ buy_id = trade.get("buy_order_id", "")
122
+ sell_id = trade.get("sell_order_id", "")
123
+ symbol = trade.get("symbol", "")
124
+ price = float(trade.get("price", 0))
125
+ qty = int(trade.get("quantity", 0))
126
+
127
+ if not symbol or price <= 0 or qty <= 0:
128
+ continue
129
+
130
+ # Detect CH member orders by cl_ord_id prefix pattern USRxx-
131
+ for order_id, side in [(buy_id, "BUY"), (sell_id, "SELL")]:
132
+ m = re.match(r"^(USR\d{2})-", order_id)
133
+ if m:
134
+ member_id = m.group(1)
135
+ db.record_trade(member_id, symbol, side, qty, price, order_id)
136
+ print(f"[CH-AI] Attributed {side} {qty} {symbol}@{price:.2f} → {member_id}")
137
+ except Exception as e:
138
+ print(f"[CH-AI] Trade attribution error: {e}")
139
+
140
+
141
+ # ── Thread 3: Control listener ─────────────────────────────────────────────────
142
+
143
+ def _control_listener_thread():
144
+ global _running, _suspended
145
+ try:
146
+ consumer = create_consumer(
147
+ Config.CONTROL_TOPIC,
148
+ group_id="clearing-house-control",
149
+ auto_offset_reset="latest",
150
+ component_name="CH-Control",
151
+ )
152
+ except Exception as e:
153
+ print(f"[CH-AI] Control consumer failed: {e}")
154
+ return
155
+
156
+ for msg in consumer:
157
+ try:
158
+ action = msg.value.get("action", "")
159
+ if action in ("stop", "end"):
160
+ _suspended = True
161
+ print("[CH-AI] Session stopped — AI simulation paused")
162
+ elif action == "start":
163
+ _suspended = False
164
+ print("[CH-AI] Session started — AI simulation active")
165
+ elif action == "suspend":
166
+ _suspended = True
167
+ elif action == "resume":
168
+ _suspended = False
169
+ except Exception as e:
170
+ print(f"[CH-AI] Control error: {e}")
171
+
172
+
173
+ # ── Thread 2: AI simulation ────────────────────────────────────────────────────
174
+
175
+ def _simulation_thread():
176
+ """Every CH_AI_INTERVAL seconds, generate a trade for each unoccupied member."""
177
+ while _running:
178
+ time.sleep(CH_AI_INTERVAL)
179
+ if _suspended:
180
+ continue
181
+ try:
182
+ _run_simulation_cycle()
183
+ except Exception as e:
184
+ print(f"[CH-AI] Simulation cycle error: {e}")
185
+
186
+
187
+ def _run_simulation_cycle():
188
+ # Fetch current BBO from Matcher
189
+ bbos = _fetch_bbos()
190
+ if not bbos:
191
+ return
192
+
193
+ today = db.today_str()
194
+ members = db.get_all_members()
195
+
196
+ for member in members:
197
+ mid = member["member_id"]
198
+ if is_human_active(mid):
199
+ continue
200
+
201
+ dt = db.get_daily_trades(mid, today)
202
+ obligation_remaining = max(0, db.CH_DAILY_OBLIGATION - dt["total_securities"])
203
+ holdings = db.get_holdings(mid)
204
+ capital = member["capital"]
205
+
206
+ # Skip if no market data and obligation already met
207
+ if obligation_remaining == 0 and random.random() > 0.3:
208
+ continue # occasionally trade even after obligation met
209
+
210
+ order = _decide_order_llm(mid, capital, holdings, dt, bbos, obligation_remaining)
211
+ if order:
212
+ _submit_order(mid, order)
213
+ time.sleep(0.5) # stagger submissions
214
+
215
+
216
+ def _fetch_bbos() -> dict:
217
+ """Get BBO for all symbols from Matcher API."""
218
+ try:
219
+ # Load securities list to know symbols
220
+ secs_file = os.getenv("SECURITIES_FILE", "/app/data/securities.txt")
221
+ symbols = []
222
+ try:
223
+ with open(secs_file) as f:
224
+ for line in f:
225
+ line = line.strip()
226
+ if line and not line.startswith("#"):
227
+ parts = line.split()
228
+ if parts:
229
+ symbols.append(parts[0])
230
+ except Exception:
231
+ pass
232
+
233
+ if not symbols:
234
+ return {}
235
+
236
+ bbos = {}
237
+ matcher_url = os.getenv("MATCHER_URL", Config.MATCHER_URL)
238
+ for sym in symbols:
239
+ try:
240
+ r = requests.get(f"{matcher_url}/orderbook/{sym}", timeout=2)
241
+ if r.status_code == 200:
242
+ book = r.json()
243
+ bids = book.get("bids", [])
244
+ asks = book.get("asks", [])
245
+ best_bid = max((b["price"] for b in bids), default=None)
246
+ best_ask = min((a["price"] for a in asks), default=None)
247
+ if best_bid or best_ask:
248
+ bbos[sym] = {"best_bid": best_bid, "best_ask": best_ask}
249
+ except Exception:
250
+ pass
251
+ return bbos
252
+ except Exception as e:
253
+ print(f"[CH-AI] BBO fetch error: {e}")
254
+ return {}
255
+
256
+
257
+ def _decide_order_llm(
258
+ member_id: str,
259
+ capital: float,
260
+ holdings: list,
261
+ daily_trades: dict,
262
+ bbos: dict,
263
+ obligation_remaining: int,
264
+ ) -> Optional[dict]:
265
+ """Call LLM to decide the next trade. Falls back to rule-based on failure."""
266
+ prompt = _build_prompt(member_id, capital, holdings, daily_trades, bbos, obligation_remaining)
267
+ text = _call_llm(prompt)
268
+ if text:
269
+ order = _parse_llm_order(text, bbos)
270
+ if order and _validate_order(order, capital, holdings, bbos):
271
+ return order
272
+ # Fallback: rule-based
273
+ return _fallback_order(capital, holdings, bbos)
274
+
275
+
276
+ def _build_prompt(member_id, capital, holdings, daily_trades, bbos, obligation_remaining):
277
+ market_lines = []
278
+ for sym, bbo in sorted(bbos.items()):
279
+ bid = f"{bbo['best_bid']:.2f}" if bbo.get("best_bid") else "-"
280
+ ask = f"{bbo['best_ask']:.2f}" if bbo.get("best_ask") else "-"
281
+ market_lines.append(f" {sym}: Bid {bid} / Ask {ask}")
282
+
283
+ holding_lines = [
284
+ f" {h['symbol']}: {h['quantity']} shares @ avg cost {h['avg_cost']:.2f}"
285
+ for h in holdings
286
+ ] if holdings else [" None"]
287
+
288
+ return (
289
+ f"You are simulating clearing house member {member_id} making ONE trading decision.\n\n"
290
+ f"Member state:\n"
291
+ f" Available capital: EUR {capital:,.2f}\n"
292
+ f" Securities obligation remaining today: {obligation_remaining} more to trade\n"
293
+ f" Current holdings:\n" + "\n".join(holding_lines) + "\n\n"
294
+ f"Current market (Bid/Ask):\n" + "\n".join(market_lines) + "\n\n"
295
+ f"Rules:\n"
296
+ f"- Do not spend more than your available capital\n"
297
+ f"- Do not sell more shares than you hold\n"
298
+ f"- If you have no holdings, you must BUY\n"
299
+ f"- Choose a realistic price close to the BBO mid-price\n"
300
+ f"- Quantity should be between 10 and 200\n\n"
301
+ f"Respond ONLY with valid JSON, no other text:\n"
302
+ f'Example: {{"symbol": "ALPHA", "side": "BUY", "quantity": 50, "price": 5.95}}'
303
+ )
304
+
305
+
306
+ def _parse_llm_order(text: str, bbos: dict) -> Optional[dict]:
307
+ try:
308
+ match = re.search(r"\{[^}]+\}", text, re.DOTALL)
309
+ if not match:
310
+ return None
311
+ data = json.loads(match.group())
312
+ return {
313
+ "symbol": str(data.get("symbol", "")).upper(),
314
+ "side": str(data.get("side", "")).upper(),
315
+ "quantity": int(data.get("quantity", 0)),
316
+ "price": float(data.get("price", 0)),
317
+ }
318
+ except Exception:
319
+ return None
320
+
321
+
322
+ def _validate_order(order: dict, capital: float, holdings: list, bbos: dict) -> bool:
323
+ sym = order.get("symbol", "")
324
+ side = order.get("side", "")
325
+ qty = order.get("quantity", 0)
326
+ price = order.get("price", 0)
327
+
328
+ if sym not in bbos or side not in ("BUY", "SELL") or qty <= 0 or price <= 0:
329
+ return False
330
+ if side == "BUY" and qty * price > capital:
331
+ return False
332
+ if side == "SELL":
333
+ held = next((h["quantity"] for h in holdings if h["symbol"] == sym), 0)
334
+ if qty > held:
335
+ return False
336
+ return True
337
+
338
+
339
+ def _fallback_order(capital: float, holdings: list, bbos: dict) -> Optional[dict]:
340
+ """Rule-based fallback: prefer BUY if no holdings, SELL if heavily loaded."""
341
+ if not bbos:
342
+ return None
343
+
344
+ # Decide side
345
+ total_holding_value = sum(
346
+ h["quantity"] * (bbos.get(h["symbol"], {}).get("best_ask") or h["avg_cost"])
347
+ for h in holdings
348
+ )
349
+ # BUY if holdings < 30% of total net worth, else 50/50
350
+ net_worth = capital + total_holding_value
351
+ if net_worth > 0 and total_holding_value / net_worth < 0.3:
352
+ side = "BUY"
353
+ else:
354
+ side = random.choice(["BUY", "SELL"])
355
+
356
+ if side == "SELL" and not holdings:
357
+ side = "BUY"
358
+
359
+ if side == "BUY":
360
+ # Pick a random affordable symbol
361
+ affordable = [
362
+ sym for sym, bbo in bbos.items()
363
+ if bbo.get("best_ask") and 10 * bbo["best_ask"] <= capital
364
+ ]
365
+ if not affordable:
366
+ return None
367
+ sym = random.choice(affordable)
368
+ ask = bbos[sym]["best_ask"]
369
+ qty = min(random.randint(10, 100), int(capital // ask))
370
+ if qty <= 0:
371
+ return None
372
+ return {"symbol": sym, "side": "BUY", "quantity": qty, "price": round(ask, 2)}
373
+ else:
374
+ # Sell from existing holdings
375
+ h = random.choice(holdings)
376
+ sym = h["symbol"]
377
+ bbo = bbos.get(sym, {})
378
+ bid = bbo.get("best_bid") or h["avg_cost"]
379
+ qty = random.randint(10, max(10, h["quantity"] // 2))
380
+ qty = min(qty, h["quantity"])
381
+ if qty <= 0:
382
+ return None
383
+ return {"symbol": sym, "side": "SELL", "quantity": qty, "price": round(bid, 2)}
384
+
385
+
386
+ def _submit_order(member_id: str, order: dict) -> None:
387
+ cl_ord_id = _next_cl_ord_id(member_id)
388
+ msg = {
389
+ "cl_ord_id": cl_ord_id,
390
+ "symbol": order["symbol"],
391
+ "side": order["side"],
392
+ "quantity": order["quantity"],
393
+ "price": order["price"],
394
+ "ord_type": "LIMIT",
395
+ "time_in_force": "DAY",
396
+ "timestamp": time.time(),
397
+ "source": CH_SOURCE,
398
+ }
399
+ try:
400
+ _get_producer().send(Config.ORDERS_TOPIC, msg)
401
+ print(f"[CH-AI] {member_id} → {order['side']} {order['quantity']} {order['symbol']}@{order['price']:.2f}")
402
+ except Exception as e:
403
+ print(f"[CH-AI] Order submit failed: {e}")
404
+
405
+
406
+ # ── LLM (Groq → HF → Ollama fallback) ────────────────────────────────────────
407
+
408
+ def _call_llm(prompt: str) -> Optional[str]:
409
+ return _try_groq(prompt) or _try_hf(prompt) or _try_ollama(prompt)
410
+
411
+
412
+ def _try_groq(prompt: str) -> Optional[str]:
413
+ if not GROQ_API_KEY:
414
+ return None
415
+ try:
416
+ resp = requests.post(
417
+ GROQ_URL,
418
+ headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
419
+ json={
420
+ "model": GROQ_MODEL,
421
+ "messages": [{"role": "user", "content": prompt}],
422
+ "max_tokens": 100,
423
+ "temperature": 0.4,
424
+ },
425
+ timeout=20,
426
+ )
427
+ if resp.status_code == 200:
428
+ return resp.json()["choices"][0]["message"]["content"].strip()
429
+ except Exception as e:
430
+ print(f"[CH-AI] Groq error: {e}")
431
+ return None
432
+
433
+
434
+ def _try_hf(prompt: str) -> Optional[str]:
435
+ if not HF_TOKEN:
436
+ return None
437
+ if HF_MODEL.startswith("RayMelius/"):
438
+ url = f"https://api-inference.huggingface.co/models/{HF_MODEL}/v1/chat/completions"
439
+ else:
440
+ url = "https://router.huggingface.co/v1/chat/completions"
441
+ try:
442
+ resp = requests.post(
443
+ url,
444
+ headers={"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"},
445
+ json={
446
+ "model": HF_MODEL,
447
+ "messages": [{"role": "user", "content": prompt}],
448
+ "max_tokens": 100,
449
+ "temperature": 0.4,
450
+ },
451
+ timeout=30,
452
+ )
453
+ if resp.status_code == 200:
454
+ return resp.json()["choices"][0]["message"]["content"].strip()
455
+ except Exception as e:
456
+ print(f"[CH-AI] HF error: {e}")
457
+ return None
458
+
459
+
460
+ def _try_ollama(prompt: str) -> Optional[str]:
461
+ if not OLLAMA_HOST:
462
+ return None
463
+ try:
464
+ resp = requests.post(
465
+ f"{OLLAMA_HOST}/api/chat",
466
+ json={"model": OLLAMA_MODEL, "messages": [{"role": "user", "content": prompt}], "stream": False},
467
+ timeout=60,
468
+ )
469
+ if resp.status_code == 200:
470
+ return resp.json().get("message", {}).get("content", "").strip()
471
+ except Exception as e:
472
+ print(f"[CH-AI] Ollama error: {e}")
473
+ return None
clearing_house/ch_database.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite persistence for the Clearing House simulation.
2
+
3
+ DB path: /app/data/clearing_house.db (shared volume with dashboard).
4
+ All public functions are thread-safe via thread-local connections.
5
+ """
6
+
7
+ import sqlite3
8
+ import threading
9
+ import time
10
+ import datetime
11
+ import os
12
+
13
+ CH_DB_PATH = os.getenv("CH_DB_PATH", "/app/data/clearing_house.db")
14
+ CH_MEMBERS = [f"USR{i:02d}" for i in range(1, 11)]
15
+ CH_STARTING_CAPITAL = 100_000.0
16
+ CH_DAILY_OBLIGATION = 10 # minimum securities (qty sum) per trading day
17
+
18
+ _local = threading.local()
19
+
20
+ SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS ch_members (
22
+ member_id TEXT PRIMARY KEY,
23
+ capital REAL NOT NULL DEFAULT 100000.0,
24
+ created_at REAL NOT NULL
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS ch_holdings (
28
+ member_id TEXT NOT NULL,
29
+ symbol TEXT NOT NULL,
30
+ quantity INTEGER NOT NULL DEFAULT 0,
31
+ avg_cost REAL NOT NULL DEFAULT 0.0,
32
+ PRIMARY KEY (member_id, symbol)
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS ch_daily_trades (
36
+ member_id TEXT NOT NULL,
37
+ trading_date TEXT NOT NULL,
38
+ buy_count INTEGER NOT NULL DEFAULT 0,
39
+ sell_count INTEGER NOT NULL DEFAULT 0,
40
+ total_securities INTEGER NOT NULL DEFAULT 0,
41
+ PRIMARY KEY (member_id, trading_date)
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS ch_trade_log (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ member_id TEXT NOT NULL,
47
+ symbol TEXT NOT NULL,
48
+ side TEXT NOT NULL,
49
+ quantity INTEGER NOT NULL,
50
+ price REAL NOT NULL,
51
+ cl_ord_id TEXT NOT NULL,
52
+ trading_date TEXT NOT NULL,
53
+ timestamp REAL NOT NULL
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS ch_settlements (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ member_id TEXT NOT NULL,
59
+ trading_date TEXT NOT NULL,
60
+ opening_capital REAL NOT NULL,
61
+ closing_capital REAL NOT NULL,
62
+ realized_pnl REAL NOT NULL DEFAULT 0.0,
63
+ unrealized_pnl REAL NOT NULL DEFAULT 0.0,
64
+ obligation_met INTEGER NOT NULL DEFAULT 0,
65
+ settled_at REAL NOT NULL
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_ch_trade_log_member
69
+ ON ch_trade_log(member_id, trading_date);
70
+ CREATE INDEX IF NOT EXISTS idx_ch_settlements_member
71
+ ON ch_settlements(member_id, trading_date);
72
+ """
73
+
74
+
75
+ def _conn() -> sqlite3.Connection:
76
+ """Thread-local SQLite connection."""
77
+ if not hasattr(_local, "conn") or _local.conn is None:
78
+ _local.conn = sqlite3.connect(CH_DB_PATH, check_same_thread=False)
79
+ _local.conn.row_factory = sqlite3.Row
80
+ return _local.conn
81
+
82
+
83
+ def init_db() -> None:
84
+ """Create schema and seed the 10 members if missing."""
85
+ conn = _conn()
86
+ conn.executescript(SCHEMA)
87
+ conn.commit()
88
+ now = time.time()
89
+ for mid in CH_MEMBERS:
90
+ conn.execute(
91
+ "INSERT OR IGNORE INTO ch_members (member_id, capital, created_at) VALUES (?,?,?)",
92
+ (mid, CH_STARTING_CAPITAL, now),
93
+ )
94
+ conn.commit()
95
+ print(f"[CH-DB] Initialized at {CH_DB_PATH}")
96
+
97
+
98
+ # ── Members ────────────────────────────────────────────────────────────────────
99
+
100
+ def get_member(member_id: str) -> dict | None:
101
+ row = _conn().execute(
102
+ "SELECT member_id, capital FROM ch_members WHERE member_id=?", (member_id,)
103
+ ).fetchone()
104
+ return dict(row) if row else None
105
+
106
+
107
+ def get_all_members() -> list[dict]:
108
+ rows = _conn().execute(
109
+ "SELECT member_id, capital FROM ch_members ORDER BY member_id"
110
+ ).fetchall()
111
+ return [dict(r) for r in rows]
112
+
113
+
114
+ # ── Holdings ───────────────────────────────────────────────────────────────────
115
+
116
+ def get_holdings(member_id: str) -> list[dict]:
117
+ rows = _conn().execute(
118
+ "SELECT symbol, quantity, avg_cost FROM ch_holdings WHERE member_id=? AND quantity>0",
119
+ (member_id,),
120
+ ).fetchall()
121
+ return [dict(r) for r in rows]
122
+
123
+
124
+ def get_holding(member_id: str, symbol: str) -> dict:
125
+ row = _conn().execute(
126
+ "SELECT quantity, avg_cost FROM ch_holdings WHERE member_id=? AND symbol=?",
127
+ (member_id, symbol),
128
+ ).fetchone()
129
+ return dict(row) if row else {"quantity": 0, "avg_cost": 0.0}
130
+
131
+
132
+ # ── Trade recording (atomic) ───────────────────────────────────────────────────
133
+
134
+ def today_str() -> str:
135
+ return datetime.date.today().isoformat()
136
+
137
+
138
+ def record_trade(
139
+ member_id: str,
140
+ symbol: str,
141
+ side: str,
142
+ quantity: int,
143
+ price: float,
144
+ cl_ord_id: str,
145
+ ) -> None:
146
+ """Atomically update holdings, capital, daily counter, and trade log."""
147
+ conn = _conn()
148
+ date = today_str()
149
+ value = quantity * price
150
+
151
+ with conn: # auto-commit / rollback
152
+ # 1. Update holdings
153
+ holding = get_holding(member_id, symbol)
154
+ old_qty = holding["quantity"]
155
+ old_avg = holding["avg_cost"]
156
+
157
+ if side == "BUY":
158
+ new_qty = old_qty + quantity
159
+ new_avg = (old_qty * old_avg + quantity * price) / new_qty if new_qty else price
160
+ conn.execute(
161
+ """INSERT INTO ch_holdings (member_id, symbol, quantity, avg_cost)
162
+ VALUES (?,?,?,?)
163
+ ON CONFLICT(member_id, symbol) DO UPDATE
164
+ SET quantity=excluded.quantity, avg_cost=excluded.avg_cost""",
165
+ (member_id, symbol, new_qty, round(new_avg, 4)),
166
+ )
167
+ # 2. Deduct capital
168
+ conn.execute(
169
+ "UPDATE ch_members SET capital = capital - ? WHERE member_id=?",
170
+ (value, member_id),
171
+ )
172
+ else: # SELL
173
+ new_qty = max(0, old_qty - quantity)
174
+ if new_qty == 0:
175
+ conn.execute(
176
+ "DELETE FROM ch_holdings WHERE member_id=? AND symbol=?",
177
+ (member_id, symbol),
178
+ )
179
+ else:
180
+ conn.execute(
181
+ "UPDATE ch_holdings SET quantity=? WHERE member_id=? AND symbol=?",
182
+ (new_qty, member_id, symbol),
183
+ )
184
+ # 2. Add capital
185
+ conn.execute(
186
+ "UPDATE ch_members SET capital = capital + ? WHERE member_id=?",
187
+ (value, member_id),
188
+ )
189
+
190
+ # 3. Update daily trade counter
191
+ buy_inc = 1 if side == "BUY" else 0
192
+ sell_inc = 1 if side == "SELL" else 0
193
+ conn.execute(
194
+ """INSERT INTO ch_daily_trades (member_id, trading_date, buy_count, sell_count, total_securities)
195
+ VALUES (?,?,?,?,?)
196
+ ON CONFLICT(member_id, trading_date) DO UPDATE
197
+ SET buy_count = buy_count + excluded.buy_count,
198
+ sell_count = sell_count + excluded.sell_count,
199
+ total_securities = total_securities + excluded.total_securities""",
200
+ (member_id, date, buy_inc, sell_inc, quantity),
201
+ )
202
+
203
+ # 4. Log the trade
204
+ conn.execute(
205
+ """INSERT INTO ch_trade_log
206
+ (member_id, symbol, side, quantity, price, cl_ord_id, trading_date, timestamp)
207
+ VALUES (?,?,?,?,?,?,?,?)""",
208
+ (member_id, symbol, side, quantity, price, cl_ord_id, date, time.time()),
209
+ )
210
+
211
+
212
+ def get_daily_trades(member_id: str, date: str | None = None) -> dict:
213
+ date = date or today_str()
214
+ row = _conn().execute(
215
+ "SELECT buy_count, sell_count, total_securities FROM ch_daily_trades WHERE member_id=? AND trading_date=?",
216
+ (member_id, date),
217
+ ).fetchone()
218
+ return dict(row) if row else {"buy_count": 0, "sell_count": 0, "total_securities": 0}
219
+
220
+
221
+ def get_trade_log(member_id: str, date: str | None = None, limit: int = 50) -> list[dict]:
222
+ date = date or today_str()
223
+ rows = _conn().execute(
224
+ """SELECT symbol, side, quantity, price, cl_ord_id, timestamp
225
+ FROM ch_trade_log WHERE member_id=? AND trading_date=?
226
+ ORDER BY timestamp DESC LIMIT ?""",
227
+ (member_id, date, limit),
228
+ ).fetchall()
229
+ return [dict(r) for r in rows]
230
+
231
+
232
+ # ── EOD Settlement ─────────────────────────────────────────────────────────────
233
+
234
+ def record_settlement(
235
+ member_id: str,
236
+ trading_date: str,
237
+ opening_capital: float,
238
+ closing_capital: float,
239
+ realized_pnl: float,
240
+ unrealized_pnl: float,
241
+ obligation_met: bool,
242
+ ) -> None:
243
+ _conn().execute(
244
+ """INSERT INTO ch_settlements
245
+ (member_id, trading_date, opening_capital, closing_capital,
246
+ realized_pnl, unrealized_pnl, obligation_met, settled_at)
247
+ VALUES (?,?,?,?,?,?,?,?)""",
248
+ (
249
+ member_id, trading_date, opening_capital, closing_capital,
250
+ realized_pnl, unrealized_pnl, int(obligation_met), time.time(),
251
+ ),
252
+ )
253
+ _conn().commit()
254
+
255
+
256
+ def get_settlements(member_id: str, limit: int = 30) -> list[dict]:
257
+ rows = _conn().execute(
258
+ """SELECT trading_date, opening_capital, closing_capital,
259
+ realized_pnl, unrealized_pnl, obligation_met, settled_at
260
+ FROM ch_settlements WHERE member_id=?
261
+ ORDER BY settled_at DESC LIMIT ?""",
262
+ (member_id, limit),
263
+ ).fetchall()
264
+ return [dict(r) for r in rows]
265
+
266
+
267
+ # ── Leaderboard ────────────────────────────────────────────────────────────────
268
+
269
+ def get_leaderboard(date: str | None = None) -> list[dict]:
270
+ """Returns member stats. Caller adds holdings_value using live prices."""
271
+ date = date or today_str()
272
+ members = get_all_members()
273
+ result = []
274
+ for m in members:
275
+ mid = m["member_id"]
276
+ dt = get_daily_trades(mid, date)
277
+ holdings = get_holdings(mid)
278
+ result.append({
279
+ "member_id": mid,
280
+ "capital": round(m["capital"], 2),
281
+ "holdings": holdings,
282
+ "buy_count": dt["buy_count"],
283
+ "sell_count": dt["sell_count"],
284
+ "total_securities": dt["total_securities"],
285
+ "obligation_met": dt["total_securities"] >= CH_DAILY_OBLIGATION,
286
+ })
287
+ return result
288
+
289
+
290
+ # ── Auth ───────────────────────────────────────────────────────────────────────
291
+
292
+ def verify_password(member_id: str, password: str) -> bool:
293
+ """Password equals the member ID (e.g. USR01 / USR01)."""
294
+ return member_id in CH_MEMBERS and password == member_id
clearing_house/templates/base.html ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}StockEx Clearing House{% endblock %}</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0d1117;
12
+ --bg2: #161b22;
13
+ --bg3: #21262d;
14
+ --border: #30363d;
15
+ --text: #e6edf3;
16
+ --muted: #8b949e;
17
+ --green: #3fb950;
18
+ --red: #f85149;
19
+ --yellow: #d29922;
20
+ --blue: #58a6ff;
21
+ --purple: #bc8cff;
22
+ --accent: #1f6feb;
23
+ }
24
+
25
+ body {
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
29
+ font-size: 13px;
30
+ min-height: 100vh;
31
+ }
32
+
33
+ /* ── Nav ── */
34
+ nav {
35
+ background: var(--bg2);
36
+ border-bottom: 1px solid var(--border);
37
+ padding: 0 24px;
38
+ height: 52px;
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 24px;
42
+ }
43
+ nav .brand {
44
+ font-size: 15px;
45
+ font-weight: 700;
46
+ color: var(--blue);
47
+ text-decoration: none;
48
+ letter-spacing: 0.5px;
49
+ }
50
+ nav .brand span { color: var(--muted); font-weight: 400; }
51
+ nav .nav-links { display: flex; gap: 16px; flex: 1; }
52
+ nav .nav-links a {
53
+ color: var(--muted);
54
+ text-decoration: none;
55
+ padding: 4px 8px;
56
+ border-radius: 4px;
57
+ transition: color .15s, background .15s;
58
+ }
59
+ nav .nav-links a:hover { color: var(--text); background: var(--bg3); }
60
+ nav .nav-links a.active { color: var(--text); }
61
+ nav .nav-right { display: flex; align-items: center; gap: 12px; }
62
+ nav .member-badge {
63
+ background: var(--accent);
64
+ color: #fff;
65
+ padding: 3px 10px;
66
+ border-radius: 12px;
67
+ font-size: 12px;
68
+ font-weight: 600;
69
+ }
70
+ nav form { display: inline; }
71
+ nav button.logout {
72
+ background: none;
73
+ border: 1px solid var(--border);
74
+ color: var(--muted);
75
+ padding: 4px 12px;
76
+ border-radius: 4px;
77
+ cursor: pointer;
78
+ font-size: 12px;
79
+ font-family: inherit;
80
+ }
81
+ nav button.logout:hover { border-color: var(--red); color: var(--red); }
82
+ nav a.login-btn {
83
+ background: var(--accent);
84
+ color: #fff;
85
+ padding: 5px 14px;
86
+ border-radius: 4px;
87
+ text-decoration: none;
88
+ font-size: 12px;
89
+ font-weight: 600;
90
+ }
91
+
92
+ /* ── Layout ── */
93
+ main { padding: 24px; max-width: 1400px; margin: 0 auto; }
94
+
95
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; }
96
+ h2 { font-size: 15px; font-weight: 600; margin-bottom: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
97
+
98
+ /* ── Cards ── */
99
+ .card {
100
+ background: var(--bg2);
101
+ border: 1px solid var(--border);
102
+ border-radius: 8px;
103
+ padding: 20px;
104
+ margin-bottom: 20px;
105
+ }
106
+
107
+ /* ── Tables ── */
108
+ table { width: 100%; border-collapse: collapse; }
109
+ thead th {
110
+ text-align: left;
111
+ padding: 8px 12px;
112
+ color: var(--muted);
113
+ font-size: 11px;
114
+ text-transform: uppercase;
115
+ letter-spacing: 0.5px;
116
+ border-bottom: 1px solid var(--border);
117
+ }
118
+ tbody td {
119
+ padding: 10px 12px;
120
+ border-bottom: 1px solid var(--border);
121
+ vertical-align: middle;
122
+ }
123
+ tbody tr:last-child td { border-bottom: none; }
124
+ tbody tr:hover td { background: var(--bg3); }
125
+
126
+ /* ── Utilities ── */
127
+ .positive { color: var(--green); }
128
+ .negative { color: var(--red); }
129
+ .neutral { color: var(--muted); }
130
+ .badge-ok { background: rgba(63,185,80,.15); color: var(--green); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
131
+ .badge-bad { background: rgba(248,81,73,.12); color: var(--red); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
132
+ .badge-ai { background: rgba(188,140,255,.15); color: var(--purple); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
133
+ .badge-human { background: rgba(88,166,255,.15); color: var(--blue); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
134
+
135
+ /* ── Buttons ── */
136
+ .btn {
137
+ display: inline-block;
138
+ padding: 8px 18px;
139
+ border-radius: 6px;
140
+ font-size: 13px;
141
+ font-family: inherit;
142
+ cursor: pointer;
143
+ border: none;
144
+ font-weight: 600;
145
+ transition: opacity .15s;
146
+ }
147
+ .btn:hover { opacity: .85; }
148
+ .btn-primary { background: var(--accent); color: #fff; }
149
+ .btn-danger { background: var(--red); color: #fff; }
150
+ .btn-sm { padding: 5px 12px; font-size: 12px; }
151
+
152
+ /* ── Forms ── */
153
+ .form-group { margin-bottom: 14px; }
154
+ label { display: block; margin-bottom: 4px; color: var(--muted); font-size: 12px; }
155
+ input, select {
156
+ width: 100%;
157
+ background: var(--bg3);
158
+ border: 1px solid var(--border);
159
+ color: var(--text);
160
+ padding: 8px 10px;
161
+ border-radius: 6px;
162
+ font-family: inherit;
163
+ font-size: 13px;
164
+ }
165
+ input:focus, select:focus {
166
+ outline: none;
167
+ border-color: var(--accent);
168
+ }
169
+
170
+ /* ── Flash messages ── */
171
+ .flash { padding: 10px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
172
+ .flash-error { background: rgba(248,81,73,.12); border: 1px solid var(--red); color: var(--red); }
173
+
174
+ /* ── Grid ── */
175
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
176
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
177
+ @media (max-width: 900px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }
178
+
179
+ /* ── Stat tiles ── */
180
+ .stat { text-align: center; }
181
+ .stat .val { font-size: 24px; font-weight: 700; }
182
+ .stat .lbl { font-size: 11px; color: var(--muted); margin-top: 4px; }
183
+
184
+ #toast {
185
+ position: fixed;
186
+ bottom: 24px;
187
+ right: 24px;
188
+ background: var(--bg3);
189
+ border: 1px solid var(--border);
190
+ padding: 12px 20px;
191
+ border-radius: 8px;
192
+ display: none;
193
+ z-index: 999;
194
+ font-size: 13px;
195
+ }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <nav>
200
+ <a href="/ch/" class="brand">StockEx <span>/ Clearing House</span></a>
201
+ <div class="nav-links">
202
+ <a href="/ch/" class="{{ 'active' if request.path == '/ch/' else '' }}">Leaderboard</a>
203
+ {% if member %}
204
+ <a href="/ch/portfolio" class="{{ 'active' if '/portfolio' in request.path else '' }}">My Portfolio</a>
205
+ {% endif %}
206
+ <a href="/" target="_blank">Main Dashboard ↗</a>
207
+ </div>
208
+ <div class="nav-right">
209
+ {% if member %}
210
+ <span class="member-badge">{{ member }}</span>
211
+ <form action="/ch/logout" method="post">
212
+ <button class="logout" type="submit">Logout</button>
213
+ </form>
214
+ {% else %}
215
+ <a href="/ch/login" class="login-btn">Login</a>
216
+ {% endif %}
217
+ </div>
218
+ </nav>
219
+
220
+ <main>
221
+ {% block content %}{% endblock %}
222
+ </main>
223
+
224
+ <div id="toast"></div>
225
+
226
+ <script>
227
+ // SSE for real-time updates
228
+ const es = new EventSource('/ch/stream');
229
+ const toast = document.getElementById('toast');
230
+
231
+ function showToast(msg, ms=3000) {
232
+ toast.textContent = msg;
233
+ toast.style.display = 'block';
234
+ setTimeout(() => { toast.style.display = 'none'; }, ms);
235
+ }
236
+
237
+ es.addEventListener('eod_settlement', () => {
238
+ showToast('EOD Settlement complete — page refreshing…', 2000);
239
+ setTimeout(() => location.reload(), 2200);
240
+ });
241
+
242
+ es.addEventListener('member_login', e => {
243
+ const d = JSON.parse(e.data);
244
+ showToast(`${d.member_id} logged in`);
245
+ });
246
+
247
+ {% block extra_scripts %}{% endblock %}
248
+ </script>
249
+ </body>
250
+ </html>
clearing_house/templates/dashboard.html ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Leaderboard — StockEx Clearing House{% endblock %}
3
+
4
+ {% block content %}
5
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px;">
6
+ <h1 style="margin:0;">Clearing House Leaderboard</h1>
7
+ <span style="color:var(--muted); font-size:12px;" id="last-update">Live</span>
8
+ </div>
9
+
10
+ <!-- Summary stats -->
11
+ <div class="grid-3" style="margin-bottom:20px;">
12
+ {% set total_capital = leaderboard | sum(attribute='capital') %}
13
+ {% set met_count = leaderboard | selectattr('obligation_met') | list | length %}
14
+ <div class="card stat">
15
+ <div class="val">{{ leaderboard | length }}</div>
16
+ <div class="lbl">Active Members</div>
17
+ </div>
18
+ <div class="card stat">
19
+ <div class="val positive">{{ met_count }}</div>
20
+ <div class="lbl">Obligation Met Today</div>
21
+ </div>
22
+ <div class="card stat">
23
+ <div class="val">€{{ "{:,.0f}".format(total_capital) }}</div>
24
+ <div class="lbl">Total Capital in Market</div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Leaderboard table -->
29
+ <div class="card" style="padding:0; overflow:hidden;">
30
+ <table id="lb-table">
31
+ <thead>
32
+ <tr>
33
+ <th>#</th>
34
+ <th>Member</th>
35
+ <th>Type</th>
36
+ <th style="text-align:right">Capital</th>
37
+ <th style="text-align:right">Holdings Value</th>
38
+ <th style="text-align:right">Total Value</th>
39
+ <th style="text-align:right">P&amp;L</th>
40
+ <th style="text-align:center">Buys</th>
41
+ <th style="text-align:center">Sells</th>
42
+ <th style="text-align:center">Securities</th>
43
+ <th style="text-align:center">Obligation</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ {% for row in leaderboard %}
48
+ <tr data-member="{{ row.member_id }}">
49
+ <td style="color:var(--muted)">{{ row.rank }}</td>
50
+ <td><strong>{{ row.member_id }}</strong></td>
51
+ <td>
52
+ {% if row.is_human %}
53
+ <span class="badge-human">Human</span>
54
+ {% else %}
55
+ <span class="badge-ai">AI</span>
56
+ {% endif %}
57
+ </td>
58
+ <td style="text-align:right">€{{ "{:,.2f}".format(row.capital) }}</td>
59
+ <td style="text-align:right">€{{ "{:,.2f}".format(row.holdings_value) }}</td>
60
+ <td style="text-align:right"><strong>€{{ "{:,.2f}".format(row.total_value) }}</strong></td>
61
+ <td style="text-align:right" class="{{ 'positive' if row.pnl >= 0 else 'negative' }}">
62
+ {{ '+' if row.pnl >= 0 else '' }}€{{ "{:,.2f}".format(row.pnl) }}
63
+ </td>
64
+ <td style="text-align:center">{{ row.buy_count }}</td>
65
+ <td style="text-align:center">{{ row.sell_count }}</td>
66
+ <td style="text-align:center">{{ row.total_securities }}</td>
67
+ <td style="text-align:center">
68
+ {% if row.obligation_met %}
69
+ <span class="badge-ok">✓ Met ({{ obligation }})</span>
70
+ {% else %}
71
+ <span class="badge-bad">{{ row.total_securities }}/{{ obligation }}</span>
72
+ {% endif %}
73
+ </td>
74
+ </tr>
75
+ {% endfor %}
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+
80
+ <p style="color:var(--muted); font-size:11px; margin-top:8px;">
81
+ Daily obligation: each member must trade at least {{ obligation }} securities.
82
+ Holdings value is calculated at current market mid-price.
83
+ Refreshes every 10 seconds.
84
+ </p>
85
+ {% endblock %}
86
+
87
+ {% block extra_scripts %}
88
+ // Auto-refresh leaderboard every 10 seconds
89
+ let refreshTimer = setInterval(refreshLeaderboard, 10000);
90
+
91
+ async function refreshLeaderboard() {
92
+ try {
93
+ const resp = await fetch('/ch/api/leaderboard');
94
+ if (!resp.ok) return;
95
+ const rows = await resp.json();
96
+ const tbody = document.querySelector('#lb-table tbody');
97
+ tbody.innerHTML = '';
98
+ rows.forEach(row => {
99
+ const pnlClass = row.pnl >= 0 ? 'positive' : 'negative';
100
+ const pnlSign = row.pnl >= 0 ? '+' : '';
101
+ const oblBadge = row.obligation_met
102
+ ? `<span class="badge-ok">✓ Met ({{ obligation }})</span>`
103
+ : `<span class="badge-bad">${row.total_securities}/{{ obligation }}</span>`;
104
+ const typeBadge = row.is_human
105
+ ? `<span class="badge-human">Human</span>`
106
+ : `<span class="badge-ai">AI</span>`;
107
+ tbody.innerHTML += `
108
+ <tr data-member="${row.member_id}">
109
+ <td style="color:var(--muted)">${row.rank}</td>
110
+ <td><strong>${row.member_id}</strong></td>
111
+ <td>${typeBadge}</td>
112
+ <td style="text-align:right">€${fmt(row.capital)}</td>
113
+ <td style="text-align:right">€${fmt(row.holdings_value)}</td>
114
+ <td style="text-align:right"><strong>€${fmt(row.total_value)}</strong></td>
115
+ <td style="text-align:right" class="${pnlClass}">${pnlSign}€${fmt(row.pnl)}</td>
116
+ <td style="text-align:center">${row.buy_count}</td>
117
+ <td style="text-align:center">${row.sell_count}</td>
118
+ <td style="text-align:center">${row.total_securities}</td>
119
+ <td style="text-align:center">${oblBadge}</td>
120
+ </tr>`;
121
+ });
122
+ document.getElementById('last-update').textContent =
123
+ 'Updated ' + new Date().toLocaleTimeString();
124
+ } catch(e) { /* ignore */ }
125
+ }
126
+
127
+ function fmt(n) {
128
+ return Number(n).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
129
+ }
130
+ {% endblock %}
clearing_house/templates/login.html ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Login — StockEx Clearing House{% endblock %}
3
+
4
+ {% block content %}
5
+ <div style="max-width:400px; margin:80px auto;">
6
+ <div class="card">
7
+ <h1 style="text-align:center; margin-bottom:24px; font-size:18px;">
8
+ Clearing House Login
9
+ </h1>
10
+
11
+ {% if error %}
12
+ <div class="flash flash-error">{{ error }}</div>
13
+ {% endif %}
14
+
15
+ <form action="/ch/login" method="post">
16
+ <div class="form-group">
17
+ <label>Member ID</label>
18
+ <select name="member_id" required>
19
+ <option value="">Select member…</option>
20
+ {% for m in members %}
21
+ <option value="{{ m }}">{{ m }}</option>
22
+ {% endfor %}
23
+ </select>
24
+ </div>
25
+ <div class="form-group">
26
+ <label>Password</label>
27
+ <input type="password" name="password" placeholder="Same as your member ID" required autocomplete="current-password">
28
+ </div>
29
+ <button type="submit" class="btn btn-primary" style="width:100%; margin-top:8px;">
30
+ Login
31
+ </button>
32
+ </form>
33
+
34
+ <p style="margin-top:20px; color:var(--muted); text-align:center; font-size:12px;">
35
+ Password = your member ID &nbsp;·&nbsp; e.g. USR01 / USR01
36
+ </p>
37
+ </div>
38
+ </div>
39
+ {% endblock %}
clearing_house/templates/portfolio.html ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ member }} Portfolio — StockEx Clearing House{% endblock %}
3
+
4
+ {% block content %}
5
+ <!-- Header -->
6
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px;">
7
+ <h1 style="margin:0;">{{ member }} — Portfolio</h1>
8
+ <div style="display:flex; gap:12px; align-items:center;">
9
+ <span style="color:var(--muted); font-size:12px;">
10
+ Obligation:
11
+ <strong class="{{ 'positive' if daily.total_securities >= obligation else 'negative' }}">
12
+ {{ daily.total_securities }}/{{ obligation }}
13
+ </strong> securities
14
+ </span>
15
+ </div>
16
+ </div>
17
+
18
+ <!-- Stats -->
19
+ <div class="grid-3" style="margin-bottom:20px;">
20
+ <div class="card stat">
21
+ <div class="val">€{{ "{:,.2f}".format(capital) }}</div>
22
+ <div class="lbl">Available Cash</div>
23
+ </div>
24
+ <div class="card stat">
25
+ <div class="val">€{{ "{:,.2f}".format(total_value) }}</div>
26
+ <div class="lbl">Total Portfolio Value</div>
27
+ </div>
28
+ <div class="card stat">
29
+ <div class="val {{ 'positive' if total_pnl >= 0 else 'negative' }}">
30
+ {{ '+' if total_pnl >= 0 else '' }}€{{ "{:,.2f}".format(total_pnl) }}
31
+ </div>
32
+ <div class="lbl">Total P&amp;L</div>
33
+ </div>
34
+ </div>
35
+
36
+ <div class="grid-2">
37
+ <!-- Holdings -->
38
+ <div>
39
+ <div class="card" style="padding:0; overflow:hidden;">
40
+ <div style="padding:16px 20px 12px; border-bottom:1px solid var(--border);">
41
+ <h2 style="margin:0;">Holdings</h2>
42
+ </div>
43
+ {% if holdings %}
44
+ <table>
45
+ <thead>
46
+ <tr>
47
+ <th>Symbol</th>
48
+ <th style="text-align:right">Qty</th>
49
+ <th style="text-align:right">Avg Cost</th>
50
+ <th style="text-align:right">Price</th>
51
+ <th style="text-align:right">Value</th>
52
+ <th style="text-align:right">P&amp;L</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>
56
+ {% for h in holdings %}
57
+ <tr>
58
+ <td><strong>{{ h.symbol }}</strong></td>
59
+ <td style="text-align:right">{{ h.quantity }}</td>
60
+ <td style="text-align:right">€{{ "%.2f"|format(h.avg_cost) }}</td>
61
+ <td style="text-align:right">€{{ "%.2f"|format(h.current_price) }}</td>
62
+ <td style="text-align:right">€{{ "{:,.2f}".format(h.value) }}</td>
63
+ <td style="text-align:right" class="{{ 'positive' if h.unrealized_pnl >= 0 else 'negative' }}">
64
+ {{ '+' if h.unrealized_pnl >= 0 else '' }}€{{ "%.2f"|format(h.unrealized_pnl) }}
65
+ </td>
66
+ </tr>
67
+ {% endfor %}
68
+ </tbody>
69
+ </table>
70
+ {% else %}
71
+ <p style="padding:20px; color:var(--muted); text-align:center;">No holdings</p>
72
+ {% endif %}
73
+ </div>
74
+
75
+ <!-- Settlement history -->
76
+ {% if settlements %}
77
+ <div class="card" style="padding:0; overflow:hidden; margin-top:20px;">
78
+ <div style="padding:16px 20px 12px; border-bottom:1px solid var(--border);">
79
+ <h2 style="margin:0;">Settlement History</h2>
80
+ </div>
81
+ <table>
82
+ <thead>
83
+ <tr>
84
+ <th>Date</th>
85
+ <th style="text-align:right">Closing Capital</th>
86
+ <th style="text-align:right">Unrealized P&amp;L</th>
87
+ <th style="text-align:center">Obligation</th>
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {% for s in settlements %}
92
+ <tr>
93
+ <td>{{ s.trading_date }}</td>
94
+ <td style="text-align:right">€{{ "{:,.2f}".format(s.closing_capital) }}</td>
95
+ <td style="text-align:right" class="{{ 'positive' if s.unrealized_pnl >= 0 else 'negative' }}">
96
+ {{ '+' if s.unrealized_pnl >= 0 else '' }}€{{ "%.2f"|format(s.unrealized_pnl) }}
97
+ </td>
98
+ <td style="text-align:center">
99
+ {% if s.obligation_met %}
100
+ <span class="badge-ok">Met</span>
101
+ {% else %}
102
+ <span class="badge-bad">Not met</span>
103
+ {% endif %}
104
+ </td>
105
+ </tr>
106
+ {% endfor %}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+ {% endif %}
111
+ </div>
112
+
113
+ <!-- Right column: Order entry + Trade history -->
114
+ <div>
115
+ <!-- Order entry -->
116
+ <div class="card">
117
+ <h2>Submit Order</h2>
118
+ <div id="order-msg" style="display:none;" class="flash"></div>
119
+ <form id="order-form">
120
+ <div class="form-group">
121
+ <label>Symbol</label>
122
+ <select name="symbol" id="symbol" required>
123
+ {% for s in symbols %}
124
+ <option value="{{ s }}">{{ s }}</option>
125
+ {% endfor %}
126
+ </select>
127
+ </div>
128
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
129
+ <div class="form-group">
130
+ <label>Side</label>
131
+ <select name="side" id="side" required>
132
+ <option value="BUY">BUY</option>
133
+ <option value="SELL">SELL</option>
134
+ </select>
135
+ </div>
136
+ <div class="form-group">
137
+ <label>Quantity</label>
138
+ <input type="number" name="quantity" id="quantity" min="1" value="100" required>
139
+ </div>
140
+ </div>
141
+ <div class="form-group">
142
+ <label>Limit Price (€)</label>
143
+ <input type="number" name="price" id="price" min="0.01" step="0.05" value="" required>
144
+ </div>
145
+ <button type="submit" class="btn btn-primary" style="width:100%;">Submit Order</button>
146
+ </form>
147
+ </div>
148
+
149
+ <!-- Today's trades -->
150
+ <div class="card" style="padding:0; overflow:hidden;">
151
+ <div style="padding:16px 20px 12px; border-bottom:1px solid var(--border);">
152
+ <h2 style="margin:0;">Today's Trades</h2>
153
+ </div>
154
+ {% if trades %}
155
+ <table id="trades-table">
156
+ <thead>
157
+ <tr>
158
+ <th>Time</th>
159
+ <th>Symbol</th>
160
+ <th style="text-align:center">Side</th>
161
+ <th style="text-align:right">Qty</th>
162
+ <th style="text-align:right">Price</th>
163
+ <th style="text-align:right">Value</th>
164
+ </tr>
165
+ </thead>
166
+ <tbody>
167
+ {% for t in trades %}
168
+ <tr>
169
+ <td style="color:var(--muted); font-size:11px;">
170
+ {{ t.timestamp | ts }}
171
+ </td>
172
+ <td><strong>{{ t.symbol }}</strong></td>
173
+ <td style="text-align:center">
174
+ <span class="{{ 'positive' if t.side == 'BUY' else 'negative' }}">{{ t.side }}</span>
175
+ </td>
176
+ <td style="text-align:right">{{ t.quantity }}</td>
177
+ <td style="text-align:right">€{{ "%.2f"|format(t.price) }}</td>
178
+ <td style="text-align:right">€{{ "{:,.2f}".format(t.quantity * t.price) }}</td>
179
+ </tr>
180
+ {% endfor %}
181
+ </tbody>
182
+ </table>
183
+ {% else %}
184
+ <p style="padding:20px; color:var(--muted); text-align:center;">No trades today</p>
185
+ {% endif %}
186
+ </div>
187
+ </div>
188
+ </div>
189
+ {% endblock %}
190
+
191
+ {% block extra_scripts %}
192
+ // Order form submission
193
+ document.getElementById('order-form').addEventListener('submit', async function(e) {
194
+ e.preventDefault();
195
+ const msg = document.getElementById('order-msg');
196
+ const data = {
197
+ symbol: document.getElementById('symbol').value,
198
+ side: document.getElementById('side').value,
199
+ quantity: parseInt(document.getElementById('quantity').value),
200
+ price: parseFloat(document.getElementById('price').value),
201
+ };
202
+ try {
203
+ const resp = await fetch('/ch/order', {
204
+ method: 'POST',
205
+ headers: {'Content-Type': 'application/json'},
206
+ body: JSON.stringify(data),
207
+ });
208
+ const result = await resp.json();
209
+ msg.style.display = 'block';
210
+ if (resp.ok) {
211
+ msg.className = 'flash';
212
+ msg.style.background = 'rgba(63,185,80,.12)';
213
+ msg.style.border = '1px solid var(--green)';
214
+ msg.style.color = 'var(--green)';
215
+ msg.textContent = `Order submitted: ${data.side} ${data.quantity} ${data.symbol} @ €${data.price}`;
216
+ setTimeout(() => location.reload(), 1500);
217
+ } else {
218
+ msg.className = 'flash flash-error';
219
+ msg.textContent = result.error || 'Order failed';
220
+ }
221
+ } catch(err) {
222
+ msg.style.display = 'block';
223
+ msg.className = 'flash flash-error';
224
+ msg.textContent = 'Network error';
225
+ }
226
+ });
227
+
228
+ // Auto-refresh portfolio every 15 seconds
229
+ setInterval(async () => {
230
+ try {
231
+ const resp = await fetch('/ch/api/portfolio');
232
+ if (!resp.ok) return;
233
+ const d = await resp.json();
234
+ // Just reload the page for simplicity
235
+ // (A production app would do a DOM patch)
236
+ } catch(e) {}
237
+ }, 15000);
238
+ {% endblock %}
dashboard/dashboard.py CHANGED
@@ -570,6 +570,14 @@ def _do_session_end():
570
  session_state["suspended"] = False
571
  broadcast_event("session", {"status": "ended", "time": time.time()})
572
 
 
 
 
 
 
 
 
 
573
 
574
  def schedule_runner():
575
  """Background thread: auto start/end session based on market_schedule.txt."""
 
570
  session_state["suspended"] = False
571
  broadcast_event("session", {"status": "ended", "time": time.time()})
572
 
573
+ # Notify Clearing House for EOD settlement
574
+ try:
575
+ ch_url = os.getenv("CH_SERVICE_URL", "http://localhost:5004")
576
+ requests.post(f"{ch_url}/ch/eod", timeout=10)
577
+ print("[Dashboard] CH EOD settlement triggered")
578
+ except Exception as e:
579
+ print(f"[Dashboard] CH EOD hook failed (non-critical): {e}")
580
+
581
 
582
  def schedule_runner():
583
  """Background thread: auto start/end session based on market_schedule.txt."""
docker-compose.yml CHANGED
@@ -181,6 +181,36 @@ services:
181
  - FRONTEND_URL=http://localhost:5000
182
  - HF_TOKEN=${HF_TOKEN:-}
183
  - HF_MODEL=${HF_MODEL:-Qwen/Qwen2.5-7B-Instruct}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  volumes:
186
  matcher_data: # Persists SQLite database across container restarts
 
 
181
  - FRONTEND_URL=http://localhost:5000
182
  - HF_TOKEN=${HF_TOKEN:-}
183
  - HF_MODEL=${HF_MODEL:-Qwen/Qwen2.5-7B-Instruct}
184
+ - CH_SERVICE_URL=http://clearing_house:5004
185
+
186
+ clearing_house:
187
+ build:
188
+ context: .
189
+ dockerfile: clearing_house/Dockerfile
190
+ container_name: clearing_house
191
+ ports:
192
+ - "5004:5004"
193
+ volumes:
194
+ - ./shared:/app/shared
195
+ - ./shared_data:/app/shared_data # securities.txt read-only
196
+ - ch_data:/app/data # clearing_house.db persistence
197
+ depends_on:
198
+ - kafka
199
+ - matcher
200
+ environment:
201
+ - KAFKA_BOOTSTRAP=kafka:9092
202
+ - MATCHER_URL=http://matcher:6000
203
+ - SECURITIES_FILE=/app/shared_data/securities.txt
204
+ - CH_DB_PATH=/app/data/clearing_house.db
205
+ - CH_PORT=5004
206
+ - HF_TOKEN=${HF_TOKEN:-}
207
+ - HF_MODEL=${HF_MODEL:-Qwen/Qwen2.5-7B-Instruct}
208
+ - GROQ_API_KEY=${GROQ_API_KEY:-}
209
+ - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
210
+ - OLLAMA_HOST=${OLLAMA_HOST:-}
211
+ extra_hosts:
212
+ - "host.docker.internal:host-gateway"
213
 
214
  volumes:
215
  matcher_data: # Persists SQLite database across container restarts
216
+ ch_data: # Persists Clearing House SQLite DB and securities file
entrypoint.sh CHANGED
@@ -73,6 +73,12 @@ echo "[startup] Starting Frontend on port 5003..."
73
  PORT=$FRONTEND_PORT TEMPLATE_FOLDER=/app/frontend_templates python3 /app/frontend.py &
74
  sleep 2
75
 
 
 
 
 
 
 
76
  # ── nginx (reverse proxy: port 7860 → dashboard:5000 + fix-ui:5002 + frontend:5003) ──
77
  echo "[startup] Starting nginx on port 7860..."
78
  nginx
 
73
  PORT=$FRONTEND_PORT TEMPLATE_FOLDER=/app/frontend_templates python3 /app/frontend.py &
74
  sleep 2
75
 
76
+ echo "[startup] Starting Clearing House on port 5004..."
77
+ CH_PORT=5004 CH_SERVICE_URL=http://localhost:5004 \
78
+ MATCHER_URL=http://localhost:6000 \
79
+ PYTHONPATH=/app python3 /app/clearing_house/app.py &
80
+ sleep 3
81
+
82
  # ── nginx (reverse proxy: port 7860 → dashboard:5000 + fix-ui:5002 + frontend:5003) ──
83
  echo "[startup] Starting nginx on port 7860..."
84
  nginx
nginx.conf CHANGED
@@ -44,6 +44,24 @@ http {
44
  proxy_redirect / /fix/;
45
  }
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  # Dashboard – everything else
48
  location / {
49
  proxy_pass http://127.0.0.1:5000;
 
44
  proxy_redirect / /fix/;
45
  }
46
 
47
+ # Clearing House SSE stream – disable buffering
48
+ location /ch/stream {
49
+ proxy_pass http://127.0.0.1:5004/ch/stream;
50
+ proxy_set_header Host $host;
51
+ proxy_buffering off;
52
+ proxy_cache off;
53
+ proxy_read_timeout 3600s;
54
+ chunked_transfer_encoding on;
55
+ }
56
+
57
+ # Clearing House portal
58
+ location /ch/ {
59
+ proxy_pass http://127.0.0.1:5004/ch/;
60
+ proxy_set_header Host $host;
61
+ proxy_set_header X-Real-IP $remote_addr;
62
+ proxy_buffering off;
63
+ }
64
+
65
  # Dashboard – everything else
66
  location / {
67
  proxy_pass http://127.0.0.1:5000;
shared/config.py CHANGED
@@ -35,3 +35,10 @@ class Config:
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"))
 
 
 
 
 
 
 
 
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"))
38
+
39
+ # Clearing House
40
+ CH_DB_PATH: str = os.getenv("CH_DB_PATH", "/app/data/clearing_house.db")
41
+ CH_MEMBERS: list = [f"USR{i:02d}" for i in range(1, 11)]
42
+ CH_STARTING_CAPITAL: float = 100_000.0
43
+ CH_DAILY_OBLIGATION: int = 10
44
+ CH_SERVICE_URL: str = os.getenv("CH_SERVICE_URL", "http://localhost:5004")