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 +3 -0
- clearing_house/Dockerfile +17 -0
- clearing_house/__init__.py +0 -0
- clearing_house/app.py +393 -0
- clearing_house/ch_ai_trader.py +473 -0
- clearing_house/ch_database.py +294 -0
- clearing_house/templates/base.html +250 -0
- clearing_house/templates/dashboard.html +130 -0
- clearing_house/templates/login.html +39 -0
- clearing_house/templates/portfolio.html +238 -0
- dashboard/dashboard.py +8 -0
- docker-compose.yml +30 -0
- entrypoint.sh +6 -0
- nginx.conf +18 -0
- shared/config.py +7 -0
|
@@ -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 |
|
|
@@ -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"]
|
|
File without changes
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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>
|
|
@@ -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&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 %}
|
|
@@ -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 · e.g. USR01 / USR01
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
{% endblock %}
|
|
@@ -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&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&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&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 %}
|
|
@@ -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."""
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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;
|
|
@@ -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")
|