Commit ·
9e5fa5b
0
Parent(s):
Initial commit: StockEx trading platform
Browse filesKafka-based stock exchange simulation with:
- Order matcher engine with SQLite persistence
- FIX protocol OEG server and UI clients
- Frontend order entry with real-time book/trades
- Trading dashboard with SSE streaming and charts
- Market data feeder, consumer, and snapshot viewer
- Docker Compose orchestration for all services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .gitignore +50 -0
- Dockerfile.base +13 -0
- IMPROVEMENT_PLAN.md +271 -0
- README_OPTIQ.md +73 -0
- StockEx_Developer_Guide.html +569 -0
- StockEx_Technical_Guide.md +814 -0
- StockEx_User_Guide.html +428 -0
- consumer/Dockerfile +6 -0
- consumer/consumer.py +22 -0
- consumer/requirements.txt +2 -0
- dashboard/Dockerfile +10 -0
- dashboard/dashboard.py +249 -0
- dashboard/templates/index - Copy (6).html +198 -0
- dashboard/templates/index.html +964 -0
- dashboard/templates/index_Matcher.html +194 -0
- docker-compose.yml +164 -0
- fix-ui-client/Dockerfile +12 -0
- fix-ui-client/FIX44.xml +0 -0
- fix-ui-client/client1.cfg +21 -0
- fix-ui-client/client2.cfg +22 -0
- fix-ui-client/fix-ui-client.py +187 -0
- fix-ui-client/requirements.txt +0 -0
- fix-ui-client/templates/index.html +162 -0
- fix_oeg/Dockerfile +45 -0
- fix_oeg/FIX44.xml +0 -0
- fix_oeg/fix_oeg_server.py +341 -0
- fix_oeg/fix_server.cfg +27 -0
- fix_oeg/requirements.txt +2 -0
- frontend/Dockerfile +7 -0
- frontend/frontend.py +98 -0
- frontend/requirements.txt +4 -0
- frontend/templates/index.html +312 -0
- matcher/Dockerfile +6 -0
- matcher/database.py +242 -0
- matcher/matcher - Copy.py +135 -0
- matcher/matcher.py +547 -0
- matcher/requirements.txt +4 -0
- matcher/test_matcher.py +429 -0
- md_feeder/Dockerfile +8 -0
- md_feeder/mdf_simulator.py +133 -0
- oeg/Dockerfile +10 -0
- oeg/oeg_simulator.py +29 -0
- requirements.txt +2 -0
- shared/__init__.py +6 -0
- shared/config.py +31 -0
- shared/kafka_utils.py +114 -0
- shared/logging_utils.py +168 -0
- shared_data/securities.txt +5 -0
- snapshot_viewer/Dockerfile +8 -0
- snapshot_viewer/snapshot_viewer.py +64 -0
.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
*.egg-info/
|
| 8 |
+
dist/
|
| 9 |
+
build/
|
| 10 |
+
*.egg
|
| 11 |
+
|
| 12 |
+
# Virtual environments
|
| 13 |
+
venv/
|
| 14 |
+
.venv/
|
| 15 |
+
env/
|
| 16 |
+
|
| 17 |
+
# FIX session runtime files
|
| 18 |
+
fix-ui-client/log/
|
| 19 |
+
fix-ui-client/store/
|
| 20 |
+
fix_oeg/log/
|
| 21 |
+
fix_oeg/store/
|
| 22 |
+
|
| 23 |
+
# Application logs
|
| 24 |
+
logs/
|
| 25 |
+
|
| 26 |
+
# Runtime state
|
| 27 |
+
shared_data/order_id.txt
|
| 28 |
+
|
| 29 |
+
# SQLite databases
|
| 30 |
+
*.db
|
| 31 |
+
*.sqlite3
|
| 32 |
+
|
| 33 |
+
# Docker volumes / local data
|
| 34 |
+
matcher_data/
|
| 35 |
+
|
| 36 |
+
# Windows artifact
|
| 37 |
+
nul
|
| 38 |
+
|
| 39 |
+
# Claude Code local config
|
| 40 |
+
.claude/
|
| 41 |
+
|
| 42 |
+
# IDE
|
| 43 |
+
.vscode/
|
| 44 |
+
.idea/
|
| 45 |
+
*.swp
|
| 46 |
+
*.swo
|
| 47 |
+
|
| 48 |
+
# OS
|
| 49 |
+
.DS_Store
|
| 50 |
+
Thumbs.db
|
Dockerfile.base
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install build tools and deps for QuickFIX
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
build-essential \
|
| 6 |
+
gcc \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
WORKDIR /opt
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
IMPROVEMENT_PLAN.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# KafkaTradingSystem Improvement Plan
|
| 2 |
+
|
| 3 |
+
> **Created:** 2026-02-03
|
| 4 |
+
> **Status:** ✅ Core implementation complete (Phases 1-5 done)
|
| 5 |
+
> **Last Updated:** 2026-02-03
|
| 6 |
+
> **Resume:** Share this file with Claude to continue from where we left off
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Overview
|
| 11 |
+
|
| 12 |
+
This plan outlines improvements to transform the trading system from a demo/prototype into a more robust, production-like system. Improvements are organized by priority and complexity.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Phase 1: Real-Time Dashboard (WebSocket/SSE)
|
| 17 |
+
|
| 18 |
+
**Problem:** Dashboard polls `/data` every 2 seconds (`setInterval(refresh, 2000)`), causing unnecessary load and delayed updates.
|
| 19 |
+
|
| 20 |
+
**Solution:** Implement Server-Sent Events (SSE) for real-time push updates.
|
| 21 |
+
|
| 22 |
+
### Tasks
|
| 23 |
+
|
| 24 |
+
- [x] **1.1** Add SSE endpoint to `dashboard/dashboard.py` ✅
|
| 25 |
+
- Create `/stream` endpoint using Flask's `Response` with `text/event-stream`
|
| 26 |
+
- Push events when new orders/trades/snapshots arrive from Kafka
|
| 27 |
+
- File: `dashboard/dashboard.py`
|
| 28 |
+
|
| 29 |
+
- [x] **1.2** Update dashboard frontend to use SSE ✅
|
| 30 |
+
- Replace `setInterval(refresh, 2000)` with `EventSource('/stream')`
|
| 31 |
+
- Handle reconnection on disconnect
|
| 32 |
+
- File: `dashboard/templates/index.html`
|
| 33 |
+
|
| 34 |
+
- [x] **1.3** Add connection status indicator ✅
|
| 35 |
+
- Show connected/disconnected state in UI
|
| 36 |
+
- File: `dashboard/templates/index.html`
|
| 37 |
+
|
| 38 |
+
### Why SSE over WebSocket?
|
| 39 |
+
- Simpler implementation (HTTP-based, no special protocol)
|
| 40 |
+
- One-way server-to-client is sufficient for this use case
|
| 41 |
+
- Better browser support and automatic reconnection
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Phase 2: SQLite Persistence
|
| 46 |
+
|
| 47 |
+
**Problem:** All data is in-memory; order books and trades lost on restart.
|
| 48 |
+
|
| 49 |
+
**Solution:** Add SQLite database for persistence with in-memory caching.
|
| 50 |
+
|
| 51 |
+
### Tasks
|
| 52 |
+
|
| 53 |
+
- [x] **2.1** Create database schema and initialization ✅
|
| 54 |
+
- Tables: `orders`, `trades`, `order_book_entries`
|
| 55 |
+
- Add migration/init script
|
| 56 |
+
- New file: `matcher/database.py`
|
| 57 |
+
|
| 58 |
+
- [x] **2.2** Modify matcher to persist trades ✅
|
| 59 |
+
- Write trades to SQLite when matched
|
| 60 |
+
- Load recent trades on startup
|
| 61 |
+
- File: `matcher/matcher.py`
|
| 62 |
+
|
| 63 |
+
- [x] **2.3** Persist order book state ✅
|
| 64 |
+
- Save resting orders to database
|
| 65 |
+
- Restore order books on matcher restart
|
| 66 |
+
- File: `matcher/matcher.py`
|
| 67 |
+
|
| 68 |
+
- [x] **2.4** Add trade history endpoint ✅
|
| 69 |
+
- `GET /trades?symbol=X&limit=N&offset=M`
|
| 70 |
+
- Support filtering and pagination
|
| 71 |
+
- File: `matcher/matcher.py`
|
| 72 |
+
|
| 73 |
+
- [x] **2.5** Add Docker volume for database persistence ✅
|
| 74 |
+
- Mount SQLite file to host
|
| 75 |
+
- File: `docker-compose.yml`
|
| 76 |
+
|
| 77 |
+
### Schema Design
|
| 78 |
+
|
| 79 |
+
```sql
|
| 80 |
+
CREATE TABLE trades (
|
| 81 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 82 |
+
symbol TEXT NOT NULL,
|
| 83 |
+
price REAL NOT NULL,
|
| 84 |
+
quantity INTEGER NOT NULL,
|
| 85 |
+
buy_order_id TEXT,
|
| 86 |
+
sell_order_id TEXT,
|
| 87 |
+
timestamp REAL NOT NULL,
|
| 88 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
CREATE TABLE order_book (
|
| 92 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 93 |
+
cl_ord_id TEXT UNIQUE,
|
| 94 |
+
symbol TEXT NOT NULL,
|
| 95 |
+
side TEXT NOT NULL, -- 'BUY' or 'SELL'
|
| 96 |
+
price REAL,
|
| 97 |
+
quantity INTEGER NOT NULL,
|
| 98 |
+
remaining_qty INTEGER NOT NULL,
|
| 99 |
+
status TEXT DEFAULT 'OPEN', -- OPEN, FILLED, CANCELLED
|
| 100 |
+
timestamp REAL NOT NULL,
|
| 101 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
CREATE INDEX idx_trades_symbol ON trades(symbol);
|
| 105 |
+
CREATE INDEX idx_trades_timestamp ON trades(timestamp);
|
| 106 |
+
CREATE INDEX idx_orderbook_symbol_side ON order_book(symbol, side);
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## Phase 3: FIX Protocol Improvements
|
| 112 |
+
|
| 113 |
+
**Problem:** Basic FIX handling with no validation, no execution reports, no order management.
|
| 114 |
+
|
| 115 |
+
### Tasks
|
| 116 |
+
|
| 117 |
+
- [x] **3.1** Add FIX message validation ✅
|
| 118 |
+
- Validate required fields (ClOrdID, Symbol, Side, OrderQty)
|
| 119 |
+
- Validate field formats and ranges
|
| 120 |
+
- Return Reject (35=3) for invalid messages
|
| 121 |
+
- File: `fix_oeg/fix_oeg_server.py`
|
| 122 |
+
|
| 123 |
+
- [x] **3.2** Send Execution Reports back to clients ✅
|
| 124 |
+
- Send 35=8 (ExecutionReport) for order acknowledgment
|
| 125 |
+
- Send fill reports when trades occur
|
| 126 |
+
- Requires Kafka consumer for trades topic in fix_oeg
|
| 127 |
+
- File: `fix_oeg/fix_oeg_server.py`
|
| 128 |
+
|
| 129 |
+
- [x] **3.3** Support Order Cancel Request (35=F) ✅
|
| 130 |
+
- Parse cancel requests
|
| 131 |
+
- Publish to orders Kafka topic (with type=cancel)
|
| 132 |
+
- Files: `fix_oeg/fix_oeg_server.py`
|
| 133 |
+
|
| 134 |
+
- [x] **3.4** Support Order Cancel/Replace (35=G) ✅
|
| 135 |
+
- Parse modify requests
|
| 136 |
+
- Implement price/quantity amendments
|
| 137 |
+
- Files: `fix_oeg/fix_oeg_server.py`, `matcher/matcher.py`
|
| 138 |
+
|
| 139 |
+
- [ ] **3.5** Add session-level validation (DEFERRED)
|
| 140 |
+
- Sequence number checking handled by QuickFIX engine
|
| 141 |
+
- Heartbeat/TestRequest handled automatically
|
| 142 |
+
- File: `fix_oeg/fix_oeg_server.py`
|
| 143 |
+
|
| 144 |
+
### Execution Report Fields (35=8)
|
| 145 |
+
|
| 146 |
+
```
|
| 147 |
+
Tag 35 = 8 (ExecutionReport)
|
| 148 |
+
Tag 37 = OrderID (exchange-assigned)
|
| 149 |
+
Tag 11 = ClOrdID (client order ID)
|
| 150 |
+
Tag 17 = ExecID
|
| 151 |
+
Tag 20 = ExecTransType (0=New)
|
| 152 |
+
Tag 39 = OrdStatus (0=New, 1=PartialFill, 2=Filled)
|
| 153 |
+
Tag 150 = ExecType (0=New, F=Trade)
|
| 154 |
+
Tag 55 = Symbol
|
| 155 |
+
Tag 54 = Side
|
| 156 |
+
Tag 38 = OrderQty
|
| 157 |
+
Tag 44 = Price
|
| 158 |
+
Tag 32 = LastShares (fill qty)
|
| 159 |
+
Tag 31 = LastPx (fill price)
|
| 160 |
+
Tag 14 = CumQty
|
| 161 |
+
Tag 151 = LeavesQty
|
| 162 |
+
Tag 6 = AvgPx
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## Phase 4: Enhanced Order Types
|
| 168 |
+
|
| 169 |
+
**Problem:** Only limit orders supported.
|
| 170 |
+
|
| 171 |
+
### Tasks
|
| 172 |
+
|
| 173 |
+
- [x] **4.1** Support Market Orders ✅
|
| 174 |
+
- Match immediately at best available price
|
| 175 |
+
- No price field required (OrdType=1)
|
| 176 |
+
- File: `matcher/matcher.py`
|
| 177 |
+
|
| 178 |
+
- [x] **4.2** Support IOC (Immediate-or-Cancel) ✅
|
| 179 |
+
- TimeInForce=3: Fill what's available, cancel rest
|
| 180 |
+
- File: `matcher/matcher.py`
|
| 181 |
+
|
| 182 |
+
- [x] **4.3** Support FOK (Fill-or-Kill) ✅
|
| 183 |
+
- TimeInForce=4: Fill entire order or reject
|
| 184 |
+
- File: `matcher/matcher.py`
|
| 185 |
+
|
| 186 |
+
- [x] **4.4** Support GTC (Good-Till-Cancel) ✅
|
| 187 |
+
- TimeInForce=1: Persist until explicitly cancelled
|
| 188 |
+
- Implemented via SQLite persistence (Phase 2)
|
| 189 |
+
- File: `matcher/matcher.py`
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## Phase 5: Monitoring & Observability
|
| 194 |
+
|
| 195 |
+
### Tasks
|
| 196 |
+
|
| 197 |
+
- [x] **5.1** Add health check endpoints ✅
|
| 198 |
+
- `/health` endpoint for matcher, dashboard, frontend
|
| 199 |
+
- Check Kafka/DB connectivity and service stats
|
| 200 |
+
- Files: `matcher/matcher.py`, `dashboard/dashboard.py`, `frontend/frontend.py`
|
| 201 |
+
|
| 202 |
+
- [x] **5.2** Add structured logging ✅
|
| 203 |
+
- JSON log format for parsing
|
| 204 |
+
- Include correlation IDs
|
| 205 |
+
- New file: `shared/logging_utils.py`
|
| 206 |
+
|
| 207 |
+
- [x] **5.3** Add metrics endpoint ✅
|
| 208 |
+
- Order count, trade count, latency stats
|
| 209 |
+
- Prometheus-compatible format (`/metrics` endpoint)
|
| 210 |
+
- File: `matcher/matcher.py`
|
| 211 |
+
|
| 212 |
+
- [ ] **5.4** Add Kafka lag monitoring (OPTIONAL)
|
| 213 |
+
- Track consumer group lag
|
| 214 |
+
- Alert on high lag
|
| 215 |
+
- New file: `shared/monitoring.py`
|
| 216 |
+
|
| 217 |
+
---
|
| 218 |
+
|
| 219 |
+
## Phase 6: Testing & Quality
|
| 220 |
+
|
| 221 |
+
### Tasks
|
| 222 |
+
|
| 223 |
+
- [x] **6.1** Add unit tests for matcher ✅
|
| 224 |
+
- Test matching logic (full/partial fills, price-time priority)
|
| 225 |
+
- Test order types (market, limit, IOC, FOK)
|
| 226 |
+
- Test cancel and amend operations
|
| 227 |
+
- New file: `matcher/test_matcher.py`
|
| 228 |
+
|
| 229 |
+
- [ ] **6.2** Add integration tests (OPTIONAL)
|
| 230 |
+
- End-to-end order flow tests
|
| 231 |
+
- FIX client to trade execution
|
| 232 |
+
- New directory: `tests/`
|
| 233 |
+
|
| 234 |
+
- [ ] **6.3** Add load testing script (OPTIONAL)
|
| 235 |
+
- Generate high-volume order flow
|
| 236 |
+
- Measure latency and throughput
|
| 237 |
+
- New file: `tests/load_test.py`
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## Implementation Order (Recommended)
|
| 242 |
+
|
| 243 |
+
1. **Phase 1** (Real-Time Dashboard) - Quick win, improves UX
|
| 244 |
+
2. **Phase 2** (SQLite Persistence) - Foundation for reliability
|
| 245 |
+
3. **Phase 3.1-3.2** (FIX Validation + Execution Reports) - Core trading functionality
|
| 246 |
+
4. **Phase 5.1** (Health Checks) - Operational necessity
|
| 247 |
+
5. **Phase 4.1** (Market Orders) - Common order type
|
| 248 |
+
6. **Phase 3.3** (Order Cancellation) - Essential for trading
|
| 249 |
+
7. Remaining phases as needed
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## Quick Reference: Key Files
|
| 254 |
+
|
| 255 |
+
| Component | Main File | Port |
|
| 256 |
+
|-----------|-----------|------|
|
| 257 |
+
| FIX Gateway | `fix_oeg/fix_oeg_server.py` | 5001 |
|
| 258 |
+
| Matcher | `matcher/matcher.py` | 6000 |
|
| 259 |
+
| Dashboard | `dashboard/dashboard.py` | 5005 |
|
| 260 |
+
| Frontend | `frontend/frontend.py` | 5000 |
|
| 261 |
+
| Config | `shared/config.py` | - |
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## How to Continue
|
| 266 |
+
|
| 267 |
+
When resuming work, tell Claude:
|
| 268 |
+
1. Which phase/task to work on (e.g., "Let's implement Phase 1.1")
|
| 269 |
+
2. Or ask Claude to pick the next logical task
|
| 270 |
+
|
| 271 |
+
Each task is designed to be implementable in a single session.
|
README_OPTIQ.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# StockEx — Euronext/OPTIQ Trading System Emulator
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
**StockEx** is a real-time trading simulation platform inspired by Euronext OPTIQ. It provides a complete order flow from FIX protocol entry through matching engine to trade execution, with live visualization via a web dashboard.
|
| 6 |
+
|
| 7 |
+
## Architecture
|
| 8 |
+
|
| 9 |
+
| Component | Description |
|
| 10 |
+
|-----------|-------------|
|
| 11 |
+
| **FIX OEG** | QuickFIX acceptor receiving FIX 4.4 orders |
|
| 12 |
+
| **Kafka** | Message backbone (topics: `orders`, `snapshots`, `trades`) |
|
| 13 |
+
| **Matcher** | Order matching engine with price-time priority |
|
| 14 |
+
| **MDF** | Market Data Feeder publishing BBO snapshots |
|
| 15 |
+
| **Dashboard** | Real-time web UI with SSE streaming |
|
| 16 |
+
|
| 17 |
+
## Data Flow
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
FIX Client → FIX OEG → Kafka [orders] → Matcher → Kafka [trades]
|
| 21 |
+
↓
|
| 22 |
+
MDF → Kafka [snapshots] ───────────→ Dashboard (Flask)
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Dashboard Features
|
| 26 |
+
|
| 27 |
+
- **Orders Panel** — Live order feed with Edit/Cancel actions
|
| 28 |
+
- **Market Snapshot** — Best Bid/Ask with spread, mid price, and scrolling ticker
|
| 29 |
+
- **Trades Panel** — Executed trades with value calculation
|
| 30 |
+
- **Order Book** — Depth of book per symbol (bids/asks)
|
| 31 |
+
- **Trade Chart** — Price and volume visualization
|
| 32 |
+
- **Trading Statistics** — Volume, Value, VWAP per symbol with bar charts
|
| 33 |
+
|
| 34 |
+

|
| 35 |
+
|
| 36 |
+
## Quick Start
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
docker compose up --build
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Open dashboard: **http://localhost:5005**
|
| 43 |
+
|
| 44 |
+
## Directory Structure
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
StockEx/
|
| 48 |
+
├── docker-compose.yml
|
| 49 |
+
├── fix_oeg/ # FIX Order Entry Gateway
|
| 50 |
+
├── fix-ui-client/ # FIX test clients
|
| 51 |
+
├── matcher/ # Order matching engine
|
| 52 |
+
├── md_feeder/ # Market data simulator
|
| 53 |
+
├── dashboard/ # Flask web dashboard
|
| 54 |
+
├── frontend/ # Manual order entry UI
|
| 55 |
+
├── shared/ # Common config and utilities
|
| 56 |
+
└── shared_data/ # Securities and state files
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Configuration
|
| 60 |
+
|
| 61 |
+
| Variable | Default | Description |
|
| 62 |
+
|----------|---------|-------------|
|
| 63 |
+
| `KAFKA_BOOTSTRAP` | kafka:9092 | Kafka broker address |
|
| 64 |
+
| `TICK_SIZE` | 0.05 | Minimum price increment |
|
| 65 |
+
| `ORDERS_PER_MIN` | 8 | MDF order generation rate |
|
| 66 |
+
|
| 67 |
+
## Requirements
|
| 68 |
+
|
| 69 |
+
- Docker & Docker Compose
|
| 70 |
+
- Ports: 5005 (Dashboard), 6000 (Matcher), 9092 (Kafka)
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
*StockEx v1.0 — Trading Simulation Platform*
|
StockEx_Developer_Guide.html
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>StockEx - Developer Guide</title>
|
| 6 |
+
<style>
|
| 7 |
+
@page { margin: 1.5cm; size: A4; }
|
| 8 |
+
body { font-family: 'Segoe UI', Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.5; color: #333; font-size: 11pt; }
|
| 9 |
+
h1 { color: #1a1a2e; border-bottom: 3px solid #4CAF50; padding-bottom: 10px; font-size: 24pt; }
|
| 10 |
+
h2 { color: #2e7d32; margin-top: 25px; border-bottom: 1px solid #ddd; padding-bottom: 5px; font-size: 16pt; page-break-after: avoid; }
|
| 11 |
+
h3 { color: #1565c0; margin-top: 18px; font-size: 13pt; }
|
| 12 |
+
h4 { color: #555; margin-top: 12px; font-size: 11pt; }
|
| 13 |
+
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 10pt; }
|
| 14 |
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
| 15 |
+
th { background: #f5f5f5; font-weight: bold; }
|
| 16 |
+
tr:nth-child(even) { background: #fafafa; }
|
| 17 |
+
.header { text-align: center; margin-bottom: 25px; }
|
| 18 |
+
.subtitle { color: #666; font-size: 14pt; }
|
| 19 |
+
.version { color: #999; font-size: 10pt; }
|
| 20 |
+
.section { page-break-inside: avoid; margin-bottom: 20px; }
|
| 21 |
+
.highlight { background: #e8f5e9; padding: 12px; border-radius: 5px; margin: 10px 0; border-left: 4px solid #4CAF50; }
|
| 22 |
+
.info { background: #e3f2fd; padding: 12px; border-radius: 5px; margin: 10px 0; border-left: 4px solid #2196F3; }
|
| 23 |
+
.warning { background: #fff3e0; padding: 12px; border-radius: 5px; margin: 10px 0; border-left: 4px solid #ff9800; }
|
| 24 |
+
.code-block { background: #263238; color: #aed581; padding: 15px; border-radius: 5px; font-family: 'Consolas', monospace; font-size: 10pt; overflow-x: auto; margin: 10px 0; white-space: pre; }
|
| 25 |
+
.json { background: #37474f; color: #80cbc4; }
|
| 26 |
+
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-family: 'Consolas', monospace; font-size: 10pt; }
|
| 27 |
+
.green { color: #2e7d32; }
|
| 28 |
+
.red { color: #c62828; }
|
| 29 |
+
.blue { color: #1565c0; }
|
| 30 |
+
hr { border: none; border-top: 1px solid #ddd; margin: 25px 0; }
|
| 31 |
+
.footer { text-align: center; color: #666; font-size: 9pt; margin-top: 30px; padding-top: 15px; border-top: 1px solid #ddd; }
|
| 32 |
+
.toc { background: #fafafa; padding: 15px; border-radius: 5px; margin: 15px 0; columns: 2; }
|
| 33 |
+
.toc ul { margin: 0; padding-left: 20px; }
|
| 34 |
+
.toc li { margin: 4px 0; font-size: 10pt; }
|
| 35 |
+
.arch-diagram { background: #f5f5f5; padding: 15px; border-radius: 5px; font-family: 'Consolas', monospace; white-space: pre; font-size: 9pt; overflow-x: auto; line-height: 1.3; }
|
| 36 |
+
.module-box { border: 1px solid #ddd; border-radius: 6px; padding: 12px; margin: 12px 0; background: #fafafa; page-break-inside: avoid; }
|
| 37 |
+
.module-box h4 { margin: 0 0 8px 0; color: #1a1a2e; border-bottom: 1px solid #eee; padding-bottom: 5px; }
|
| 38 |
+
.port { display: inline-block; background: #e3f2fd; padding: 2px 8px; border-radius: 3px; font-family: monospace; font-size: 9pt; margin-right: 5px; }
|
| 39 |
+
.tech { display: inline-block; background: #f3e5f5; padding: 2px 8px; border-radius: 3px; font-size: 9pt; margin-right: 5px; }
|
| 40 |
+
.endpoint { font-family: monospace; background: #e8f5e9; padding: 2px 6px; border-radius: 3px; }
|
| 41 |
+
.two-col { display: flex; gap: 20px; }
|
| 42 |
+
.two-col > div { flex: 1; }
|
| 43 |
+
.screenshot { text-align: center; margin: 15px 0; }
|
| 44 |
+
.screenshot img { max-width: 100%; border: 1px solid #ddd; border-radius: 5px; }
|
| 45 |
+
</style>
|
| 46 |
+
</head>
|
| 47 |
+
<body>
|
| 48 |
+
|
| 49 |
+
<div class="header">
|
| 50 |
+
<h1>StockEx Trading Platform</h1>
|
| 51 |
+
<p class="subtitle">Developer & Technical Guide</p>
|
| 52 |
+
<p class="version">Version 1.0 | Euronext OPTIQ Inspired</p>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="toc">
|
| 56 |
+
<strong>Contents</strong>
|
| 57 |
+
<ul>
|
| 58 |
+
<li>1. Overview</li>
|
| 59 |
+
<li>2. Architecture</li>
|
| 60 |
+
<li>3. Modules</li>
|
| 61 |
+
<li>4. Database Persistence</li>
|
| 62 |
+
<li>5. Data Flow Diagrams</li>
|
| 63 |
+
<li>6. Kafka Topics</li>
|
| 64 |
+
<li>7. Message Formats</li>
|
| 65 |
+
<li>8. SSE Events</li>
|
| 66 |
+
<li>9. Configuration</li>
|
| 67 |
+
<li>10. Development</li>
|
| 68 |
+
</ul>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<hr>
|
| 72 |
+
|
| 73 |
+
<div class="section">
|
| 74 |
+
<h2>1. Overview</h2>
|
| 75 |
+
<p><strong>StockEx</strong> is a real-time trading simulation platform providing complete order-to-trade lifecycle emulation. Built with microservices architecture using Docker containers.</p>
|
| 76 |
+
|
| 77 |
+
<div class="two-col">
|
| 78 |
+
<div class="highlight">
|
| 79 |
+
<strong>Core Features</strong>
|
| 80 |
+
<ul style="margin:5px 0; padding-left:20px;">
|
| 81 |
+
<li>FIX 4.4 protocol gateway</li>
|
| 82 |
+
<li>Price-time priority matching</li>
|
| 83 |
+
<li>Kafka event streaming</li>
|
| 84 |
+
<li>SSE real-time dashboard</li>
|
| 85 |
+
<li>SQLite persistence</li>
|
| 86 |
+
</ul>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="info">
|
| 89 |
+
<strong>Tech Stack</strong>
|
| 90 |
+
<ul style="margin:5px 0; padding-left:20px;">
|
| 91 |
+
<li>Python 3.11 / Flask</li>
|
| 92 |
+
<li>QuickFIX/Python</li>
|
| 93 |
+
<li>Apache Kafka 7.5</li>
|
| 94 |
+
<li>Docker Compose</li>
|
| 95 |
+
<li>SQLite</li>
|
| 96 |
+
</ul>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div class="section">
|
| 102 |
+
<h2>2. System Architecture</h2>
|
| 103 |
+
<div class="arch-diagram">
|
| 104 |
+
┌───────���─────────┐ ┌─────────────────┐ ┌─────────────────┐
|
| 105 |
+
│ FIX UI Client │ │ FIX UI Client │ │ Frontend │
|
| 106 |
+
│ (Port 5002) │ │ (Port 5003) │ │ (Port 5000) │
|
| 107 |
+
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
| 108 |
+
│ FIX 4.4 │ FIX 4.4 │ HTTP/JSON
|
| 109 |
+
└───────────────┬───────┴───────────────────────┘
|
| 110 |
+
▼
|
| 111 |
+
┌───────────────────────────────┐
|
| 112 |
+
│ FIX OEG (Port 5001) │
|
| 113 |
+
│ QuickFIX Order Gateway │
|
| 114 |
+
└───────────────┬───────────────┘
|
| 115 |
+
│ JSON
|
| 116 |
+
▼
|
| 117 |
+
┌────────────────────────────────────────────────────────────────────┐
|
| 118 |
+
│ APACHE KAFKA (Port 9092) │
|
| 119 |
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
| 120 |
+
│ │ orders │ │ trades │ │ snapshots│ │
|
| 121 |
+
│ └──────────┘ └──────────┘ └──────────┘ │
|
| 122 |
+
└─────┬─────────────────┬─────────────────┬─────────────────────────┘
|
| 123 |
+
│ │ │
|
| 124 |
+
▼ │ ▼
|
| 125 |
+
┌─────────────┐ │ ┌─────────────┐
|
| 126 |
+
│ Matcher │─────────┘ │ MD Feeder │
|
| 127 |
+
│ (Port 6000) │ │ (MDF) │
|
| 128 |
+
│ │ │ │
|
| 129 |
+
│ Order Book │◄──────────────────│ Price Sim │
|
| 130 |
+
│ Trade Exec │ │ BBO Publish │
|
| 131 |
+
└─────────────┘ └─────────────┘
|
| 132 |
+
│
|
| 133 |
+
▼ REST API
|
| 134 |
+
┌─────────────────────────────────────────┐
|
| 135 |
+
│ Dashboard (Port 5005) │
|
| 136 |
+
│ Orders │ Trades │ Book │ Chart │ Stats │
|
| 137 |
+
└─────────────────────────────────────────┘
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<div class="section">
|
| 142 |
+
<h2>3. Module Specifications</h2>
|
| 143 |
+
|
| 144 |
+
<div class="module-box">
|
| 145 |
+
<h4>Kafka Message Broker</h4>
|
| 146 |
+
<span class="port">9092</span> <span class="port">29092 (host)</span> <span class="tech">Confluent 7.5.0</span>
|
| 147 |
+
<p>Central event bus. All order flow distributed via topics. Zookeeper (port 2181) for coordination.</p>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="module-box">
|
| 151 |
+
<h4>FIX Order Entry Gateway</h4>
|
| 152 |
+
<span class="port">5001</span> <span class="tech">QuickFIX/Python</span>
|
| 153 |
+
<p>FIX 4.4 acceptor. Receives NewOrderSingle (D), OrderCancelRequest (F), OrderCancelReplaceRequest (G). Normalizes to JSON → Kafka <code>orders</code>.</p>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div class="module-box">
|
| 157 |
+
<h4>Matcher Engine</h4>
|
| 158 |
+
<span class="port">6000</span> <span class="tech">Python/Flask/SQLite</span>
|
| 159 |
+
<p>Consumes <code>orders</code>, matches with price-time priority, publishes <code>trades</code>. Maintains order book per symbol. SQLite persistence.</p>
|
| 160 |
+
<table>
|
| 161 |
+
<tr><th>Endpoint</th><th>Method</th><th>Description</th></tr>
|
| 162 |
+
<tr><td><span class="endpoint">/orderbook/<symbol></span></td><td>GET</td><td>Order book depth</td></tr>
|
| 163 |
+
<tr><td><span class="endpoint">/trades</span></td><td>GET</td><td>Recent trades</td></tr>
|
| 164 |
+
<tr><td><span class="endpoint">/health</span></td><td>GET</td><td>Health + stats</td></tr>
|
| 165 |
+
</table>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div class="module-box">
|
| 169 |
+
<h4>Market Data Feeder (MDF)</h4>
|
| 170 |
+
<span class="tech">Python</span>
|
| 171 |
+
<p>Simulates market activity. 90% passive orders (book building), 10% aggressive (trades). Publishes BBO snapshots.</p>
|
| 172 |
+
<p>Output: <code>orders</code> + <code>snapshots</code> topics</p>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div class="module-box">
|
| 176 |
+
<h4>Dashboard</h4>
|
| 177 |
+
<span class="port">5005</span> <span class="tech">Flask/SSE/JavaScript</span>
|
| 178 |
+
<p>Real-time web UI. Consumes Kafka + Matcher API. Server-Sent Events for live streaming. Edit/Cancel order management.</p>
|
| 179 |
+
<table>
|
| 180 |
+
<tr><th>Endpoint</th><th>Method</th><th>Description</th></tr>
|
| 181 |
+
<tr><td><span class="endpoint">/stream</span></td><td>GET</td><td>SSE event stream</td></tr>
|
| 182 |
+
<tr><td><span class="endpoint">/data</span></td><td>GET</td><td>Polling fallback</td></tr>
|
| 183 |
+
<tr><td><span class="endpoint">/order/cancel</span></td><td>POST</td><td>Cancel order</td></tr>
|
| 184 |
+
<tr><td><span class="endpoint">/order/amend</span></td><td>POST</td><td>Amend order</td></tr>
|
| 185 |
+
</table>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div class="module-box">
|
| 189 |
+
<h4>FIX UI Clients</h4>
|
| 190 |
+
<span class="port">5002</span> <span class="port">5003</span> <span class="tech">QuickFIX/Flask</span>
|
| 191 |
+
<p>Web-based FIX initiators. Connect to FIX OEG for institutional order submission.</p>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<div class="section">
|
| 196 |
+
<h2>4. Database Persistence</h2>
|
| 197 |
+
|
| 198 |
+
<div class="info">
|
| 199 |
+
<strong>Storage:</strong> SQLite database at <code>/app/data/matcher.db</code><br>
|
| 200 |
+
<strong>Docker Volume:</strong> <code>stockex_matcher_data</code> (survives container restarts)
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<h4>4.1 Database Schema</h4>
|
| 204 |
+
|
| 205 |
+
<div class="module-box">
|
| 206 |
+
<h4>order_book — All Orders</h4>
|
| 207 |
+
<table>
|
| 208 |
+
<tr><th>Column</th><th>Type</th><th>Description</th></tr>
|
| 209 |
+
<tr><td><code>id</code></td><td>INTEGER</td><td>Auto-increment primary key</td></tr>
|
| 210 |
+
<tr><td><code>cl_ord_id</code></td><td>TEXT</td><td>Unique client order ID</td></tr>
|
| 211 |
+
<tr><td><code>symbol</code></td><td>TEXT</td><td>Security (ALPHA, EXAE, etc.)</td></tr>
|
| 212 |
+
<tr><td><code>side</code></td><td>TEXT</td><td>BUY / SELL</td></tr>
|
| 213 |
+
<tr><td><code>price</code></td><td>REAL</td><td>Limit price</td></tr>
|
| 214 |
+
<tr><td><code>quantity</code></td><td>INTEGER</td><td>Original order quantity</td></tr>
|
| 215 |
+
<tr><td><code>remaining_qty</code></td><td>INTEGER</td><td>Unfilled quantity</td></tr>
|
| 216 |
+
<tr><td><code>status</code></td><td>TEXT</td><td>OPEN / FILLED / CANCELLED</td></tr>
|
| 217 |
+
<tr><td><code>timestamp</code></td><td>REAL</td><td>Order entry time (Unix)</td></tr>
|
| 218 |
+
<tr><td><code>created_at</code></td><td>DATETIME</td><td>DB insert timestamp</td></tr>
|
| 219 |
+
</table>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<div class="module-box">
|
| 223 |
+
<h4>trades — Executed Trades</h4>
|
| 224 |
+
<table>
|
| 225 |
+
<tr><th>Column</th><th>Type</th><th>Description</th></tr>
|
| 226 |
+
<tr><td><code>id</code></td><td>INTEGER</td><td>Auto-increment primary key</td></tr>
|
| 227 |
+
<tr><td><code>symbol</code></td><td>TEXT</td><td>Traded security</td></tr>
|
| 228 |
+
<tr><td><code>price</code></td><td>REAL</td><td>Execution price</td></tr>
|
| 229 |
+
<tr><td><code>quantity</code></td><td>INTEGER</td><td>Traded quantity</td></tr>
|
| 230 |
+
<tr><td><code>buy_order_id</code></td><td>TEXT</td><td>Buyer's cl_ord_id</td></tr>
|
| 231 |
+
<tr><td><code>sell_order_id</code></td><td>TEXT</td><td>Seller's cl_ord_id</td></tr>
|
| 232 |
+
<tr><td><code>timestamp</code></td><td>REAL</td><td>Trade time (Unix)</td></tr>
|
| 233 |
+
<tr><td><code>created_at</code></td><td>DATETIME</td><td>DB insert timestamp</td></tr>
|
| 234 |
+
</table>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<h4>4.2 Database Functions</h4>
|
| 238 |
+
<table>
|
| 239 |
+
<tr><th>Function</th><th>Description</th></tr>
|
| 240 |
+
<tr><td><code>save_order(order)</code></td><td>Insert new order into order_book</td></tr>
|
| 241 |
+
<tr><td><code>update_order_quantity(id, qty)</code></td><td>Update remaining_qty after partial fill</td></tr>
|
| 242 |
+
<tr><td><code>cancel_order(cl_ord_id)</code></td><td>Set status = 'CANCELLED'</td></tr>
|
| 243 |
+
<tr><td><code>save_trade(trade)</code></td><td>Insert executed trade</td></tr>
|
| 244 |
+
<tr><td><code>get_open_orders(symbol, side)</code></td><td>Query open orders for matching</td></tr>
|
| 245 |
+
<tr><td><code>load_order_books()</code></td><td>Restore order books on startup</td></tr>
|
| 246 |
+
<tr><td><code>get_trades(symbol, limit)</code></td><td>Retrieve recent trades</td></tr>
|
| 247 |
+
<tr><td><code>delete_filled_orders(days)</code></td><td>Cleanup old filled/cancelled orders</td></tr>
|
| 248 |
+
</table>
|
| 249 |
+
|
| 250 |
+
<h4>4.3 Indexes</h4>
|
| 251 |
+
<div class="code-block">CREATE INDEX idx_trades_symbol ON trades(symbol);
|
| 252 |
+
CREATE INDEX idx_trades_timestamp ON trades(timestamp);
|
| 253 |
+
CREATE INDEX idx_orderbook_symbol_side ON order_book(symbol, side);
|
| 254 |
+
CREATE INDEX idx_orderbook_status ON order_book(status);
|
| 255 |
+
CREATE INDEX idx_orderbook_cl_ord_id ON order_book(cl_ord_id);</div>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<div class="section">
|
| 259 |
+
<h2>5. Data Flow Diagrams</h2>
|
| 260 |
+
|
| 261 |
+
<h4>4.1 Order Entry Flow</h4>
|
| 262 |
+
<div class="arch-diagram">
|
| 263 |
+
┌──────────────┐
|
| 264 |
+
│ FIX Client │
|
| 265 |
+
└──────┬───────┘
|
| 266 |
+
│ FIX 4.4 NewOrderSingle (35=D)
|
| 267 |
+
▼
|
| 268 |
+
┌──────────────┐
|
| 269 |
+
│ FIX OEG │ Validate → Normalize → Generate cl_ord_id
|
| 270 |
+
└──────┬───────┘
|
| 271 |
+
│ JSON
|
| 272 |
+
▼
|
| 273 |
+
┌──────────────┐
|
| 274 |
+
│ Kafka │ Topic: orders
|
| 275 |
+
│ [orders] │
|
| 276 |
+
└──────┬───────┘
|
| 277 |
+
│
|
| 278 |
+
┌─────┴─────┐
|
| 279 |
+
▼ ▼
|
| 280 |
+
┌────────┐ ┌���──────────┐
|
| 281 |
+
│Matcher │ │ Dashboard │
|
| 282 |
+
└────────┘ └───────────┘
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
<h4>4.2 Order Matching Flow</h4>
|
| 286 |
+
<div class="arch-diagram">
|
| 287 |
+
┌─────────────────┐
|
| 288 |
+
│ Incoming Order │
|
| 289 |
+
│ (from Kafka) │
|
| 290 |
+
└────────┬────────┘
|
| 291 |
+
│
|
| 292 |
+
▼
|
| 293 |
+
┌─────────────────┐
|
| 294 |
+
│ Parse & Validate│
|
| 295 |
+
└────────┬────────┘
|
| 296 |
+
│
|
| 297 |
+
┌──────────────┴──────────────┐
|
| 298 |
+
▼ ▼
|
| 299 |
+
┌────────────┐ ┌────────────┐
|
| 300 |
+
│ BUY Order │ │ SELL Order │
|
| 301 |
+
└──────┬─────┘ └──────┬─────┘
|
| 302 |
+
│ │
|
| 303 |
+
▼ ▼
|
| 304 |
+
┌──────────────────┐ ┌──────────────────┐
|
| 305 |
+
│ Check SELL book │ │ Check BUY book │
|
| 306 |
+
│ for price ≤ bid │ │ for price ≥ ask │
|
| 307 |
+
└────────┬─────────┘ └────────┬─────────┘
|
| 308 |
+
│ │
|
| 309 |
+
┌──────┴──────┐ ┌──────┴──────┐
|
| 310 |
+
▼ ▼ ▼ ▼
|
| 311 |
+
┌───────┐ ┌────────┐ ┌───────┐ ┌────────┐
|
| 312 |
+
│ Match │ │No Match│ │ Match │ │No Match│
|
| 313 |
+
│ Found │ │ │ │ Found │ │ │
|
| 314 |
+
└───┬───┘ └───┬────┘ └───┬───┘ └───┬────┘
|
| 315 |
+
│ │ │ │
|
| 316 |
+
▼ ▼ ▼ ▼
|
| 317 |
+
┌───────┐ ┌────────┐ ┌───────┐ ┌────────┐
|
| 318 |
+
│Execute│ │Add to │ │Execute│ │Add to │
|
| 319 |
+
│ Trade │ │BUY Book│ │ Trade │ │SELLBook│
|
| 320 |
+
└───┬───┘ └────────┘ └───┬───┘ └────────┘
|
| 321 |
+
│ │
|
| 322 |
+
└───────────┬───────────────┘
|
| 323 |
+
▼
|
| 324 |
+
┌────────────┐
|
| 325 |
+
│Kafka:trades│
|
| 326 |
+
└────────────┘
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<h4>4.3 Real-time Dashboard Flow</h4>
|
| 330 |
+
<div class="arch-diagram">
|
| 331 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 332 |
+
│ BROWSER │
|
| 333 |
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
| 334 |
+
│ │ Dashboard UI │ │
|
| 335 |
+
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐│ │
|
| 336 |
+
│ │ │ Orders │ │ Trades │ │ Book │ │ Statistics ││ │
|
| 337 |
+
│ │ └────▲────┘ └────▲────┘ └────▲────┘ └────────▲────────┘│ │
|
| 338 |
+
│ └───────┼───────────┼───────────┼───────────────┼──────────┘ │
|
| 339 |
+
│ │ │ │ │ │
|
| 340 |
+
│ └───────────┴─────┬─────┴───────────────┘ │
|
| 341 |
+
│ │ │
|
| 342 |
+
│ ┌───────▼────────┐ │
|
| 343 |
+
│ │ EventSource │ SSE Connection │
|
| 344 |
+
│ │ /stream │ │
|
| 345 |
+
│ └───────┬────────┘ │
|
| 346 |
+
└─���──────────────────────────┼────────────────────────────────────┘
|
| 347 |
+
│ HTTP (SSE)
|
| 348 |
+
▼
|
| 349 |
+
┌────────────────────────────────────────────────────────────────┐
|
| 350 |
+
│ DASHBOARD SERVER │
|
| 351 |
+
│ │
|
| 352 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
| 353 |
+
│ │Kafka Consumer│─────▶│ SSE Broadcast│────▶│ Clients │ │
|
| 354 |
+
│ │ (orders, │ │ Queue │ │ Queue[] │ │
|
| 355 |
+
│ │ trades, │ └──────────────┘ └────────────┘ │
|
| 356 |
+
│ │ snapshots) │ │
|
| 357 |
+
│ └──────────────┘ │
|
| 358 |
+
│ │
|
| 359 |
+
│ ┌──────────────┐ ┌──────────────┐ │
|
| 360 |
+
│ │ REST API │◀────▶│ Matcher │ /orderbook, /trades │
|
| 361 |
+
│ │ /data │ │ Proxy │ │
|
| 362 |
+
│ └──────────────┘ └──────────────┘ │
|
| 363 |
+
└────────────────────────────────────────────────────────────────┘
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<h4>4.4 Complete System Interaction</h4>
|
| 367 |
+
<div class="arch-diagram">
|
| 368 |
+
┌─────────┐ ┌─────────┐ ┌─────────┐
|
| 369 |
+
│FIX Cli 1│ │FIX Cli 2│ │Frontend │
|
| 370 |
+
└────┬────┘ └────┬────┘ └────┬────┘
|
| 371 |
+
│ │ │
|
| 372 |
+
└─────┬─────┴───────────┘
|
| 373 |
+
│
|
| 374 |
+
▼
|
| 375 |
+
┌─────────────┐
|
| 376 |
+
│ FIX OEG │◄──── FIX 4.4 Protocol
|
| 377 |
+
└──────┬──────┘
|
| 378 |
+
│
|
| 379 |
+
▼
|
| 380 |
+
┌────────────────────────────────────┐
|
| 381 |
+
│ KAFKA CLUSTER │
|
| 382 |
+
│ ┌────────┐┌────────┐┌──────────┐ │
|
| 383 |
+
│ │orders ││trades ││snapshots │ │
|
| 384 |
+
│ └───┬────┘└───▲────┘└────▲─────┘ │
|
| 385 |
+
└──────┼─────────┼──────────┼───────┘
|
| 386 |
+
│ │ │
|
| 387 |
+
┌─────┼─────────┼──────────┼─────┐
|
| 388 |
+
│ ▼ │ │ │
|
| 389 |
+
│ ┌────────┐ │ │ │
|
| 390 |
+
│ │MATCHER │────┘ │ │
|
| 391 |
+
│ │ │ │ │
|
| 392 |
+
│ │ Book │ │ │
|
| 393 |
+
│ │ Match │ │ │
|
| 394 |
+
│ │ Trade │ │ │
|
| 395 |
+
│ └────────┘ │ │
|
| 396 |
+
│ ▲ │ │
|
| 397 |
+
│ │ REST │ │
|
| 398 |
+
│ │ │ │
|
| 399 |
+
│ ┌───┴────────────────────┴──┐ │
|
| 400 |
+
│ │ DASHBOARD │ │
|
| 401 |
+
│ │ (SSE + Kafka Consumer) │ │
|
| 402 |
+
│ └───────────────────────────┘ │
|
| 403 |
+
│ │
|
| 404 |
+
│ ┌───────────────────────────┐ │
|
| 405 |
+
│ │ MD FEEDER (MDF) │──┘
|
| 406 |
+
│ │ Orders + Snapshots │
|
| 407 |
+
│ └───────────────────────────┘
|
| 408 |
+
│ DOCKER NETWORK
|
| 409 |
+
└────────────────────────────────┘
|
| 410 |
+
</div>
|
| 411 |
+
|
| 412 |
+
<h4>4.5 Message Sequence: New Order to Trade</h4>
|
| 413 |
+
<div class="arch-diagram">
|
| 414 |
+
FIX Client FIX OEG Kafka Matcher Dashboard
|
| 415 |
+
│ │ │ │ │
|
| 416 |
+
│──35=D────▶│ │ │ │
|
| 417 |
+
│NewOrder │ │ │ │
|
| 418 |
+
│ │──JSON────▶│ │ │
|
| 419 |
+
│ │ [orders] │ │ │
|
| 420 |
+
│ │ │──consume──▶│ │
|
| 421 |
+
│ │ │ │ │
|
| 422 |
+
│ │ │ │──match() │
|
| 423 |
+
│ │ │ │ │
|
| 424 |
+
│ │ │◀──trade────│ │
|
| 425 |
+
│ │ │ [trades] │ │
|
| 426 |
+
│ │ │ │ │
|
| 427 |
+
│ │ │──────────────consume───▶│
|
| 428 |
+
│ │ │ │ │
|
| 429 |
+
│ │ │ │ render()
|
| 430 |
+
│ │ │ │ │
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
|
| 434 |
+
<div class="section">
|
| 435 |
+
<h2>6. Kafka Topics</h2>
|
| 436 |
+
<table>
|
| 437 |
+
<tr><th>Topic</th><th>Producers</th><th>Consumers</th><th>Content</th></tr>
|
| 438 |
+
<tr><td><code>orders</code></td><td>FIX OEG, MDF, Frontend</td><td>Matcher, Dashboard</td><td>New/Cancel/Amend orders</td></tr>
|
| 439 |
+
<tr><td><code>trades</code></td><td>Matcher</td><td>Dashboard, Consumer</td><td>Executed trades</td></tr>
|
| 440 |
+
<tr><td><code>snapshots</code></td><td>MDF</td><td>Dashboard</td><td>BBO updates</td></tr>
|
| 441 |
+
</table>
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
<div class="section">
|
| 445 |
+
<h2>7. Message Formats</h2>
|
| 446 |
+
|
| 447 |
+
<h4>Order (New)</h4>
|
| 448 |
+
<div class="code-block json">{
|
| 449 |
+
"symbol": "ALPHA",
|
| 450 |
+
"side": "BUY",
|
| 451 |
+
"price": 25.50,
|
| 452 |
+
"quantity": 100,
|
| 453 |
+
"cl_ord_id": "MDF-1234567890-1",
|
| 454 |
+
"timestamp": 1234567890.123,
|
| 455 |
+
"source": "MDF"
|
| 456 |
+
}</div>
|
| 457 |
+
|
| 458 |
+
<h4>Order (Cancel)</h4>
|
| 459 |
+
<div class="code-block json">{
|
| 460 |
+
"type": "cancel",
|
| 461 |
+
"orig_cl_ord_id": "MDF-1234567890-1",
|
| 462 |
+
"symbol": "ALPHA",
|
| 463 |
+
"timestamp": 1234567890.456
|
| 464 |
+
}</div>
|
| 465 |
+
|
| 466 |
+
<h4>Order (Amend)</h4>
|
| 467 |
+
<div class="code-block json">{
|
| 468 |
+
"type": "amend",
|
| 469 |
+
"orig_cl_ord_id": "MDF-1234567890-1",
|
| 470 |
+
"cl_ord_id": "amend-1234567890",
|
| 471 |
+
"symbol": "ALPHA",
|
| 472 |
+
"quantity": 150,
|
| 473 |
+
"price": 25.45,
|
| 474 |
+
"timestamp": 1234567890.789
|
| 475 |
+
}</div>
|
| 476 |
+
|
| 477 |
+
<h4>Trade</h4>
|
| 478 |
+
<div class="code-block json">{
|
| 479 |
+
"symbol": "ALPHA",
|
| 480 |
+
"price": 25.50,
|
| 481 |
+
"quantity": 100,
|
| 482 |
+
"buy_order_id": "order-123",
|
| 483 |
+
"sell_order_id": "order-456",
|
| 484 |
+
"timestamp": 1234567890.123
|
| 485 |
+
}</div>
|
| 486 |
+
|
| 487 |
+
<h4>Snapshot (BBO)</h4>
|
| 488 |
+
<div class="code-block json">{
|
| 489 |
+
"symbol": "ALPHA",
|
| 490 |
+
"best_bid": 25.45,
|
| 491 |
+
"best_ask": 25.55,
|
| 492 |
+
"bid_size": 500,
|
| 493 |
+
"ask_size": 300,
|
| 494 |
+
"timestamp": 1234567890.123,
|
| 495 |
+
"source": "MDF"
|
| 496 |
+
}</div>
|
| 497 |
+
</div>
|
| 498 |
+
|
| 499 |
+
<div class="section">
|
| 500 |
+
<h2>8. SSE Events</h2>
|
| 501 |
+
<table>
|
| 502 |
+
<tr><th>Event</th><th>Data</th><th>Trigger</th></tr>
|
| 503 |
+
<tr><td><code>connected</code></td><td>{status}</td><td>Client connects</td></tr>
|
| 504 |
+
<tr><td><code>init</code></td><td>{orders, bbos, trades}</td><td>Initial state dump</td></tr>
|
| 505 |
+
<tr><td><code>order</code></td><td>Order JSON</td><td>New order received</td></tr>
|
| 506 |
+
<tr><td><code>trade</code></td><td>Trade JSON</td><td>Trade executed</td></tr>
|
| 507 |
+
<tr><td><code>snapshot</code></td><td>BBO JSON</td><td>Price update</td></tr>
|
| 508 |
+
</table>
|
| 509 |
+
</div>
|
| 510 |
+
|
| 511 |
+
<div class="section">
|
| 512 |
+
<h2>9. Configuration</h2>
|
| 513 |
+
<table>
|
| 514 |
+
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
|
| 515 |
+
<tr><td><code>KAFKA_BOOTSTRAP</code></td><td>kafka:9092</td><td>Broker address</td></tr>
|
| 516 |
+
<tr><td><code>MATCHER_URL</code></td><td>http://matcher:6000</td><td>Matcher API</td></tr>
|
| 517 |
+
<tr><td><code>TICK_SIZE</code></td><td>0.05</td><td>Min price increment</td></tr>
|
| 518 |
+
<tr><td><code>ORDERS_PER_MIN</code></td><td>8</td><td>MDF rate</td></tr>
|
| 519 |
+
<tr><td><code>KAFKA_RETRIES</code></td><td>30</td><td>Connection retries</td></tr>
|
| 520 |
+
</table>
|
| 521 |
+
|
| 522 |
+
<h4>Securities (shared_data/securities.txt)</h4>
|
| 523 |
+
<div class="code-block">#SYMBOL start_price current_price
|
| 524 |
+
ALPHA 25.00 25.00
|
| 525 |
+
EXAE 42.00 42.00
|
| 526 |
+
PEIR 18.50 18.50
|
| 527 |
+
QUEST 12.75 12.75</div>
|
| 528 |
+
</div>
|
| 529 |
+
|
| 530 |
+
<div class="section">
|
| 531 |
+
<h2>10. Development Commands</h2>
|
| 532 |
+
|
| 533 |
+
<h4>Build & Run</h4>
|
| 534 |
+
<div class="code-block">docker compose up --build # Start all
|
| 535 |
+
docker compose up -d --build # Background
|
| 536 |
+
docker compose logs -f dashboard # Follow logs
|
| 537 |
+
docker compose down # Stop all</div>
|
| 538 |
+
|
| 539 |
+
<h4>Reset Data</h4>
|
| 540 |
+
<div class="code-block">docker compose down
|
| 541 |
+
docker volume rm stockex_matcher_data
|
| 542 |
+
docker compose up -d</div>
|
| 543 |
+
|
| 544 |
+
<h4>Container Access</h4>
|
| 545 |
+
<div class="code-block">docker exec -it matcher bash
|
| 546 |
+
docker exec -it dashboard bash
|
| 547 |
+
docker logs matcher --tail 50</div>
|
| 548 |
+
|
| 549 |
+
<h4>API Testing</h4>
|
| 550 |
+
<div class="code-block">curl http://localhost:6000/orderbook/ALPHA
|
| 551 |
+
curl http://localhost:6000/trades
|
| 552 |
+
curl http://localhost:5005/data</div>
|
| 553 |
+
</div>
|
| 554 |
+
|
| 555 |
+
<div class="screenshot">
|
| 556 |
+
<img src="screenshots/dashboard.png" alt="StockEx Dashboard">
|
| 557 |
+
<p style="font-size:10pt; color:#666;"><em>StockEx Trading Dashboard - Real-time Market View</em></p>
|
| 558 |
+
</div>
|
| 559 |
+
|
| 560 |
+
<hr>
|
| 561 |
+
|
| 562 |
+
<div class="footer">
|
| 563 |
+
<strong>StockEx Trading Platform v1.0</strong><br>
|
| 564 |
+
Developer Guide | Euronext OPTIQ Inspired<br>
|
| 565 |
+
<p style="margin-top:10px;">Print to PDF: Ctrl+P → Save as PDF</p>
|
| 566 |
+
</div>
|
| 567 |
+
|
| 568 |
+
</body>
|
| 569 |
+
</html>
|
StockEx_Technical_Guide.md
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# StockEx Trading Platform
|
| 2 |
+
## Complete Technical & Developer Guide v1.0
|
| 3 |
+
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
## Table of Contents
|
| 7 |
+
|
| 8 |
+
1. [Introduction](#1-introduction)
|
| 9 |
+
2. [System Architecture](#2-system-architecture)
|
| 10 |
+
3. [Module Descriptions](#3-module-descriptions)
|
| 11 |
+
4. [Dashboard User Interface](#4-dashboard-user-interface)
|
| 12 |
+
5. [Data Flow & Messaging](#5-data-flow--messaging)
|
| 13 |
+
6. [Configuration](#6-configuration)
|
| 14 |
+
7. [Quick Reference](#7-quick-reference)
|
| 15 |
+
8. [Troubleshooting](#8-troubleshooting)
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 1. Introduction
|
| 20 |
+
|
| 21 |
+
**StockEx** is a comprehensive real-time trading simulation platform inspired by Euronext OPTIQ. It provides a complete electronic trading ecosystem including order entry via FIX protocol, order matching engine, market data distribution, and live visualization through a web dashboard.
|
| 22 |
+
|
| 23 |
+
### Key Features
|
| 24 |
+
|
| 25 |
+
- **FIX 4.4 Protocol Support** — Institutional-grade order entry via QuickFIX
|
| 26 |
+
- **Real-time Order Matching** — Price-time priority matching engine with SQLite persistence
|
| 27 |
+
- **Live Market Data Streaming** — Kafka-based event distribution
|
| 28 |
+
- **Web Dashboard with SSE** — Real-time updates without page refresh
|
| 29 |
+
- **Order Management** — Edit and Cancel capabilities from the UI
|
| 30 |
+
- **Trading Analytics** — Volume, Value, VWAP statistics with visualizations
|
| 31 |
+
|
| 32 |
+
### Access URLs
|
| 33 |
+
|
| 34 |
+
| Service | URL | Purpose |
|
| 35 |
+
|---------|-----|---------|
|
| 36 |
+
| Dashboard | http://localhost:5005 | Main trading view |
|
| 37 |
+
| Frontend | http://localhost:5000 | Manual order entry |
|
| 38 |
+
| FIX Client 1 | http://localhost:5002 | FIX order submission |
|
| 39 |
+
| FIX Client 2 | http://localhost:5003 | FIX order submission |
|
| 40 |
+
| Matcher API | http://localhost:6000 | REST API for order book/trades |
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## 2. System Architecture
|
| 45 |
+
|
| 46 |
+
### High-Level Architecture Diagram
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
| 50 |
+
│ FIX UI Client │ │ FIX UI Client │ │ Frontend │
|
| 51 |
+
│ (Port 5002) │ │ (Port 5003) │ │ (Port 5000) │
|
| 52 |
+
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
| 53 |
+
│ │ │
|
| 54 |
+
│ FIX 4.4 │ FIX 4.4 │ HTTP
|
| 55 |
+
▼ ▼ ▼
|
| 56 |
+
┌────────────────────────────────────────────────────────────────┐
|
| 57 |
+
│ FIX OEG (Port 5001) │
|
| 58 |
+
│ QuickFIX Order Entry Gateway │
|
| 59 |
+
└────────────────────────────────┬───────────────────────────────┘
|
| 60 |
+
│
|
| 61 |
+
▼ Kafka [orders]
|
| 62 |
+
┌────────────────────────────────────────────────────────────────┐
|
| 63 |
+
│ Apache Kafka (Port 9092) │
|
| 64 |
+
│ Topics: orders, trades, snapshots │
|
| 65 |
+
└───────┬────────────────────┬───────────────────────┬───────────┘
|
| 66 |
+
│ │ │
|
| 67 |
+
▼ ▼ ▼
|
| 68 |
+
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
| 69 |
+
│ Matcher │ │ MD Feeder │ │ Dashboard │
|
| 70 |
+
│ (Port 6000) │ │ (MDF) │ │ (Port 5005) │
|
| 71 |
+
│ │ │ │ │ │
|
| 72 |
+
│ Order Book │ │ Price Sim │ │ Web UI + SSE │
|
| 73 |
+
│ Trade Match │ │ BBO Publish │ │ Real-time │
|
| 74 |
+
└───────────────┘ └───────────────┘ └───────────────┘
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### Component Summary
|
| 78 |
+
|
| 79 |
+
| Component | Technology | Port | Function |
|
| 80 |
+
|-----------|------------|------|----------|
|
| 81 |
+
| Zookeeper | Confluent 7.5.0 | 2181 | Kafka coordination |
|
| 82 |
+
| Kafka | Confluent 7.5.0 | 9092, 29092 | Message streaming |
|
| 83 |
+
| FIX OEG | QuickFIX/Python | 5001 | FIX protocol gateway |
|
| 84 |
+
| Matcher | Python/Flask/SQLite | 6000 | Order matching engine |
|
| 85 |
+
| MDF | Python | - | Market data simulation |
|
| 86 |
+
| Dashboard | Flask/SSE | 5005 | Real-time web UI |
|
| 87 |
+
| Frontend | Flask | 5000 | Manual order entry |
|
| 88 |
+
| FIX Clients | QuickFIX/Flask | 5002, 5003 | FIX order UI |
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## 3. Module Descriptions
|
| 93 |
+
|
| 94 |
+
### 3.1 Zookeeper
|
| 95 |
+
|
| 96 |
+
**Port:** 2181
|
| 97 |
+
|
| 98 |
+
Apache Zookeeper provides distributed coordination for the Kafka cluster. It manages broker metadata, topic configurations, and cluster membership.
|
| 99 |
+
|
| 100 |
+
- **Image:** confluentinc/cp-zookeeper:7.5.0
|
| 101 |
+
- **Dependencies:** None
|
| 102 |
+
- **Environment:** `ZOOKEEPER_CLIENT_PORT=2181`
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
### 3.2 Apache Kafka
|
| 107 |
+
|
| 108 |
+
**Ports:** 9092 (internal), 29092 (host access)
|
| 109 |
+
|
| 110 |
+
Apache Kafka serves as the central message backbone for the entire trading system. All order flow, trade executions, and market data are distributed through Kafka topics.
|
| 111 |
+
|
| 112 |
+
- **Image:** confluentinc/cp-kafka:7.5.0
|
| 113 |
+
- **Dependencies:** Zookeeper
|
| 114 |
+
|
| 115 |
+
**Topics:**
|
| 116 |
+
|
| 117 |
+
| Topic | Purpose | Producers | Consumers |
|
| 118 |
+
|-------|---------|-----------|-----------|
|
| 119 |
+
| `orders` | Order flow | FIX OEG, MDF, Frontend | Matcher, Dashboard |
|
| 120 |
+
| `trades` | Executed trades | Matcher | Dashboard, Consumer |
|
| 121 |
+
| `snapshots` | BBO updates | MDF | Dashboard, Snapshot Viewer |
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
### 3.3 FIX Order Entry Gateway (FIX OEG)
|
| 126 |
+
|
| 127 |
+
**Port:** 5001
|
| 128 |
+
|
| 129 |
+
The FIX OEG is a QuickFIX/Python acceptor that receives orders via FIX 4.4 protocol from institutional clients. It validates incoming FIX messages, normalizes them to JSON format, and publishes to the Kafka `orders` topic.
|
| 130 |
+
|
| 131 |
+
**Supported FIX Messages:**
|
| 132 |
+
|
| 133 |
+
| Message Type | Tag 35 | Description |
|
| 134 |
+
|--------------|--------|-------------|
|
| 135 |
+
| NewOrderSingle | D | New order submission |
|
| 136 |
+
| OrderCancelRequest | F | Cancel existing order |
|
| 137 |
+
| OrderCancelReplaceRequest | G | Modify existing order |
|
| 138 |
+
|
| 139 |
+
**Key Functions:**
|
| 140 |
+
- FIX session management (logon, heartbeat, logout)
|
| 141 |
+
- Message validation and normalization
|
| 142 |
+
- Order ID generation
|
| 143 |
+
- Kafka publishing
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
### 3.4 FIX UI Clients
|
| 148 |
+
|
| 149 |
+
**Ports:** 5002 (Client 1), 5003 (Client 2)
|
| 150 |
+
|
| 151 |
+
Web-based FIX initiator clients that connect to the FIX OEG. They provide a user interface for submitting orders via FIX protocol, simulating institutional trading terminals.
|
| 152 |
+
|
| 153 |
+
**Features:**
|
| 154 |
+
- New Order Single submission
|
| 155 |
+
- Order cancellation
|
| 156 |
+
- Order amendment
|
| 157 |
+
- Real-time execution reports
|
| 158 |
+
|
| 159 |
+
**Configuration Files:**
|
| 160 |
+
- `client1.cfg` — FIX session config for Client 1
|
| 161 |
+
- `client2.cfg` — FIX session config for Client 2
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
### 3.5 Matcher (Order Matching Engine)
|
| 166 |
+
|
| 167 |
+
**Port:** 6000
|
| 168 |
+
|
| 169 |
+
The core matching engine that maintains order books for all securities. It consumes orders from Kafka, attempts to match them using price-time priority, and publishes resulting trades.
|
| 170 |
+
|
| 171 |
+
**Matching Algorithm:** Price-Time Priority (FIFO)
|
| 172 |
+
- Buy orders sorted by price descending (highest first)
|
| 173 |
+
- Sell orders sorted by price ascending (lowest first)
|
| 174 |
+
- Within same price level, earlier orders have priority
|
| 175 |
+
|
| 176 |
+
**Persistence:** SQLite database (`/app/data/matcher.db`)
|
| 177 |
+
- Order book survives container restarts
|
| 178 |
+
- Volume-mapped for data durability
|
| 179 |
+
|
| 180 |
+
**REST API Endpoints:**
|
| 181 |
+
|
| 182 |
+
| Endpoint | Method | Description |
|
| 183 |
+
|----------|--------|-------------|
|
| 184 |
+
| `/orderbook/<symbol>` | GET | Get order book depth for symbol |
|
| 185 |
+
| `/trades` | GET | Get recent trades |
|
| 186 |
+
| `/health` | GET | Health check with statistics |
|
| 187 |
+
|
| 188 |
+
**Order Book Response Example:**
|
| 189 |
+
```json
|
| 190 |
+
{
|
| 191 |
+
"symbol": "ALPHA",
|
| 192 |
+
"bids": [
|
| 193 |
+
{"price": 25.45, "quantity": 100, "cl_ord_id": "MDF-123"},
|
| 194 |
+
{"price": 25.40, "quantity": 200, "cl_ord_id": "MDF-124"}
|
| 195 |
+
],
|
| 196 |
+
"asks": [
|
| 197 |
+
{"price": 25.55, "quantity": 150, "cl_ord_id": "MDF-125"},
|
| 198 |
+
{"price": 25.60, "quantity": 100, "cl_ord_id": "MDF-126"}
|
| 199 |
+
]
|
| 200 |
+
}
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
### 3.6 Market Data Feeder (MDF)
|
| 206 |
+
|
| 207 |
+
**Port:** Internal only
|
| 208 |
+
|
| 209 |
+
Simulates market data by generating random orders and publishing Best Bid/Offer (BBO) snapshots. Creates realistic market activity with configurable order rates and price movements.
|
| 210 |
+
|
| 211 |
+
**Order Generation:**
|
| 212 |
+
- **90% Passive Orders** — Placed away from mid price to build order book
|
| 213 |
+
- **10% Aggressive Orders** — Cross the spread to generate trades
|
| 214 |
+
|
| 215 |
+
**Price Simulation:**
|
| 216 |
+
- Small random drift (±2 ticks) with 10% probability
|
| 217 |
+
- Prices bounded by minimum (1.00)
|
| 218 |
+
- Half spread: 0.10 (10 cents)
|
| 219 |
+
|
| 220 |
+
**Output:**
|
| 221 |
+
- Orders → Kafka `orders` topic
|
| 222 |
+
- Snapshots → Kafka `snapshots` topic
|
| 223 |
+
|
| 224 |
+
**Configuration:**
|
| 225 |
+
|
| 226 |
+
| Variable | Default | Description |
|
| 227 |
+
|----------|---------|-------------|
|
| 228 |
+
| `ORDERS_PER_MIN` | 8 | Order generation rate |
|
| 229 |
+
| `TICK_SIZE` | 0.05 | Minimum price increment |
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
### 3.7 Dashboard
|
| 234 |
+
|
| 235 |
+
**Port:** 5005
|
| 236 |
+
|
| 237 |
+
Real-time web dashboard providing comprehensive market visualization. Uses Server-Sent Events (SSE) for live streaming updates without page refresh.
|
| 238 |
+
|
| 239 |
+
**Technology Stack:**
|
| 240 |
+
- Backend: Python/Flask
|
| 241 |
+
- Frontend: HTML5/CSS3/JavaScript
|
| 242 |
+
- Streaming: Server-Sent Events (SSE)
|
| 243 |
+
- Data: Kafka consumer + REST API calls to Matcher
|
| 244 |
+
|
| 245 |
+
**Features:**
|
| 246 |
+
|
| 247 |
+
| Panel | Description |
|
| 248 |
+
|-------|-------------|
|
| 249 |
+
| Orders | Live order feed with Edit/Cancel actions |
|
| 250 |
+
| Market Snapshot | BBO table with scrolling ticker tape |
|
| 251 |
+
| Trades | Executed trades with value calculation |
|
| 252 |
+
| Order Book | Depth of book per symbol |
|
| 253 |
+
| Trade Chart | Price line + volume bars visualization |
|
| 254 |
+
| Trading Statistics | Aggregated metrics with bar charts |
|
| 255 |
+
|
| 256 |
+
**SSE Events:**
|
| 257 |
+
|
| 258 |
+
| Event | Data | Trigger |
|
| 259 |
+
|-------|------|---------|
|
| 260 |
+
| `connected` | Status | Initial connection |
|
| 261 |
+
| `init` | Full state | On connect |
|
| 262 |
+
| `order` | Order JSON | New order received |
|
| 263 |
+
| `trade` | Trade JSON | Trade executed |
|
| 264 |
+
| `snapshot` | BBO JSON | Price update |
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
### 3.8 Frontend (Manual Order Entry)
|
| 269 |
+
|
| 270 |
+
**Port:** 5000
|
| 271 |
+
|
| 272 |
+
Simple web interface for manual order submission. Allows users to enter orders directly without FIX protocol.
|
| 273 |
+
|
| 274 |
+
**Features:**
|
| 275 |
+
- Symbol selection dropdown
|
| 276 |
+
- Side selection (BUY/SELL)
|
| 277 |
+
- Price and quantity input
|
| 278 |
+
- Submit order to Kafka
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
### 3.9 Consumer (Debug Utility)
|
| 283 |
+
|
| 284 |
+
**Port:** Internal only
|
| 285 |
+
|
| 286 |
+
Debug utility that consumes and logs messages from Kafka `trades` topic. Outputs trade information to console for monitoring.
|
| 287 |
+
|
| 288 |
+
**Output Format:**
|
| 289 |
+
```
|
| 290 |
+
TRADE: ALPHA - 100 @ 25.50
|
| 291 |
+
TRADE: EXAE - 50 @ 42.10
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
### 3.10 Snapshot Viewer
|
| 297 |
+
|
| 298 |
+
**Port:** Internal only
|
| 299 |
+
|
| 300 |
+
Utility service that subscribes to the `snapshots` topic and logs BBO updates. Writes to log files in `/app/logs` for analysis.
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## 4. Dashboard User Interface
|
| 305 |
+
|
| 306 |
+
### 4.1 Orders Panel
|
| 307 |
+
|
| 308 |
+
Displays real-time incoming orders with full management capabilities.
|
| 309 |
+
|
| 310 |
+
| Column | Description |
|
| 311 |
+
|--------|-------------|
|
| 312 |
+
| Symbol | Stock ticker (ALPHA, EXAE, PEIR, QUEST) |
|
| 313 |
+
| Side | BUY (green) or SELL (red) |
|
| 314 |
+
| Qty | Order quantity in shares |
|
| 315 |
+
| Price | Limit price |
|
| 316 |
+
| Source | Order origin (MDF, FIX, Manual) |
|
| 317 |
+
| Time | Order timestamp |
|
| 318 |
+
| Actions | Edit / Cancel buttons |
|
| 319 |
+
|
| 320 |
+
**Order Actions:**
|
| 321 |
+
- **Edit** — Opens modal to modify quantity and/or price (sends amend to Kafka)
|
| 322 |
+
- **Cancel** — Sends cancel request to Kafka
|
| 323 |
+
- **Row Selection** — Click row to select, use header buttons for actions
|
| 324 |
+
|
| 325 |
+
---
|
| 326 |
+
|
| 327 |
+
### 4.2 Market Snapshot
|
| 328 |
+
|
| 329 |
+
Shows Best Bid/Offer (BBO) for all securities with real-time updates.
|
| 330 |
+
|
| 331 |
+
| Column | Description |
|
| 332 |
+
|--------|-------------|
|
| 333 |
+
| Symbol | Security identifier |
|
| 334 |
+
| Best Bid | Highest buy price (green) |
|
| 335 |
+
| Best Ask | Lowest sell price (red) |
|
| 336 |
+
| Spread | Ask - Bid difference |
|
| 337 |
+
| Mid | Midpoint: (Bid + Ask) / 2 |
|
| 338 |
+
| Updated | Last update timestamp |
|
| 339 |
+
|
| 340 |
+
**Ticker Tape:**
|
| 341 |
+
- Scrolling bar at bottom shows recent trades
|
| 342 |
+
- ▲ Green: Price up from previous
|
| 343 |
+
- ▼ Red: Price down from previous
|
| 344 |
+
- ● Yellow: No change
|
| 345 |
+
- Hover to pause scrolling
|
| 346 |
+
|
| 347 |
+
---
|
| 348 |
+
|
| 349 |
+
### 4.3 Trades Panel
|
| 350 |
+
|
| 351 |
+
Lists all executed trades with calculated values.
|
| 352 |
+
|
| 353 |
+
| Column | Description |
|
| 354 |
+
|--------|-------------|
|
| 355 |
+
| Symbol | Traded security |
|
| 356 |
+
| Qty | Executed quantity |
|
| 357 |
+
| Price | Execution price |
|
| 358 |
+
| Value | Trade value (Qty × Price) |
|
| 359 |
+
| Time | Execution timestamp |
|
| 360 |
+
|
| 361 |
+
---
|
| 362 |
+
|
| 363 |
+
### 4.4 Order Book
|
| 364 |
+
|
| 365 |
+
Displays full market depth for selected symbol.
|
| 366 |
+
|
| 367 |
+
**Controls:**
|
| 368 |
+
- **Symbol Dropdown** — Select security to view
|
| 369 |
+
- **Refresh Button** — Manual refresh (auto-refreshes every 3 seconds)
|
| 370 |
+
|
| 371 |
+
**Display:**
|
| 372 |
+
- **Left Side:** Bid Qty | Bid Price (green, sorted price descending)
|
| 373 |
+
- **Right Side:** Ask Price | Ask Qty (red, sorted price ascending)
|
| 374 |
+
- **Header:** Shows count of bids and asks
|
| 375 |
+
|
| 376 |
+
---
|
| 377 |
+
|
| 378 |
+
### 4.5 Trade Chart
|
| 379 |
+
|
| 380 |
+
Visual representation of trade activity over time.
|
| 381 |
+
|
| 382 |
+
**Elements:**
|
| 383 |
+
- **Green Line** — Price trend connecting trade execution prices
|
| 384 |
+
- **Blue Bars** — Volume per trade
|
| 385 |
+
- **Y-Axis** — Price scale
|
| 386 |
+
- **X-Axis** — Trade sequence (last 100 trades)
|
| 387 |
+
|
| 388 |
+
**Controls:**
|
| 389 |
+
- Symbol dropdown to filter by security or view all
|
| 390 |
+
|
| 391 |
+
---
|
| 392 |
+
|
| 393 |
+
### 4.6 Trading Statistics
|
| 394 |
+
|
| 395 |
+
Aggregated metrics calculated from all trades.
|
| 396 |
+
|
| 397 |
+
| Metric | Description | Formula |
|
| 398 |
+
|--------|-------------|---------|
|
| 399 |
+
| Trades | Count of executed trades | COUNT(*) |
|
| 400 |
+
| Volume | Total shares traded | Σ Quantity |
|
| 401 |
+
| Value | Total monetary value | Σ (Qty × Price) |
|
| 402 |
+
| Start | First trade price | First price in session |
|
| 403 |
+
| Last | Most recent price | Latest price |
|
| 404 |
+
| VWAP | Volume-Weighted Average Price | Σ(Qty × Price) / Σ Qty |
|
| 405 |
+
|
| 406 |
+
**Bar Charts:**
|
| 407 |
+
- Grouped by symbol showing Volume (green) and Value (blue) side by side
|
| 408 |
+
- Normalized to maximum value in dataset
|
| 409 |
+
|
| 410 |
+
---
|
| 411 |
+
|
| 412 |
+
## 5. Data Flow & Messaging
|
| 413 |
+
|
| 414 |
+
### 5.1 Order Entry Flow
|
| 415 |
+
|
| 416 |
+
```
|
| 417 |
+
┌──────────────┐
|
| 418 |
+
│ FIX Client │
|
| 419 |
+
└──────┬───────┘
|
| 420 |
+
│ FIX 4.4 NewOrderSingle (35=D)
|
| 421 |
+
▼
|
| 422 |
+
┌──────────────┐
|
| 423 |
+
│ FIX OEG │ Validate → Normalize → Generate cl_ord_id
|
| 424 |
+
└──────┬───────┘
|
| 425 |
+
│ JSON
|
| 426 |
+
▼
|
| 427 |
+
┌──────────────┐
|
| 428 |
+
│ Kafka │ Topic: orders
|
| 429 |
+
│ [orders] │
|
| 430 |
+
└──────┬───────┘
|
| 431 |
+
│
|
| 432 |
+
┌─────┴─────┐
|
| 433 |
+
▼ ▼
|
| 434 |
+
┌────────┐ ┌───────────┐
|
| 435 |
+
│Matcher │ │ Dashboard │
|
| 436 |
+
└────────┘ └───────────┘
|
| 437 |
+
```
|
| 438 |
+
|
| 439 |
+
### 5.2 Order Matching Flow
|
| 440 |
+
|
| 441 |
+
```
|
| 442 |
+
┌─────────────────┐
|
| 443 |
+
│ Incoming Order │
|
| 444 |
+
│ (from Kafka) │
|
| 445 |
+
└────────┬────────┘
|
| 446 |
+
│
|
| 447 |
+
▼
|
| 448 |
+
┌─────────────────┐
|
| 449 |
+
│ Parse & Validate│
|
| 450 |
+
└────────┬────────┘
|
| 451 |
+
│
|
| 452 |
+
┌──────────────┴──────────────┐
|
| 453 |
+
▼ ▼
|
| 454 |
+
┌────────────┐ ┌────────────┐
|
| 455 |
+
│ BUY Order │ │ SELL Order │
|
| 456 |
+
└──────┬─────┘ └──────┬─────┘
|
| 457 |
+
│ │
|
| 458 |
+
▼ ▼
|
| 459 |
+
┌──────────────────┐ ┌──────────────────┐
|
| 460 |
+
│ Check SELL book │ │ Check BUY book │
|
| 461 |
+
│ for price ≤ bid │ │ for price ≥ ask │
|
| 462 |
+
└────────┬─────────┘ └────────┬─────────┘
|
| 463 |
+
│ │
|
| 464 |
+
┌──────┴──────┐ ┌──────┴──────┐
|
| 465 |
+
▼ ▼ ▼ ▼
|
| 466 |
+
┌───────┐ ┌────────┐ ┌───────┐ ┌────────┐
|
| 467 |
+
│ Match │ │No Match│ │ Match │ │No Match│
|
| 468 |
+
│ Found │ │ │ │ Found │ │ │
|
| 469 |
+
└───┬───┘ └───┬────┘ └───┬───┘ └───┬────┘
|
| 470 |
+
│ │ │ │
|
| 471 |
+
▼ ▼ ▼ ▼
|
| 472 |
+
┌───────┐ ┌────────┐ ┌───────┐ ┌────────┐
|
| 473 |
+
│Execute│ │Add to │ │Execute│ │Add to │
|
| 474 |
+
│ Trade │ │BUY Book│ │ Trade │ │SELLBook│
|
| 475 |
+
└───┬───┘ └────────┘ └───┬───┘ └────────┘
|
| 476 |
+
│ │
|
| 477 |
+
└───────────┬───────────────┘
|
| 478 |
+
▼
|
| 479 |
+
┌────────────┐
|
| 480 |
+
│Kafka:trades│
|
| 481 |
+
└────────────┘
|
| 482 |
+
```
|
| 483 |
+
|
| 484 |
+
### 5.3 Real-time Dashboard Flow
|
| 485 |
+
|
| 486 |
+
```
|
| 487 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 488 |
+
│ BROWSER │
|
| 489 |
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
| 490 |
+
│ │ Dashboard UI │ │
|
| 491 |
+
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐│ │
|
| 492 |
+
│ │ │ Orders │ │ Trades │ │ Book │ │ Statistics ││ │
|
| 493 |
+
│ │ └────▲────┘ └────▲────┘ └────▲────┘ └────────▲────────┘│ │
|
| 494 |
+
│ └───────┼───────────┼───────────┼───────────────┼──────────┘ │
|
| 495 |
+
│ └───────────┴─────┬─────┴───────────────┘ │
|
| 496 |
+
│ ┌───────▼────────┐ │
|
| 497 |
+
│ │ EventSource │ SSE Connection │
|
| 498 |
+
│ │ /stream │ │
|
| 499 |
+
│ └───────┬────────┘ │
|
| 500 |
+
└────────────────────────────┼────────────────────────────────────┘
|
| 501 |
+
│ HTTP (SSE)
|
| 502 |
+
▼
|
| 503 |
+
┌────────────────────────────────────────────────────────────────┐
|
| 504 |
+
│ DASHBOARD SERVER ��
|
| 505 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
| 506 |
+
│ │Kafka Consumer│─────▶│ SSE Broadcast│────▶│ Clients │ │
|
| 507 |
+
│ │ (orders, │ │ Queue │ │ Queue[] │ │
|
| 508 |
+
│ │ trades, │ └──────────────┘ └────────────┘ │
|
| 509 |
+
│ │ snapshots) │ │
|
| 510 |
+
│ └──────────────┘ │
|
| 511 |
+
└────────────────────────────────────────────────────────────────┘
|
| 512 |
+
```
|
| 513 |
+
|
| 514 |
+
### 5.4 Complete System Interaction
|
| 515 |
+
|
| 516 |
+
```
|
| 517 |
+
┌─────────┐ ┌─────────┐ ┌─────────┐
|
| 518 |
+
│FIX Cli 1│ │FIX Cli 2│ │Frontend │
|
| 519 |
+
└────┬────┘ └────┬────┘ └────┬────┘
|
| 520 |
+
└─────┬─────┴───────────┘
|
| 521 |
+
▼
|
| 522 |
+
┌─────────────┐
|
| 523 |
+
│ FIX OEG │◄──── FIX 4.4 Protocol
|
| 524 |
+
└──────┬──────┘
|
| 525 |
+
▼
|
| 526 |
+
┌────────────────────────────────────┐
|
| 527 |
+
│ KAFKA CLUSTER │
|
| 528 |
+
│ ┌────────┐┌────────┐┌──────────┐ │
|
| 529 |
+
│ │orders ││trades ││snapshots │ │
|
| 530 |
+
│ └───┬────┘└───▲────┘└────▲─────┘ │
|
| 531 |
+
└──────┼─────────┼──────────┼───────┘
|
| 532 |
+
│ │ │
|
| 533 |
+
┌─────┼─────────┼──────────┼─────┐
|
| 534 |
+
│ ▼ │ │ │
|
| 535 |
+
│ ┌────────┐ │ │ │
|
| 536 |
+
│ │MATCHER │────┘ │ │
|
| 537 |
+
│ │ Book │ │ │
|
| 538 |
+
│ │ Match │ │ │
|
| 539 |
+
│ └────────┘ │ │
|
| 540 |
+
│ ▲ │ │
|
| 541 |
+
│ │ REST │ │
|
| 542 |
+
│ ┌───┴────────────────────┴──┐ │
|
| 543 |
+
│ │ DASHBOARD │ │
|
| 544 |
+
│ │ (SSE + Kafka Consumer) │ │
|
| 545 |
+
│ └───────────────────────────┘ │
|
| 546 |
+
│ ┌───────────────────────────┐ │
|
| 547 |
+
│ │ MD FEEDER (MDF) │──┘
|
| 548 |
+
│ │ Orders + Snapshots │
|
| 549 |
+
│ └───────────────────────────┘
|
| 550 |
+
└────────────────────────────────┘
|
| 551 |
+
```
|
| 552 |
+
|
| 553 |
+
### 5.5 Message Sequence: New Order to Trade
|
| 554 |
+
|
| 555 |
+
```
|
| 556 |
+
FIX Client FIX OEG Kafka Matcher Dashboard
|
| 557 |
+
│ │ │ │ │
|
| 558 |
+
│──35=D────▶│ │ │ │
|
| 559 |
+
│NewOrder │ │ │ │
|
| 560 |
+
│ │──JSON────▶│ │ │
|
| 561 |
+
│ │ [orders] │ │ │
|
| 562 |
+
│ │ │──consume──▶│ │
|
| 563 |
+
│ │ │ │──match() │
|
| 564 |
+
│ │ │◀──trade────│ │
|
| 565 |
+
│ │ │ [trades] │ │
|
| 566 |
+
│ │ │──────────────consume───▶│
|
| 567 |
+
│ │ │ │ render()
|
| 568 |
+
```
|
| 569 |
+
|
| 570 |
+
---
|
| 571 |
+
|
| 572 |
+
### 5.6 Message Formats
|
| 573 |
+
|
| 574 |
+
**Order Message:**
|
| 575 |
+
```json
|
| 576 |
+
{
|
| 577 |
+
"symbol": "ALPHA",
|
| 578 |
+
"side": "BUY",
|
| 579 |
+
"price": 25.50,
|
| 580 |
+
"quantity": 100,
|
| 581 |
+
"cl_ord_id": "MDF-1234567890-1",
|
| 582 |
+
"timestamp": 1234567890.123,
|
| 583 |
+
"source": "MDF"
|
| 584 |
+
}
|
| 585 |
+
```
|
| 586 |
+
|
| 587 |
+
**Cancel Message:**
|
| 588 |
+
```json
|
| 589 |
+
{
|
| 590 |
+
"type": "cancel",
|
| 591 |
+
"orig_cl_ord_id": "MDF-1234567890-1",
|
| 592 |
+
"symbol": "ALPHA",
|
| 593 |
+
"timestamp": 1234567890.456
|
| 594 |
+
}
|
| 595 |
+
```
|
| 596 |
+
|
| 597 |
+
**Amend Message:**
|
| 598 |
+
```json
|
| 599 |
+
{
|
| 600 |
+
"type": "amend",
|
| 601 |
+
"orig_cl_ord_id": "MDF-1234567890-1",
|
| 602 |
+
"cl_ord_id": "amend-1234567890",
|
| 603 |
+
"symbol": "ALPHA",
|
| 604 |
+
"quantity": 150,
|
| 605 |
+
"price": 25.45,
|
| 606 |
+
"timestamp": 1234567890.789
|
| 607 |
+
}
|
| 608 |
+
```
|
| 609 |
+
|
| 610 |
+
**Trade Message:**
|
| 611 |
+
```json
|
| 612 |
+
{
|
| 613 |
+
"symbol": "ALPHA",
|
| 614 |
+
"price": 25.50,
|
| 615 |
+
"quantity": 100,
|
| 616 |
+
"buy_order_id": "order-123",
|
| 617 |
+
"sell_order_id": "order-456",
|
| 618 |
+
"timestamp": 1234567890.123
|
| 619 |
+
}
|
| 620 |
+
```
|
| 621 |
+
|
| 622 |
+
**Snapshot Message:**
|
| 623 |
+
```json
|
| 624 |
+
{
|
| 625 |
+
"symbol": "ALPHA",
|
| 626 |
+
"best_bid": 25.45,
|
| 627 |
+
"best_ask": 25.55,
|
| 628 |
+
"bid_size": 500,
|
| 629 |
+
"ask_size": 300,
|
| 630 |
+
"timestamp": 1234567890.123,
|
| 631 |
+
"source": "MDF"
|
| 632 |
+
}
|
| 633 |
+
```
|
| 634 |
+
|
| 635 |
+
---
|
| 636 |
+
|
| 637 |
+
## 6. Configuration
|
| 638 |
+
|
| 639 |
+
### 6.1 Environment Variables
|
| 640 |
+
|
| 641 |
+
| Variable | Default | Description |
|
| 642 |
+
|----------|---------|-------------|
|
| 643 |
+
| `KAFKA_BOOTSTRAP` | kafka:9092 | Kafka broker address |
|
| 644 |
+
| `MATCHER_URL` | http://matcher:6000 | Matcher API endpoint |
|
| 645 |
+
| `TICK_SIZE` | 0.05 | Minimum price increment |
|
| 646 |
+
| `ORDERS_PER_MIN` | 8 | MDF order generation rate |
|
| 647 |
+
| `SECURITIES_FILE` | /app/data/securities.txt | Securities configuration |
|
| 648 |
+
| `KAFKA_RETRIES` | 30 | Connection retry attempts |
|
| 649 |
+
| `KAFKA_RETRY_DELAY` | 2 | Seconds between retries |
|
| 650 |
+
|
| 651 |
+
### 6.2 Securities Configuration
|
| 652 |
+
|
| 653 |
+
File: `shared_data/securities.txt`
|
| 654 |
+
|
| 655 |
+
```
|
| 656 |
+
#SYMBOL <start_price> <current_price>
|
| 657 |
+
ALPHA 25.00 25.00
|
| 658 |
+
PEIR 18.50 18.50
|
| 659 |
+
EXAE 42.00 42.00
|
| 660 |
+
QUEST 12.75 12.75
|
| 661 |
+
```
|
| 662 |
+
|
| 663 |
+
| Symbol | Start Price | Description |
|
| 664 |
+
|--------|-------------|-------------|
|
| 665 |
+
| ALPHA | 25.00 | Test Security A |
|
| 666 |
+
| EXAE | 42.00 | Test Security B |
|
| 667 |
+
| PEIR | 18.50 | Test Security C |
|
| 668 |
+
| QUEST | 12.75 | Test Security D |
|
| 669 |
+
|
| 670 |
+
### 6.3 Docker Volumes
|
| 671 |
+
|
| 672 |
+
| Volume | Path | Purpose |
|
| 673 |
+
|--------|------|---------|
|
| 674 |
+
| `matcher_data` | /app/data | SQLite database persistence |
|
| 675 |
+
| `shared_data` | /app/data | Securities and order ID files |
|
| 676 |
+
| `logs` | /app/logs | Snapshot viewer logs |
|
| 677 |
+
|
| 678 |
+
---
|
| 679 |
+
|
| 680 |
+
## 7. Quick Reference
|
| 681 |
+
|
| 682 |
+
### 7.1 Starting the System
|
| 683 |
+
|
| 684 |
+
```bash
|
| 685 |
+
# Start all services
|
| 686 |
+
docker compose up --build
|
| 687 |
+
|
| 688 |
+
# Start in background
|
| 689 |
+
docker compose up -d --build
|
| 690 |
+
|
| 691 |
+
# View logs
|
| 692 |
+
docker compose logs -f dashboard
|
| 693 |
+
docker compose logs -f matcher
|
| 694 |
+
|
| 695 |
+
# Stop all services
|
| 696 |
+
docker compose down
|
| 697 |
+
|
| 698 |
+
# Reset matcher database
|
| 699 |
+
docker volume rm stockex_matcher_data
|
| 700 |
+
```
|
| 701 |
+
|
| 702 |
+
### 7.2 Dashboard Actions
|
| 703 |
+
|
| 704 |
+
| Action | How To |
|
| 705 |
+
|--------|--------|
|
| 706 |
+
| View order book depth | Select symbol from Order Book dropdown |
|
| 707 |
+
| Edit an order | Click Edit button on order row |
|
| 708 |
+
| Cancel an order | Click Cancel button on order row |
|
| 709 |
+
| Filter trade chart | Select symbol from Trade Chart dropdown |
|
| 710 |
+
| Pause ticker tape | Hover mouse over the ticker |
|
| 711 |
+
| Select order row | Click on the row |
|
| 712 |
+
| Bulk actions | Select row, use header Edit/Cancel buttons |
|
| 713 |
+
|
| 714 |
+
### 7.3 Connection Status
|
| 715 |
+
|
| 716 |
+
| Status | Indicator | Meaning |
|
| 717 |
+
|--------|-----------|---------|
|
| 718 |
+
| Live | ● Green | Connected to SSE stream |
|
| 719 |
+
| Connecting | ● Yellow | Establishing connection |
|
| 720 |
+
| Disconnected | ● Red | Connection lost, auto-reconnecting |
|
| 721 |
+
|
| 722 |
+
### 7.4 API Endpoints
|
| 723 |
+
|
| 724 |
+
**Matcher API (Port 6000):**
|
| 725 |
+
|
| 726 |
+
| Endpoint | Method | Description |
|
| 727 |
+
|----------|--------|-------------|
|
| 728 |
+
| `/orderbook/<symbol>` | GET | Order book for symbol |
|
| 729 |
+
| `/trades` | GET | Recent trades list |
|
| 730 |
+
| `/health` | GET | Service health status |
|
| 731 |
+
|
| 732 |
+
**Dashboard API (Port 5005):**
|
| 733 |
+
|
| 734 |
+
| Endpoint | Method | Description |
|
| 735 |
+
|----------|--------|-------------|
|
| 736 |
+
| `/` | GET | Main dashboard page |
|
| 737 |
+
| `/data` | GET | Current state (polling fallback) |
|
| 738 |
+
| `/stream` | GET | SSE event stream |
|
| 739 |
+
| `/orderbook/<symbol>` | GET | Proxy to matcher |
|
| 740 |
+
| `/order/cancel` | POST | Cancel order request |
|
| 741 |
+
| `/order/amend` | POST | Amend order request |
|
| 742 |
+
|
| 743 |
+
---
|
| 744 |
+
|
| 745 |
+
## 8. Troubleshooting
|
| 746 |
+
|
| 747 |
+
### Orders/Trades Panel Empty
|
| 748 |
+
|
| 749 |
+
1. Check Kafka connection:
|
| 750 |
+
```bash
|
| 751 |
+
docker logs dashboard | grep "Kafka"
|
| 752 |
+
```
|
| 753 |
+
2. Verify MDF is producing:
|
| 754 |
+
```bash
|
| 755 |
+
docker logs stockex-md_feeder-1 --tail 10
|
| 756 |
+
```
|
| 757 |
+
3. Restart dashboard:
|
| 758 |
+
```bash
|
| 759 |
+
docker restart dashboard
|
| 760 |
+
```
|
| 761 |
+
|
| 762 |
+
### Order Book Empty
|
| 763 |
+
|
| 764 |
+
1. Check matcher is receiving orders:
|
| 765 |
+
```bash
|
| 766 |
+
docker logs matcher | grep "received"
|
| 767 |
+
```
|
| 768 |
+
2. Verify matcher Kafka consumer connected:
|
| 769 |
+
```bash
|
| 770 |
+
docker logs matcher | grep "consumer connected"
|
| 771 |
+
```
|
| 772 |
+
3. Reset matcher database if corrupted:
|
| 773 |
+
```bash
|
| 774 |
+
docker compose down
|
| 775 |
+
docker volume rm stockex_matcher_data
|
| 776 |
+
docker compose up -d
|
| 777 |
+
```
|
| 778 |
+
|
| 779 |
+
### Connection Status Shows Disconnected
|
| 780 |
+
|
| 781 |
+
1. SSE stream timeout — will auto-reconnect after 3 seconds
|
| 782 |
+
2. Check dashboard container is running:
|
| 783 |
+
```bash
|
| 784 |
+
docker ps | grep dashboard
|
| 785 |
+
```
|
| 786 |
+
3. Check browser console for errors (F12)
|
| 787 |
+
|
| 788 |
+
### Prices Going Negative or Extreme
|
| 789 |
+
|
| 790 |
+
1. Old bug in MDF — update to latest version
|
| 791 |
+
2. Reset securities file:
|
| 792 |
+
```
|
| 793 |
+
#SYMBOL <start_price> <current_price>
|
| 794 |
+
ALPHA 25.00 25.00
|
| 795 |
+
PEIR 18.50 18.50
|
| 796 |
+
EXAE 42.00 42.00
|
| 797 |
+
QUEST 12.75 12.75
|
| 798 |
+
```
|
| 799 |
+
3. Restart MDF and matcher:
|
| 800 |
+
```bash
|
| 801 |
+
docker restart stockex-md_feeder-1 matcher
|
| 802 |
+
```
|
| 803 |
+
|
| 804 |
+
---
|
| 805 |
+
|
| 806 |
+
## Document Information
|
| 807 |
+
|
| 808 |
+
- **Version:** 1.0
|
| 809 |
+
- **Platform:** StockEx Trading Simulation
|
| 810 |
+
- **Inspired by:** Euronext OPTIQ
|
| 811 |
+
|
| 812 |
+
---
|
| 813 |
+
|
| 814 |
+
*StockEx v1.0 — Trading Simulation Platform*
|
StockEx_User_Guide.html
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>StockEx User Guide</title>
|
| 6 |
+
<style>
|
| 7 |
+
@page { margin: 2cm; }
|
| 8 |
+
body { font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; line-height: 1.6; color: #333; }
|
| 9 |
+
h1 { color: #1a1a2e; border-bottom: 3px solid #4CAF50; padding-bottom: 10px; }
|
| 10 |
+
h2 { color: #2e7d32; margin-top: 30px; border-bottom: 1px solid #ddd; padding-bottom: 5px; page-break-after: avoid; }
|
| 11 |
+
h3 { color: #1565c0; margin-top: 20px; }
|
| 12 |
+
h4 { color: #555; margin-top: 15px; }
|
| 13 |
+
table { width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 14px; }
|
| 14 |
+
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
|
| 15 |
+
th { background: #f5f5f5; font-weight: bold; }
|
| 16 |
+
.header { text-align: center; margin-bottom: 30px; }
|
| 17 |
+
.subtitle { color: #666; font-size: 18px; }
|
| 18 |
+
.section { page-break-inside: avoid; margin-bottom: 25px; }
|
| 19 |
+
.highlight { background: #e8f5e9; padding: 15px; border-radius: 5px; margin: 10px 0; border-left: 4px solid #4CAF50; }
|
| 20 |
+
.info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin: 10px 0; border-left: 4px solid #2196F3; }
|
| 21 |
+
.warning { background: #fff3e0; padding: 15px; border-radius: 5px; margin: 10px 0; border-left: 4px solid #ff9800; }
|
| 22 |
+
.green { color: #2e7d32; }
|
| 23 |
+
.red { color: #c62828; }
|
| 24 |
+
.blue { color: #1565c0; }
|
| 25 |
+
.screenshot { text-align: center; margin: 20px 0; }
|
| 26 |
+
.screenshot img { max-width: 100%; border: 1px solid #ddd; border-radius: 5px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
| 27 |
+
hr { border: none; border-top: 1px solid #ddd; margin: 30px 0; }
|
| 28 |
+
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; }
|
| 29 |
+
.toc { background: #fafafa; padding: 20px; border-radius: 5px; margin: 20px 0; }
|
| 30 |
+
.toc ul { margin: 0; padding-left: 20px; }
|
| 31 |
+
.toc li { margin: 5px 0; }
|
| 32 |
+
.arch-diagram { background: #f5f5f5; padding: 20px; border-radius: 5px; font-family: monospace; white-space: pre; font-size: 12px; overflow-x: auto; }
|
| 33 |
+
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
| 34 |
+
.module-box { border: 1px solid #ddd; border-radius: 8px; padding: 15px; margin: 15px 0; background: #fafafa; }
|
| 35 |
+
.module-box h4 { margin-top: 0; color: #1a1a2e; }
|
| 36 |
+
.port { display: inline-block; background: #e3f2fd; padding: 2px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
|
| 37 |
+
</style>
|
| 38 |
+
</head>
|
| 39 |
+
<body>
|
| 40 |
+
|
| 41 |
+
<div class="header">
|
| 42 |
+
<h1>StockEx Trading Dashboard</h1>
|
| 43 |
+
<p class="subtitle">Complete User & Technical Guide v1.0</p>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div class="toc">
|
| 47 |
+
<strong>Table of Contents</strong>
|
| 48 |
+
<ul>
|
| 49 |
+
<li>1. Introduction</li>
|
| 50 |
+
<li>2. System Architecture</li>
|
| 51 |
+
<li>3. Module Descriptions</li>
|
| 52 |
+
<li>4. Dashboard User Interface</li>
|
| 53 |
+
<li>5. Data Flow & Messaging</li>
|
| 54 |
+
<li>6. Configuration</li>
|
| 55 |
+
<li>7. Quick Reference</li>
|
| 56 |
+
</ul>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<hr>
|
| 60 |
+
|
| 61 |
+
<div class="section">
|
| 62 |
+
<h2>1. Introduction</h2>
|
| 63 |
+
<p><strong>StockEx</strong> is a comprehensive real-time trading simulation platform inspired by Euronext OPTIQ. It provides a complete electronic trading ecosystem including order entry, matching engine, market data distribution, and live visualization.</p>
|
| 64 |
+
|
| 65 |
+
<div class="highlight">
|
| 66 |
+
<strong>Key Features:</strong>
|
| 67 |
+
<ul>
|
| 68 |
+
<li>FIX 4.4 protocol support for institutional order entry</li>
|
| 69 |
+
<li>Real-time order matching with price-time priority</li>
|
| 70 |
+
<li>Live market data streaming via Kafka</li>
|
| 71 |
+
<li>Web-based dashboard with Server-Sent Events (SSE)</li>
|
| 72 |
+
<li>Order management (Edit/Cancel) capabilities</li>
|
| 73 |
+
<li>Trading analytics and statistics</li>
|
| 74 |
+
</ul>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="info">
|
| 78 |
+
<strong>Access URLs:</strong><br>
|
| 79 |
+
Dashboard: <code>http://localhost:5005</code><br>
|
| 80 |
+
Frontend (Order Entry): <code>http://localhost:5000</code><br>
|
| 81 |
+
FIX Gateway: <code>localhost:5001</code><br>
|
| 82 |
+
Matcher API: <code>http://localhost:6000</code>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="screenshot">
|
| 87 |
+
<img src="screenshots/dashboard.png" alt="Trading Dashboard">
|
| 88 |
+
<p><em>StockEx Trading Dashboard - Real-time Market View</em></p>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div class="section">
|
| 92 |
+
<h2>2. System Architecture</h2>
|
| 93 |
+
|
| 94 |
+
<div class="arch-diagram">
|
| 95 |
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
| 96 |
+
│ FIX UI Client │ │ FIX UI Client │ │ Frontend │
|
| 97 |
+
│ (Port 5002) │ │ (Port 5003) │ │ (Port 5000) │
|
| 98 |
+
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
| 99 |
+
│ │ │
|
| 100 |
+
│ FIX 4.4 │ FIX 4.4 │ HTTP
|
| 101 |
+
▼ ▼ ▼
|
| 102 |
+
┌─��──────────────────────────────────────────────────────────────┐
|
| 103 |
+
│ FIX OEG (Port 5001) │
|
| 104 |
+
│ QuickFIX Order Entry Gateway │
|
| 105 |
+
└────────────────────────────────┬───────────────────────────────┘
|
| 106 |
+
│
|
| 107 |
+
▼ Kafka [orders]
|
| 108 |
+
┌────────────────────────────────────────────────────────────────┐
|
| 109 |
+
│ Apache Kafka (Port 9092) │
|
| 110 |
+
│ Topics: orders, trades, snapshots │
|
| 111 |
+
└───────┬────────────────────┬───────────────────────┬───────────┘
|
| 112 |
+
│ │ │
|
| 113 |
+
▼ ▼ ▼
|
| 114 |
+
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
| 115 |
+
│ Matcher │ │ MD Feeder │ │ Dashboard │
|
| 116 |
+
│ (Port 6000) │ │ (MDF) │ │ (Port 5005) │
|
| 117 |
+
│ │ │ │ │ │
|
| 118 |
+
│ Order Book │ │ Price Sim │ │ Web UI + SSE │
|
| 119 |
+
│ Trade Match │ │ BBO Publish │ │ Real-time │
|
| 120 |
+
└───────────────┘ └───────────────┘ └───────────────┘
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div class="section">
|
| 125 |
+
<h2>3. Module Descriptions</h2>
|
| 126 |
+
|
| 127 |
+
<div class="module-box">
|
| 128 |
+
<h4>Zookeeper</h4>
|
| 129 |
+
<p><span class="port">Port 2181</span></p>
|
| 130 |
+
<p>Apache Zookeeper provides distributed coordination for the Kafka cluster. It manages broker metadata, topic configurations, and cluster membership.</p>
|
| 131 |
+
<table>
|
| 132 |
+
<tr><th>Function</th><td>Kafka cluster coordination</td></tr>
|
| 133 |
+
<tr><th>Technology</th><td>Confluent Zookeeper 7.5.0</td></tr>
|
| 134 |
+
<tr><th>Dependencies</th><td>None</td></tr>
|
| 135 |
+
</table>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div class="module-box">
|
| 139 |
+
<h4>Kafka Message Broker</h4>
|
| 140 |
+
<p><span class="port">Port 9092</span> <span class="port">Port 29092 (host)</span></p>
|
| 141 |
+
<p>Apache Kafka serves as the central message backbone for the entire system. All order flow, trade executions, and market data are distributed through Kafka topics.</p>
|
| 142 |
+
<table>
|
| 143 |
+
<tr><th>Function</th><td>Message streaming and event distribution</td></tr>
|
| 144 |
+
<tr><th>Technology</th><td>Confluent Kafka 7.5.0</td></tr>
|
| 145 |
+
<tr><th>Topics</th><td><code>orders</code>, <code>trades</code>, <code>snapshots</code></td></tr>
|
| 146 |
+
<tr><th>Dependencies</th><td>Zookeeper</td></tr>
|
| 147 |
+
</table>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="module-box">
|
| 151 |
+
<h4>FIX Order Entry Gateway (FIX OEG)</h4>
|
| 152 |
+
<p><span class="port">Port 5001</span></p>
|
| 153 |
+
<p>The FIX OEG is a QuickFIX/Python acceptor that receives orders via FIX 4.4 protocol from institutional clients. It validates incoming messages, normalizes them to JSON format, and publishes to the Kafka <code>orders</code> topic.</p>
|
| 154 |
+
<table>
|
| 155 |
+
<tr><th>Function</th><td>FIX protocol order reception and normalization</td></tr>
|
| 156 |
+
<tr><th>Technology</th><td>QuickFIX/Python</td></tr>
|
| 157 |
+
<tr><th>Protocol</th><td>FIX 4.4</td></tr>
|
| 158 |
+
<tr><th>Message Types</th><td>NewOrderSingle (D), OrderCancelRequest (F), OrderCancelReplaceRequest (G)</td></tr>
|
| 159 |
+
<tr><th>Output</th><td>Kafka <code>orders</code> topic</td></tr>
|
| 160 |
+
</table>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div class="module-box">
|
| 164 |
+
<h4>FIX UI Clients</h4>
|
| 165 |
+
<p><span class="port">Port 5002 (Client 1)</span> <span class="port">Port 5003 (Client 2)</span></p>
|
| 166 |
+
<p>Web-based FIX initiator clients that connect to the FIX OEG. They provide a user interface for submitting orders via FIX protocol, simulating institutional trading terminals.</p>
|
| 167 |
+
<table>
|
| 168 |
+
<tr><th>Function</th><td>FIX order submission interface</td></tr>
|
| 169 |
+
<tr><th>Technology</th><td>QuickFIX/Python + Flask</td></tr>
|
| 170 |
+
<tr><th>Features</th><td>New Order, Cancel, Amend</td></tr>
|
| 171 |
+
<tr><th>Connection</th><td>FIX 4.4 to FIX OEG</td></tr>
|
| 172 |
+
</table>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div class="module-box">
|
| 176 |
+
<h4>Matcher (Order Matching Engine)</h4>
|
| 177 |
+
<p><span class="port">Port 6000</span></p>
|
| 178 |
+
<p>The core matching engine that maintains order books for all securities. It consumes orders from Kafka, attempts to match them using price-time priority, and publishes resulting trades. Provides REST API for order book and trade queries.</p>
|
| 179 |
+
<table>
|
| 180 |
+
<tr><th>Function</th><td>Order matching, trade execution, order book management</td></tr>
|
| 181 |
+
<tr><th>Technology</th><td>Python/Flask with SQLite persistence</td></tr>
|
| 182 |
+
<tr><th>Algorithm</th><td>Price-Time Priority (FIFO)</td></tr>
|
| 183 |
+
<tr><th>Input</th><td>Kafka <code>orders</code> topic</td></tr>
|
| 184 |
+
<tr><th>Output</th><td>Kafka <code>trades</code> topic</td></tr>
|
| 185 |
+
<tr><th>API Endpoints</th><td><code>/orderbook/<symbol></code>, <code>/trades</code>, <code>/health</code></td></tr>
|
| 186 |
+
</table>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="module-box">
|
| 190 |
+
<h4>Market Data Feeder (MDF)</h4>
|
| 191 |
+
<p><span class="port">Internal</span></p>
|
| 192 |
+
<p>Simulates market data by generating random orders and publishing Best Bid/Offer (BBO) snapshots. Creates realistic market activity with configurable order rates and price movements.</p>
|
| 193 |
+
<table>
|
| 194 |
+
<tr><th>Function</th><td>Market simulation and BBO publishing</td></tr>
|
| 195 |
+
<tr><th>Technology</th><td>Python</td></tr>
|
| 196 |
+
<tr><th>Output</th><td>Kafka <code>orders</code> and <code>snapshots</code> topics</td></tr>
|
| 197 |
+
<tr><th>Order Mix</th><td>90% passive (book building), 10% aggressive (trades)</td></tr>
|
| 198 |
+
<tr><th>Rate</th><td>Configurable (default: 8 orders/minute)</td></tr>
|
| 199 |
+
</table>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div class="module-box">
|
| 203 |
+
<h4>Dashboard</h4>
|
| 204 |
+
<p><span class="port">Port 5005</span></p>
|
| 205 |
+
<p>Real-time web dashboard providing comprehensive market visualization. Uses Server-Sent Events (SSE) for live streaming updates without page refresh. Displays orders, trades, market snapshots, order book depth, and trading statistics.</p>
|
| 206 |
+
<table>
|
| 207 |
+
<tr><th>Function</th><td>Real-time market visualization and order management</td></tr>
|
| 208 |
+
<tr><th>Technology</th><td>Python/Flask + JavaScript</td></tr>
|
| 209 |
+
<tr><th>Streaming</th><td>Server-Sent Events (SSE)</td></tr>
|
| 210 |
+
<tr><th>Input</th><td>Kafka topics + Matcher REST API</td></tr>
|
| 211 |
+
<tr><th>Features</th><td>Orders, Trades, BBO, Order Book, Charts, Statistics</td></tr>
|
| 212 |
+
</table>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<div class="module-box">
|
| 216 |
+
<h4>Frontend (Manual Order Entry)</h4>
|
| 217 |
+
<p><span class="port">Port 5000</span></p>
|
| 218 |
+
<p>Simple web interface for manual order submission. Allows users to enter orders directly without FIX protocol, useful for testing and demonstration.</p>
|
| 219 |
+
<table>
|
| 220 |
+
<tr><th>Function</th><td>Manual order entry interface</td></tr>
|
| 221 |
+
<tr><th>Technology</th><td>Python/Flask</td></tr>
|
| 222 |
+
<tr><th>Output</th><td>Kafka <code>orders</code> topic</td></tr>
|
| 223 |
+
</table>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<div class="module-box">
|
| 227 |
+
<h4>Consumer (Debug)</h4>
|
| 228 |
+
<p><span class="port">Internal</span></p>
|
| 229 |
+
<p>Debug utility that consumes and logs messages from Kafka topics. Useful for monitoring message flow and troubleshooting.</p>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div class="module-box">
|
| 233 |
+
<h4>Snapshot Viewer</h4>
|
| 234 |
+
<p><span class="port">Internal</span></p>
|
| 235 |
+
<p>Utility service that subscribes to the <code>snapshots</code> topic and logs BBO updates. Writes to log files for analysis.</p>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div class="section">
|
| 240 |
+
<h2>4. Dashboard User Interface</h2>
|
| 241 |
+
|
| 242 |
+
<h3>4.1 Orders Panel</h3>
|
| 243 |
+
<p>Displays real-time incoming orders with full management capabilities.</p>
|
| 244 |
+
<table>
|
| 245 |
+
<tr><th>Column</th><th>Description</th></tr>
|
| 246 |
+
<tr><td>Symbol</td><td>Stock ticker (ALPHA, EXAE, PEIR, QUEST)</td></tr>
|
| 247 |
+
<tr><td>Side</td><td><span class="green">BUY</span> (green) or <span class="red">SELL</span> (red)</td></tr>
|
| 248 |
+
<tr><td>Qty</td><td>Order quantity in shares</td></tr>
|
| 249 |
+
<tr><td>Price</td><td>Limit price</td></tr>
|
| 250 |
+
<tr><td>Source</td><td>Order origin (MDF, FIX, Manual)</td></tr>
|
| 251 |
+
<tr><td>Time</td><td>Order timestamp</td></tr>
|
| 252 |
+
<tr><td>Actions</td><td><span class="blue">Edit</span> / <span class="red">Cancel</span> buttons</td></tr>
|
| 253 |
+
</table>
|
| 254 |
+
<p><strong>Row Selection:</strong> Click any row to select, then use header buttons for bulk actions.</p>
|
| 255 |
+
|
| 256 |
+
<h3>4.2 Market Snapshot</h3>
|
| 257 |
+
<p>Shows Best Bid/Offer (BBO) for all securities with real-time updates.</p>
|
| 258 |
+
<table>
|
| 259 |
+
<tr><th>Column</th><th>Description</th></tr>
|
| 260 |
+
<tr><td>Symbol</td><td>Security identifier</td></tr>
|
| 261 |
+
<tr><td>Best Bid</td><td>Highest buy price <span class="green">(green)</span></td></tr>
|
| 262 |
+
<tr><td>Best Ask</td><td>Lowest sell price <span class="red">(red)</span></td></tr>
|
| 263 |
+
<tr><td>Spread</td><td>Ask - Bid difference</td></tr>
|
| 264 |
+
<tr><td>Mid</td><td>Midpoint: (Bid + Ask) / 2</td></tr>
|
| 265 |
+
<tr><td>Updated</td><td>Last update timestamp</td></tr>
|
| 266 |
+
</table>
|
| 267 |
+
<p><strong>Ticker Tape:</strong> Scrolling bar at bottom displays recent trades with price direction (▲ green up, ▼ red down).</p>
|
| 268 |
+
|
| 269 |
+
<h3>4.3 Trades Panel</h3>
|
| 270 |
+
<p>Lists all executed trades with calculated values.</p>
|
| 271 |
+
<table>
|
| 272 |
+
<tr><th>Column</th><th>Description</th></tr>
|
| 273 |
+
<tr><td>Symbol</td><td>Traded security</td></tr>
|
| 274 |
+
<tr><td>Qty</td><td>Executed quantity</td></tr>
|
| 275 |
+
<tr><td>Price</td><td>Execution price</td></tr>
|
| 276 |
+
<tr><td>Value</td><td>Trade value (Qty × Price)</td></tr>
|
| 277 |
+
<tr><td>Time</td><td>Execution timestamp</td></tr>
|
| 278 |
+
</table>
|
| 279 |
+
|
| 280 |
+
<h3>4.4 Order Book</h3>
|
| 281 |
+
<p>Displays full market depth for selected symbol.</p>
|
| 282 |
+
<ul>
|
| 283 |
+
<li>Select symbol from dropdown menu</li>
|
| 284 |
+
<li>Click <strong>Refresh</strong> to update (auto-refreshes every 3 seconds)</li>
|
| 285 |
+
<li><span class="green">Bid Qty / Bid Price</span> — Buy orders sorted by price (highest first)</li>
|
| 286 |
+
<li><span class="red">Ask Price / Ask Qty</span> — Sell orders sorted by price (lowest first)</li>
|
| 287 |
+
<li>Header shows total bid and ask counts</li>
|
| 288 |
+
</ul>
|
| 289 |
+
|
| 290 |
+
<h3>4.5 Trade Chart</h3>
|
| 291 |
+
<p>Visual representation of trade activity over time.</p>
|
| 292 |
+
<ul>
|
| 293 |
+
<li><span class="green">Green line</span> — Price trend connecting trade prices</li>
|
| 294 |
+
<li><span class="blue">Blue bars</span> — Volume per trade</li>
|
| 295 |
+
<li>Dropdown to filter by symbol or view all</li>
|
| 296 |
+
<li>Displays last 100 trades</li>
|
| 297 |
+
</ul>
|
| 298 |
+
|
| 299 |
+
<h3>4.6 Trading Statistics</h3>
|
| 300 |
+
<p>Aggregated metrics calculated from all trades.</p>
|
| 301 |
+
<table>
|
| 302 |
+
<tr><th>Metric</th><th>Description</th></tr>
|
| 303 |
+
<tr><td>Trades</td><td>Number of executed trades</td></tr>
|
| 304 |
+
<tr><td>Volume</td><td>Total shares traded</td></tr>
|
| 305 |
+
<tr><td>Value</td><td>Total monetary value (Σ Qty × Price)</td></tr>
|
| 306 |
+
<tr><td>Start</td><td>First trade price (opening)</td></tr>
|
| 307 |
+
<tr><td>Last</td><td>Most recent trade price</td></tr>
|
| 308 |
+
<tr><td>VWAP</td><td>Volume-Weighted Average Price</td></tr>
|
| 309 |
+
</table>
|
| 310 |
+
<p><strong>Bar Charts:</strong> Visual comparison showing <span class="green">Volume</span> and <span class="blue">Value</span> per symbol side by side.</p>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<div class="section">
|
| 314 |
+
<h2>5. Data Flow & Messaging</h2>
|
| 315 |
+
|
| 316 |
+
<h3>5.1 Kafka Topics</h3>
|
| 317 |
+
<table>
|
| 318 |
+
<tr><th>Topic</th><th>Producers</th><th>Consumers</th><th>Content</th></tr>
|
| 319 |
+
<tr><td><code>orders</code></td><td>FIX OEG, MDF, Frontend</td><td>Matcher, Dashboard</td><td>New orders, cancels, amends</td></tr>
|
| 320 |
+
<tr><td><code>trades</code></td><td>Matcher</td><td>Dashboard, Consumer</td><td>Executed trades</td></tr>
|
| 321 |
+
<tr><td><code>snapshots</code></td><td>MDF</td><td>Dashboard, Snapshot Viewer</td><td>BBO updates</td></tr>
|
| 322 |
+
</table>
|
| 323 |
+
|
| 324 |
+
<h3>5.2 Message Formats</h3>
|
| 325 |
+
|
| 326 |
+
<h4>Order Message</h4>
|
| 327 |
+
<div class="arch-diagram" style="font-size: 11px;">{
|
| 328 |
+
"symbol": "ALPHA",
|
| 329 |
+
"side": "BUY",
|
| 330 |
+
"price": 25.50,
|
| 331 |
+
"quantity": 100,
|
| 332 |
+
"cl_ord_id": "MDF-1234567890-1",
|
| 333 |
+
"timestamp": 1234567890.123,
|
| 334 |
+
"source": "MDF"
|
| 335 |
+
}</div>
|
| 336 |
+
|
| 337 |
+
<h4>Trade Message</h4>
|
| 338 |
+
<div class="arch-diagram" style="font-size: 11px;">{
|
| 339 |
+
"symbol": "ALPHA",
|
| 340 |
+
"price": 25.50,
|
| 341 |
+
"quantity": 100,
|
| 342 |
+
"buy_order_id": "order-123",
|
| 343 |
+
"sell_order_id": "order-456",
|
| 344 |
+
"timestamp": 1234567890.123
|
| 345 |
+
}</div>
|
| 346 |
+
|
| 347 |
+
<h4>Snapshot Message</h4>
|
| 348 |
+
<div class="arch-diagram" style="font-size: 11px;">{
|
| 349 |
+
"symbol": "ALPHA",
|
| 350 |
+
"best_bid": 25.45,
|
| 351 |
+
"best_ask": 25.55,
|
| 352 |
+
"bid_size": 500,
|
| 353 |
+
"ask_size": 300,
|
| 354 |
+
"timestamp": 1234567890.123,
|
| 355 |
+
"source": "MDF"
|
| 356 |
+
}</div>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div class="section">
|
| 360 |
+
<h2>6. Configuration</h2>
|
| 361 |
+
|
| 362 |
+
<table>
|
| 363 |
+
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
|
| 364 |
+
<tr><td><code>KAFKA_BOOTSTRAP</code></td><td>kafka:9092</td><td>Kafka broker address</td></tr>
|
| 365 |
+
<tr><td><code>MATCHER_URL</code></td><td>http://matcher:6000</td><td>Matcher API endpoint</td></tr>
|
| 366 |
+
<tr><td><code>TICK_SIZE</code></td><td>0.05</td><td>Minimum price increment</td></tr>
|
| 367 |
+
<tr><td><code>ORDERS_PER_MIN</code></td><td>8</td><td>MDF order generation rate</td></tr>
|
| 368 |
+
<tr><td><code>SECURITIES_FILE</code></td><td>/app/data/securities.txt</td><td>Securities configuration</td></tr>
|
| 369 |
+
</table>
|
| 370 |
+
|
| 371 |
+
<h3>6.1 Supported Securities</h3>
|
| 372 |
+
<table>
|
| 373 |
+
<tr><th>Symbol</th><th>Start Price</th><th>Description</th></tr>
|
| 374 |
+
<tr><td>ALPHA</td><td>25.00</td><td>Test Security A</td></tr>
|
| 375 |
+
<tr><td>EXAE</td><td>42.00</td><td>Test Security B</td></tr>
|
| 376 |
+
<tr><td>PEIR</td><td>18.50</td><td>Test Security C</td></tr>
|
| 377 |
+
<tr><td>QUEST</td><td>12.75</td><td>Test Security D</td></tr>
|
| 378 |
+
</table>
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
<div class="section">
|
| 382 |
+
<h2>7. Quick Reference</h2>
|
| 383 |
+
|
| 384 |
+
<h3>7.1 Starting the System</h3>
|
| 385 |
+
<div class="highlight">
|
| 386 |
+
<code>docker compose up --build</code>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<h3>7.2 Service URLs</h3>
|
| 390 |
+
<table>
|
| 391 |
+
<tr><th>Service</th><th>URL</th><th>Purpose</th></tr>
|
| 392 |
+
<tr><td>Dashboard</td><td>http://localhost:5005</td><td>Main trading view</td></tr>
|
| 393 |
+
<tr><td>Frontend</td><td>http://localhost:5000</td><td>Manual order entry</td></tr>
|
| 394 |
+
<tr><td>FIX Client 1</td><td>http://localhost:5002</td><td>FIX order submission</td></tr>
|
| 395 |
+
<tr><td>FIX Client 2</td><td>http://localhost:5003</td><td>FIX order submission</td></tr>
|
| 396 |
+
<tr><td>Matcher API</td><td>http://localhost:6000</td><td>REST API</td></tr>
|
| 397 |
+
</table>
|
| 398 |
+
|
| 399 |
+
<h3>7.3 Dashboard Actions</h3>
|
| 400 |
+
<table>
|
| 401 |
+
<tr><th>Action</th><th>How To</th></tr>
|
| 402 |
+
<tr><td>View order book depth</td><td>Select symbol from Order Book dropdown</td></tr>
|
| 403 |
+
<tr><td>Edit an order</td><td>Click <span class="blue">Edit</span> button on order row</td></tr>
|
| 404 |
+
<tr><td>Cancel an order</td><td>Click <span class="red">Cancel</span> button on order row</td></tr>
|
| 405 |
+
<tr><td>Filter trade chart</td><td>Select symbol from Trade Chart dropdown</td></tr>
|
| 406 |
+
<tr><td>Pause ticker tape</td><td>Hover mouse over the ticker</td></tr>
|
| 407 |
+
<tr><td>Select multiple orders</td><td>Click rows, use header Edit/Cancel buttons</td></tr>
|
| 408 |
+
</table>
|
| 409 |
+
|
| 410 |
+
<h3>7.4 Connection Status Indicators</h3>
|
| 411 |
+
<table>
|
| 412 |
+
<tr><th>Status</th><th>Indicator</th><th>Meaning</th></tr>
|
| 413 |
+
<tr><td>Live</td><td><span class="green">● Green</span></td><td>Connected to real-time stream</td></tr>
|
| 414 |
+
<tr><td>Connecting</td><td><span style="color:#ffc107;">● Yellow</span></td><td>Establishing connection</td></tr>
|
| 415 |
+
<tr><td>Disconnected</td><td><span class="red">● Red</span></td><td>Connection lost, auto-reconnecting</td></tr>
|
| 416 |
+
</table>
|
| 417 |
+
</div>
|
| 418 |
+
|
| 419 |
+
<hr>
|
| 420 |
+
|
| 421 |
+
<div class="footer">
|
| 422 |
+
<p><strong>StockEx v1.0</strong> — Trading Simulation Platform</p>
|
| 423 |
+
<p>Inspired by Euronext OPTIQ</p>
|
| 424 |
+
<p style="margin-top: 15px; font-size: 11px;">To create PDF: Open in browser → Print (Ctrl+P) → Save as PDF</p>
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
</body>
|
| 428 |
+
</html>
|
consumer/Dockerfile
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY consumer.py .
|
| 6 |
+
CMD ["python", "consumer.py"]
|
consumer/consumer.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.path.insert(0, "/app")
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from shared.config import Config
|
| 6 |
+
from shared.kafka_utils import create_consumer
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
consumer = create_consumer(
|
| 10 |
+
topics=Config.TRADES_TOPIC,
|
| 11 |
+
group_id="order-group",
|
| 12 |
+
auto_offset_reset="earliest",
|
| 13 |
+
component_name="Consumer"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
print("Listening for trades...")
|
| 17 |
+
|
| 18 |
+
for msg in consumer:
|
| 19 |
+
trade = msg.value
|
| 20 |
+
qty = trade.get("quantity") or trade.get("qty") or "-"
|
| 21 |
+
price = trade.get("price", "-")
|
| 22 |
+
print(f"TRADE: {trade.get('symbol', '?')} - {qty} @ {price}")
|
consumer/requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
kafka-python==2.0.2
|
| 2 |
+
|
dashboard/Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM fix-base
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dashboard-specific deps
|
| 6 |
+
RUN pip install --no-cache-dir kafka-python requests
|
| 7 |
+
|
| 8 |
+
COPY . /app
|
| 9 |
+
|
| 10 |
+
CMD ["python", "dashboard.py"]
|
dashboard/dashboard.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.path.insert(0, "/app")
|
| 3 |
+
|
| 4 |
+
from flask import Flask, render_template, jsonify, Response, request
|
| 5 |
+
import threading, json, os, time, requests
|
| 6 |
+
from queue import Queue, Empty
|
| 7 |
+
|
| 8 |
+
from shared.config import Config
|
| 9 |
+
from shared.kafka_utils import create_consumer, create_producer
|
| 10 |
+
|
| 11 |
+
app = Flask(__name__, template_folder="templates")
|
| 12 |
+
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
| 13 |
+
|
| 14 |
+
# Shared state
|
| 15 |
+
orders, bbos, trades_cache = [], {}, []
|
| 16 |
+
lock = threading.Lock()
|
| 17 |
+
|
| 18 |
+
# SSE: list of queues for connected clients
|
| 19 |
+
sse_clients = []
|
| 20 |
+
sse_clients_lock = threading.Lock()
|
| 21 |
+
|
| 22 |
+
def broadcast_event(event_type, data):
|
| 23 |
+
"""Send an event to all connected SSE clients."""
|
| 24 |
+
message = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
| 25 |
+
with sse_clients_lock:
|
| 26 |
+
dead_clients = []
|
| 27 |
+
for q in sse_clients:
|
| 28 |
+
try:
|
| 29 |
+
q.put_nowait(message)
|
| 30 |
+
except:
|
| 31 |
+
dead_clients.append(q)
|
| 32 |
+
for q in dead_clients:
|
| 33 |
+
sse_clients.remove(q)
|
| 34 |
+
|
| 35 |
+
# Kafka consumer thread
|
| 36 |
+
def consume_kafka():
|
| 37 |
+
consumer = create_consumer(
|
| 38 |
+
topics=[Config.ORDERS_TOPIC, Config.SNAPSHOTS_TOPIC, Config.TRADES_TOPIC],
|
| 39 |
+
group_id="dashboard",
|
| 40 |
+
component_name="Dashboard"
|
| 41 |
+
)
|
| 42 |
+
for msg in consumer:
|
| 43 |
+
with lock:
|
| 44 |
+
if msg.topic == "orders":
|
| 45 |
+
order = msg.value
|
| 46 |
+
orders.insert(0, order)
|
| 47 |
+
orders[:] = orders[:50] # keep last 50
|
| 48 |
+
broadcast_event("order", order)
|
| 49 |
+
|
| 50 |
+
elif msg.topic == "snapshots":
|
| 51 |
+
snap = msg.value
|
| 52 |
+
# Handle both simple snapshots and MDF snapshots
|
| 53 |
+
symbol = snap.get("symbol")
|
| 54 |
+
if not symbol:
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
bbo_data = {
|
| 58 |
+
"best_bid": snap.get("best_bid"),
|
| 59 |
+
"best_ask": snap.get("best_ask"),
|
| 60 |
+
"bid_size": snap.get("bid_size"),
|
| 61 |
+
"ask_size": snap.get("ask_size"),
|
| 62 |
+
"timestamp": snap.get("timestamp", time.time()),
|
| 63 |
+
"source": snap.get("source", "unknown")
|
| 64 |
+
}
|
| 65 |
+
bbos[symbol] = bbo_data
|
| 66 |
+
broadcast_event("snapshot", {"symbol": symbol, **bbo_data})
|
| 67 |
+
|
| 68 |
+
elif msg.topic == "trades":
|
| 69 |
+
trade = msg.value
|
| 70 |
+
trades_cache.insert(0, trade)
|
| 71 |
+
trades_cache[:] = trades_cache[:200] # keep last 200
|
| 72 |
+
broadcast_event("trade", trade)
|
| 73 |
+
|
| 74 |
+
threading.Thread(target=consume_kafka, daemon=True).start()
|
| 75 |
+
|
| 76 |
+
@app.route("/")
|
| 77 |
+
def index():
|
| 78 |
+
return render_template("index.html")
|
| 79 |
+
|
| 80 |
+
@app.route("/health")
|
| 81 |
+
def health():
|
| 82 |
+
"""Health check endpoint."""
|
| 83 |
+
status = {
|
| 84 |
+
"status": "healthy",
|
| 85 |
+
"service": "dashboard",
|
| 86 |
+
"timestamp": time.time(),
|
| 87 |
+
"stats": {
|
| 88 |
+
"orders_cached": len(orders),
|
| 89 |
+
"trades_cached": len(trades_cache),
|
| 90 |
+
"symbols_tracked": len(bbos),
|
| 91 |
+
"sse_clients": len(sse_clients)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
# Check matcher connectivity
|
| 95 |
+
try:
|
| 96 |
+
r = requests.get(f"{Config.MATCHER_URL}/health", timeout=2)
|
| 97 |
+
if r.status_code == 200:
|
| 98 |
+
status["matcher"] = "connected"
|
| 99 |
+
else:
|
| 100 |
+
status["matcher"] = f"error: status {r.status_code}"
|
| 101 |
+
status["status"] = "degraded"
|
| 102 |
+
except Exception as e:
|
| 103 |
+
status["matcher"] = f"error: {e}"
|
| 104 |
+
status["status"] = "degraded"
|
| 105 |
+
|
| 106 |
+
return jsonify(status)
|
| 107 |
+
|
| 108 |
+
@app.route("/data")
|
| 109 |
+
def data():
|
| 110 |
+
"""Fallback polling endpoint (still useful for initial load or SSE fallback)."""
|
| 111 |
+
# Always fetch trades from matcher (more reliable)
|
| 112 |
+
try:
|
| 113 |
+
r = requests.get(f"{Config.MATCHER_URL}/trades", timeout=2)
|
| 114 |
+
resp = r.json()
|
| 115 |
+
trades = resp.get('trades', []) if isinstance(resp, dict) else resp
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print("Cannot fetch trades:", e)
|
| 118 |
+
with lock:
|
| 119 |
+
trades = list(trades_cache)
|
| 120 |
+
|
| 121 |
+
# Order book for dropdown (fetch latest for all symbols)
|
| 122 |
+
try:
|
| 123 |
+
r = requests.get(f"{Config.MATCHER_URL}/orderbook/ALPHA", timeout=2)
|
| 124 |
+
book = r.json()
|
| 125 |
+
except Exception as e:
|
| 126 |
+
print("Cannot fetch orderbook:", e)
|
| 127 |
+
book = {"bids": [], "asks": []}
|
| 128 |
+
|
| 129 |
+
with lock:
|
| 130 |
+
return jsonify({
|
| 131 |
+
"orders": list(orders),
|
| 132 |
+
"bbos": dict(bbos),
|
| 133 |
+
"trades": trades,
|
| 134 |
+
"book": book
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
@app.route("/orderbook/<symbol>")
|
| 138 |
+
def orderbook(symbol):
|
| 139 |
+
"""Proxy to matcher orderbook API."""
|
| 140 |
+
try:
|
| 141 |
+
r = requests.get(f"{Config.MATCHER_URL}/orderbook/{symbol}", timeout=2)
|
| 142 |
+
return (r.text, r.status_code, {"Content-Type": "application/json"})
|
| 143 |
+
except Exception as e:
|
| 144 |
+
return jsonify({"error": str(e), "bids": [], "asks": []}), 500
|
| 145 |
+
|
| 146 |
+
# Kafka producer for order management (lazy init)
|
| 147 |
+
_producer = None
|
| 148 |
+
def get_producer():
|
| 149 |
+
global _producer
|
| 150 |
+
if _producer is None:
|
| 151 |
+
_producer = create_producer(component_name="Dashboard")
|
| 152 |
+
return _producer
|
| 153 |
+
|
| 154 |
+
@app.route("/order/cancel", methods=["POST"])
|
| 155 |
+
def cancel_order():
|
| 156 |
+
"""Send cancel request to matcher via Kafka."""
|
| 157 |
+
try:
|
| 158 |
+
data = request.get_json()
|
| 159 |
+
orig_cl_ord_id = data.get("orig_cl_ord_id")
|
| 160 |
+
symbol = data.get("symbol")
|
| 161 |
+
|
| 162 |
+
if not orig_cl_ord_id:
|
| 163 |
+
return jsonify({"status": "error", "error": "Missing orig_cl_ord_id"}), 400
|
| 164 |
+
|
| 165 |
+
cancel_msg = {
|
| 166 |
+
"type": "cancel",
|
| 167 |
+
"orig_cl_ord_id": orig_cl_ord_id,
|
| 168 |
+
"symbol": symbol,
|
| 169 |
+
"timestamp": time.time()
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
producer = get_producer()
|
| 173 |
+
producer.send(Config.ORDERS_TOPIC, cancel_msg)
|
| 174 |
+
producer.flush()
|
| 175 |
+
|
| 176 |
+
return jsonify({"status": "ok", "message": f"Cancel request sent for {orig_cl_ord_id}"})
|
| 177 |
+
except Exception as e:
|
| 178 |
+
return jsonify({"status": "error", "error": str(e)}), 500
|
| 179 |
+
|
| 180 |
+
@app.route("/order/amend", methods=["POST"])
|
| 181 |
+
def amend_order():
|
| 182 |
+
"""Send amend request to matcher via Kafka."""
|
| 183 |
+
try:
|
| 184 |
+
data = request.get_json()
|
| 185 |
+
orig_cl_ord_id = data.get("orig_cl_ord_id")
|
| 186 |
+
symbol = data.get("symbol")
|
| 187 |
+
quantity = data.get("quantity")
|
| 188 |
+
price = data.get("price")
|
| 189 |
+
|
| 190 |
+
if not orig_cl_ord_id:
|
| 191 |
+
return jsonify({"status": "error", "error": "Missing orig_cl_ord_id"}), 400
|
| 192 |
+
|
| 193 |
+
amend_msg = {
|
| 194 |
+
"type": "amend",
|
| 195 |
+
"orig_cl_ord_id": orig_cl_ord_id,
|
| 196 |
+
"cl_ord_id": f"amend-{int(time.time()*1000)}",
|
| 197 |
+
"symbol": symbol,
|
| 198 |
+
"quantity": quantity,
|
| 199 |
+
"price": price,
|
| 200 |
+
"timestamp": time.time()
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
producer = get_producer()
|
| 204 |
+
producer.send(Config.ORDERS_TOPIC, amend_msg)
|
| 205 |
+
producer.flush()
|
| 206 |
+
|
| 207 |
+
return jsonify({"status": "ok", "message": f"Amend request sent for {orig_cl_ord_id}"})
|
| 208 |
+
except Exception as e:
|
| 209 |
+
return jsonify({"status": "error", "error": str(e)}), 500
|
| 210 |
+
|
| 211 |
+
@app.route("/stream")
|
| 212 |
+
def stream():
|
| 213 |
+
"""SSE endpoint for real-time updates."""
|
| 214 |
+
def event_stream():
|
| 215 |
+
q = Queue(maxsize=100)
|
| 216 |
+
with sse_clients_lock:
|
| 217 |
+
sse_clients.append(q)
|
| 218 |
+
try:
|
| 219 |
+
# Send initial connection event
|
| 220 |
+
yield f"event: connected\ndata: {json.dumps({'status': 'connected'})}\n\n"
|
| 221 |
+
|
| 222 |
+
# Send current state on connect (including trades)
|
| 223 |
+
with lock:
|
| 224 |
+
yield f"event: init\ndata: {json.dumps({'orders': list(orders), 'bbos': dict(bbos), 'trades': list(trades_cache)})}\n\n"
|
| 225 |
+
|
| 226 |
+
while True:
|
| 227 |
+
try:
|
| 228 |
+
message = q.get(timeout=30) # 30s timeout for keepalive
|
| 229 |
+
yield message
|
| 230 |
+
except Empty:
|
| 231 |
+
# Send keepalive comment
|
| 232 |
+
yield ": keepalive\n\n"
|
| 233 |
+
finally:
|
| 234 |
+
with sse_clients_lock:
|
| 235 |
+
if q in sse_clients:
|
| 236 |
+
sse_clients.remove(q)
|
| 237 |
+
|
| 238 |
+
return Response(
|
| 239 |
+
event_stream(),
|
| 240 |
+
mimetype="text/event-stream",
|
| 241 |
+
headers={
|
| 242 |
+
"Cache-Control": "no-cache",
|
| 243 |
+
"X-Accel-Buffering": "no", # Disable nginx buffering
|
| 244 |
+
"Connection": "keep-alive"
|
| 245 |
+
}
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
if __name__ == "__main__":
|
| 249 |
+
app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False)
|
dashboard/templates/index - Copy (6).html
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<title>Trading Dashboard</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; }
|
| 8 |
+
h2 { margin: 5px 0; }
|
| 9 |
+
.container { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; }
|
| 10 |
+
.panel {
|
| 11 |
+
background: #fff;
|
| 12 |
+
border-radius: 8px;
|
| 13 |
+
padding: 10px;
|
| 14 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 15 |
+
height: 400px;
|
| 16 |
+
display: flex;
|
| 17 |
+
flex-direction: column;
|
| 18 |
+
}
|
| 19 |
+
.panel pre {
|
| 20 |
+
flex-grow: 1;
|
| 21 |
+
overflow-y: scroll;
|
| 22 |
+
background: #fafafa;
|
| 23 |
+
padding: 10px;
|
| 24 |
+
margin: 0;
|
| 25 |
+
border-radius: 6px;
|
| 26 |
+
font-size: 13px;
|
| 27 |
+
white-space: pre;
|
| 28 |
+
font-family: monospace;
|
| 29 |
+
}
|
| 30 |
+
table {
|
| 31 |
+
width: 100%;
|
| 32 |
+
border-collapse: collapse;
|
| 33 |
+
font-size: 13px;
|
| 34 |
+
}
|
| 35 |
+
th, td {
|
| 36 |
+
border: 1px solid #ccc;
|
| 37 |
+
padding: 4px;
|
| 38 |
+
text-align: center;
|
| 39 |
+
}
|
| 40 |
+
.updated {
|
| 41 |
+
animation: flash 1s ease-in-out;
|
| 42 |
+
}
|
| 43 |
+
@keyframes flash {
|
| 44 |
+
from { background: yellow; }
|
| 45 |
+
to { background: transparent; }
|
| 46 |
+
}
|
| 47 |
+
</style>
|
| 48 |
+
</head>
|
| 49 |
+
<body>
|
| 50 |
+
<h1>📊 Trading Dashboard</h1>
|
| 51 |
+
<div class="container">
|
| 52 |
+
<div class="panel">
|
| 53 |
+
<h2>📝 Orders</h2>
|
| 54 |
+
<pre id="orders"></pre>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="panel">
|
| 58 |
+
<h2>💹 BBOs (from Order Book API)</h2>
|
| 59 |
+
<pre id="book"></pre>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="panel">
|
| 63 |
+
<h2>🤝 Trades</h2>
|
| 64 |
+
<pre id="trades"></pre>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="panel">
|
| 68 |
+
<h2>📖 Full Order Book</h2>
|
| 69 |
+
<label for="symbol-select">Select symbol:</label>
|
| 70 |
+
<select id="symbol-select" onchange="refresh()"></select>
|
| 71 |
+
<div id="full-book" style="flex-grow:1; overflow-y:scroll;"></div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="panel">
|
| 75 |
+
<h2>📊 Market Snapshots (BBO)</h2>
|
| 76 |
+
<table id="bbos-table">
|
| 77 |
+
<thead>
|
| 78 |
+
<tr>
|
| 79 |
+
<th>Symbol</th>
|
| 80 |
+
<th>Best Bid</th>
|
| 81 |
+
<th>Best Ask</th>
|
| 82 |
+
<th>Timestamp</th>
|
| 83 |
+
<th>Source</th>
|
| 84 |
+
</tr>
|
| 85 |
+
</thead>
|
| 86 |
+
<tbody id="bbos-body"></tbody>
|
| 87 |
+
</table>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<script>
|
| 92 |
+
function fmtOrder(o) {
|
| 93 |
+
const sym = (o.symbol ?? "?").padEnd(6);
|
| 94 |
+
const side = (o.type ?? o.side ?? "?").padEnd(6);
|
| 95 |
+
const qty = String(o.quantity ?? o.qty ?? "?").padStart(6);
|
| 96 |
+
const price = o.price !== undefined ? Number(o.price).toFixed(2).padStart(8) : " - ";
|
| 97 |
+
const src = (o.source ?? "?").padEnd(5);
|
| 98 |
+
const ts = o.timestamp ? new Date(o.timestamp * 1000).toLocaleTimeString() : "-";
|
| 99 |
+
return `${sym} | ${side} | ${qty} @ ${price} | ${src} | ${ts}\n`;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function fmtTrade(t) {
|
| 103 |
+
const sym = (t.symbol ?? "?").padEnd(6);
|
| 104 |
+
const qty = String(t.quantity ?? t.qty ?? "-").padStart(6);
|
| 105 |
+
const price = t.price !== undefined ? Number(t.price).toFixed(2).padStart(8) : " - ";
|
| 106 |
+
const ts = t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : "-";
|
| 107 |
+
return `${sym} | ${qty} x ${price} | ${ts}\n`;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function renderOrderBook(book) {
|
| 111 |
+
const bids = (book.buy || []).sort((a,b) => b.price - a.price);
|
| 112 |
+
const asks = (book.sell || []).sort((a,b) => a.price - b.price);
|
| 113 |
+
const maxRows = Math.max(bids.length, asks.length);
|
| 114 |
+
let html = "<table><tr><th>Bid Size</th><th>Bid Price</th><th>Ask Price</th><th>Ask Size</th></tr>";
|
| 115 |
+
for (let i=0; i<maxRows; i++) {
|
| 116 |
+
const b = bids[i] || {};
|
| 117 |
+
const a = asks[i] || {};
|
| 118 |
+
html += `<tr>
|
| 119 |
+
<td>${b.quantity ?? ""}</td>
|
| 120 |
+
<td>${b.price !== undefined ? Number(b.price).toFixed(2) : ""}</td>
|
| 121 |
+
<td>${a.price !== undefined ? Number(a.price).toFixed(2) : ""}</td>
|
| 122 |
+
<td>${a.quantity ?? ""}</td>
|
| 123 |
+
</tr>`;
|
| 124 |
+
}
|
| 125 |
+
html += "</table>";
|
| 126 |
+
return html;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
async function refresh() {
|
| 130 |
+
try {
|
| 131 |
+
const r = await fetch("/data");
|
| 132 |
+
const data = await r.json();
|
| 133 |
+
|
| 134 |
+
// Orders
|
| 135 |
+
document.getElementById("orders").textContent =
|
| 136 |
+
data.orders.slice().reverse().map(fmtOrder).join("");
|
| 137 |
+
|
| 138 |
+
// Trades
|
| 139 |
+
document.getElementById("trades").textContent =
|
| 140 |
+
data.trades.slice().reverse().map(fmtTrade).join("");
|
| 141 |
+
|
| 142 |
+
// Book JSON dump
|
| 143 |
+
document.getElementById("book").textContent =
|
| 144 |
+
JSON.stringify(data.book ?? {}, null, 2);
|
| 145 |
+
|
| 146 |
+
// Snapshots → fill table
|
| 147 |
+
const tbody = document.getElementById("bbos-body");
|
| 148 |
+
tbody.innerHTML = "";
|
| 149 |
+
for (const [symbol, snap] of Object.entries(data.bbos)) {
|
| 150 |
+
const row = document.createElement("tr");
|
| 151 |
+
row.innerHTML = `
|
| 152 |
+
<td>${symbol}</td>
|
| 153 |
+
<td>${snap.best_bid !== null ? Number(snap.best_bid).toFixed(2) : "-"}</td>
|
| 154 |
+
<td>${snap.best_ask !== null ? Number(snap.best_ask).toFixed(2) : "-"}</td>
|
| 155 |
+
<td>${snap.timestamp ? new Date(snap.timestamp*1000).toLocaleTimeString() : "-"}</td>
|
| 156 |
+
<td>${snap.source ?? "-"}</td>
|
| 157 |
+
`;
|
| 158 |
+
row.classList.add("updated");
|
| 159 |
+
setTimeout(() => row.classList.remove("updated"), 1000);
|
| 160 |
+
tbody.appendChild(row);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Populate dropdown once
|
| 164 |
+
const sel = document.getElementById("symbol-select");
|
| 165 |
+
if (!sel.options.length) {
|
| 166 |
+
const symbols = [...new Set([
|
| 167 |
+
...Object.keys(data.bbos),
|
| 168 |
+
...(data.book?.buy || []).map(o => o.symbol),
|
| 169 |
+
...(data.book?.sell || []).map(o => o.symbol)
|
| 170 |
+
])];
|
| 171 |
+
symbols.forEach(sym => {
|
| 172 |
+
const opt = document.createElement("option");
|
| 173 |
+
opt.value = sym;
|
| 174 |
+
opt.textContent = sym;
|
| 175 |
+
sel.appendChild(opt);
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Render order book for selected symbol
|
| 180 |
+
const sym = sel.value;
|
| 181 |
+
if (sym) {
|
| 182 |
+
const bookForSymbol = {
|
| 183 |
+
buy: (data.book?.buy || []).filter(o => o.symbol === sym),
|
| 184 |
+
sell: (data.book?.sell || []).filter(o => o.symbol === sym),
|
| 185 |
+
};
|
| 186 |
+
document.getElementById("full-book").innerHTML = renderOrderBook(bookForSymbol);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
} catch(e) {
|
| 190 |
+
console.error("Refresh error", e);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
setInterval(refresh, 2000);
|
| 195 |
+
refresh();
|
| 196 |
+
</script>
|
| 197 |
+
</body>
|
| 198 |
+
</html>
|
dashboard/templates/index.html
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<title>Trading Dashboard</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; }
|
| 8 |
+
h1 { display: flex; align-items: center; gap: 15px; }
|
| 9 |
+
h2 { margin: 5px 0; }
|
| 10 |
+
.container { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; }
|
| 11 |
+
.panel {
|
| 12 |
+
background: #fff;
|
| 13 |
+
border-radius: 8px;
|
| 14 |
+
padding: 10px;
|
| 15 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 16 |
+
height: 400px;
|
| 17 |
+
display: flex;
|
| 18 |
+
flex-direction: column;
|
| 19 |
+
min-width: 0;
|
| 20 |
+
overflow: hidden;
|
| 21 |
+
}
|
| 22 |
+
.panel pre {
|
| 23 |
+
flex-grow: 1;
|
| 24 |
+
overflow-y: scroll;
|
| 25 |
+
background: #fafafa;
|
| 26 |
+
padding: 10px;
|
| 27 |
+
margin: 0;
|
| 28 |
+
border-radius: 6px;
|
| 29 |
+
font-size: 13px;
|
| 30 |
+
white-space: pre;
|
| 31 |
+
font-family: monospace;
|
| 32 |
+
}
|
| 33 |
+
table {
|
| 34 |
+
width: 100%;
|
| 35 |
+
border-collapse: collapse;
|
| 36 |
+
font-size: 13px;
|
| 37 |
+
}
|
| 38 |
+
th, td {
|
| 39 |
+
border: 1px solid #ccc;
|
| 40 |
+
padding: 4px;
|
| 41 |
+
text-align: center;
|
| 42 |
+
}
|
| 43 |
+
.updated {
|
| 44 |
+
animation: flash 1s ease-in-out;
|
| 45 |
+
}
|
| 46 |
+
@keyframes flash {
|
| 47 |
+
from { background: yellow; }
|
| 48 |
+
to { background: transparent; }
|
| 49 |
+
}
|
| 50 |
+
/* Connection status indicator */
|
| 51 |
+
.status {
|
| 52 |
+
display: inline-flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
gap: 6px;
|
| 55 |
+
padding: 4px 12px;
|
| 56 |
+
border-radius: 20px;
|
| 57 |
+
font-size: 12px;
|
| 58 |
+
font-weight: bold;
|
| 59 |
+
}
|
| 60 |
+
.status .dot {
|
| 61 |
+
width: 10px;
|
| 62 |
+
height: 10px;
|
| 63 |
+
border-radius: 50%;
|
| 64 |
+
}
|
| 65 |
+
.status.connected { background: #d4edda; color: #155724; }
|
| 66 |
+
.status.connected .dot { background: #28a745; }
|
| 67 |
+
.status.disconnected { background: #f8d7da; color: #721c24; }
|
| 68 |
+
.status.disconnected .dot { background: #dc3545; }
|
| 69 |
+
.status.connecting { background: #fff3cd; color: #856404; }
|
| 70 |
+
.status.connecting .dot { background: #ffc107; animation: pulse 1s infinite; }
|
| 71 |
+
@keyframes pulse {
|
| 72 |
+
0%, 100% { opacity: 1; }
|
| 73 |
+
50% { opacity: 0.4; }
|
| 74 |
+
}
|
| 75 |
+
.new-item {
|
| 76 |
+
animation: highlight 2s ease-out;
|
| 77 |
+
}
|
| 78 |
+
@keyframes highlight {
|
| 79 |
+
from { background: #c8e6c9; }
|
| 80 |
+
to { background: transparent; }
|
| 81 |
+
}
|
| 82 |
+
/* Selectable rows */
|
| 83 |
+
.selectable-row { cursor: pointer; }
|
| 84 |
+
.selectable-row:hover { background: #f5f5f5; }
|
| 85 |
+
.selectable-row.selected { background: #e3f2fd !important; }
|
| 86 |
+
|
| 87 |
+
/* Ticker tape */
|
| 88 |
+
.ticker-container {
|
| 89 |
+
background: #1a1a2e;
|
| 90 |
+
overflow: hidden;
|
| 91 |
+
padding: 8px 0;
|
| 92 |
+
border-radius: 4px;
|
| 93 |
+
margin-top: 10px;
|
| 94 |
+
}
|
| 95 |
+
.ticker-tape {
|
| 96 |
+
display: flex;
|
| 97 |
+
animation: ticker-scroll 30s linear infinite;
|
| 98 |
+
white-space: nowrap;
|
| 99 |
+
}
|
| 100 |
+
.ticker-tape:hover {
|
| 101 |
+
animation-play-state: paused;
|
| 102 |
+
}
|
| 103 |
+
@keyframes ticker-scroll {
|
| 104 |
+
0% { transform: translateX(0); }
|
| 105 |
+
100% { transform: translateX(-50%); }
|
| 106 |
+
}
|
| 107 |
+
.ticker-item {
|
| 108 |
+
display: inline-flex;
|
| 109 |
+
align-items: center;
|
| 110 |
+
padding: 0 20px;
|
| 111 |
+
font-size: 13px;
|
| 112 |
+
font-weight: bold;
|
| 113 |
+
}
|
| 114 |
+
.ticker-symbol {
|
| 115 |
+
color: #fff;
|
| 116 |
+
margin-right: 8px;
|
| 117 |
+
}
|
| 118 |
+
.ticker-price {
|
| 119 |
+
margin-right: 5px;
|
| 120 |
+
}
|
| 121 |
+
.ticker-price.up { color: #4caf50; }
|
| 122 |
+
.ticker-price.down { color: #f44336; }
|
| 123 |
+
.ticker-price.neutral { color: #ffc107; }
|
| 124 |
+
.ticker-change {
|
| 125 |
+
font-size: 11px;
|
| 126 |
+
}
|
| 127 |
+
.ticker-change.up { color: #4caf50; }
|
| 128 |
+
.ticker-change.down { color: #f44336; }
|
| 129 |
+
.ticker-arrow { margin-right: 3px; }
|
| 130 |
+
</style>
|
| 131 |
+
</head>
|
| 132 |
+
<body>
|
| 133 |
+
<h1>Trading Dashboard <span id="status" class="status connecting"><span class="dot"></span><span id="status-text">Connecting...</span></span></h1>
|
| 134 |
+
<div class="container">
|
| 135 |
+
|
| 136 |
+
<div class="panel">
|
| 137 |
+
<h2>Orders <span id="order-count" style="font-size:22px;color:#333;font-weight:bold;"></span>
|
| 138 |
+
<button id="edit-selected-btn" onclick="editSelectedOrder()" style="display:none; margin-left:10px; padding:4px 10px; background:#2196F3; color:#fff; border:none; border-radius:4px; cursor:pointer;">Edit</button>
|
| 139 |
+
<button id="cancel-selected-btn" onclick="cancelSelectedOrder()" style="display:none; margin-left:5px; padding:4px 10px; background:#f44336; color:#fff; border:none; border-radius:4px; cursor:pointer;">Cancel</button>
|
| 140 |
+
</h2>
|
| 141 |
+
<div style="flex-grow:1; overflow-y:auto;">
|
| 142 |
+
<table id="orders-table">
|
| 143 |
+
<thead>
|
| 144 |
+
<tr style="background:#f0f0f0;">
|
| 145 |
+
<th>Symbol</th>
|
| 146 |
+
<th>Side</th>
|
| 147 |
+
<th>Qty</th>
|
| 148 |
+
<th>Price</th>
|
| 149 |
+
<th>Source</th>
|
| 150 |
+
<th>Time</th>
|
| 151 |
+
<th>Actions</th>
|
| 152 |
+
</tr>
|
| 153 |
+
</thead>
|
| 154 |
+
<tbody id="orders-body"></tbody>
|
| 155 |
+
</table>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<!-- Order Edit Modal -->
|
| 160 |
+
<div id="order-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000;">
|
| 161 |
+
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; padding:20px; border-radius:8px; min-width:300px;">
|
| 162 |
+
<h3 style="margin:0 0 15px;">Edit Order</h3>
|
| 163 |
+
<input type="hidden" id="edit-order-id">
|
| 164 |
+
<input type="hidden" id="edit-order-symbol">
|
| 165 |
+
<div style="margin-bottom:10px;">
|
| 166 |
+
<label style="display:block; font-size:12px; color:#666;">Quantity:</label>
|
| 167 |
+
<input type="number" id="edit-qty" style="width:100%; padding:8px; border:1px solid #ccc; border-radius:4px;">
|
| 168 |
+
</div>
|
| 169 |
+
<div style="margin-bottom:15px;">
|
| 170 |
+
<label style="display:block; font-size:12px; color:#666;">Price:</label>
|
| 171 |
+
<input type="number" step="0.01" id="edit-price" style="width:100%; padding:8px; border:1px solid #ccc; border-radius:4px;">
|
| 172 |
+
</div>
|
| 173 |
+
<div style="display:flex; gap:10px;">
|
| 174 |
+
<button onclick="submitAmend()" style="flex:1; padding:8px; background:#2196F3; color:#fff; border:none; border-radius:4px; cursor:pointer;">Update</button>
|
| 175 |
+
<button onclick="closeModal()" style="flex:1; padding:8px; background:#ccc; border:none; border-radius:4px; cursor:pointer;">Cancel</button>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<div class="panel">
|
| 181 |
+
<h2>Market Snapshot</h2>
|
| 182 |
+
<div style="flex-grow:1; overflow-y:auto;">
|
| 183 |
+
<table id="bbos-table">
|
| 184 |
+
<thead>
|
| 185 |
+
<tr>
|
| 186 |
+
<th>Symbol</th>
|
| 187 |
+
<th>Best Bid</th>
|
| 188 |
+
<th>Best Ask</th>
|
| 189 |
+
<th>Spread</th>
|
| 190 |
+
<th>Mid</th>
|
| 191 |
+
<th>Updated</th>
|
| 192 |
+
</tr>
|
| 193 |
+
</thead>
|
| 194 |
+
<tbody id="bbos-body"></tbody>
|
| 195 |
+
</table>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="ticker-container">
|
| 198 |
+
<div class="ticker-tape" id="ticker-tape">
|
| 199 |
+
<span class="ticker-item"><span class="ticker-symbol">Loading trades...</span></span>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div class="panel">
|
| 205 |
+
<h2>Trades <span id="trade-count" style="font-size:22px;color:#333;font-weight:bold;"></span></h2>
|
| 206 |
+
<div style="flex-grow:1; overflow-y:auto;">
|
| 207 |
+
<table id="trades-table">
|
| 208 |
+
<thead>
|
| 209 |
+
<tr style="background:#f0f0f0;">
|
| 210 |
+
<th>Symbol</th>
|
| 211 |
+
<th>Qty</th>
|
| 212 |
+
<th>Price</th>
|
| 213 |
+
<th>Value</th>
|
| 214 |
+
<th>Time</th>
|
| 215 |
+
</tr>
|
| 216 |
+
</thead>
|
| 217 |
+
<tbody id="trades-body"></tbody>
|
| 218 |
+
</table>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<div class="panel">
|
| 223 |
+
<h2>Trade Chart
|
| 224 |
+
<select id="chart-symbol" onchange="renderPriceChart()" style="margin-left:10px; padding:2px 5px;">
|
| 225 |
+
<option value="">All Symbols</option>
|
| 226 |
+
</select>
|
| 227 |
+
</h2>
|
| 228 |
+
<canvas id="price-chart" style="width:100%; flex-grow:1;"></canvas>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div class="panel">
|
| 232 |
+
<h2>Order Book
|
| 233 |
+
<select id="bbo-symbol-select" onchange="renderOrderBookPanel()" style="margin-left:10px; padding:2px 5px;">
|
| 234 |
+
</select>
|
| 235 |
+
<button onclick="renderOrderBookPanel()" style="margin-left:5px; padding:2px 8px; cursor:pointer;">Refresh</button>
|
| 236 |
+
</h2>
|
| 237 |
+
<div id="full-bbo-container" style="flex-grow:1; overflow-y:auto; padding:5px;">
|
| 238 |
+
<p style="color:#666; text-align:center;">Select a symbol</p>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<div class="panel">
|
| 243 |
+
<h2>Trading Statistics</h2>
|
| 244 |
+
<div id="stats-container" style="flex-grow:1; overflow-y:auto; padding: 10px;">
|
| 245 |
+
<table id="stats-table" style="width:100%; margin-bottom:10px; font-size:12px;">
|
| 246 |
+
<thead>
|
| 247 |
+
<tr>
|
| 248 |
+
<th>Symbol</th>
|
| 249 |
+
<th>Trades</th>
|
| 250 |
+
<th>Volume</th>
|
| 251 |
+
<th>Value</th>
|
| 252 |
+
<th>Start</th>
|
| 253 |
+
<th>Last</th>
|
| 254 |
+
<th>VWAP</th>
|
| 255 |
+
</tr>
|
| 256 |
+
</thead>
|
| 257 |
+
<tbody id="stats-body"></tbody>
|
| 258 |
+
</table>
|
| 259 |
+
<div style="display:flex; gap:4px; justify-content:center; margin-top:5px;">
|
| 260 |
+
<div style="font-size:10px; color:#4CAF50; margin-right:10px;">■ Volume</div>
|
| 261 |
+
<div style="font-size:10px; color:#2196F3;">■ Value</div>
|
| 262 |
+
</div>
|
| 263 |
+
<div id="stats-chart" style="display:flex; align-items:flex-end; justify-content:center; gap:15px; height:120px; margin-top:5px;"></div>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
<script>
|
| 270 |
+
// State
|
| 271 |
+
const state = {
|
| 272 |
+
orders: [],
|
| 273 |
+
trades: [],
|
| 274 |
+
bbos: {},
|
| 275 |
+
connected: false,
|
| 276 |
+
lastPrices: {} // Track last price per symbol for ticker
|
| 277 |
+
};
|
| 278 |
+
|
| 279 |
+
function setStatus(status, text) {
|
| 280 |
+
const el = document.getElementById("status");
|
| 281 |
+
const textEl = document.getElementById("status-text");
|
| 282 |
+
el.className = "status " + status;
|
| 283 |
+
textEl.textContent = text;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function fmtOrder(o) {
|
| 287 |
+
const sym = (o.symbol ?? "?").padEnd(6);
|
| 288 |
+
const side = (o.type ?? o.side ?? "?").padEnd(4);
|
| 289 |
+
const price = o.price !== undefined ? Number(o.price).toFixed(2).padStart(8) : " ? ";
|
| 290 |
+
const qty = String(o.quantity ?? o.qty ?? "?").padStart(6);
|
| 291 |
+
const src = (o.source ?? "?").padEnd(8);
|
| 292 |
+
const ts = o.timestamp ? new Date(o.timestamp * 1000).toLocaleTimeString() : "-";
|
| 293 |
+
return `${sym} ${side} ${qty} @ ${price} | ${src} | ${ts}`;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function fmtTrade(t) {
|
| 297 |
+
const sym = (t.symbol ?? "?").padEnd(6);
|
| 298 |
+
const qty = String(t.quantity ?? t.qty ?? "-").padStart(6);
|
| 299 |
+
const price = t.price !== undefined ? Number(t.price).toFixed(2).padStart(8) : " - ";
|
| 300 |
+
const ts = t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : "-";
|
| 301 |
+
return `${sym} ${qty} x ${price} | ${ts}`;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// Selected order state
|
| 305 |
+
let selectedOrder = null;
|
| 306 |
+
|
| 307 |
+
function renderOrders() {
|
| 308 |
+
const tbody = document.getElementById("orders-body");
|
| 309 |
+
tbody.innerHTML = "";
|
| 310 |
+
for (let i = 0; i < state.orders.length; i++) {
|
| 311 |
+
const o = state.orders[i];
|
| 312 |
+
const row = document.createElement("tr");
|
| 313 |
+
const side = o.type ?? o.side ?? "?";
|
| 314 |
+
const isBuy = side.toLowerCase().includes("buy");
|
| 315 |
+
const orderId = o.cl_ord_id || o.order_id || o.id || "";
|
| 316 |
+
|
| 317 |
+
row.className = "selectable-row";
|
| 318 |
+
row.dataset.index = i;
|
| 319 |
+
row.dataset.orderId = orderId;
|
| 320 |
+
row.dataset.symbol = o.symbol || "";
|
| 321 |
+
row.dataset.qty = o.quantity || o.qty || 0;
|
| 322 |
+
row.dataset.price = o.price || 0;
|
| 323 |
+
|
| 324 |
+
if (selectedOrder && selectedOrder.index === i) {
|
| 325 |
+
row.classList.add("selected");
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
row.onclick = () => selectOrder(i, orderId, o.symbol, o.quantity || o.qty || 0, o.price || 0);
|
| 329 |
+
|
| 330 |
+
row.innerHTML = `
|
| 331 |
+
<td><strong>${o.symbol ?? "?"}</strong></td>
|
| 332 |
+
<td style="color:${isBuy ? '#2e7d32' : '#c62828'}; font-weight:bold;">${side}</td>
|
| 333 |
+
<td>${o.quantity ?? o.qty ?? "-"}</td>
|
| 334 |
+
<td>${o.price !== undefined ? Number(o.price).toFixed(2) : "-"}</td>
|
| 335 |
+
<td style="font-size:11px;">${o.source ?? "-"}</td>
|
| 336 |
+
<td style="font-size:11px;">${o.timestamp ? new Date(o.timestamp * 1000).toLocaleTimeString() : "-"}</td>
|
| 337 |
+
<td>
|
| 338 |
+
${orderId ? `
|
| 339 |
+
<button onclick="event.stopPropagation(); editOrder('${orderId}', '${o.symbol}', ${o.quantity || o.qty || 0}, ${o.price || 0})"
|
| 340 |
+
style="padding:2px 6px; font-size:11px; cursor:pointer; background:#2196F3; color:#fff; border:none; border-radius:3px; margin-right:3px;">Edit</button>
|
| 341 |
+
<button onclick="event.stopPropagation(); cancelOrder('${orderId}', '${o.symbol}')"
|
| 342 |
+
style="padding:2px 6px; font-size:11px; cursor:pointer; background:#f44336; color:#fff; border:none; border-radius:3px;">Cancel</button>
|
| 343 |
+
` : '<span style="color:#999; font-size:10px;">N/A</span>'}
|
| 344 |
+
</td>
|
| 345 |
+
`;
|
| 346 |
+
tbody.appendChild(row);
|
| 347 |
+
}
|
| 348 |
+
document.getElementById("order-count").textContent = `(${state.orders.length})`;
|
| 349 |
+
updateSelectionButtons();
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function selectOrder(index, orderId, symbol, qty, price) {
|
| 353 |
+
// Toggle selection
|
| 354 |
+
if (selectedOrder && selectedOrder.index === index) {
|
| 355 |
+
selectedOrder = null;
|
| 356 |
+
} else {
|
| 357 |
+
selectedOrder = { index, orderId, symbol, qty, price };
|
| 358 |
+
}
|
| 359 |
+
// Update row highlighting
|
| 360 |
+
document.querySelectorAll("#orders-body tr").forEach((row, i) => {
|
| 361 |
+
row.classList.toggle("selected", selectedOrder && selectedOrder.index === i);
|
| 362 |
+
});
|
| 363 |
+
updateSelectionButtons();
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function updateSelectionButtons() {
|
| 367 |
+
const editBtn = document.getElementById("edit-selected-btn");
|
| 368 |
+
const cancelBtn = document.getElementById("cancel-selected-btn");
|
| 369 |
+
if (selectedOrder && selectedOrder.orderId) {
|
| 370 |
+
editBtn.style.display = "inline-block";
|
| 371 |
+
cancelBtn.style.display = "inline-block";
|
| 372 |
+
} else {
|
| 373 |
+
editBtn.style.display = "none";
|
| 374 |
+
cancelBtn.style.display = "none";
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function editSelectedOrder() {
|
| 379 |
+
if (selectedOrder && selectedOrder.orderId) {
|
| 380 |
+
editOrder(selectedOrder.orderId, selectedOrder.symbol, selectedOrder.qty, selectedOrder.price);
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function cancelSelectedOrder() {
|
| 385 |
+
if (selectedOrder && selectedOrder.orderId) {
|
| 386 |
+
cancelOrder(selectedOrder.orderId, selectedOrder.symbol);
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
function renderTrades() {
|
| 391 |
+
const tbody = document.getElementById("trades-body");
|
| 392 |
+
tbody.innerHTML = "";
|
| 393 |
+
for (const t of state.trades) {
|
| 394 |
+
const row = document.createElement("tr");
|
| 395 |
+
const qty = t.quantity ?? t.qty ?? 0;
|
| 396 |
+
const price = t.price ?? 0;
|
| 397 |
+
const value = (qty * price).toFixed(2);
|
| 398 |
+
row.innerHTML = `
|
| 399 |
+
<td><strong>${t.symbol ?? "?"}</strong></td>
|
| 400 |
+
<td>${qty}</td>
|
| 401 |
+
<td>${price.toFixed(2)}</td>
|
| 402 |
+
<td style="color:#666;">${value}</td>
|
| 403 |
+
<td style="font-size:11px;">${t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : "-"}</td>
|
| 404 |
+
`;
|
| 405 |
+
tbody.appendChild(row);
|
| 406 |
+
}
|
| 407 |
+
document.getElementById("trade-count").textContent = `(${state.trades.length})`;
|
| 408 |
+
renderTicker();
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
function renderTicker() {
|
| 412 |
+
const ticker = document.getElementById("ticker-tape");
|
| 413 |
+
if (state.trades.length === 0) {
|
| 414 |
+
ticker.innerHTML = '<span class="ticker-item"><span class="ticker-symbol">Waiting for trades...</span></span>';
|
| 415 |
+
return;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// Get last 20 trades and build ticker items
|
| 419 |
+
const recentTrades = state.trades.slice(0, 20);
|
| 420 |
+
let tickerHTML = "";
|
| 421 |
+
|
| 422 |
+
for (const t of recentTrades) {
|
| 423 |
+
const symbol = t.symbol || "?";
|
| 424 |
+
const price = t.price || 0;
|
| 425 |
+
const qty = t.quantity || t.qty || 0;
|
| 426 |
+
const lastPrice = state.lastPrices[symbol];
|
| 427 |
+
|
| 428 |
+
// Determine direction
|
| 429 |
+
let direction = "neutral";
|
| 430 |
+
let arrow = "●";
|
| 431 |
+
if (lastPrice !== undefined) {
|
| 432 |
+
if (price > lastPrice) {
|
| 433 |
+
direction = "up";
|
| 434 |
+
arrow = "▲";
|
| 435 |
+
} else if (price < lastPrice) {
|
| 436 |
+
direction = "down";
|
| 437 |
+
arrow = "▼";
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
// Update last price
|
| 442 |
+
state.lastPrices[symbol] = price;
|
| 443 |
+
|
| 444 |
+
tickerHTML += `
|
| 445 |
+
<span class="ticker-item">
|
| 446 |
+
<span class="ticker-symbol">${symbol}</span>
|
| 447 |
+
<span class="ticker-price ${direction}">${price.toFixed(2)}</span>
|
| 448 |
+
<span class="ticker-change ${direction}">
|
| 449 |
+
<span class="ticker-arrow">${arrow}</span>${qty}
|
| 450 |
+
</span>
|
| 451 |
+
</span>
|
| 452 |
+
`;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
// Duplicate for seamless scroll
|
| 456 |
+
ticker.innerHTML = tickerHTML + tickerHTML;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Order management functions
|
| 460 |
+
function editOrder(orderId, symbol, qty, price) {
|
| 461 |
+
document.getElementById("edit-order-id").value = orderId;
|
| 462 |
+
document.getElementById("edit-order-symbol").value = symbol;
|
| 463 |
+
document.getElementById("edit-qty").value = qty;
|
| 464 |
+
document.getElementById("edit-price").value = price;
|
| 465 |
+
document.getElementById("order-modal").style.display = "block";
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
function closeModal() {
|
| 469 |
+
document.getElementById("order-modal").style.display = "none";
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
async function submitAmend() {
|
| 473 |
+
const orderId = document.getElementById("edit-order-id").value;
|
| 474 |
+
const symbol = document.getElementById("edit-order-symbol").value;
|
| 475 |
+
const qty = parseInt(document.getElementById("edit-qty").value);
|
| 476 |
+
const price = parseFloat(document.getElementById("edit-price").value);
|
| 477 |
+
|
| 478 |
+
try {
|
| 479 |
+
const r = await fetch("/order/amend", {
|
| 480 |
+
method: "POST",
|
| 481 |
+
headers: {"Content-Type": "application/json"},
|
| 482 |
+
body: JSON.stringify({
|
| 483 |
+
orig_cl_ord_id: orderId,
|
| 484 |
+
symbol: symbol,
|
| 485 |
+
quantity: qty,
|
| 486 |
+
price: price
|
| 487 |
+
})
|
| 488 |
+
});
|
| 489 |
+
const result = await r.json();
|
| 490 |
+
if (result.status === "ok") {
|
| 491 |
+
alert("Order amended successfully");
|
| 492 |
+
closeModal();
|
| 493 |
+
renderOrderBookPanel();
|
| 494 |
+
} else {
|
| 495 |
+
alert("Amend failed: " + (result.error || "Unknown error"));
|
| 496 |
+
}
|
| 497 |
+
} catch (e) {
|
| 498 |
+
alert("Error: " + e.message);
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
async function cancelOrder(orderId, symbol) {
|
| 503 |
+
if (!confirm(`Cancel order ${orderId}?`)) return;
|
| 504 |
+
|
| 505 |
+
try {
|
| 506 |
+
const r = await fetch("/order/cancel", {
|
| 507 |
+
method: "POST",
|
| 508 |
+
headers: {"Content-Type": "application/json"},
|
| 509 |
+
body: JSON.stringify({
|
| 510 |
+
orig_cl_ord_id: orderId,
|
| 511 |
+
symbol: symbol
|
| 512 |
+
})
|
| 513 |
+
});
|
| 514 |
+
const result = await r.json();
|
| 515 |
+
if (result.status === "ok") {
|
| 516 |
+
alert("Order cancelled");
|
| 517 |
+
renderOrderBookPanel();
|
| 518 |
+
} else {
|
| 519 |
+
alert("Cancel failed: " + (result.error || "Unknown error"));
|
| 520 |
+
}
|
| 521 |
+
} catch (e) {
|
| 522 |
+
alert("Error: " + e.message);
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
function renderBBOs() {
|
| 527 |
+
const tbody = document.getElementById("bbos-body");
|
| 528 |
+
tbody.innerHTML = "";
|
| 529 |
+
for (const [symbol, snap] of Object.entries(state.bbos).sort()) {
|
| 530 |
+
const row = document.createElement("tr");
|
| 531 |
+
row.id = "bbo-" + symbol;
|
| 532 |
+
const spread = (snap.best_ask && snap.best_bid) ? (snap.best_ask - snap.best_bid).toFixed(2) : "-";
|
| 533 |
+
const mid = (snap.best_ask && snap.best_bid) ? ((snap.best_ask + snap.best_bid) / 2).toFixed(2) : "-";
|
| 534 |
+
row.innerHTML = `
|
| 535 |
+
<td><strong>${symbol}</strong></td>
|
| 536 |
+
<td style="color:green;">${snap.best_bid !== null ? Number(snap.best_bid).toFixed(2) : "-"}</td>
|
| 537 |
+
<td style="color:red;">${snap.best_ask !== null ? Number(snap.best_ask).toFixed(2) : "-"}</td>
|
| 538 |
+
<td>${spread}</td>
|
| 539 |
+
<td>${mid}</td>
|
| 540 |
+
<td>${snap.timestamp ? new Date(snap.timestamp*1000).toLocaleTimeString() : "-"}</td>
|
| 541 |
+
`;
|
| 542 |
+
tbody.appendChild(row);
|
| 543 |
+
}
|
| 544 |
+
updateSymbolDropdown();
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
function updateBBO(symbol, data) {
|
| 548 |
+
state.bbos[symbol] = data;
|
| 549 |
+
const row = document.getElementById("bbo-" + symbol);
|
| 550 |
+
if (row) {
|
| 551 |
+
const spread = (data.best_ask && data.best_bid) ? (data.best_ask - data.best_bid).toFixed(2) : "-";
|
| 552 |
+
const mid = (data.best_ask && data.best_bid) ? ((data.best_ask + data.best_bid) / 2).toFixed(2) : "-";
|
| 553 |
+
row.innerHTML = `
|
| 554 |
+
<td><strong>${symbol}</strong></td>
|
| 555 |
+
<td style="color:green;">${data.best_bid !== null ? Number(data.best_bid).toFixed(2) : "-"}</td>
|
| 556 |
+
<td style="color:red;">${data.best_ask !== null ? Number(data.best_ask).toFixed(2) : "-"}</td>
|
| 557 |
+
<td>${spread}</td>
|
| 558 |
+
<td>${mid}</td>
|
| 559 |
+
<td>${data.timestamp ? new Date(data.timestamp*1000).toLocaleTimeString() : "-"}</td>
|
| 560 |
+
`;
|
| 561 |
+
row.classList.add("updated");
|
| 562 |
+
setTimeout(() => row.classList.remove("updated"), 1000);
|
| 563 |
+
} else {
|
| 564 |
+
renderBBOs();
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
function updateSymbolDropdown() {
|
| 569 |
+
const symbols = Object.keys(state.bbos).sort();
|
| 570 |
+
|
| 571 |
+
// Update BBO symbol select
|
| 572 |
+
const bboSel = document.getElementById("bbo-symbol-select");
|
| 573 |
+
const bboCurrent = bboSel.value;
|
| 574 |
+
if (symbols.length > 0 && bboSel.options.length !== symbols.length) {
|
| 575 |
+
bboSel.innerHTML = "";
|
| 576 |
+
symbols.forEach(sym => {
|
| 577 |
+
const opt = document.createElement("option");
|
| 578 |
+
opt.value = sym;
|
| 579 |
+
opt.textContent = sym;
|
| 580 |
+
bboSel.appendChild(opt);
|
| 581 |
+
});
|
| 582 |
+
if (bboCurrent && symbols.includes(bboCurrent)) {
|
| 583 |
+
bboSel.value = bboCurrent;
|
| 584 |
+
}
|
| 585 |
+
renderOrderBookPanel();
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// Update chart symbol select
|
| 589 |
+
const chartSel = document.getElementById("chart-symbol");
|
| 590 |
+
const chartCurrent = chartSel.value;
|
| 591 |
+
const existingOpts = Array.from(chartSel.options).map(o => o.value);
|
| 592 |
+
symbols.forEach(sym => {
|
| 593 |
+
if (!existingOpts.includes(sym)) {
|
| 594 |
+
const opt = document.createElement("option");
|
| 595 |
+
opt.value = sym;
|
| 596 |
+
opt.textContent = sym;
|
| 597 |
+
chartSel.appendChild(opt);
|
| 598 |
+
}
|
| 599 |
+
});
|
| 600 |
+
if (chartCurrent) chartSel.value = chartCurrent;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
async function renderOrderBookPanel() {
|
| 604 |
+
const sel = document.getElementById("bbo-symbol-select");
|
| 605 |
+
const symbol = sel.value;
|
| 606 |
+
const container = document.getElementById("full-bbo-container");
|
| 607 |
+
|
| 608 |
+
if (!symbol) {
|
| 609 |
+
container.innerHTML = "<p style='color:#666; text-align:center;'>Select a symbol</p>";
|
| 610 |
+
return;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
container.innerHTML = "<p style='color:#666; text-align:center;'>Loading...</p>";
|
| 614 |
+
|
| 615 |
+
try {
|
| 616 |
+
const r = await fetch(`/orderbook/${symbol}`);
|
| 617 |
+
const book = await r.json();
|
| 618 |
+
const bids = (book.bids || []).sort((a,b) => (b.price || 0) - (a.price || 0));
|
| 619 |
+
const asks = (book.asks || []).sort((a,b) => (a.price || 0) - (b.price || 0));
|
| 620 |
+
|
| 621 |
+
let html = `<div style="text-align:center; margin-bottom:10px; font-size:12px; color:#666;">
|
| 622 |
+
${bids.length} bids | ${asks.length} asks
|
| 623 |
+
</div>`;
|
| 624 |
+
|
| 625 |
+
html += `<table style="width:100%; font-size:12px; border-collapse:collapse;">
|
| 626 |
+
<thead>
|
| 627 |
+
<tr style="background:#f0f0f0;">
|
| 628 |
+
<th style="padding:6px; border:1px solid #ddd; color:#2e7d32;">Bid Qty</th>
|
| 629 |
+
<th style="padding:6px; border:1px solid #ddd; color:#2e7d32;">Bid Price</th>
|
| 630 |
+
<th style="padding:6px; border:1px solid #ddd; color:#c62828;">Ask Price</th>
|
| 631 |
+
<th style="padding:6px; border:1px solid #ddd; color:#c62828;">Ask Qty</th>
|
| 632 |
+
</tr>
|
| 633 |
+
</thead>
|
| 634 |
+
<tbody>`;
|
| 635 |
+
|
| 636 |
+
const maxRows = Math.max(bids.length, asks.length, 1);
|
| 637 |
+
for (let i = 0; i < Math.min(maxRows, 20); i++) {
|
| 638 |
+
const b = bids[i] || {};
|
| 639 |
+
const a = asks[i] || {};
|
| 640 |
+
html += `<tr>
|
| 641 |
+
<td style="padding:4px 6px; border:1px solid #eee; color:#2e7d32; font-weight:bold; text-align:right;">${b.quantity ?? ""}</td>
|
| 642 |
+
<td style="padding:4px 6px; border:1px solid #eee; color:#2e7d32; text-align:right;">${b.price !== undefined ? Number(b.price).toFixed(2) : ""}</td>
|
| 643 |
+
<td style="padding:4px 6px; border:1px solid #eee; color:#c62828; text-align:left;">${a.price !== undefined ? Number(a.price).toFixed(2) : ""}</td>
|
| 644 |
+
<td style="padding:4px 6px; border:1px solid #eee; color:#c62828; font-weight:bold; text-align:left;">${a.quantity ?? ""}</td>
|
| 645 |
+
</tr>`;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
html += `</tbody></table>`;
|
| 649 |
+
|
| 650 |
+
if (bids.length === 0 && asks.length === 0) {
|
| 651 |
+
html = "<p style='color:#666; text-align:center; margin-top:20px;'>No resting orders</p>";
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
container.innerHTML = html;
|
| 655 |
+
} catch (e) {
|
| 656 |
+
container.innerHTML = `<p style='color:red; text-align:center;'>Error: ${e.message}</p>`;
|
| 657 |
+
}
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
// Alias for backward compatibility
|
| 661 |
+
function renderFullBBO() { renderOrderBookPanel(); }
|
| 662 |
+
|
| 663 |
+
function renderPriceChart() {
|
| 664 |
+
const canvas = document.getElementById("price-chart");
|
| 665 |
+
const ctx = canvas.getContext("2d");
|
| 666 |
+
const selectedSymbol = document.getElementById("chart-symbol").value;
|
| 667 |
+
|
| 668 |
+
// Filter trades
|
| 669 |
+
let trades = state.trades.filter(t => !selectedSymbol || t.symbol === selectedSymbol);
|
| 670 |
+
trades = trades.slice(0, 100).reverse(); // Last 100, oldest first
|
| 671 |
+
|
| 672 |
+
if (trades.length === 0) {
|
| 673 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 674 |
+
ctx.fillStyle = "#666";
|
| 675 |
+
ctx.font = "14px Arial";
|
| 676 |
+
ctx.textAlign = "center";
|
| 677 |
+
ctx.fillText("No trade data available", canvas.width / 2, canvas.height / 2);
|
| 678 |
+
return;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
// Set canvas size
|
| 682 |
+
canvas.width = canvas.offsetWidth || 400;
|
| 683 |
+
canvas.height = canvas.offsetHeight || 300;
|
| 684 |
+
|
| 685 |
+
const padding = { top: 20, right: 60, bottom: 50, left: 60 };
|
| 686 |
+
const chartWidth = canvas.width - padding.left - padding.right;
|
| 687 |
+
const chartHeight = canvas.height - padding.top - padding.bottom;
|
| 688 |
+
|
| 689 |
+
// Calculate ranges
|
| 690 |
+
const prices = trades.map(t => t.price);
|
| 691 |
+
const volumes = trades.map(t => t.quantity || t.qty || 0);
|
| 692 |
+
const minPrice = Math.min(...prices) * 0.995;
|
| 693 |
+
const maxPrice = Math.max(...prices) * 1.005;
|
| 694 |
+
const maxVolume = Math.max(...volumes);
|
| 695 |
+
|
| 696 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 697 |
+
|
| 698 |
+
// Draw grid
|
| 699 |
+
ctx.strokeStyle = "#eee";
|
| 700 |
+
ctx.lineWidth = 1;
|
| 701 |
+
for (let i = 0; i <= 5; i++) {
|
| 702 |
+
const y = padding.top + (chartHeight * i / 5);
|
| 703 |
+
ctx.beginPath();
|
| 704 |
+
ctx.moveTo(padding.left, y);
|
| 705 |
+
ctx.lineTo(canvas.width - padding.right, y);
|
| 706 |
+
ctx.stroke();
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// Draw volume bars
|
| 710 |
+
const barWidth = Math.max(2, chartWidth / trades.length - 2);
|
| 711 |
+
ctx.fillStyle = "rgba(33, 150, 243, 0.3)";
|
| 712 |
+
trades.forEach((t, i) => {
|
| 713 |
+
const x = padding.left + (i / trades.length) * chartWidth;
|
| 714 |
+
const vol = t.quantity || t.qty || 0;
|
| 715 |
+
const barHeight = maxVolume > 0 ? (vol / maxVolume) * (chartHeight * 0.3) : 0;
|
| 716 |
+
ctx.fillRect(x, canvas.height - padding.bottom - barHeight, barWidth, barHeight);
|
| 717 |
+
});
|
| 718 |
+
|
| 719 |
+
// Draw price line
|
| 720 |
+
ctx.strokeStyle = "#4CAF50";
|
| 721 |
+
ctx.lineWidth = 2;
|
| 722 |
+
ctx.beginPath();
|
| 723 |
+
trades.forEach((t, i) => {
|
| 724 |
+
const x = padding.left + (i / trades.length) * chartWidth + barWidth / 2;
|
| 725 |
+
const y = padding.top + chartHeight - ((t.price - minPrice) / (maxPrice - minPrice)) * chartHeight;
|
| 726 |
+
if (i === 0) ctx.moveTo(x, y);
|
| 727 |
+
else ctx.lineTo(x, y);
|
| 728 |
+
});
|
| 729 |
+
ctx.stroke();
|
| 730 |
+
|
| 731 |
+
// Draw price points
|
| 732 |
+
ctx.fillStyle = "#4CAF50";
|
| 733 |
+
trades.forEach((t, i) => {
|
| 734 |
+
const x = padding.left + (i / trades.length) * chartWidth + barWidth / 2;
|
| 735 |
+
const y = padding.top + chartHeight - ((t.price - minPrice) / (maxPrice - minPrice)) * chartHeight;
|
| 736 |
+
ctx.beginPath();
|
| 737 |
+
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
| 738 |
+
ctx.fill();
|
| 739 |
+
});
|
| 740 |
+
|
| 741 |
+
// Y-axis labels (price)
|
| 742 |
+
ctx.fillStyle = "#333";
|
| 743 |
+
ctx.font = "11px Arial";
|
| 744 |
+
ctx.textAlign = "right";
|
| 745 |
+
for (let i = 0; i <= 5; i++) {
|
| 746 |
+
const price = minPrice + (maxPrice - minPrice) * (5 - i) / 5;
|
| 747 |
+
const y = padding.top + (chartHeight * i / 5);
|
| 748 |
+
ctx.fillText(price.toFixed(2), padding.left - 5, y + 4);
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
// Legend
|
| 752 |
+
ctx.font = "12px Arial";
|
| 753 |
+
ctx.textAlign = "left";
|
| 754 |
+
ctx.fillStyle = "#4CAF50";
|
| 755 |
+
ctx.fillText("Price", padding.left, 12);
|
| 756 |
+
ctx.fillStyle = "rgba(33, 150, 243, 0.6)";
|
| 757 |
+
ctx.fillText("Volume", padding.left + 60, 12);
|
| 758 |
+
ctx.fillStyle = "#666";
|
| 759 |
+
ctx.fillText(`(${trades.length} trades${selectedSymbol ? " for " + selectedSymbol : ""})`, padding.left + 130, 12);
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
function renderOrderBook(book) {
|
| 763 |
+
const bids = (book.bids || []).sort((a,b) => (b.price || 0) - (a.price || 0));
|
| 764 |
+
const asks = (book.asks || []).sort((a,b) => (a.price || 0) - (b.price || 0));
|
| 765 |
+
const maxRows = Math.max(bids.length, asks.length, 1);
|
| 766 |
+
let html = "<table><tr><th>Bid Qty</th><th>Bid Price</th><th>Ask Price</th><th>Ask Qty</th></tr>";
|
| 767 |
+
for (let i = 0; i < maxRows; i++) {
|
| 768 |
+
const b = bids[i] || {};
|
| 769 |
+
const a = asks[i] || {};
|
| 770 |
+
html += `<tr>
|
| 771 |
+
<td style="color:green;">${b.quantity ?? ""}</td>
|
| 772 |
+
<td style="color:green;">${b.price !== undefined ? Number(b.price).toFixed(2) : ""}</td>
|
| 773 |
+
<td style="color:red;">${a.price !== undefined ? Number(a.price).toFixed(2) : ""}</td>
|
| 774 |
+
<td style="color:red;">${a.quantity ?? ""}</td>
|
| 775 |
+
</tr>`;
|
| 776 |
+
}
|
| 777 |
+
html += "</table>";
|
| 778 |
+
if (bids.length === 0 && asks.length === 0) {
|
| 779 |
+
html = "<p style='color:#666;text-align:center;margin-top:20px;'>No orders in book</p>";
|
| 780 |
+
}
|
| 781 |
+
return html;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
function renderStats() {
|
| 785 |
+
// Calculate statistics from trades
|
| 786 |
+
const stats = {};
|
| 787 |
+
let maxVolume = 0;
|
| 788 |
+
let maxValue = 0;
|
| 789 |
+
|
| 790 |
+
for (const trade of state.trades) {
|
| 791 |
+
const sym = trade.symbol || "?";
|
| 792 |
+
if (!stats[sym]) {
|
| 793 |
+
stats[sym] = { trades: 0, volume: 0, totalValue: 0, startPrice: null, lastPrice: 0 };
|
| 794 |
+
}
|
| 795 |
+
const qty = trade.quantity || trade.qty || 0;
|
| 796 |
+
const price = trade.price || 0;
|
| 797 |
+
stats[sym].trades++;
|
| 798 |
+
stats[sym].volume += qty;
|
| 799 |
+
stats[sym].totalValue += qty * price;
|
| 800 |
+
if (stats[sym].startPrice === null) stats[sym].startPrice = price;
|
| 801 |
+
stats[sym].lastPrice = price;
|
| 802 |
+
if (stats[sym].volume > maxVolume) maxVolume = stats[sym].volume;
|
| 803 |
+
if (stats[sym].totalValue > maxValue) maxValue = stats[sym].totalValue;
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
// Render stats table
|
| 807 |
+
const tbody = document.getElementById("stats-body");
|
| 808 |
+
tbody.innerHTML = "";
|
| 809 |
+
const symbols = Object.keys(stats).sort();
|
| 810 |
+
|
| 811 |
+
for (const sym of symbols) {
|
| 812 |
+
const s = stats[sym];
|
| 813 |
+
const vwap = s.volume > 0 ? (s.totalValue / s.volume).toFixed(2) : "-";
|
| 814 |
+
const row = document.createElement("tr");
|
| 815 |
+
row.innerHTML = `
|
| 816 |
+
<td><strong>${sym}</strong></td>
|
| 817 |
+
<td>${s.trades}</td>
|
| 818 |
+
<td>${s.volume.toLocaleString()}</td>
|
| 819 |
+
<td>${s.totalValue.toLocaleString(undefined, {minimumFractionDigits:0, maximumFractionDigits:0})}</td>
|
| 820 |
+
<td>${s.startPrice !== null ? s.startPrice.toFixed(2) : "-"}</td>
|
| 821 |
+
<td>${s.lastPrice.toFixed(2)}</td>
|
| 822 |
+
<td>${vwap}</td>
|
| 823 |
+
`;
|
| 824 |
+
tbody.appendChild(row);
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
// Render grouped bar chart (Volume + Value side by side per symbol)
|
| 828 |
+
const statsChart = document.getElementById("stats-chart");
|
| 829 |
+
statsChart.innerHTML = "";
|
| 830 |
+
|
| 831 |
+
if (symbols.length === 0) {
|
| 832 |
+
statsChart.innerHTML = "<p style='color:#666; text-align:center; font-size:11px;'>No data</p>";
|
| 833 |
+
return;
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
for (const sym of symbols) {
|
| 837 |
+
const s = stats[sym];
|
| 838 |
+
const volPct = maxVolume > 0 ? (s.volume / maxVolume * 100) : 0;
|
| 839 |
+
const valPct = maxValue > 0 ? (s.totalValue / maxValue * 100) : 0;
|
| 840 |
+
|
| 841 |
+
const group = document.createElement("div");
|
| 842 |
+
group.style.cssText = "display:flex; flex-direction:column; align-items:center; gap:2px;";
|
| 843 |
+
group.innerHTML = `
|
| 844 |
+
<div style="display:flex; gap:2px; align-items:flex-end; height:80px;">
|
| 845 |
+
<div style="display:flex; flex-direction:column; align-items:center;">
|
| 846 |
+
<span style="font-size:9px; color:#4CAF50;">${s.volume >= 1000 ? (s.volume/1000).toFixed(1)+'k' : s.volume}</span>
|
| 847 |
+
<div style="width:20px; height:70px; background:#eee; border-radius:2px; display:flex; align-items:flex-end; overflow:hidden;">
|
| 848 |
+
<div style="width:100%; height:${volPct}%; background:linear-gradient(0deg, #4CAF50, #8BC34A);"></div>
|
| 849 |
+
</div>
|
| 850 |
+
</div>
|
| 851 |
+
<div style="display:flex; flex-direction:column; align-items:center;">
|
| 852 |
+
<span style="font-size:9px; color:#2196F3;">${s.totalValue >= 1000 ? (s.totalValue/1000).toFixed(1)+'k' : s.totalValue.toFixed(0)}</span>
|
| 853 |
+
<div style="width:20px; height:70px; background:#eee; border-radius:2px; display:flex; align-items:flex-end; overflow:hidden;">
|
| 854 |
+
<div style="width:100%; height:${valPct}%; background:linear-gradient(0deg, #2196F3, #64B5F6);"></div>
|
| 855 |
+
</div>
|
| 856 |
+
</div>
|
| 857 |
+
</div>
|
| 858 |
+
<span style="font-size:11px; font-weight:bold;">${sym}</span>
|
| 859 |
+
`;
|
| 860 |
+
statsChart.appendChild(group);
|
| 861 |
+
}
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
async function fetchOrderBook() {
|
| 865 |
+
// Deprecated - now using stats panel
|
| 866 |
+
renderStats();
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
// SSE Connection
|
| 870 |
+
let eventSource = null;
|
| 871 |
+
let reconnectTimeout = null;
|
| 872 |
+
|
| 873 |
+
function connectSSE() {
|
| 874 |
+
if (eventSource) {
|
| 875 |
+
eventSource.close();
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
setStatus("connecting", "Connecting...");
|
| 879 |
+
eventSource = new EventSource("/stream");
|
| 880 |
+
|
| 881 |
+
eventSource.addEventListener("connected", (e) => {
|
| 882 |
+
setStatus("connected", "Live");
|
| 883 |
+
state.connected = true;
|
| 884 |
+
});
|
| 885 |
+
|
| 886 |
+
eventSource.addEventListener("init", (e) => {
|
| 887 |
+
const data = JSON.parse(e.data);
|
| 888 |
+
state.orders = data.orders || [];
|
| 889 |
+
state.bbos = data.bbos || {};
|
| 890 |
+
state.trades = data.trades || [];
|
| 891 |
+
renderOrders();
|
| 892 |
+
renderTrades();
|
| 893 |
+
renderBBOs();
|
| 894 |
+
renderStats();
|
| 895 |
+
renderPriceChart();
|
| 896 |
+
});
|
| 897 |
+
|
| 898 |
+
eventSource.addEventListener("order", (e) => {
|
| 899 |
+
const order = JSON.parse(e.data);
|
| 900 |
+
state.orders.unshift(order);
|
| 901 |
+
if (state.orders.length > 50) state.orders.pop();
|
| 902 |
+
renderOrders();
|
| 903 |
+
});
|
| 904 |
+
|
| 905 |
+
eventSource.addEventListener("trade", (e) => {
|
| 906 |
+
const trade = JSON.parse(e.data);
|
| 907 |
+
state.trades.unshift(trade);
|
| 908 |
+
if (state.trades.length > 200) state.trades.pop();
|
| 909 |
+
renderTrades();
|
| 910 |
+
renderStats();
|
| 911 |
+
renderPriceChart();
|
| 912 |
+
});
|
| 913 |
+
|
| 914 |
+
eventSource.addEventListener("snapshot", (e) => {
|
| 915 |
+
const snap = JSON.parse(e.data);
|
| 916 |
+
updateBBO(snap.symbol, snap);
|
| 917 |
+
});
|
| 918 |
+
|
| 919 |
+
eventSource.onerror = () => {
|
| 920 |
+
setStatus("disconnected", "Disconnected");
|
| 921 |
+
state.connected = false;
|
| 922 |
+
eventSource.close();
|
| 923 |
+
// Reconnect after 3 seconds
|
| 924 |
+
reconnectTimeout = setTimeout(connectSSE, 3000);
|
| 925 |
+
};
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
// Fetch data from REST API
|
| 929 |
+
async function fetchData() {
|
| 930 |
+
try {
|
| 931 |
+
const r = await fetch("/data");
|
| 932 |
+
const data = await r.json();
|
| 933 |
+
state.orders = data.orders || [];
|
| 934 |
+
state.trades = data.trades || [];
|
| 935 |
+
state.bbos = data.bbos || {};
|
| 936 |
+
renderOrders();
|
| 937 |
+
renderTrades();
|
| 938 |
+
renderBBOs();
|
| 939 |
+
renderStats();
|
| 940 |
+
renderPriceChart();
|
| 941 |
+
} catch (e) {
|
| 942 |
+
console.error("Fetch data failed:", e);
|
| 943 |
+
}
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
// Initial load: fetch data via REST, then connect SSE
|
| 947 |
+
async function init() {
|
| 948 |
+
await fetchData();
|
| 949 |
+
connectSSE();
|
| 950 |
+
|
| 951 |
+
// Refresh order book panel every 3 seconds
|
| 952 |
+
setInterval(() => {
|
| 953 |
+
const sym = document.getElementById("bbo-symbol-select").value;
|
| 954 |
+
if (sym) renderOrderBookPanel();
|
| 955 |
+
}, 3000);
|
| 956 |
+
|
| 957 |
+
// Fallback: poll data every 5 seconds in case SSE fails
|
| 958 |
+
setInterval(fetchData, 5000);
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
init();
|
| 962 |
+
</script>
|
| 963 |
+
</body>
|
| 964 |
+
</html>
|
dashboard/templates/index_Matcher.html
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<title>Trading Dashboard</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; }
|
| 8 |
+
h2 { margin: 5px 0; }
|
| 9 |
+
.container { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; }
|
| 10 |
+
.panel {
|
| 11 |
+
background: #fff;
|
| 12 |
+
border-radius: 8px;
|
| 13 |
+
padding: 10px;
|
| 14 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 15 |
+
height: 400px;
|
| 16 |
+
display: flex;
|
| 17 |
+
flex-direction: column;
|
| 18 |
+
}
|
| 19 |
+
.panel pre {
|
| 20 |
+
flex-grow: 1;
|
| 21 |
+
overflow-y: scroll;
|
| 22 |
+
background: #fafafa;
|
| 23 |
+
padding: 10px;
|
| 24 |
+
margin: 0;
|
| 25 |
+
border-radius: 6px;
|
| 26 |
+
font-size: 13px;
|
| 27 |
+
white-space: pre;
|
| 28 |
+
font-family: monospace;
|
| 29 |
+
}
|
| 30 |
+
table {
|
| 31 |
+
width: 100%;
|
| 32 |
+
border-collapse: collapse;
|
| 33 |
+
font-size: 13px;
|
| 34 |
+
}
|
| 35 |
+
th, td {
|
| 36 |
+
border: 1px solid #ccc;
|
| 37 |
+
padding: 4px;
|
| 38 |
+
text-align: center;
|
| 39 |
+
}
|
| 40 |
+
.updated {
|
| 41 |
+
animation: flash 1s ease-in-out;
|
| 42 |
+
}
|
| 43 |
+
@keyframes flash {
|
| 44 |
+
from { background: yellow; }
|
| 45 |
+
to { background: transparent; }
|
| 46 |
+
}
|
| 47 |
+
</style>
|
| 48 |
+
</head>
|
| 49 |
+
<body>
|
| 50 |
+
<h1>📊 Trading Dashboard</h1>
|
| 51 |
+
<div class="container">
|
| 52 |
+
<div class="panel">
|
| 53 |
+
<h2>📝 Orders</h2>
|
| 54 |
+
<pre id="orders"></pre>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="panel">
|
| 58 |
+
<h2>💹 BBOs (from Order Book API)</h2>
|
| 59 |
+
<pre id="book"></pre>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="panel">
|
| 63 |
+
<h2>🤝 Trades</h2>
|
| 64 |
+
<pre id="trades"></pre>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div class="panel">
|
| 68 |
+
<h2>📖 Full Order Book</h2>
|
| 69 |
+
<label for="symbol-select">Select symbol:</label>
|
| 70 |
+
<select id="symbol-select" onchange="refresh()"></select>
|
| 71 |
+
<div id="full-book" style="flex-grow:1; overflow-y:scroll;"></div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div class="panel">
|
| 75 |
+
<h2>📊 Market Snapshots (BBO)</h2>
|
| 76 |
+
<table id="bbos-table">
|
| 77 |
+
<thead>
|
| 78 |
+
<tr>
|
| 79 |
+
<th>Symbol</th>
|
| 80 |
+
<th>Best Bid</th>
|
| 81 |
+
<th>Best Ask</th>
|
| 82 |
+
<th>Timestamp</th>
|
| 83 |
+
</tr>
|
| 84 |
+
</thead>
|
| 85 |
+
<tbody id="bbos-body"></tbody>
|
| 86 |
+
</table>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<script>
|
| 91 |
+
function fmtOrder(o) {
|
| 92 |
+
const sym = o.symbol ?? "?";
|
| 93 |
+
const side = o.type ?? o.side ?? "?";
|
| 94 |
+
const price = o.price !== undefined ? Number(o.price).toFixed(2) : "?";
|
| 95 |
+
const qty = o.quantity ?? o.qty ?? "?";
|
| 96 |
+
return `${sym} | ${side} | ${qty} @ ${price}\n`;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function fmtTrade(t) {
|
| 100 |
+
const sym = (t.symbol ?? "?").padEnd(6);
|
| 101 |
+
const qty = String(t.quantity ?? t.qty ?? "-").padStart(6);
|
| 102 |
+
const price = t.price !== undefined ? Number(t.price).toFixed(2).padStart(8) : " - ";
|
| 103 |
+
const ts = t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : "-";
|
| 104 |
+
return `${sym} | ${qty} x ${price} | ${ts}\n`;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function renderOrderBook(book) {
|
| 108 |
+
const bids = (book.buy || []).sort((a,b) => b.price - a.price);
|
| 109 |
+
const asks = (book.sell || []).sort((a,b) => a.price - b.price);
|
| 110 |
+
const maxRows = Math.max(bids.length, asks.length);
|
| 111 |
+
let html = "<table><tr><th>Bid Size</th><th>Bid Price</th><th>Ask Price</th><th>Ask Size</th></tr>";
|
| 112 |
+
for (let i=0; i<maxRows; i++) {
|
| 113 |
+
const b = bids[i] || {};
|
| 114 |
+
const a = asks[i] || {};
|
| 115 |
+
html += `<tr>
|
| 116 |
+
<td>${b.quantity ?? ""}</td>
|
| 117 |
+
<td>${b.price !== undefined ? Number(b.price).toFixed(2) : ""}</td>
|
| 118 |
+
<td>${a.price !== undefined ? Number(a.price).toFixed(2) : ""}</td>
|
| 119 |
+
<td>${a.quantity ?? ""}</td>
|
| 120 |
+
</tr>`;
|
| 121 |
+
}
|
| 122 |
+
html += "</table>";
|
| 123 |
+
return html;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
async function refresh() {
|
| 127 |
+
try {
|
| 128 |
+
const r = await fetch("/data");
|
| 129 |
+
const data = await r.json();
|
| 130 |
+
|
| 131 |
+
// Orders
|
| 132 |
+
document.getElementById("orders").textContent =
|
| 133 |
+
data.orders.slice().reverse().map(fmtOrder).join("");
|
| 134 |
+
|
| 135 |
+
// Trades
|
| 136 |
+
document.getElementById("trades").textContent =
|
| 137 |
+
data.trades.slice().reverse().map(fmtTrade).join("");
|
| 138 |
+
|
| 139 |
+
// Book JSON dump
|
| 140 |
+
document.getElementById("book").textContent =
|
| 141 |
+
JSON.stringify(data.book, null, 2);
|
| 142 |
+
|
| 143 |
+
// Snapshots → fill table
|
| 144 |
+
const tbody = document.getElementById("bbos-body");
|
| 145 |
+
tbody.innerHTML = "";
|
| 146 |
+
for (const [symbol, snap] of Object.entries(data.bbos)) {
|
| 147 |
+
const row = document.createElement("tr");
|
| 148 |
+
row.innerHTML = `
|
| 149 |
+
<td>${symbol}</td>
|
| 150 |
+
<td>${snap.best_bid !== null ? Number(snap.best_bid).toFixed(2) : "-"}</td>
|
| 151 |
+
<td>${snap.best_ask !== null ? Number(snap.best_ask).toFixed(2) : "-"}</td>
|
| 152 |
+
<td>${snap.timestamp ? new Date(snap.timestamp*1000).toLocaleTimeString() : "-"}</td>
|
| 153 |
+
`;
|
| 154 |
+
row.classList.add("updated");
|
| 155 |
+
setTimeout(() => row.classList.remove("updated"), 1000);
|
| 156 |
+
tbody.appendChild(row);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Populate dropdown once
|
| 160 |
+
const sel = document.getElementById("symbol-select");
|
| 161 |
+
if (!sel.options.length) {
|
| 162 |
+
const symbols = [...new Set([
|
| 163 |
+
...Object.keys(data.bbos),
|
| 164 |
+
...(data.book.buy || []).map(o => o.symbol),
|
| 165 |
+
...(data.book.sell || []).map(o => o.symbol)
|
| 166 |
+
])];
|
| 167 |
+
symbols.forEach(sym => {
|
| 168 |
+
const opt = document.createElement("option");
|
| 169 |
+
opt.value = sym;
|
| 170 |
+
opt.textContent = sym;
|
| 171 |
+
sel.appendChild(opt);
|
| 172 |
+
});
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Render order book for selected symbol
|
| 176 |
+
const sym = sel.value;
|
| 177 |
+
if (sym) {
|
| 178 |
+
const bookForSymbol = {
|
| 179 |
+
buy: (data.book.buy || []).filter(o => o.symbol === sym),
|
| 180 |
+
sell: (data.book.sell || []).filter(o => o.symbol === sym),
|
| 181 |
+
};
|
| 182 |
+
document.getElementById("full-book").innerHTML = renderOrderBook(bookForSymbol);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
} catch(e) {
|
| 186 |
+
console.error("Refresh error", e);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
setInterval(refresh, 2000);
|
| 191 |
+
refresh();
|
| 192 |
+
</script>
|
| 193 |
+
</body>
|
| 194 |
+
</html>
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
services:
|
| 3 |
+
# Build-only base image
|
| 4 |
+
fix-base:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile.base
|
| 8 |
+
image: fix-base
|
| 9 |
+
# No need to run a container for base
|
| 10 |
+
deploy:
|
| 11 |
+
replicas: 0
|
| 12 |
+
|
| 13 |
+
zookeeper:
|
| 14 |
+
image: confluentinc/cp-zookeeper:7.5.0
|
| 15 |
+
container_name: zookeeper
|
| 16 |
+
environment:
|
| 17 |
+
ZOOKEEPER_CLIENT_PORT: 2181
|
| 18 |
+
ZOOKEEPER_TICK_TIME: 2000
|
| 19 |
+
ports:
|
| 20 |
+
- "2181:2181"
|
| 21 |
+
|
| 22 |
+
kafka:
|
| 23 |
+
image: confluentinc/cp-kafka:7.5.0
|
| 24 |
+
container_name: kafka
|
| 25 |
+
depends_on:
|
| 26 |
+
- zookeeper
|
| 27 |
+
ports:
|
| 28 |
+
- "9092:9092" # for containers & optionally host
|
| 29 |
+
- "29092:29092" # for host access
|
| 30 |
+
environment:
|
| 31 |
+
KAFKA_BROKER_ID: 1
|
| 32 |
+
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
| 33 |
+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
|
| 34 |
+
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_HOST://0.0.0.0:29092
|
| 35 |
+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092
|
| 36 |
+
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
| 37 |
+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
| 38 |
+
|
| 39 |
+
matcher:
|
| 40 |
+
build: ./matcher
|
| 41 |
+
container_name: matcher
|
| 42 |
+
depends_on:
|
| 43 |
+
- kafka
|
| 44 |
+
ports:
|
| 45 |
+
- "6000:6000"
|
| 46 |
+
volumes:
|
| 47 |
+
- ./shared:/app/shared
|
| 48 |
+
- matcher_data:/app/data # SQLite database persistence
|
| 49 |
+
|
| 50 |
+
frontend:
|
| 51 |
+
build: ./frontend
|
| 52 |
+
container_name: frontend
|
| 53 |
+
depends_on:
|
| 54 |
+
- matcher
|
| 55 |
+
- kafka
|
| 56 |
+
ports:
|
| 57 |
+
- "5000:5000"
|
| 58 |
+
volumes:
|
| 59 |
+
- ./shared:/app/shared
|
| 60 |
+
environment:
|
| 61 |
+
- MATCHER_URL=http://matcher:6000
|
| 62 |
+
|
| 63 |
+
consumer:
|
| 64 |
+
build: ./consumer
|
| 65 |
+
container_name: consumer
|
| 66 |
+
depends_on:
|
| 67 |
+
- kafka
|
| 68 |
+
volumes:
|
| 69 |
+
- ./shared:/app/shared
|
| 70 |
+
|
| 71 |
+
md_feeder:
|
| 72 |
+
build:
|
| 73 |
+
context: ./md_feeder
|
| 74 |
+
dockerfile: Dockerfile
|
| 75 |
+
volumes:
|
| 76 |
+
- ./shared_data:/app/data # shared volume for global order IDs
|
| 77 |
+
- ./shared:/app/shared
|
| 78 |
+
|
| 79 |
+
oeg:
|
| 80 |
+
build: ./oeg
|
| 81 |
+
container_name: oeg
|
| 82 |
+
environment:
|
| 83 |
+
- FRONTEND_URL=http://frontend:5000
|
| 84 |
+
command: ["python","oeg_simulator.py"]
|
| 85 |
+
depends_on:
|
| 86 |
+
- frontend
|
| 87 |
+
networks:
|
| 88 |
+
- default
|
| 89 |
+
|
| 90 |
+
fix_oeg:
|
| 91 |
+
build: ./fix_oeg
|
| 92 |
+
container_name: fix_oeg
|
| 93 |
+
depends_on:
|
| 94 |
+
- kafka
|
| 95 |
+
ports:
|
| 96 |
+
- "5001:5001"
|
| 97 |
+
volumes:
|
| 98 |
+
- ./shared:/app/shared
|
| 99 |
+
networks:
|
| 100 |
+
- default
|
| 101 |
+
|
| 102 |
+
snapshot_viewer:
|
| 103 |
+
build: ./snapshot_viewer
|
| 104 |
+
#dockerfile: snapshot_viewer/Dockerfile
|
| 105 |
+
volumes:
|
| 106 |
+
- ./logs:/app/logs # logs will appear in ./logs folder on your host
|
| 107 |
+
- ./shared:/app/shared
|
| 108 |
+
depends_on:
|
| 109 |
+
- kafka
|
| 110 |
+
- md_feeder
|
| 111 |
+
networks:
|
| 112 |
+
- default
|
| 113 |
+
|
| 114 |
+
fix-ui-client-1:
|
| 115 |
+
build:
|
| 116 |
+
context: ./fix-ui-client
|
| 117 |
+
container_name: fix-ui-client-1
|
| 118 |
+
ports:
|
| 119 |
+
- "5002:5002"
|
| 120 |
+
volumes:
|
| 121 |
+
- ./fix-ui-client/client1.cfg:/app/client.cfg
|
| 122 |
+
- ./fix-ui-client/log:/app/log
|
| 123 |
+
- ./fix-ui-client/store:/app/store
|
| 124 |
+
- ./shared_data:/app/data # shared volume for global order IDs
|
| 125 |
+
- ./shared:/app/shared
|
| 126 |
+
environment:
|
| 127 |
+
FIX_CFG: client.cfg
|
| 128 |
+
depends_on:
|
| 129 |
+
- fix_oeg
|
| 130 |
+
|
| 131 |
+
fix-ui-client-2:
|
| 132 |
+
build:
|
| 133 |
+
context: ./fix-ui-client
|
| 134 |
+
container_name: fix-ui-client-2
|
| 135 |
+
ports:
|
| 136 |
+
- "5003:5002"
|
| 137 |
+
volumes:
|
| 138 |
+
- ./fix-ui-client/client2.cfg:/app/client.cfg
|
| 139 |
+
- ./fix-ui-client/log:/app/log
|
| 140 |
+
- ./fix-ui-client/store:/app/store
|
| 141 |
+
- ./shared_data:/app/data # shared volume for global order IDs
|
| 142 |
+
- ./shared:/app/shared
|
| 143 |
+
environment:
|
| 144 |
+
FIX_CFG: client.cfg
|
| 145 |
+
depends_on:
|
| 146 |
+
- fix_oeg
|
| 147 |
+
|
| 148 |
+
dashboard:
|
| 149 |
+
build:
|
| 150 |
+
context: ./dashboard
|
| 151 |
+
container_name: dashboard
|
| 152 |
+
ports:
|
| 153 |
+
- "5005:5000"
|
| 154 |
+
volumes:
|
| 155 |
+
- ./dashboard:/app
|
| 156 |
+
- ./shared:/app/shared
|
| 157 |
+
depends_on:
|
| 158 |
+
- kafka
|
| 159 |
+
- matcher
|
| 160 |
+
environment:
|
| 161 |
+
- MATCHER_URL=http://matcher:6000
|
| 162 |
+
|
| 163 |
+
volumes:
|
| 164 |
+
matcher_data: # Persists SQLite database across container restarts
|
fix-ui-client/Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM fix-base
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY . /app
|
| 5 |
+
|
| 6 |
+
# Make sure store/ and log/ exist
|
| 7 |
+
RUN mkdir -p /app/store /app/log
|
| 8 |
+
|
| 9 |
+
# Install specific deps
|
| 10 |
+
RUN pip install --no-cache-dir kafka-python requests
|
| 11 |
+
|
| 12 |
+
CMD ["python", "fix-ui-client.py"]
|
fix-ui-client/FIX44.xml
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
fix-ui-client/client1.cfg
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[DEFAULT]
|
| 2 |
+
ConnectionType=initiator
|
| 3 |
+
StartTime=00:00:00
|
| 4 |
+
EndTime=23:59:59
|
| 5 |
+
UseLocalTime=Y
|
| 6 |
+
HeartBtInt=30
|
| 7 |
+
FileStorePath=store
|
| 8 |
+
FileLogPath=log
|
| 9 |
+
UseDataDictionary=N
|
| 10 |
+
DataDictionary=FIX44.xml
|
| 11 |
+
ReconnectInterval=5
|
| 12 |
+
ResetOnLogon=Y
|
| 13 |
+
ResetOnLogout=Y
|
| 14 |
+
ResetOnDisconnect=Y
|
| 15 |
+
|
| 16 |
+
[SESSION]
|
| 17 |
+
BeginString=FIX.4.4
|
| 18 |
+
SenderCompID=CLIENT1
|
| 19 |
+
TargetCompID=FIX_SERVER
|
| 20 |
+
SocketConnectHost=fix_oeg
|
| 21 |
+
SocketConnectPort=5001
|
fix-ui-client/client2.cfg
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[DEFAULT]
|
| 2 |
+
ConnectionType=initiator
|
| 3 |
+
StartTime=00:00:00
|
| 4 |
+
EndTime=23:59:59
|
| 5 |
+
UseLocalTime=Y
|
| 6 |
+
HeartBtInt=30
|
| 7 |
+
FileStorePath=store
|
| 8 |
+
FileLogPath=log
|
| 9 |
+
UseDataDictionary=N
|
| 10 |
+
DataDictionary=FIX44.xml
|
| 11 |
+
ReconnectInterval=5
|
| 12 |
+
ResetOnLogon=Y
|
| 13 |
+
ResetOnLogout=Y
|
| 14 |
+
ResetOnDisconnect=Y
|
| 15 |
+
|
| 16 |
+
[SESSION]
|
| 17 |
+
BeginString=FIX.4.4
|
| 18 |
+
SenderCompID=CLIENT2
|
| 19 |
+
TargetCompID=FIX_SERVER
|
| 20 |
+
SocketConnectHost=fix_oeg
|
| 21 |
+
SocketConnectPort=5001
|
| 22 |
+
|
fix-ui-client/fix-ui-client.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, "/app")
|
| 4 |
+
|
| 5 |
+
import quickfix as fix
|
| 6 |
+
import quickfix44 as fix44
|
| 7 |
+
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
| 8 |
+
import json
|
| 9 |
+
import threading, time, os
|
| 10 |
+
from collections import deque
|
| 11 |
+
from threading import Lock
|
| 12 |
+
|
| 13 |
+
from shared.config import Config
|
| 14 |
+
from shared.kafka_utils import create_producer
|
| 15 |
+
|
| 16 |
+
app = Flask(__name__)
|
| 17 |
+
|
| 18 |
+
fix_initiator = None
|
| 19 |
+
fix_app = None
|
| 20 |
+
_messages = deque(maxlen=500)
|
| 21 |
+
_msgs_lock = Lock()
|
| 22 |
+
|
| 23 |
+
# --- Global OrderID counter ---
|
| 24 |
+
LOCK = threading.Lock()
|
| 25 |
+
|
| 26 |
+
def next_order_id():
|
| 27 |
+
os.makedirs(os.path.dirname(Config.ORDER_ID_FILE), exist_ok=True)
|
| 28 |
+
|
| 29 |
+
with LOCK: # thread safety inside container
|
| 30 |
+
if not os.path.exists(Config.ORDER_ID_FILE):
|
| 31 |
+
with open(Config.ORDER_ID_FILE, "w") as f:
|
| 32 |
+
f.write("0")
|
| 33 |
+
|
| 34 |
+
with open(Config.ORDER_ID_FILE, "r+") as f:
|
| 35 |
+
current = int(f.read().strip() or 0)
|
| 36 |
+
new_id = current + 1
|
| 37 |
+
f.seek(0)
|
| 38 |
+
f.write(str(new_id))
|
| 39 |
+
f.truncate()
|
| 40 |
+
|
| 41 |
+
return new_id
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def log(msg: str):
|
| 45 |
+
with _msgs_lock:
|
| 46 |
+
_messages.append(msg)
|
| 47 |
+
|
| 48 |
+
class FixUIClientApp(fix.Application):
|
| 49 |
+
def __init__(self):
|
| 50 |
+
super().__init__()
|
| 51 |
+
self.sessionID = None
|
| 52 |
+
self.connected = False
|
| 53 |
+
self.producer = create_producer(component_name="FIX-UI-Client")
|
| 54 |
+
|
| 55 |
+
def onCreate(self, sessionID): self.sessionID = sessionID
|
| 56 |
+
def onLogon(self, sessionID):
|
| 57 |
+
self.connected = True
|
| 58 |
+
self.sessionID = sessionID
|
| 59 |
+
log(f"✅ Logon: {sessionID}")
|
| 60 |
+
def onLogout(self, sessionID):
|
| 61 |
+
self.connected = False
|
| 62 |
+
log(f"❌ Logout: {sessionID}")
|
| 63 |
+
|
| 64 |
+
def toAdmin(self, message, sessionID): pass
|
| 65 |
+
def fromAdmin(self, message, sessionID): pass
|
| 66 |
+
|
| 67 |
+
def toApp(self, message, sessionID):
|
| 68 |
+
log(f"➡️ Sent App: {message.toString()}")
|
| 69 |
+
|
| 70 |
+
def fromApp(self, message, sessionID):
|
| 71 |
+
msg_type = fix.MsgType()
|
| 72 |
+
message.getHeader().getField(msg_type)
|
| 73 |
+
if msg_type.getValue() == fix.MsgType_ExecutionReport:
|
| 74 |
+
execType, ordStatus, clOrdID = fix.ExecType(), fix.OrdStatus(), fix.ClOrdID()
|
| 75 |
+
try: message.getField(execType)
|
| 76 |
+
except: pass
|
| 77 |
+
try: message.getField(ordStatus)
|
| 78 |
+
except: pass
|
| 79 |
+
try: message.getField(clOrdID)
|
| 80 |
+
except: pass
|
| 81 |
+
|
| 82 |
+
status_map = {
|
| 83 |
+
fix.ExecType_NEW: "New",
|
| 84 |
+
fix.ExecType_PARTIAL_FILL: "Partial Fill",
|
| 85 |
+
fix.ExecType_FILL: "Fill",
|
| 86 |
+
fix.ExecType_CANCELED: "Canceled",
|
| 87 |
+
fix.ExecType_REPLACED: "Replaced",
|
| 88 |
+
fix.ExecType_REJECTED: "Rejected",
|
| 89 |
+
}
|
| 90 |
+
status = status_map.get(execType.getValue(), f"ExecType={execType.getValue()}")
|
| 91 |
+
log(f"📥 ExecReport: ClOrdID={clOrdID.getValue() if clOrdID else '?'} Status={status}")
|
| 92 |
+
else:
|
| 93 |
+
log(f"📩 App: {message.toString()}")
|
| 94 |
+
|
| 95 |
+
def send_order(self, side, symbol, qty, price):
|
| 96 |
+
if not self.sessionID:
|
| 97 |
+
return "⚠️ No FIX session active"
|
| 98 |
+
|
| 99 |
+
order = fix44.NewOrderSingle()
|
| 100 |
+
cl_ord_id = next_order_id()
|
| 101 |
+
order.setField(fix.ClOrdID(str(cl_ord_id)))
|
| 102 |
+
order.setField(fix.HandlInst('1'))
|
| 103 |
+
order.setField(fix.Symbol(symbol))
|
| 104 |
+
order.setField(fix.Side(side))
|
| 105 |
+
order.setField(fix.TransactTime())
|
| 106 |
+
order.setField(fix.OrdType(fix.OrdType_LIMIT))
|
| 107 |
+
order.setField(fix.OrderQty(qty))
|
| 108 |
+
order.setField(fix.Price(price))
|
| 109 |
+
|
| 110 |
+
fix.Session.sendToTarget(order, self.sessionID)
|
| 111 |
+
log(f"📤 Sent Order (ID={cl_ord_id}): {order.toString()}")
|
| 112 |
+
|
| 113 |
+
# 🔥 Publish to Kafka
|
| 114 |
+
self.producer.send(Config.ORDERS_TOPIC,
|
| 115 |
+
{
|
| 116 |
+
"id": cl_ord_id,
|
| 117 |
+
"symbol": symbol,
|
| 118 |
+
"side": "buy" if side == "1" else "sell",
|
| 119 |
+
"qty": qty,
|
| 120 |
+
"price": price,
|
| 121 |
+
"timestamp": time.time(),
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
return "Order sent!"
|
| 125 |
+
|
| 126 |
+
# --- FIX lifecycle ---
|
| 127 |
+
def start_fix():
|
| 128 |
+
global fix_initiator, fix_app
|
| 129 |
+
if fix_initiator is not None:
|
| 130 |
+
log("ℹ️ FIX already starting/started")
|
| 131 |
+
return
|
| 132 |
+
settings = fix.SessionSettings(CONFIG_FILE)
|
| 133 |
+
fix_app = FixUIClientApp()
|
| 134 |
+
store = fix.FileStoreFactory(settings)
|
| 135 |
+
logfile = fix.FileLogFactory(settings)
|
| 136 |
+
fix_initiator = fix.SocketInitiator(fix_app, store, settings, logfile)
|
| 137 |
+
fix_initiator.start()
|
| 138 |
+
log(f"🔌 FIX initiator started with {CONFIG_FILE}")
|
| 139 |
+
|
| 140 |
+
def stop_fix():
|
| 141 |
+
global fix_initiator
|
| 142 |
+
if fix_initiator:
|
| 143 |
+
fix_initiator.stop()
|
| 144 |
+
log("🪫 FIX initiator stopped")
|
| 145 |
+
fix_initiator = None
|
| 146 |
+
|
| 147 |
+
# --- Flask routes ---
|
| 148 |
+
@app.route("/")
|
| 149 |
+
def index():
|
| 150 |
+
with _msgs_lock:
|
| 151 |
+
msgs = list(reversed(_messages)) # newest first
|
| 152 |
+
connected = bool(fix_app and fix_app.connected)
|
| 153 |
+
return render_template("index.html", messages=msgs, connected=connected)
|
| 154 |
+
|
| 155 |
+
@app.route("/status")
|
| 156 |
+
def status():
|
| 157 |
+
return jsonify({"connected": bool(fix_app and fix_app.connected)})
|
| 158 |
+
|
| 159 |
+
@app.route("/connect")
|
| 160 |
+
def connect():
|
| 161 |
+
threading.Thread(target=start_fix, daemon=True).start()
|
| 162 |
+
return redirect(url_for("index"))
|
| 163 |
+
|
| 164 |
+
@app.route("/disconnect")
|
| 165 |
+
def disconnect():
|
| 166 |
+
stop_fix()
|
| 167 |
+
return redirect(url_for("index"))
|
| 168 |
+
|
| 169 |
+
@app.route("/order", methods=["POST"])
|
| 170 |
+
def order():
|
| 171 |
+
if not fix_app or not fix_app.connected:
|
| 172 |
+
log("⚠️ Tried to send while disconnected")
|
| 173 |
+
return redirect(url_for("index"))
|
| 174 |
+
side = request.form.get("side", "buy")
|
| 175 |
+
side_tag = "1" if side.lower() == "buy" else "2"
|
| 176 |
+
symbol = request.form.get("symbol", "FOO")
|
| 177 |
+
qty = float(request.form.get("qty", "100"))
|
| 178 |
+
price = float(request.form.get("price", "10"))
|
| 179 |
+
fix_app.send_order(side_tag, symbol, qty, price)
|
| 180 |
+
return redirect(url_for("index"))
|
| 181 |
+
|
| 182 |
+
# --- Configurable ---
|
| 183 |
+
CONFIG_FILE = os.getenv("FIX_CONFIG", "client.cfg")
|
| 184 |
+
PORT = int(os.getenv("UI_PORT", "5002"))
|
| 185 |
+
|
| 186 |
+
if __name__ == "__main__":
|
| 187 |
+
app.run(host="0.0.0.0", port=PORT, debug=True)
|
fix-ui-client/requirements.txt
ADDED
|
File without changes
|
fix-ui-client/templates/index.html
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>FIX UI Client</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; }
|
| 8 |
+
h1 { display: flex; align-items: center; gap: 15px; margin-bottom: 20px; }
|
| 9 |
+
h2 { margin: 5px 0 10px; font-size: 16px; }
|
| 10 |
+
.container { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; }
|
| 11 |
+
|
| 12 |
+
.panel {
|
| 13 |
+
background: #fff;
|
| 14 |
+
border-radius: 8px;
|
| 15 |
+
padding: 15px;
|
| 16 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 17 |
+
display: flex;
|
| 18 |
+
flex-direction: column;
|
| 19 |
+
min-height: 400px;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* Status badge */
|
| 23 |
+
.status {
|
| 24 |
+
display: inline-flex;
|
| 25 |
+
align-items: center;
|
| 26 |
+
gap: 6px;
|
| 27 |
+
padding: 4px 12px;
|
| 28 |
+
border-radius: 20px;
|
| 29 |
+
font-size: 12px;
|
| 30 |
+
font-weight: bold;
|
| 31 |
+
}
|
| 32 |
+
.status .dot {
|
| 33 |
+
width: 10px;
|
| 34 |
+
height: 10px;
|
| 35 |
+
border-radius: 50%;
|
| 36 |
+
}
|
| 37 |
+
.status.connected { background: #d4edda; color: #155724; }
|
| 38 |
+
.status.connected .dot { background: #28a745; }
|
| 39 |
+
.status.disconnected { background: #f8d7da; color: #721c24; }
|
| 40 |
+
.status.disconnected .dot { background: #dc3545; }
|
| 41 |
+
|
| 42 |
+
/* Buttons */
|
| 43 |
+
.btn-group { display: flex; gap: 8px; margin-top: 10px; }
|
| 44 |
+
.btn {
|
| 45 |
+
padding: 7px 18px;
|
| 46 |
+
border: none;
|
| 47 |
+
border-radius: 4px;
|
| 48 |
+
cursor: pointer;
|
| 49 |
+
font-size: 13px;
|
| 50 |
+
font-weight: bold;
|
| 51 |
+
text-decoration: none;
|
| 52 |
+
display: inline-block;
|
| 53 |
+
}
|
| 54 |
+
.btn-connect { background: #28a745; color: #fff; }
|
| 55 |
+
.btn-connect:hover { background: #218838; }
|
| 56 |
+
.btn-disconnect { background: #dc3545; color: #fff; }
|
| 57 |
+
.btn-disconnect:hover { background: #c82333; }
|
| 58 |
+
|
| 59 |
+
/* Divider */
|
| 60 |
+
.divider { border: none; border-top: 1px solid #eee; margin: 15px 0; }
|
| 61 |
+
|
| 62 |
+
/* Order form */
|
| 63 |
+
.form-group { margin-bottom: 12px; }
|
| 64 |
+
.form-group label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
|
| 65 |
+
.form-group input,
|
| 66 |
+
.form-group select {
|
| 67 |
+
width: 100%;
|
| 68 |
+
padding: 8px;
|
| 69 |
+
border: 1px solid #ccc;
|
| 70 |
+
border-radius: 4px;
|
| 71 |
+
font-size: 13px;
|
| 72 |
+
box-sizing: border-box;
|
| 73 |
+
}
|
| 74 |
+
.form-group select option[value="buy"] { color: #2e7d32; }
|
| 75 |
+
.form-group select option[value="sell"] { color: #c62828; }
|
| 76 |
+
.btn-send {
|
| 77 |
+
width: 100%;
|
| 78 |
+
padding: 10px;
|
| 79 |
+
background: #2196F3;
|
| 80 |
+
color: #fff;
|
| 81 |
+
border: none;
|
| 82 |
+
border-radius: 4px;
|
| 83 |
+
cursor: pointer;
|
| 84 |
+
font-size: 14px;
|
| 85 |
+
font-weight: bold;
|
| 86 |
+
}
|
| 87 |
+
.btn-send:hover { background: #1976D2; }
|
| 88 |
+
|
| 89 |
+
/* Messages box */
|
| 90 |
+
.messages-box {
|
| 91 |
+
flex-grow: 1;
|
| 92 |
+
overflow-y: auto;
|
| 93 |
+
background: #1a1a2e;
|
| 94 |
+
color: #e0e0e0;
|
| 95 |
+
padding: 12px;
|
| 96 |
+
border-radius: 6px;
|
| 97 |
+
font-family: monospace;
|
| 98 |
+
font-size: 12px;
|
| 99 |
+
white-space: pre-wrap;
|
| 100 |
+
line-height: 1.5;
|
| 101 |
+
min-height: 300px;
|
| 102 |
+
}
|
| 103 |
+
</style>
|
| 104 |
+
</head>
|
| 105 |
+
<body>
|
| 106 |
+
|
| 107 |
+
<h1>
|
| 108 |
+
FIX UI Client
|
| 109 |
+
<span class="status {{ 'connected' if connected else 'disconnected' }}">
|
| 110 |
+
<span class="dot"></span>
|
| 111 |
+
<span>{{ 'CONNECTED' if connected else 'DISCONNECTED' }}</span>
|
| 112 |
+
</span>
|
| 113 |
+
</h1>
|
| 114 |
+
|
| 115 |
+
<div class="container">
|
| 116 |
+
|
| 117 |
+
<!-- Left panel: Connection + Order form -->
|
| 118 |
+
<div class="panel">
|
| 119 |
+
<h2>Connection</h2>
|
| 120 |
+
<div class="btn-group">
|
| 121 |
+
<a href="{{ url_for('connect') }}" class="btn btn-connect">Connect</a>
|
| 122 |
+
<a href="{{ url_for('disconnect') }}" class="btn btn-disconnect">Disconnect</a>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<hr class="divider">
|
| 126 |
+
|
| 127 |
+
<h2>Send Order</h2>
|
| 128 |
+
<form action="{{ url_for('order') }}" method="post">
|
| 129 |
+
<div class="form-group">
|
| 130 |
+
<label>Side</label>
|
| 131 |
+
<select name="side">
|
| 132 |
+
<option value="buy">BUY</option>
|
| 133 |
+
<option value="sell">SELL</option>
|
| 134 |
+
</select>
|
| 135 |
+
</div>
|
| 136 |
+
<div class="form-group">
|
| 137 |
+
<label>Symbol</label>
|
| 138 |
+
<input type="text" name="symbol" value="AAPL">
|
| 139 |
+
</div>
|
| 140 |
+
<div class="form-group">
|
| 141 |
+
<label>Quantity</label>
|
| 142 |
+
<input type="number" step="1" name="qty" value="100">
|
| 143 |
+
</div>
|
| 144 |
+
<div class="form-group">
|
| 145 |
+
<label>Price</label>
|
| 146 |
+
<input type="number" step="0.01" name="price" value="150.00">
|
| 147 |
+
</div>
|
| 148 |
+
<button type="submit" class="btn-send">Send Order</button>
|
| 149 |
+
</form>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- Right panel: Messages log -->
|
| 153 |
+
<div class="panel">
|
| 154 |
+
<h2>Messages <span style="font-size:12px; color:#999; font-weight:normal;">FIX Session Log</span></h2>
|
| 155 |
+
<div class="messages-box">{% for msg in messages %}{{ msg }}
|
| 156 |
+
{% endfor %}</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
</body>
|
| 162 |
+
</html>
|
fix_oeg/Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build stage with all dependencies
|
| 2 |
+
FROM python:3.11-slim as builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install build dependencies
|
| 7 |
+
RUN apt-get update && \
|
| 8 |
+
apt-get install -y --no-install-recommends \
|
| 9 |
+
build-essential \
|
| 10 |
+
libssl-dev \
|
| 11 |
+
zlib1g-dev \
|
| 12 |
+
libbz2-dev \
|
| 13 |
+
libreadline-dev \
|
| 14 |
+
libsqlite3-dev \
|
| 15 |
+
wget \
|
| 16 |
+
curl \
|
| 17 |
+
llvm \
|
| 18 |
+
libncurses5-dev \
|
| 19 |
+
libncursesw5-dev \
|
| 20 |
+
xz-utils \
|
| 21 |
+
tk-dev \
|
| 22 |
+
libffi-dev \
|
| 23 |
+
liblzma-dev \
|
| 24 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 25 |
+
|
| 26 |
+
# Install Python dependencies
|
| 27 |
+
COPY requirements.txt .
|
| 28 |
+
RUN pip install --user --no-cache-dir -r requirements.txt
|
| 29 |
+
|
| 30 |
+
# Stage 2: Runtime image
|
| 31 |
+
FROM python:3.11-slim
|
| 32 |
+
|
| 33 |
+
WORKDIR /app
|
| 34 |
+
|
| 35 |
+
# Copy only the necessary files from builder
|
| 36 |
+
COPY --from=builder /root/.local /root/.local
|
| 37 |
+
COPY fix_oeg_server.py fix_server.cfg ./
|
| 38 |
+
|
| 39 |
+
COPY FIX44.xml .
|
| 40 |
+
|
| 41 |
+
# Ensure scripts in .local are usable
|
| 42 |
+
ENV PATH=/root/.local/bin:$PATH
|
| 43 |
+
|
| 44 |
+
EXPOSE 5001
|
| 45 |
+
CMD ["python", "fix_oeg_server.py"]
|
fix_oeg/FIX44.xml
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
fix_oeg/fix_oeg_server.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.path.insert(0, "/app")
|
| 3 |
+
|
| 4 |
+
import time, json, threading, uuid
|
| 5 |
+
import quickfix as fix
|
| 6 |
+
import quickfix44 as fix44
|
| 7 |
+
|
| 8 |
+
from shared.config import Config
|
| 9 |
+
from shared.kafka_utils import create_producer, create_consumer
|
| 10 |
+
|
| 11 |
+
# Order tracking for execution reports
|
| 12 |
+
order_sessions = {} # cl_ord_id -> (sessionID, order_details)
|
| 13 |
+
order_sessions_lock = threading.Lock()
|
| 14 |
+
|
| 15 |
+
class Application(fix.Application):
|
| 16 |
+
def __init__(self):
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.producer = create_producer(component_name="FIX-OEG")
|
| 19 |
+
self.sessions = {} # sessionID -> session for sending messages
|
| 20 |
+
|
| 21 |
+
def onCreate(self, sessionID):
|
| 22 |
+
print("FIX-OEG onCreate:", sessionID)
|
| 23 |
+
self.sessions[sessionID] = sessionID
|
| 24 |
+
|
| 25 |
+
def onLogon(self, sessionID):
|
| 26 |
+
print("FIX-OEG onLogon:", sessionID)
|
| 27 |
+
|
| 28 |
+
def onLogout(self, sessionID):
|
| 29 |
+
print("FIX-OEG onLogout:", sessionID)
|
| 30 |
+
if sessionID in self.sessions:
|
| 31 |
+
del self.sessions[sessionID]
|
| 32 |
+
|
| 33 |
+
def toAdmin(self, message, sessionID): pass
|
| 34 |
+
def fromAdmin(self, message, sessionID): pass
|
| 35 |
+
def toApp(self, message, sessionID): pass
|
| 36 |
+
|
| 37 |
+
def fromApp(self, message, sessionID):
|
| 38 |
+
msgType = fix.MsgType()
|
| 39 |
+
message.getHeader().getField(msgType)
|
| 40 |
+
mtype = msgType.getValue()
|
| 41 |
+
if mtype == fix.MsgType_NewOrderSingle:
|
| 42 |
+
self.onNewOrderSingle(message, sessionID)
|
| 43 |
+
elif mtype == fix.MsgType_OrderCancelRequest:
|
| 44 |
+
self.onOrderCancelRequest(message, sessionID)
|
| 45 |
+
elif mtype == fix.MsgType_OrderCancelReplaceRequest:
|
| 46 |
+
self.onOrderCancelReplaceRequest(message, sessionID)
|
| 47 |
+
else:
|
| 48 |
+
print("FIX-OEG: Unsupported MsgType:", mtype)
|
| 49 |
+
self.sendReject(sessionID, message, f"Unsupported message type: {mtype}")
|
| 50 |
+
|
| 51 |
+
def validateNewOrderSingle(self, message):
|
| 52 |
+
"""Validate required fields for NewOrderSingle. Returns (valid, error_msg)."""
|
| 53 |
+
errors = []
|
| 54 |
+
|
| 55 |
+
def gf(tag, name):
|
| 56 |
+
try:
|
| 57 |
+
return message.getField(tag)
|
| 58 |
+
except Exception:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
# Required fields
|
| 62 |
+
cl_ord_id = gf(11, "ClOrdID")
|
| 63 |
+
symbol = gf(55, "Symbol")
|
| 64 |
+
side = gf(54, "Side")
|
| 65 |
+
qty = gf(38, "OrderQty")
|
| 66 |
+
|
| 67 |
+
if not cl_ord_id:
|
| 68 |
+
errors.append("Missing required field ClOrdID (11)")
|
| 69 |
+
if not symbol:
|
| 70 |
+
errors.append("Missing required field Symbol (55)")
|
| 71 |
+
if not side:
|
| 72 |
+
errors.append("Missing required field Side (54)")
|
| 73 |
+
elif side not in ("1", "2"):
|
| 74 |
+
errors.append(f"Invalid Side (54): must be 1 (Buy) or 2 (Sell), got {side}")
|
| 75 |
+
if not qty:
|
| 76 |
+
errors.append("Missing required field OrderQty (38)")
|
| 77 |
+
else:
|
| 78 |
+
try:
|
| 79 |
+
qty_val = float(qty)
|
| 80 |
+
if qty_val <= 0:
|
| 81 |
+
errors.append(f"OrderQty (38) must be positive, got {qty_val}")
|
| 82 |
+
except ValueError:
|
| 83 |
+
errors.append(f"Invalid OrderQty (38): {qty}")
|
| 84 |
+
|
| 85 |
+
# Price validation for limit orders
|
| 86 |
+
ord_type = gf(40, "OrdType")
|
| 87 |
+
if ord_type == "2": # Limit order
|
| 88 |
+
price = gf(44, "Price")
|
| 89 |
+
if not price:
|
| 90 |
+
errors.append("Missing Price (44) for limit order")
|
| 91 |
+
else:
|
| 92 |
+
try:
|
| 93 |
+
if float(price) <= 0:
|
| 94 |
+
errors.append(f"Price (44) must be positive, got {price}")
|
| 95 |
+
except ValueError:
|
| 96 |
+
errors.append(f"Invalid Price (44): {price}")
|
| 97 |
+
|
| 98 |
+
return (len(errors) == 0, "; ".join(errors))
|
| 99 |
+
|
| 100 |
+
def sendReject(self, sessionID, original_msg, reason):
|
| 101 |
+
"""Send a Reject message (35=3) for invalid messages."""
|
| 102 |
+
reject = fix.Message()
|
| 103 |
+
reject.getHeader().setField(fix.MsgType(fix.MsgType_Reject))
|
| 104 |
+
reject.setField(fix.RefSeqNum(0)) # Reference sequence number
|
| 105 |
+
reject.setField(fix.Text(reason))
|
| 106 |
+
try:
|
| 107 |
+
fix.Session.sendToTarget(reject, sessionID)
|
| 108 |
+
print(f"FIX-OEG: Sent Reject: {reason}")
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"FIX-OEG: Failed to send Reject: {e}")
|
| 111 |
+
|
| 112 |
+
def sendExecutionReport(self, sessionID, order, exec_type, ord_status,
|
| 113 |
+
exec_qty=0, exec_price=0, cum_qty=0, leaves_qty=0):
|
| 114 |
+
"""Send an ExecutionReport (35=8)."""
|
| 115 |
+
exec_report = fix.Message()
|
| 116 |
+
exec_report.getHeader().setField(fix.MsgType(fix.MsgType_ExecutionReport))
|
| 117 |
+
|
| 118 |
+
# Required fields
|
| 119 |
+
exec_report.setField(fix.OrderID(order.get('order_id', str(uuid.uuid4()))))
|
| 120 |
+
exec_report.setField(fix.ClOrdID(order.get('cl_ord_id', '')))
|
| 121 |
+
exec_report.setField(fix.ExecID(str(uuid.uuid4())))
|
| 122 |
+
exec_report.setField(fix.ExecType(exec_type))
|
| 123 |
+
exec_report.setField(fix.OrdStatus(ord_status))
|
| 124 |
+
exec_report.setField(fix.Symbol(order.get('symbol', '')))
|
| 125 |
+
exec_report.setField(fix.Side(fix.Side_BUY if order.get('type') == 'buy' else fix.Side_SELL))
|
| 126 |
+
exec_report.setField(fix.OrderQty(order.get('quantity', 0)))
|
| 127 |
+
|
| 128 |
+
if order.get('price'):
|
| 129 |
+
exec_report.setField(fix.Price(order.get('price')))
|
| 130 |
+
|
| 131 |
+
# Fill information
|
| 132 |
+
exec_report.setField(fix.LastShares(exec_qty))
|
| 133 |
+
exec_report.setField(fix.LastPx(exec_price))
|
| 134 |
+
exec_report.setField(fix.CumQty(cum_qty))
|
| 135 |
+
exec_report.setField(fix.LeavesQty(leaves_qty))
|
| 136 |
+
exec_report.setField(fix.AvgPx(exec_price if cum_qty > 0 else 0))
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
fix.Session.sendToTarget(exec_report, sessionID)
|
| 140 |
+
print(f"FIX-OEG: Sent ExecutionReport: ExecType={exec_type}, OrdStatus={ord_status}")
|
| 141 |
+
except Exception as e:
|
| 142 |
+
print(f"FIX-OEG: Failed to send ExecutionReport: {e}")
|
| 143 |
+
|
| 144 |
+
def onNewOrderSingle(self, message, sessionID):
|
| 145 |
+
# Validate message first
|
| 146 |
+
valid, error_msg = self.validateNewOrderSingle(message)
|
| 147 |
+
if not valid:
|
| 148 |
+
self.sendReject(sessionID, message, error_msg)
|
| 149 |
+
return
|
| 150 |
+
|
| 151 |
+
def gf(tag, default=None, cast=str):
|
| 152 |
+
try:
|
| 153 |
+
return cast(message.getField(tag))
|
| 154 |
+
except Exception:
|
| 155 |
+
return default
|
| 156 |
+
|
| 157 |
+
cl_ord_id = gf(11)
|
| 158 |
+
symbol = gf(55, "FOO")
|
| 159 |
+
side_val = gf(54, "1") # 1=Buy, 2=Sell
|
| 160 |
+
qty = gf(38, 0.0, float)
|
| 161 |
+
price = gf(44, 0.0, float)
|
| 162 |
+
|
| 163 |
+
order_id = cl_ord_id or f"fix-{int(time.time()*1000)}"
|
| 164 |
+
order = {
|
| 165 |
+
"order_id": order_id,
|
| 166 |
+
"cl_ord_id": cl_ord_id,
|
| 167 |
+
"symbol": symbol,
|
| 168 |
+
"type": "buy" if str(side_val) == "1" else "sell",
|
| 169 |
+
"quantity": qty,
|
| 170 |
+
"price": price,
|
| 171 |
+
"timestamp": time.time(),
|
| 172 |
+
"source": "fix-oeg"
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
# Track order for execution reports
|
| 176 |
+
with order_sessions_lock:
|
| 177 |
+
order_sessions[cl_ord_id] = (sessionID, order)
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
meta = self.producer.send(Config.ORDERS_TOPIC, value=order).get(timeout=10)
|
| 181 |
+
print(f"📥 FIX → Kafka: {order}")
|
| 182 |
+
print(json.dumps({"component":"fix-oeg","event":"order_received","payload":{"order":order,"topic":meta.topic,"partition":meta.partition,"offset":meta.offset}}))
|
| 183 |
+
|
| 184 |
+
# Send Execution Report: New (ExecType=0, OrdStatus=0)
|
| 185 |
+
self.sendExecutionReport(
|
| 186 |
+
sessionID, order,
|
| 187 |
+
exec_type=fix.ExecType_NEW,
|
| 188 |
+
ord_status=fix.OrdStatus_NEW,
|
| 189 |
+
leaves_qty=qty
|
| 190 |
+
)
|
| 191 |
+
except Exception as e:
|
| 192 |
+
print(json.dumps({"component":"fix-oeg","event":"produce_failed","payload":{"order":order,"error":str(e)}}))
|
| 193 |
+
|
| 194 |
+
def onOrderCancelRequest(self, message, sessionID):
|
| 195 |
+
"""Handle Order Cancel Request (35=F)."""
|
| 196 |
+
def gf(tag, default=None):
|
| 197 |
+
try:
|
| 198 |
+
return message.getField(tag)
|
| 199 |
+
except Exception:
|
| 200 |
+
return default
|
| 201 |
+
|
| 202 |
+
orig_cl_ord_id = gf(41) # OrigClOrdID
|
| 203 |
+
cl_ord_id = gf(11) # ClOrdID (for cancel request)
|
| 204 |
+
symbol = gf(55)
|
| 205 |
+
|
| 206 |
+
if not orig_cl_ord_id:
|
| 207 |
+
self.sendReject(sessionID, message, "Missing OrigClOrdID (41)")
|
| 208 |
+
return
|
| 209 |
+
|
| 210 |
+
cancel_msg = {
|
| 211 |
+
"type": "cancel",
|
| 212 |
+
"orig_cl_ord_id": orig_cl_ord_id,
|
| 213 |
+
"cl_ord_id": cl_ord_id,
|
| 214 |
+
"symbol": symbol,
|
| 215 |
+
"timestamp": time.time(),
|
| 216 |
+
"source": "fix-oeg"
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
self.producer.send(Config.ORDERS_TOPIC, value=cancel_msg).get(timeout=10)
|
| 221 |
+
print(f"📥 FIX Cancel → Kafka: {cancel_msg}")
|
| 222 |
+
except Exception as e:
|
| 223 |
+
print(f"FIX-OEG: Failed to send cancel: {e}")
|
| 224 |
+
|
| 225 |
+
def onOrderCancelReplaceRequest(self, message, sessionID):
|
| 226 |
+
"""Handle Order Cancel/Replace Request (35=G) - amend order price/quantity."""
|
| 227 |
+
def gf(tag, default=None, cast=str):
|
| 228 |
+
try:
|
| 229 |
+
return cast(message.getField(tag))
|
| 230 |
+
except Exception:
|
| 231 |
+
return default
|
| 232 |
+
|
| 233 |
+
orig_cl_ord_id = gf(41) # OrigClOrdID - order to modify
|
| 234 |
+
cl_ord_id = gf(11) # ClOrdID - new ID for modified order
|
| 235 |
+
symbol = gf(55)
|
| 236 |
+
side = gf(54)
|
| 237 |
+
new_qty = gf(38, 0.0, float) # New OrderQty
|
| 238 |
+
new_price = gf(44, 0.0, float) # New Price
|
| 239 |
+
|
| 240 |
+
if not orig_cl_ord_id:
|
| 241 |
+
self.sendReject(sessionID, message, "Missing OrigClOrdID (41)")
|
| 242 |
+
return
|
| 243 |
+
|
| 244 |
+
amend_msg = {
|
| 245 |
+
"type": "amend",
|
| 246 |
+
"orig_cl_ord_id": orig_cl_ord_id,
|
| 247 |
+
"cl_ord_id": cl_ord_id,
|
| 248 |
+
"symbol": symbol,
|
| 249 |
+
"side": "buy" if str(side) == "1" else "sell",
|
| 250 |
+
"quantity": new_qty,
|
| 251 |
+
"price": new_price,
|
| 252 |
+
"timestamp": time.time(),
|
| 253 |
+
"source": "fix-oeg"
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
# Track for execution reports
|
| 257 |
+
with order_sessions_lock:
|
| 258 |
+
order_sessions[cl_ord_id] = (sessionID, amend_msg)
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
self.producer.send(Config.ORDERS_TOPIC, value=amend_msg).get(timeout=10)
|
| 262 |
+
print(f"📥 FIX Amend → Kafka: {amend_msg}")
|
| 263 |
+
except Exception as e:
|
| 264 |
+
print(f"FIX-OEG: Failed to send amend: {e}")
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def start_trades_consumer(app):
|
| 268 |
+
"""Consume trades from Kafka and send execution reports to FIX clients."""
|
| 269 |
+
try:
|
| 270 |
+
consumer = create_consumer(
|
| 271 |
+
topics=[Config.TRADES_TOPIC],
|
| 272 |
+
group_id="fix-oeg-execreports",
|
| 273 |
+
component_name="FIX-OEG-TradesConsumer"
|
| 274 |
+
)
|
| 275 |
+
except Exception as e:
|
| 276 |
+
print(f"FIX-OEG: Failed to create trades consumer: {e}")
|
| 277 |
+
return
|
| 278 |
+
|
| 279 |
+
for msg in consumer:
|
| 280 |
+
try:
|
| 281 |
+
trade = msg.value
|
| 282 |
+
buy_id = trade.get('buy_id')
|
| 283 |
+
sell_id = trade.get('sell_id')
|
| 284 |
+
price = trade.get('price', 0)
|
| 285 |
+
qty = trade.get('quantity', 0)
|
| 286 |
+
|
| 287 |
+
# Send fill report to buyer
|
| 288 |
+
with order_sessions_lock:
|
| 289 |
+
if buy_id and buy_id in order_sessions:
|
| 290 |
+
session_id, order = order_sessions[buy_id]
|
| 291 |
+
# Calculate remaining quantity (simplified - would need proper tracking)
|
| 292 |
+
app.sendExecutionReport(
|
| 293 |
+
session_id, order,
|
| 294 |
+
exec_type=fix.ExecType_TRADE,
|
| 295 |
+
ord_status=fix.OrdStatus_FILLED, # Simplified: assume full fill
|
| 296 |
+
exec_qty=qty,
|
| 297 |
+
exec_price=price,
|
| 298 |
+
cum_qty=qty,
|
| 299 |
+
leaves_qty=0
|
| 300 |
+
)
|
| 301 |
+
print(f"FIX-OEG: Sent fill report to buyer {buy_id}")
|
| 302 |
+
|
| 303 |
+
# Send fill report to seller
|
| 304 |
+
if sell_id and sell_id in order_sessions:
|
| 305 |
+
session_id, order = order_sessions[sell_id]
|
| 306 |
+
app.sendExecutionReport(
|
| 307 |
+
session_id, order,
|
| 308 |
+
exec_type=fix.ExecType_TRADE,
|
| 309 |
+
ord_status=fix.OrdStatus_FILLED,
|
| 310 |
+
exec_qty=qty,
|
| 311 |
+
exec_price=price,
|
| 312 |
+
cum_qty=qty,
|
| 313 |
+
leaves_qty=0
|
| 314 |
+
)
|
| 315 |
+
print(f"FIX-OEG: Sent fill report to seller {sell_id}")
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
print(f"FIX-OEG: Error processing trade: {e}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
if __name__ == "__main__":
|
| 322 |
+
settings = fix.SessionSettings("fix_server.cfg")
|
| 323 |
+
app = Application()
|
| 324 |
+
store_factory = fix.FileStoreFactory(settings)
|
| 325 |
+
log_factory = fix.FileLogFactory(settings)
|
| 326 |
+
acceptor = fix.SocketAcceptor(app, store_factory, settings, log_factory)
|
| 327 |
+
acceptor.start()
|
| 328 |
+
print("FIX OEG Gateway listening on 0.0.0.0:5001 (FIX 4.4)")
|
| 329 |
+
|
| 330 |
+
# Start trades consumer for execution reports
|
| 331 |
+
trades_thread = threading.Thread(target=start_trades_consumer, args=(app,), daemon=True)
|
| 332 |
+
trades_thread.start()
|
| 333 |
+
print("FIX-OEG: Started trades consumer for execution reports")
|
| 334 |
+
|
| 335 |
+
try:
|
| 336 |
+
while True:
|
| 337 |
+
time.sleep(1)
|
| 338 |
+
except KeyboardInterrupt:
|
| 339 |
+
pass
|
| 340 |
+
finally:
|
| 341 |
+
acceptor.stop()
|
fix_oeg/fix_server.cfg
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[DEFAULT]
|
| 2 |
+
ConnectionType=acceptor
|
| 3 |
+
SocketAcceptPort=5001
|
| 4 |
+
SocketReuseAddress=Y
|
| 5 |
+
StartTime=00:00:00
|
| 6 |
+
EndTime=23:59:59
|
| 7 |
+
UseLocalTime=Y
|
| 8 |
+
UseDataDictionary=Y
|
| 9 |
+
DataDictionary=FIX44.xml
|
| 10 |
+
FileStorePath=store
|
| 11 |
+
FileLogPath=log
|
| 12 |
+
HeartBtInt=30
|
| 13 |
+
ValidateUserDefinedFields=N
|
| 14 |
+
ResetOnLogon=Y
|
| 15 |
+
ResetOnLogout=Y
|
| 16 |
+
ResetOnDisconnect=Y
|
| 17 |
+
|
| 18 |
+
[SESSION]
|
| 19 |
+
BeginString=FIX.4.4
|
| 20 |
+
SenderCompID=FIX_SERVER
|
| 21 |
+
TargetCompID=CLIENT1
|
| 22 |
+
|
| 23 |
+
[SESSION]
|
| 24 |
+
BeginString=FIX.4.4
|
| 25 |
+
SenderCompID=FIX_SERVER
|
| 26 |
+
TargetCompID=CLIENT2
|
| 27 |
+
|
fix_oeg/requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
quickfix
|
| 2 |
+
kafka-python
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY frontend.py .
|
| 6 |
+
COPY templates templates
|
| 7 |
+
CMD ["python", "frontend.py"]
|
frontend/frontend.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.path.insert(0, "/app")
|
| 3 |
+
|
| 4 |
+
from flask import Flask, request, jsonify, render_template
|
| 5 |
+
import json, time, os
|
| 6 |
+
|
| 7 |
+
from shared.config import Config
|
| 8 |
+
from shared.kafka_utils import create_producer
|
| 9 |
+
|
| 10 |
+
app = Flask(__name__, template_folder='templates')
|
| 11 |
+
|
| 12 |
+
producer = create_producer(component_name="Frontend")
|
| 13 |
+
|
| 14 |
+
@app.route('/')
|
| 15 |
+
def index():
|
| 16 |
+
return render_template('index.html')
|
| 17 |
+
|
| 18 |
+
@app.route('/health')
|
| 19 |
+
def health():
|
| 20 |
+
"""Health check endpoint."""
|
| 21 |
+
import requests
|
| 22 |
+
status = {
|
| 23 |
+
"status": "healthy",
|
| 24 |
+
"service": "frontend",
|
| 25 |
+
"timestamp": time.time()
|
| 26 |
+
}
|
| 27 |
+
# Check Kafka producer
|
| 28 |
+
try:
|
| 29 |
+
producer.flush(timeout=1)
|
| 30 |
+
status["kafka"] = "connected"
|
| 31 |
+
except Exception as e:
|
| 32 |
+
status["kafka"] = f"error: {e}"
|
| 33 |
+
status["status"] = "degraded"
|
| 34 |
+
|
| 35 |
+
# Check matcher connectivity
|
| 36 |
+
try:
|
| 37 |
+
r = requests.get(f"{Config.MATCHER_URL}/health", timeout=2)
|
| 38 |
+
if r.status_code == 200:
|
| 39 |
+
status["matcher"] = "connected"
|
| 40 |
+
else:
|
| 41 |
+
status["matcher"] = f"error: status {r.status_code}"
|
| 42 |
+
except Exception as e:
|
| 43 |
+
status["matcher"] = f"error: {e}"
|
| 44 |
+
|
| 45 |
+
return jsonify(status)
|
| 46 |
+
|
| 47 |
+
@app.route('/order', methods=['POST'])
|
| 48 |
+
def send_order():
|
| 49 |
+
if request.is_json:
|
| 50 |
+
data = request.json
|
| 51 |
+
else:
|
| 52 |
+
data = {
|
| 53 |
+
'order_id': request.form.get('order_id') or str(time.time_ns()),
|
| 54 |
+
'symbol': request.form.get('symbol'),
|
| 55 |
+
'type': request.form.get('type'),
|
| 56 |
+
'quantity': int(request.form.get('quantity')),
|
| 57 |
+
'price': float(request.form.get('price')),
|
| 58 |
+
'timestamp': time.time()
|
| 59 |
+
}
|
| 60 |
+
producer.send(Config.ORDERS_TOPIC, value=data)
|
| 61 |
+
producer.flush()
|
| 62 |
+
return jsonify({'status':'sent','data':data})
|
| 63 |
+
|
| 64 |
+
@app.route('/book')
|
| 65 |
+
def book():
|
| 66 |
+
import requests
|
| 67 |
+
r = requests.get(f"{Config.MATCHER_URL}/book", timeout=2)
|
| 68 |
+
return (r.text, r.status_code, {'Content-Type': 'application/json'})
|
| 69 |
+
|
| 70 |
+
@app.route('/trades')
|
| 71 |
+
def trades():
|
| 72 |
+
import requests
|
| 73 |
+
r = requests.get(f"{Config.MATCHER_URL}/trades", timeout=2)
|
| 74 |
+
return (r.text, r.status_code, {'Content-Type': 'application/json'})
|
| 75 |
+
|
| 76 |
+
if __name__ == '__main__':
|
| 77 |
+
app.run(host='0.0.0.0', port=5000)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.route('/submit', methods=['POST'])
|
| 81 |
+
def submit_order():
|
| 82 |
+
from flask import request
|
| 83 |
+
data = None
|
| 84 |
+
try:
|
| 85 |
+
data = request.get_json(force=True)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return jsonify({'status':'badjson','error': str(e)}), 400
|
| 88 |
+
if not data:
|
| 89 |
+
return jsonify({'status':'no_data'}), 400
|
| 90 |
+
try:
|
| 91 |
+
fut = producer.send(Config.ORDERS_TOPIC, value=data)
|
| 92 |
+
meta = fut.get(timeout=10)
|
| 93 |
+
producer.flush()
|
| 94 |
+
app.logger.info("Produced order %s -> topic=%s partition=%s offset=%s", data.get('order_id'), meta.topic, meta.partition, meta.offset)
|
| 95 |
+
return jsonify({'status':'sent','data':data})
|
| 96 |
+
except Exception as e:
|
| 97 |
+
app.logger.exception("Failed producing order: %s", e)
|
| 98 |
+
return jsonify({'status':'failed','error':str(e)}), 500
|
frontend/requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==2.2.5
|
| 2 |
+
kafka-python==2.0.2
|
| 3 |
+
requests==2.31.0
|
| 4 |
+
|
frontend/templates/index.html
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>Order Entry</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; }
|
| 8 |
+
h1 { display: flex; align-items: center; gap: 15px; margin-bottom: 20px; }
|
| 9 |
+
h2 { margin: 5px 0 10px; font-size: 16px; }
|
| 10 |
+
|
| 11 |
+
.panel {
|
| 12 |
+
background: #fff;
|
| 13 |
+
border-radius: 8px;
|
| 14 |
+
padding: 15px;
|
| 15 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 16 |
+
margin-bottom: 20px;
|
| 17 |
+
}
|
| 18 |
+
.grid { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; }
|
| 19 |
+
.panel-inner {
|
| 20 |
+
background: #fff;
|
| 21 |
+
border-radius: 8px;
|
| 22 |
+
padding: 15px;
|
| 23 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 24 |
+
display: flex;
|
| 25 |
+
flex-direction: column;
|
| 26 |
+
min-height: 350px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* Status badge */
|
| 30 |
+
.status {
|
| 31 |
+
display: inline-flex;
|
| 32 |
+
align-items: center;
|
| 33 |
+
gap: 6px;
|
| 34 |
+
padding: 4px 12px;
|
| 35 |
+
border-radius: 20px;
|
| 36 |
+
font-size: 12px;
|
| 37 |
+
font-weight: bold;
|
| 38 |
+
}
|
| 39 |
+
.status .dot { width: 10px; height: 10px; border-radius: 50%; }
|
| 40 |
+
.status.connected { background: #d4edda; color: #155724; }
|
| 41 |
+
.status.connected .dot { background: #28a745; }
|
| 42 |
+
.status.disconnected { background: #f8d7da; color: #721c24; }
|
| 43 |
+
.status.disconnected .dot { background: #dc3545; }
|
| 44 |
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
| 45 |
+
|
| 46 |
+
/* Order form grid */
|
| 47 |
+
.order-grid {
|
| 48 |
+
display: grid;
|
| 49 |
+
grid-template-columns: 1fr 1fr;
|
| 50 |
+
gap: 12px;
|
| 51 |
+
}
|
| 52 |
+
.form-group { }
|
| 53 |
+
.form-group.full { grid-column: 1 / -1; }
|
| 54 |
+
.form-group label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
|
| 55 |
+
.form-group input,
|
| 56 |
+
.form-group select {
|
| 57 |
+
width: 100%;
|
| 58 |
+
padding: 8px;
|
| 59 |
+
border: 1px solid #ccc;
|
| 60 |
+
border-radius: 4px;
|
| 61 |
+
font-size: 13px;
|
| 62 |
+
box-sizing: border-box;
|
| 63 |
+
}
|
| 64 |
+
.btn-send {
|
| 65 |
+
width: 100%;
|
| 66 |
+
padding: 10px;
|
| 67 |
+
background: #2196F3;
|
| 68 |
+
color: #fff;
|
| 69 |
+
border: none;
|
| 70 |
+
border-radius: 4px;
|
| 71 |
+
cursor: pointer;
|
| 72 |
+
font-size: 14px;
|
| 73 |
+
font-weight: bold;
|
| 74 |
+
}
|
| 75 |
+
.btn-send:hover { background: #1976D2; }
|
| 76 |
+
|
| 77 |
+
/* Order feedback */
|
| 78 |
+
#order-status {
|
| 79 |
+
padding: 8px 12px;
|
| 80 |
+
border-radius: 4px;
|
| 81 |
+
font-size: 13px;
|
| 82 |
+
margin-top: 10px;
|
| 83 |
+
display: none;
|
| 84 |
+
}
|
| 85 |
+
#order-status.success { background: #d4edda; color: #155724; display: block; }
|
| 86 |
+
#order-status.error { background: #f8d7da; color: #721c24; display: block; }
|
| 87 |
+
|
| 88 |
+
/* Tables */
|
| 89 |
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 90 |
+
th, td { border: 1px solid #ccc; padding: 5px 8px; text-align: center; }
|
| 91 |
+
th { background: #f0f0f0; font-weight: bold; }
|
| 92 |
+
tbody tr:hover { background: #f5f5f5; }
|
| 93 |
+
.bid { color: #2e7d32; font-weight: bold; }
|
| 94 |
+
.ask { color: #c62828; font-weight: bold; }
|
| 95 |
+
|
| 96 |
+
/* Highlight animation for new rows */
|
| 97 |
+
@keyframes highlight { from { background: #c8e6c9; } to { background: transparent; } }
|
| 98 |
+
.new-row { animation: highlight 2s ease-out; }
|
| 99 |
+
|
| 100 |
+
.refresh-info { font-size: 11px; color: #999; text-align: right; margin-top: 6px; }
|
| 101 |
+
</style>
|
| 102 |
+
</head>
|
| 103 |
+
<body>
|
| 104 |
+
|
| 105 |
+
<h1>
|
| 106 |
+
Order Entry
|
| 107 |
+
<span id="status-badge" class="status connected">
|
| 108 |
+
<span class="dot"></span>
|
| 109 |
+
<span id="status-text">Live</span>
|
| 110 |
+
</span>
|
| 111 |
+
</h1>
|
| 112 |
+
|
| 113 |
+
<!-- Order Entry (full width) -->
|
| 114 |
+
<div class="panel">
|
| 115 |
+
<h2>Place Order</h2>
|
| 116 |
+
<form id="orderForm" onsubmit="sendOrder(event)">
|
| 117 |
+
<div class="order-grid">
|
| 118 |
+
<div class="form-group">
|
| 119 |
+
<label>Order ID <span style="color:#bbb;">(optional)</span></label>
|
| 120 |
+
<input name="order_id" placeholder="Auto-generated">
|
| 121 |
+
</div>
|
| 122 |
+
<div class="form-group">
|
| 123 |
+
<label>Symbol</label>
|
| 124 |
+
<input name="symbol" value="FOO">
|
| 125 |
+
</div>
|
| 126 |
+
<div class="form-group">
|
| 127 |
+
<label>Side</label>
|
| 128 |
+
<select name="type">
|
| 129 |
+
<option value="buy">BUY</option>
|
| 130 |
+
<option value="sell">SELL</option>
|
| 131 |
+
</select>
|
| 132 |
+
</div>
|
| 133 |
+
<div class="form-group">
|
| 134 |
+
<label>Quantity</label>
|
| 135 |
+
<input name="quantity" type="number" value="10">
|
| 136 |
+
</div>
|
| 137 |
+
<div class="form-group full">
|
| 138 |
+
<label>Price</label>
|
| 139 |
+
<input name="price" type="number" step="0.01" value="100.00">
|
| 140 |
+
</div>
|
| 141 |
+
<div class="form-group full">
|
| 142 |
+
<button type="submit" class="btn-send">Send Order</button>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</form>
|
| 146 |
+
<div id="order-status"></div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<!-- Order Book + Trades grid -->
|
| 150 |
+
<div class="grid">
|
| 151 |
+
|
| 152 |
+
<div class="panel-inner">
|
| 153 |
+
<h2>Order Book</h2>
|
| 154 |
+
<div style="flex-grow:1; overflow-y:auto;">
|
| 155 |
+
<table>
|
| 156 |
+
<thead>
|
| 157 |
+
<tr>
|
| 158 |
+
<th class="bid">Bid Qty</th>
|
| 159 |
+
<th class="bid">Bid Price</th>
|
| 160 |
+
<th class="ask">Ask Price</th>
|
| 161 |
+
<th class="ask">Ask Qty</th>
|
| 162 |
+
</tr>
|
| 163 |
+
</thead>
|
| 164 |
+
<tbody id="book-body">
|
| 165 |
+
<tr><td colspan="4" style="color:#999;">Loading...</td></tr>
|
| 166 |
+
</tbody>
|
| 167 |
+
</table>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="refresh-info" id="book-updated">--</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<div class="panel-inner">
|
| 173 |
+
<h2>Trades <span id="trade-count" style="font-size:13px; color:#999; font-weight:normal;"></span></h2>
|
| 174 |
+
<div style="flex-grow:1; overflow-y:auto;">
|
| 175 |
+
<table>
|
| 176 |
+
<thead>
|
| 177 |
+
<tr>
|
| 178 |
+
<th>Symbol</th>
|
| 179 |
+
<th>Qty</th>
|
| 180 |
+
<th>Price</th>
|
| 181 |
+
<th>Value</th>
|
| 182 |
+
<th>Time</th>
|
| 183 |
+
</tr>
|
| 184 |
+
</thead>
|
| 185 |
+
<tbody id="trades-body">
|
| 186 |
+
<tr><td colspan="5" style="color:#999;">Loading...</td></tr>
|
| 187 |
+
</tbody>
|
| 188 |
+
</table>
|
| 189 |
+
</div>
|
| 190 |
+
<div class="refresh-info" id="trades-updated">--</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<script>
|
| 196 |
+
async function sendOrder(evt) {
|
| 197 |
+
evt.preventDefault();
|
| 198 |
+
const form = document.getElementById('orderForm');
|
| 199 |
+
const statusEl = document.getElementById('order-status');
|
| 200 |
+
const data = {
|
| 201 |
+
order_id: form.order_id.value || Date.now().toString(),
|
| 202 |
+
symbol: form.symbol.value,
|
| 203 |
+
type: form.type.value,
|
| 204 |
+
quantity: parseInt(form.quantity.value, 10),
|
| 205 |
+
price: parseFloat(form.price.value),
|
| 206 |
+
timestamp: Date.now() / 1000
|
| 207 |
+
};
|
| 208 |
+
try {
|
| 209 |
+
const r = await fetch('/order', {
|
| 210 |
+
method: 'POST',
|
| 211 |
+
headers: {'Content-Type': 'application/json'},
|
| 212 |
+
body: JSON.stringify(data)
|
| 213 |
+
});
|
| 214 |
+
await r.json();
|
| 215 |
+
statusEl.className = 'success';
|
| 216 |
+
statusEl.textContent = `Order sent: ${data.type.toUpperCase()} ${data.quantity} ${data.symbol} @ ${data.price.toFixed(2)}`;
|
| 217 |
+
setTimeout(() => { statusEl.style.display = 'none'; }, 4000);
|
| 218 |
+
loadBook();
|
| 219 |
+
} catch(e) {
|
| 220 |
+
statusEl.className = 'error';
|
| 221 |
+
statusEl.textContent = 'Error sending order: ' + e.message;
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function renderBook(b) {
|
| 226 |
+
const bids = (b.bids || []).sort((a, c) => (c.price || 0) - (a.price || 0));
|
| 227 |
+
const asks = (b.asks || []).sort((a, c) => (a.price || 0) - (c.price || 0));
|
| 228 |
+
const tbody = document.getElementById('book-body');
|
| 229 |
+
tbody.innerHTML = '';
|
| 230 |
+
const maxRows = Math.max(bids.length, asks.length);
|
| 231 |
+
if (maxRows === 0) {
|
| 232 |
+
tbody.innerHTML = '<tr><td colspan="4" style="color:#999; text-align:center;">No resting orders</td></tr>';
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
for (let i = 0; i < Math.min(maxRows, 20); i++) {
|
| 236 |
+
const bid = bids[i] || {};
|
| 237 |
+
const ask = asks[i] || {};
|
| 238 |
+
const row = document.createElement('tr');
|
| 239 |
+
row.innerHTML = `
|
| 240 |
+
<td class="bid">${bid.quantity != null ? bid.quantity : ''}</td>
|
| 241 |
+
<td class="bid">${bid.price != null ? Number(bid.price).toFixed(2) : ''}</td>
|
| 242 |
+
<td class="ask">${ask.price != null ? Number(ask.price).toFixed(2) : ''}</td>
|
| 243 |
+
<td class="ask">${ask.quantity != null ? ask.quantity : ''}</td>
|
| 244 |
+
`;
|
| 245 |
+
tbody.appendChild(row);
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
let prevTradeCount = 0;
|
| 250 |
+
function renderTrades(trades) {
|
| 251 |
+
const tbody = document.getElementById('trades-body');
|
| 252 |
+
const isNew = trades.length > prevTradeCount;
|
| 253 |
+
tbody.innerHTML = '';
|
| 254 |
+
if (!trades || trades.length === 0) {
|
| 255 |
+
tbody.innerHTML = '<tr><td colspan="5" style="color:#999; text-align:center;">No trades yet</td></tr>';
|
| 256 |
+
document.getElementById('trade-count').textContent = '';
|
| 257 |
+
return;
|
| 258 |
+
}
|
| 259 |
+
for (const t of trades.slice(0, 50)) {
|
| 260 |
+
const qty = t.quantity || t.qty || 0;
|
| 261 |
+
const price = Number(t.price || 0);
|
| 262 |
+
const value = (qty * price).toFixed(2);
|
| 263 |
+
const ts = t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : '-';
|
| 264 |
+
const row = document.createElement('tr');
|
| 265 |
+
if (isNew) row.className = 'new-row';
|
| 266 |
+
row.innerHTML = `
|
| 267 |
+
<td><strong>${t.symbol || '?'}</strong></td>
|
| 268 |
+
<td>${qty}</td>
|
| 269 |
+
<td>${price.toFixed(2)}</td>
|
| 270 |
+
<td style="color:#666;">${value}</td>
|
| 271 |
+
<td style="font-size:11px;">${ts}</td>
|
| 272 |
+
`;
|
| 273 |
+
tbody.appendChild(row);
|
| 274 |
+
}
|
| 275 |
+
document.getElementById('trade-count').textContent = `(${trades.length})`;
|
| 276 |
+
prevTradeCount = trades.length;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
async function loadBook() {
|
| 280 |
+
const now = new Date().toLocaleTimeString();
|
| 281 |
+
const badge = document.getElementById('status-badge');
|
| 282 |
+
|
| 283 |
+
try {
|
| 284 |
+
const r = await fetch('/book');
|
| 285 |
+
const b = await r.json();
|
| 286 |
+
renderBook(b);
|
| 287 |
+
document.getElementById('book-updated').textContent = 'Updated: ' + now;
|
| 288 |
+
badge.className = 'status connected';
|
| 289 |
+
document.getElementById('status-text').textContent = 'Live';
|
| 290 |
+
} catch(e) {
|
| 291 |
+
document.getElementById('book-body').innerHTML =
|
| 292 |
+
'<tr><td colspan="4" style="color:red; text-align:center;">Error loading book</td></tr>';
|
| 293 |
+
badge.className = 'status disconnected';
|
| 294 |
+
document.getElementById('status-text').textContent = 'Disconnected';
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
try {
|
| 298 |
+
const r2 = await fetch('/trades');
|
| 299 |
+
const t = await r2.json();
|
| 300 |
+
renderTrades(Array.isArray(t) ? t : (t.trades || []));
|
| 301 |
+
document.getElementById('trades-updated').textContent = 'Updated: ' + now;
|
| 302 |
+
} catch(e) {
|
| 303 |
+
document.getElementById('trades-body').innerHTML =
|
| 304 |
+
'<tr><td colspan="5" style="color:red; text-align:center;">Error loading trades</td></tr>';
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
setInterval(loadBook, 2000);
|
| 309 |
+
window.onload = loadBook;
|
| 310 |
+
</script>
|
| 311 |
+
</body>
|
| 312 |
+
</html>
|
matcher/Dockerfile
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY matcher.py database.py ./
|
| 6 |
+
CMD ["python", "matcher.py"]
|
matcher/database.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite persistence layer for the matcher service."""
|
| 2 |
+
|
| 3 |
+
import sqlite3
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
DB_PATH = os.getenv("DB_PATH", "/app/data/matcher.db")
|
| 9 |
+
|
| 10 |
+
_local = threading.local()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_connection():
|
| 14 |
+
"""Get a thread-local database connection."""
|
| 15 |
+
if not hasattr(_local, "conn") or _local.conn is None:
|
| 16 |
+
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
| 17 |
+
_local.conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 18 |
+
_local.conn.row_factory = sqlite3.Row
|
| 19 |
+
return _local.conn
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def init_db():
|
| 23 |
+
"""Initialize database schema."""
|
| 24 |
+
conn = get_connection()
|
| 25 |
+
cursor = conn.cursor()
|
| 26 |
+
|
| 27 |
+
cursor.executescript("""
|
| 28 |
+
CREATE TABLE IF NOT EXISTS trades (
|
| 29 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 30 |
+
symbol TEXT NOT NULL,
|
| 31 |
+
price REAL NOT NULL,
|
| 32 |
+
quantity INTEGER NOT NULL,
|
| 33 |
+
buy_order_id TEXT,
|
| 34 |
+
sell_order_id TEXT,
|
| 35 |
+
timestamp REAL NOT NULL,
|
| 36 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
CREATE TABLE IF NOT EXISTS order_book (
|
| 40 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 41 |
+
cl_ord_id TEXT UNIQUE,
|
| 42 |
+
symbol TEXT NOT NULL,
|
| 43 |
+
side TEXT NOT NULL,
|
| 44 |
+
price REAL,
|
| 45 |
+
quantity INTEGER NOT NULL,
|
| 46 |
+
remaining_qty INTEGER NOT NULL,
|
| 47 |
+
status TEXT DEFAULT 'OPEN',
|
| 48 |
+
timestamp REAL NOT NULL,
|
| 49 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
|
| 53 |
+
CREATE INDEX IF NOT EXISTS idx_trades_timestamp ON trades(timestamp);
|
| 54 |
+
CREATE INDEX IF NOT EXISTS idx_orderbook_symbol_side ON order_book(symbol, side);
|
| 55 |
+
CREATE INDEX IF NOT EXISTS idx_orderbook_status ON order_book(status);
|
| 56 |
+
CREATE INDEX IF NOT EXISTS idx_orderbook_cl_ord_id ON order_book(cl_ord_id);
|
| 57 |
+
""")
|
| 58 |
+
|
| 59 |
+
conn.commit()
|
| 60 |
+
print(f"Database initialized at {DB_PATH}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def save_trade(trade: dict):
|
| 64 |
+
"""Persist a trade to the database."""
|
| 65 |
+
conn = get_connection()
|
| 66 |
+
cursor = conn.cursor()
|
| 67 |
+
cursor.execute("""
|
| 68 |
+
INSERT INTO trades (symbol, price, quantity, buy_order_id, sell_order_id, timestamp)
|
| 69 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 70 |
+
""", (
|
| 71 |
+
trade.get("symbol"),
|
| 72 |
+
trade.get("price"),
|
| 73 |
+
trade.get("quantity"),
|
| 74 |
+
trade.get("buy_id"),
|
| 75 |
+
trade.get("sell_id"),
|
| 76 |
+
trade.get("timestamp", time.time())
|
| 77 |
+
))
|
| 78 |
+
conn.commit()
|
| 79 |
+
return cursor.lastrowid
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def get_trades(symbol: str = None, limit: int = 200, offset: int = 0):
|
| 83 |
+
"""Retrieve trades from database."""
|
| 84 |
+
conn = get_connection()
|
| 85 |
+
cursor = conn.cursor()
|
| 86 |
+
|
| 87 |
+
if symbol:
|
| 88 |
+
cursor.execute("""
|
| 89 |
+
SELECT * FROM trades
|
| 90 |
+
WHERE symbol = ?
|
| 91 |
+
ORDER BY timestamp DESC
|
| 92 |
+
LIMIT ? OFFSET ?
|
| 93 |
+
""", (symbol, limit, offset))
|
| 94 |
+
else:
|
| 95 |
+
cursor.execute("""
|
| 96 |
+
SELECT * FROM trades
|
| 97 |
+
ORDER BY timestamp DESC
|
| 98 |
+
LIMIT ? OFFSET ?
|
| 99 |
+
""", (limit, offset))
|
| 100 |
+
|
| 101 |
+
rows = cursor.fetchall()
|
| 102 |
+
return [dict(row) for row in rows]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_trade_count(symbol: str = None):
|
| 106 |
+
"""Get total trade count."""
|
| 107 |
+
conn = get_connection()
|
| 108 |
+
cursor = conn.cursor()
|
| 109 |
+
|
| 110 |
+
if symbol:
|
| 111 |
+
cursor.execute("SELECT COUNT(*) FROM trades WHERE symbol = ?", (symbol,))
|
| 112 |
+
else:
|
| 113 |
+
cursor.execute("SELECT COUNT(*) FROM trades")
|
| 114 |
+
|
| 115 |
+
return cursor.fetchone()[0]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def save_order(order: dict):
|
| 119 |
+
"""Save or update an order in the order book."""
|
| 120 |
+
conn = get_connection()
|
| 121 |
+
cursor = conn.cursor()
|
| 122 |
+
|
| 123 |
+
cl_ord_id = order.get("cl_ord_id")
|
| 124 |
+
if not cl_ord_id:
|
| 125 |
+
cl_ord_id = f"gen-{time.time_ns()}"
|
| 126 |
+
|
| 127 |
+
cursor.execute("""
|
| 128 |
+
INSERT INTO order_book (cl_ord_id, symbol, side, price, quantity, remaining_qty, status, timestamp)
|
| 129 |
+
VALUES (?, ?, ?, ?, ?, ?, 'OPEN', ?)
|
| 130 |
+
ON CONFLICT(cl_ord_id) DO UPDATE SET
|
| 131 |
+
remaining_qty = excluded.remaining_qty,
|
| 132 |
+
status = CASE WHEN excluded.remaining_qty = 0 THEN 'FILLED' ELSE status END
|
| 133 |
+
""", (
|
| 134 |
+
cl_ord_id,
|
| 135 |
+
order.get("symbol"),
|
| 136 |
+
order.get("side"),
|
| 137 |
+
order.get("price"),
|
| 138 |
+
order.get("quantity"),
|
| 139 |
+
order.get("quantity"), # remaining_qty starts as full quantity
|
| 140 |
+
order.get("timestamp", time.time())
|
| 141 |
+
))
|
| 142 |
+
conn.commit()
|
| 143 |
+
return cl_ord_id
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def update_order_quantity(cl_ord_id: str, remaining_qty: int):
|
| 147 |
+
"""Update remaining quantity for an order."""
|
| 148 |
+
conn = get_connection()
|
| 149 |
+
cursor = conn.cursor()
|
| 150 |
+
|
| 151 |
+
status = "FILLED" if remaining_qty == 0 else "OPEN"
|
| 152 |
+
cursor.execute("""
|
| 153 |
+
UPDATE order_book
|
| 154 |
+
SET remaining_qty = ?, status = ?
|
| 155 |
+
WHERE cl_ord_id = ?
|
| 156 |
+
""", (remaining_qty, status, cl_ord_id))
|
| 157 |
+
conn.commit()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def cancel_order(cl_ord_id: str):
|
| 161 |
+
"""Mark an order as cancelled."""
|
| 162 |
+
conn = get_connection()
|
| 163 |
+
cursor = conn.cursor()
|
| 164 |
+
cursor.execute("""
|
| 165 |
+
UPDATE order_book SET status = 'CANCELLED' WHERE cl_ord_id = ?
|
| 166 |
+
""", (cl_ord_id,))
|
| 167 |
+
conn.commit()
|
| 168 |
+
return cursor.rowcount > 0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def get_open_orders(symbol: str = None, side: str = None):
|
| 172 |
+
"""Get all open orders, optionally filtered by symbol and side."""
|
| 173 |
+
conn = get_connection()
|
| 174 |
+
cursor = conn.cursor()
|
| 175 |
+
|
| 176 |
+
query = "SELECT * FROM order_book WHERE status = 'OPEN'"
|
| 177 |
+
params = []
|
| 178 |
+
|
| 179 |
+
if symbol:
|
| 180 |
+
query += " AND symbol = ?"
|
| 181 |
+
params.append(symbol)
|
| 182 |
+
if side:
|
| 183 |
+
query += " AND side = ?"
|
| 184 |
+
params.append(side)
|
| 185 |
+
|
| 186 |
+
query += " ORDER BY timestamp ASC"
|
| 187 |
+
cursor.execute(query, params)
|
| 188 |
+
|
| 189 |
+
rows = cursor.fetchall()
|
| 190 |
+
return [dict(row) for row in rows]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def load_order_books():
|
| 194 |
+
"""Load all open orders grouped by symbol for matcher initialization."""
|
| 195 |
+
conn = get_connection()
|
| 196 |
+
cursor = conn.cursor()
|
| 197 |
+
|
| 198 |
+
cursor.execute("""
|
| 199 |
+
SELECT * FROM order_book
|
| 200 |
+
WHERE status = 'OPEN'
|
| 201 |
+
ORDER BY symbol, side, timestamp
|
| 202 |
+
""")
|
| 203 |
+
|
| 204 |
+
books = {}
|
| 205 |
+
for row in cursor.fetchall():
|
| 206 |
+
order = dict(row)
|
| 207 |
+
symbol = order["symbol"]
|
| 208 |
+
side = order["side"]
|
| 209 |
+
|
| 210 |
+
if symbol not in books:
|
| 211 |
+
books[symbol] = {"bids": [], "asks": []}
|
| 212 |
+
|
| 213 |
+
# Convert DB format to matcher format
|
| 214 |
+
matcher_order = {
|
| 215 |
+
"cl_ord_id": order["cl_ord_id"],
|
| 216 |
+
"symbol": symbol,
|
| 217 |
+
"side": side,
|
| 218 |
+
"price": order["price"],
|
| 219 |
+
"quantity": order["remaining_qty"],
|
| 220 |
+
"timestamp": order["timestamp"]
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if side == "BUY":
|
| 224 |
+
books[symbol]["bids"].append(matcher_order)
|
| 225 |
+
else:
|
| 226 |
+
books[symbol]["asks"].append(matcher_order)
|
| 227 |
+
|
| 228 |
+
return books
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def delete_filled_orders(older_than_days: int = 7):
|
| 232 |
+
"""Clean up old filled orders."""
|
| 233 |
+
conn = get_connection()
|
| 234 |
+
cursor = conn.cursor()
|
| 235 |
+
cutoff = time.time() - (older_than_days * 24 * 60 * 60)
|
| 236 |
+
cursor.execute("""
|
| 237 |
+
DELETE FROM order_book
|
| 238 |
+
WHERE status IN ('FILLED', 'CANCELLED')
|
| 239 |
+
AND timestamp < ?
|
| 240 |
+
""", (cutoff,))
|
| 241 |
+
conn.commit()
|
| 242 |
+
return cursor.rowcount
|
matcher/matcher - Copy.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading, time, json
|
| 2 |
+
from flask import Flask, jsonify
|
| 3 |
+
from kafka import KafkaConsumer, KafkaProducer
|
| 4 |
+
from kafka.errors import NoBrokersAvailable
|
| 5 |
+
|
| 6 |
+
BOOTSTRAP = 'kafka:9092'
|
| 7 |
+
|
| 8 |
+
import json, sys, datetime
|
| 9 |
+
def jlog(event_type, payload):
|
| 10 |
+
out = {
|
| 11 |
+
"ts": datetime.datetime.utcnow().isoformat() + "Z",
|
| 12 |
+
"component": "matcher",
|
| 13 |
+
"event": event_type,
|
| 14 |
+
"payload": payload
|
| 15 |
+
}
|
| 16 |
+
sys.stdout.write(json.dumps(out, default=str) + "\n")
|
| 17 |
+
sys.stdout.flush()
|
| 18 |
+
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
|
| 21 |
+
order_book = {'buy': [], 'sell': []}
|
| 22 |
+
trades = []
|
| 23 |
+
producer = None
|
| 24 |
+
|
| 25 |
+
def create_kafka_producer(retries=20, delay=2):
|
| 26 |
+
global producer
|
| 27 |
+
for i in range(retries):
|
| 28 |
+
try:
|
| 29 |
+
producer = KafkaProducer(
|
| 30 |
+
bootstrap_servers=BOOTSTRAP,
|
| 31 |
+
value_serializer=lambda v: json.dumps(v).encode('utf-8')
|
| 32 |
+
)
|
| 33 |
+
print('Producer connected')
|
| 34 |
+
return
|
| 35 |
+
except NoBrokersAvailable:
|
| 36 |
+
print('Producer: Kafka not ready, retry', i)
|
| 37 |
+
time.sleep(delay)
|
| 38 |
+
raise RuntimeError('Producer: cannot connect to Kafka')
|
| 39 |
+
|
| 40 |
+
def create_kafka_consumer(topic='orders', retries=20, delay=2):
|
| 41 |
+
for i in range(retries):
|
| 42 |
+
try:
|
| 43 |
+
consumer = KafkaConsumer(
|
| 44 |
+
topic,
|
| 45 |
+
bootstrap_servers=BOOTSTRAP,
|
| 46 |
+
value_deserializer=lambda m: json.loads(m.decode('utf-8')),
|
| 47 |
+
auto_offset_reset='earliest',
|
| 48 |
+
enable_auto_commit=True,
|
| 49 |
+
group_id='matcher-group'
|
| 50 |
+
)
|
| 51 |
+
print('Consumer connected to topic', topic)
|
| 52 |
+
return consumer
|
| 53 |
+
except NoBrokersAvailable:
|
| 54 |
+
print('Consumer: Kafka not ready, retry', i)
|
| 55 |
+
time.sleep(delay)
|
| 56 |
+
raise RuntimeError('Consumer: cannot connect to Kafka')
|
| 57 |
+
|
| 58 |
+
def find_match(order):
|
| 59 |
+
side = order['type']
|
| 60 |
+
opp = 'sell' if side == 'buy' else 'buy'
|
| 61 |
+
candidates = [o for o in order_book[opp] if o['symbol'] == order['symbol'] and o['quantity'] > 0]
|
| 62 |
+
if not candidates:
|
| 63 |
+
return None
|
| 64 |
+
# best-price matching: for buy -> lowest sell price; for sell -> highest buy price
|
| 65 |
+
if side == 'buy':
|
| 66 |
+
valid = [o for o in candidates if o['price'] <= order['price']]
|
| 67 |
+
if not valid: return None
|
| 68 |
+
best_price = min(o['price'] for o in valid)
|
| 69 |
+
bests = [o for o in valid if o['price'] == best_price]
|
| 70 |
+
return min(bests, key=lambda x: x['timestamp'])
|
| 71 |
+
else:
|
| 72 |
+
valid = [o for o in candidates if o['price'] >= order['price']]
|
| 73 |
+
if not valid: return None
|
| 74 |
+
best_price = max(o['price'] for o in valid)
|
| 75 |
+
bests = [o for o in valid if o['price'] == best_price]
|
| 76 |
+
return min(bests, key=lambda x: x['timestamp'])
|
| 77 |
+
|
| 78 |
+
def process_order(order):
|
| 79 |
+
print('Processing order:', order)
|
| 80 |
+
while order['quantity'] > 0:
|
| 81 |
+
matched = find_match(order)
|
| 82 |
+
if not matched:
|
| 83 |
+
order_book[order['type']].append(order.copy())
|
| 84 |
+
print('Added to book:', order)
|
| 85 |
+
return
|
| 86 |
+
traded_qty = min(order['quantity'], matched['quantity'])
|
| 87 |
+
trade_price = matched['price']
|
| 88 |
+
trade = {
|
| 89 |
+
'buy_order_id': order['order_id'] if order['type']=='buy' else matched['order_id'],
|
| 90 |
+
'sell_order_id': matched['order_id'] if order['type']=='buy' else order['order_id'],
|
| 91 |
+
'symbol': order['symbol'],
|
| 92 |
+
'price': trade_price,
|
| 93 |
+
'quantity': traded_qty,
|
| 94 |
+
'timestamp': time.time()
|
| 95 |
+
}
|
| 96 |
+
trades.append(trade)
|
| 97 |
+
if producer:
|
| 98 |
+
producer.send('trades', value=trade)
|
| 99 |
+
producer.flush()
|
| 100 |
+
print('TRADE:', trade)
|
| 101 |
+
order['quantity'] -= traded_qty
|
| 102 |
+
matched['quantity'] -= traded_qty
|
| 103 |
+
if matched['quantity'] == 0:
|
| 104 |
+
try:
|
| 105 |
+
order_book['sell' if order['type']=='buy' else 'buy'].remove(matched)
|
| 106 |
+
print('Removed matched from book:', matched)
|
| 107 |
+
except ValueError:
|
| 108 |
+
pass
|
| 109 |
+
|
| 110 |
+
def consumer_loop():
|
| 111 |
+
consumer = create_kafka_consumer('orders')
|
| 112 |
+
for msg in consumer:
|
| 113 |
+
try:
|
| 114 |
+
order = msg.value
|
| 115 |
+
order['price'] = float(order['price'])
|
| 116 |
+
order['quantity'] = int(order['quantity'])
|
| 117 |
+
order.setdefault('timestamp', time.time())
|
| 118 |
+
process_order(order)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print('Error processing message:', e)
|
| 121 |
+
|
| 122 |
+
@app.route('/book')
|
| 123 |
+
def get_book():
|
| 124 |
+
# present best bids/asks sorted
|
| 125 |
+
return jsonify(order_book)
|
| 126 |
+
|
| 127 |
+
@app.route('/trades')
|
| 128 |
+
def get_trades():
|
| 129 |
+
return jsonify(trades)
|
| 130 |
+
|
| 131 |
+
if __name__ == '__main__':
|
| 132 |
+
create_kafka_producer()
|
| 133 |
+
t = threading.Thread(target=consumer_loop, daemon=True)
|
| 134 |
+
t.start()
|
| 135 |
+
app.run(host='0.0.0.0', port=6000)
|
matcher/matcher.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Simple matcher service:
|
| 3 |
+
- consumes orders from Kafka topic 'orders'
|
| 4 |
+
- maintains in-memory order book per symbol (with SQLite persistence)
|
| 5 |
+
- matches incoming orders using simple price-time priority
|
| 6 |
+
- publishes trades to Kafka 'trades' topic and persists to SQLite
|
| 7 |
+
- exposes HTTP API:
|
| 8 |
+
GET /trades -> recent trades list (from DB with pagination)
|
| 9 |
+
GET /orderbook/<symbol> -> current book for symbol
|
| 10 |
+
"""
|
| 11 |
+
import sys
|
| 12 |
+
sys.path.insert(0, "/app")
|
| 13 |
+
|
| 14 |
+
import json, time, threading
|
| 15 |
+
from collections import defaultdict, deque
|
| 16 |
+
from flask import Flask, jsonify, request
|
| 17 |
+
|
| 18 |
+
from shared.config import Config
|
| 19 |
+
from shared.kafka_utils import create_producer, create_consumer
|
| 20 |
+
from database import init_db, save_trade, get_trades, get_trade_count, save_order, update_order_quantity, load_order_books
|
| 21 |
+
|
| 22 |
+
# Initialize database on startup
|
| 23 |
+
init_db()
|
| 24 |
+
|
| 25 |
+
# Load persisted order books from database
|
| 26 |
+
_loaded_books = load_order_books()
|
| 27 |
+
order_books = defaultdict(lambda: {"bids": [], "asks": []})
|
| 28 |
+
order_books.update(_loaded_books)
|
| 29 |
+
print(f"Loaded {len(_loaded_books)} symbols from database")
|
| 30 |
+
|
| 31 |
+
# In-memory trades cache for fast access (DB is source of truth)
|
| 32 |
+
trades_log = deque(maxlen=200)
|
| 33 |
+
lock = threading.Lock()
|
| 34 |
+
|
| 35 |
+
app = Flask(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def normalize_order(raw):
|
| 39 |
+
# Expecting JSON with keys like symbol, price, quantity, side (BUY/SELL) or FIX-like tags
|
| 40 |
+
if not isinstance(raw, dict):
|
| 41 |
+
return None
|
| 42 |
+
payload = raw.get('payload') or raw.get('message') or raw
|
| 43 |
+
def pick(*keys):
|
| 44 |
+
for k in keys:
|
| 45 |
+
if k in payload:
|
| 46 |
+
return payload.get(k)
|
| 47 |
+
if k in raw:
|
| 48 |
+
return raw.get(k)
|
| 49 |
+
return None
|
| 50 |
+
sym = pick('55','Symbol','symbol')
|
| 51 |
+
side = pick('54','Side','side','type')
|
| 52 |
+
qty = pick('38','OrderQty','quantity','qty')
|
| 53 |
+
px = pick('44','Price','price','px')
|
| 54 |
+
cl = pick('11','ClOrdID','cl_ord_id','id')
|
| 55 |
+
ord_type = pick('40','OrdType','ord_type','order_type') # 1=Market, 2=Limit
|
| 56 |
+
time_in_force = pick('59','TimeInForce','time_in_force','tif') # 0=Day, 1=GTC, 3=IOC, 4=FOK
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
quantity = int(qty) if qty is not None else 0
|
| 60 |
+
except:
|
| 61 |
+
try:
|
| 62 |
+
quantity = int(float(qty))
|
| 63 |
+
except:
|
| 64 |
+
quantity = 0
|
| 65 |
+
try:
|
| 66 |
+
price = float(px) if px is not None else None
|
| 67 |
+
except:
|
| 68 |
+
price = None
|
| 69 |
+
|
| 70 |
+
side_norm = None
|
| 71 |
+
if side is not None:
|
| 72 |
+
s = str(side).lower()
|
| 73 |
+
if s in ('1','buy','b'):
|
| 74 |
+
side_norm = 'BUY'
|
| 75 |
+
elif s in ('2','sell','s'):
|
| 76 |
+
side_norm = 'SELL'
|
| 77 |
+
else:
|
| 78 |
+
# if type field contains 'buy' or 'sell'
|
| 79 |
+
if 'buy' in s:
|
| 80 |
+
side_norm = 'BUY'
|
| 81 |
+
elif 'sell' in s:
|
| 82 |
+
side_norm = 'SELL'
|
| 83 |
+
else:
|
| 84 |
+
side_norm = s.upper()
|
| 85 |
+
|
| 86 |
+
# Normalize order type: 1 or 'market' -> MARKET, 2 or 'limit' -> LIMIT
|
| 87 |
+
ord_type_norm = 'LIMIT' # Default to limit
|
| 88 |
+
if ord_type is not None:
|
| 89 |
+
ot = str(ord_type).lower()
|
| 90 |
+
if ot in ('1', 'market', 'm'):
|
| 91 |
+
ord_type_norm = 'MARKET'
|
| 92 |
+
elif ot in ('2', 'limit', 'l'):
|
| 93 |
+
ord_type_norm = 'LIMIT'
|
| 94 |
+
|
| 95 |
+
# Normalize TimeInForce: 0=Day (default), 1=GTC, 3=IOC, 4=FOK
|
| 96 |
+
tif_norm = 'DAY' # Default
|
| 97 |
+
if time_in_force is not None:
|
| 98 |
+
tif = str(time_in_force).lower()
|
| 99 |
+
if tif in ('0', 'day'):
|
| 100 |
+
tif_norm = 'DAY'
|
| 101 |
+
elif tif in ('1', 'gtc'):
|
| 102 |
+
tif_norm = 'GTC'
|
| 103 |
+
elif tif in ('3', 'ioc'):
|
| 104 |
+
tif_norm = 'IOC'
|
| 105 |
+
elif tif in ('4', 'fok'):
|
| 106 |
+
tif_norm = 'FOK'
|
| 107 |
+
|
| 108 |
+
order = {
|
| 109 |
+
'cl_ord_id': cl,
|
| 110 |
+
'symbol': sym,
|
| 111 |
+
'side': side_norm,
|
| 112 |
+
'quantity': quantity,
|
| 113 |
+
'price': price,
|
| 114 |
+
'ord_type': ord_type_norm,
|
| 115 |
+
'time_in_force': tif_norm,
|
| 116 |
+
'raw': raw,
|
| 117 |
+
'timestamp': time.time()
|
| 118 |
+
}
|
| 119 |
+
return order
|
| 120 |
+
|
| 121 |
+
def match_order(order, producer=None):
|
| 122 |
+
"""Try to match incoming order against book and produce trades when matched.
|
| 123 |
+
|
| 124 |
+
Supports:
|
| 125 |
+
- LIMIT orders: match at specified price or better
|
| 126 |
+
- MARKET orders: match at best available price (any price)
|
| 127 |
+
- IOC (Immediate-or-Cancel): fill what's available, cancel rest
|
| 128 |
+
- FOK (Fill-or-Kill): fill entire quantity or reject
|
| 129 |
+
|
| 130 |
+
Algorithm:
|
| 131 |
+
- For BUY: match against lowest-price asks where ask.price <= buy.price (or any for market)
|
| 132 |
+
- For SELL: match against highest-price bids where bid.price >= sell.price (or any for market)
|
| 133 |
+
"""
|
| 134 |
+
symbol = order['symbol']
|
| 135 |
+
if not symbol:
|
| 136 |
+
return
|
| 137 |
+
|
| 138 |
+
is_market = order.get('ord_type') == 'MARKET'
|
| 139 |
+
tif = order.get('time_in_force', 'DAY')
|
| 140 |
+
|
| 141 |
+
with lock:
|
| 142 |
+
book = order_books[symbol]
|
| 143 |
+
|
| 144 |
+
# For FOK orders, check if full quantity can be filled first
|
| 145 |
+
if tif == 'FOK':
|
| 146 |
+
available_qty = 0
|
| 147 |
+
if order['side'] == 'BUY':
|
| 148 |
+
for ask in book['asks']:
|
| 149 |
+
if is_market or (ask['price'] is not None and order['price'] is not None and ask['price'] <= order['price']):
|
| 150 |
+
available_qty += ask['quantity']
|
| 151 |
+
else:
|
| 152 |
+
for bid in book['bids']:
|
| 153 |
+
if is_market or (bid['price'] is not None and order['price'] is not None and bid['price'] >= order['price']):
|
| 154 |
+
available_qty += bid['quantity']
|
| 155 |
+
if available_qty < order['quantity']:
|
| 156 |
+
print(f"FOK order rejected: only {available_qty} available, need {order['quantity']}")
|
| 157 |
+
return # Reject FOK order
|
| 158 |
+
|
| 159 |
+
if order['side'] == 'BUY':
|
| 160 |
+
# try to match with asks (sorted by lowest price)
|
| 161 |
+
asks = book['asks']
|
| 162 |
+
asks.sort(key=lambda x: (x['price'] if x['price'] is not None else float('inf'), x['timestamp']))
|
| 163 |
+
remaining = order['quantity']
|
| 164 |
+
i = 0
|
| 165 |
+
while remaining > 0 and i < len(asks):
|
| 166 |
+
ask = asks[i]
|
| 167 |
+
# Market orders match at any price; limit orders check price
|
| 168 |
+
if not is_market:
|
| 169 |
+
if ask['price'] is None or order['price'] is None or ask['price'] > order['price']:
|
| 170 |
+
break
|
| 171 |
+
# matchable
|
| 172 |
+
traded_qty = min(remaining, ask['quantity'])
|
| 173 |
+
trade_price = ask['price']
|
| 174 |
+
trade = {
|
| 175 |
+
'symbol': symbol,
|
| 176 |
+
'price': trade_price,
|
| 177 |
+
'quantity': traded_qty,
|
| 178 |
+
'buy_id': order.get('cl_ord_id'),
|
| 179 |
+
'sell_id': ask.get('cl_ord_id'),
|
| 180 |
+
'timestamp': time.time()
|
| 181 |
+
}
|
| 182 |
+
trades_log.appendleft(trade)
|
| 183 |
+
# Persist trade to database
|
| 184 |
+
try:
|
| 185 |
+
save_trade(trade)
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print("DB save_trade error:", e)
|
| 188 |
+
if producer:
|
| 189 |
+
try:
|
| 190 |
+
producer.send(Config.TRADES_TOPIC, trade)
|
| 191 |
+
except Exception as e:
|
| 192 |
+
print("Producer send error:", e)
|
| 193 |
+
remaining -= traded_qty
|
| 194 |
+
ask['quantity'] -= traded_qty
|
| 195 |
+
# Update order quantity in database
|
| 196 |
+
if ask.get('cl_ord_id'):
|
| 197 |
+
try:
|
| 198 |
+
update_order_quantity(ask['cl_ord_id'], ask['quantity'])
|
| 199 |
+
except Exception as e:
|
| 200 |
+
print("DB update_order error:", e)
|
| 201 |
+
if ask['quantity'] == 0:
|
| 202 |
+
asks.pop(i)
|
| 203 |
+
else:
|
| 204 |
+
i += 1
|
| 205 |
+
# if remaining, add to bids and persist (unless market or IOC order)
|
| 206 |
+
if remaining > 0:
|
| 207 |
+
if is_market or tif == 'IOC':
|
| 208 |
+
# Market and IOC orders don't rest on book
|
| 209 |
+
print(f"{'Market' if is_market else 'IOC'} order cancelled: {remaining} unfilled")
|
| 210 |
+
else:
|
| 211 |
+
new_order = dict(order)
|
| 212 |
+
new_order['quantity'] = remaining
|
| 213 |
+
book['bids'].append(new_order)
|
| 214 |
+
# Persist resting order to database
|
| 215 |
+
try:
|
| 216 |
+
save_order(new_order)
|
| 217 |
+
except Exception as e:
|
| 218 |
+
print("DB save_order error:", e)
|
| 219 |
+
|
| 220 |
+
elif order['side'] == 'SELL':
|
| 221 |
+
bids = book['bids']
|
| 222 |
+
bids.sort(key=lambda x: (-x['price'] if x['price'] is not None else 0, x['timestamp']))
|
| 223 |
+
remaining = order['quantity']
|
| 224 |
+
i = 0
|
| 225 |
+
while remaining > 0 and i < len(bids):
|
| 226 |
+
bid = bids[i]
|
| 227 |
+
# Market orders match at any price; limit orders check price
|
| 228 |
+
if not is_market:
|
| 229 |
+
if bid['price'] is None or order['price'] is None or bid['price'] < order['price']:
|
| 230 |
+
break
|
| 231 |
+
traded_qty = min(remaining, bid['quantity'])
|
| 232 |
+
trade_price = bid['price']
|
| 233 |
+
trade = {
|
| 234 |
+
'symbol': symbol,
|
| 235 |
+
'price': trade_price,
|
| 236 |
+
'quantity': traded_qty,
|
| 237 |
+
'buy_id': bid.get('cl_ord_id'),
|
| 238 |
+
'sell_id': order.get('cl_ord_id'),
|
| 239 |
+
'timestamp': time.time()
|
| 240 |
+
}
|
| 241 |
+
trades_log.appendleft(trade)
|
| 242 |
+
# Persist trade to database
|
| 243 |
+
try:
|
| 244 |
+
save_trade(trade)
|
| 245 |
+
except Exception as e:
|
| 246 |
+
print("DB save_trade error:", e)
|
| 247 |
+
if producer:
|
| 248 |
+
try:
|
| 249 |
+
producer.send(Config.TRADES_TOPIC, trade)
|
| 250 |
+
except Exception as e:
|
| 251 |
+
print("Producer send error:", e)
|
| 252 |
+
remaining -= traded_qty
|
| 253 |
+
bid['quantity'] -= traded_qty
|
| 254 |
+
# Update order quantity in database
|
| 255 |
+
if bid.get('cl_ord_id'):
|
| 256 |
+
try:
|
| 257 |
+
update_order_quantity(bid['cl_ord_id'], bid['quantity'])
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print("DB update_order error:", e)
|
| 260 |
+
if bid['quantity'] == 0:
|
| 261 |
+
bids.pop(i)
|
| 262 |
+
else:
|
| 263 |
+
i += 1
|
| 264 |
+
# if remaining, add to asks and persist (unless market or IOC order)
|
| 265 |
+
if remaining > 0:
|
| 266 |
+
if is_market or tif == 'IOC':
|
| 267 |
+
# Market and IOC orders don't rest on book
|
| 268 |
+
print(f"{'Market' if is_market else 'IOC'} order cancelled: {remaining} unfilled")
|
| 269 |
+
else:
|
| 270 |
+
new_order = dict(order)
|
| 271 |
+
new_order['quantity'] = remaining
|
| 272 |
+
book['asks'].append(new_order)
|
| 273 |
+
# Persist resting order to database
|
| 274 |
+
try:
|
| 275 |
+
save_order(new_order)
|
| 276 |
+
except Exception as e:
|
| 277 |
+
print("DB save_order error:", e)
|
| 278 |
+
else:
|
| 279 |
+
# unknown side: append to book as-is
|
| 280 |
+
order_books[symbol]['bids' if order.get('side','').upper()=='BUY' else 'asks'].append(order)
|
| 281 |
+
|
| 282 |
+
def handle_cancel(msg, producer=None):
|
| 283 |
+
"""Handle order cancellation request."""
|
| 284 |
+
orig_cl_ord_id = msg.get('orig_cl_ord_id')
|
| 285 |
+
symbol = msg.get('symbol')
|
| 286 |
+
|
| 287 |
+
if not orig_cl_ord_id:
|
| 288 |
+
print("Cancel rejected: missing orig_cl_ord_id")
|
| 289 |
+
return
|
| 290 |
+
|
| 291 |
+
with lock:
|
| 292 |
+
found = False
|
| 293 |
+
# Search all order books if symbol not specified
|
| 294 |
+
symbols_to_search = [symbol] if symbol else list(order_books.keys())
|
| 295 |
+
|
| 296 |
+
for sym in symbols_to_search:
|
| 297 |
+
book = order_books.get(sym, {'bids': [], 'asks': []})
|
| 298 |
+
|
| 299 |
+
# Search bids
|
| 300 |
+
for i, order in enumerate(book['bids']):
|
| 301 |
+
if order.get('cl_ord_id') == orig_cl_ord_id:
|
| 302 |
+
book['bids'].pop(i)
|
| 303 |
+
found = True
|
| 304 |
+
print(f"Cancelled BUY order {orig_cl_ord_id} in {sym}")
|
| 305 |
+
break
|
| 306 |
+
|
| 307 |
+
if found:
|
| 308 |
+
break
|
| 309 |
+
|
| 310 |
+
# Search asks
|
| 311 |
+
for i, order in enumerate(book['asks']):
|
| 312 |
+
if order.get('cl_ord_id') == orig_cl_ord_id:
|
| 313 |
+
book['asks'].pop(i)
|
| 314 |
+
found = True
|
| 315 |
+
print(f"Cancelled SELL order {orig_cl_ord_id} in {sym}")
|
| 316 |
+
break
|
| 317 |
+
|
| 318 |
+
if found:
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
if found:
|
| 322 |
+
# Update database
|
| 323 |
+
try:
|
| 324 |
+
from database import cancel_order
|
| 325 |
+
cancel_order(orig_cl_ord_id)
|
| 326 |
+
except Exception as e:
|
| 327 |
+
print(f"DB cancel_order error: {e}")
|
| 328 |
+
else:
|
| 329 |
+
print(f"Cancel rejected: order {orig_cl_ord_id} not found")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def handle_amend(msg, producer=None):
|
| 333 |
+
"""Handle order amend (cancel/replace) request."""
|
| 334 |
+
orig_cl_ord_id = msg.get('orig_cl_ord_id')
|
| 335 |
+
new_cl_ord_id = msg.get('cl_ord_id')
|
| 336 |
+
symbol = msg.get('symbol')
|
| 337 |
+
new_qty = msg.get('quantity')
|
| 338 |
+
new_price = msg.get('price')
|
| 339 |
+
|
| 340 |
+
if not orig_cl_ord_id:
|
| 341 |
+
print("Amend rejected: missing orig_cl_ord_id")
|
| 342 |
+
return
|
| 343 |
+
|
| 344 |
+
with lock:
|
| 345 |
+
found = False
|
| 346 |
+
symbols_to_search = [symbol] if symbol else list(order_books.keys())
|
| 347 |
+
|
| 348 |
+
for sym in symbols_to_search:
|
| 349 |
+
book = order_books.get(sym, {'bids': [], 'asks': []})
|
| 350 |
+
|
| 351 |
+
# Search bids
|
| 352 |
+
for order in book['bids']:
|
| 353 |
+
if order.get('cl_ord_id') == orig_cl_ord_id:
|
| 354 |
+
# Update order in place
|
| 355 |
+
if new_qty is not None and new_qty > 0:
|
| 356 |
+
order['quantity'] = new_qty
|
| 357 |
+
if new_price is not None and new_price > 0:
|
| 358 |
+
order['price'] = new_price
|
| 359 |
+
if new_cl_ord_id:
|
| 360 |
+
order['cl_ord_id'] = new_cl_ord_id
|
| 361 |
+
found = True
|
| 362 |
+
print(f"Amended BUY order {orig_cl_ord_id} -> qty={new_qty}, price={new_price}")
|
| 363 |
+
break
|
| 364 |
+
|
| 365 |
+
if found:
|
| 366 |
+
break
|
| 367 |
+
|
| 368 |
+
# Search asks
|
| 369 |
+
for order in book['asks']:
|
| 370 |
+
if order.get('cl_ord_id') == orig_cl_ord_id:
|
| 371 |
+
if new_qty is not None and new_qty > 0:
|
| 372 |
+
order['quantity'] = new_qty
|
| 373 |
+
if new_price is not None and new_price > 0:
|
| 374 |
+
order['price'] = new_price
|
| 375 |
+
if new_cl_ord_id:
|
| 376 |
+
order['cl_ord_id'] = new_cl_ord_id
|
| 377 |
+
found = True
|
| 378 |
+
print(f"Amended SELL order {orig_cl_ord_id} -> qty={new_qty}, price={new_price}")
|
| 379 |
+
break
|
| 380 |
+
|
| 381 |
+
if found:
|
| 382 |
+
break
|
| 383 |
+
|
| 384 |
+
if not found:
|
| 385 |
+
print(f"Amend rejected: order {orig_cl_ord_id} not found")
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def consume_orders():
|
| 389 |
+
producer = None
|
| 390 |
+
try:
|
| 391 |
+
producer = create_producer(component_name="Matcher")
|
| 392 |
+
except Exception as e:
|
| 393 |
+
print("Producer unavailable:", e)
|
| 394 |
+
|
| 395 |
+
try:
|
| 396 |
+
consumer = create_consumer(
|
| 397 |
+
topics=Config.ORDERS_TOPIC,
|
| 398 |
+
group_id="matcher",
|
| 399 |
+
component_name="Matcher"
|
| 400 |
+
)
|
| 401 |
+
except Exception as e:
|
| 402 |
+
print("Consumer unavailable:", e)
|
| 403 |
+
return
|
| 404 |
+
|
| 405 |
+
for msg in consumer:
|
| 406 |
+
try:
|
| 407 |
+
raw = msg.value
|
| 408 |
+
except Exception:
|
| 409 |
+
continue
|
| 410 |
+
|
| 411 |
+
# Handle special message types (cancel, amend)
|
| 412 |
+
msg_type = raw.get('type', '').lower() if isinstance(raw, dict) else ''
|
| 413 |
+
|
| 414 |
+
if msg_type == 'cancel':
|
| 415 |
+
handle_cancel(raw, producer)
|
| 416 |
+
continue
|
| 417 |
+
elif msg_type == 'amend':
|
| 418 |
+
handle_amend(raw, producer)
|
| 419 |
+
continue
|
| 420 |
+
|
| 421 |
+
# Regular order
|
| 422 |
+
order = normalize_order(raw)
|
| 423 |
+
if not order:
|
| 424 |
+
continue
|
| 425 |
+
print("Matcher received order:", order.get('symbol'), order.get('side'), order.get('quantity'), order.get('price'))
|
| 426 |
+
match_order(order, producer=producer)
|
| 427 |
+
|
| 428 |
+
@app.route("/health")
|
| 429 |
+
def health():
|
| 430 |
+
"""Health check endpoint."""
|
| 431 |
+
status = {
|
| 432 |
+
"status": "healthy",
|
| 433 |
+
"service": "matcher",
|
| 434 |
+
"timestamp": time.time(),
|
| 435 |
+
"stats": {
|
| 436 |
+
"symbols": len(order_books),
|
| 437 |
+
"total_orders": sum(len(b["bids"]) + len(b["asks"]) for b in order_books.values()),
|
| 438 |
+
"trades_in_memory": len(trades_log)
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
# Check database connectivity
|
| 442 |
+
try:
|
| 443 |
+
from database import get_trade_count
|
| 444 |
+
status["stats"]["trades_in_db"] = get_trade_count()
|
| 445 |
+
status["database"] = "connected"
|
| 446 |
+
except Exception as e:
|
| 447 |
+
status["database"] = f"error: {e}"
|
| 448 |
+
status["status"] = "degraded"
|
| 449 |
+
|
| 450 |
+
return jsonify(status)
|
| 451 |
+
|
| 452 |
+
@app.route("/trades")
|
| 453 |
+
def trades_endpoint():
|
| 454 |
+
"""Get trades with optional filtering and pagination.
|
| 455 |
+
|
| 456 |
+
Query params:
|
| 457 |
+
symbol: Filter by symbol (optional)
|
| 458 |
+
limit: Max records to return (default 200)
|
| 459 |
+
offset: Records to skip (default 0)
|
| 460 |
+
"""
|
| 461 |
+
symbol = request.args.get('symbol')
|
| 462 |
+
limit = request.args.get('limit', 200, type=int)
|
| 463 |
+
offset = request.args.get('offset', 0, type=int)
|
| 464 |
+
|
| 465 |
+
# Fetch from database for persistence
|
| 466 |
+
trades = get_trades(symbol=symbol, limit=limit, offset=offset)
|
| 467 |
+
total = get_trade_count(symbol=symbol)
|
| 468 |
+
|
| 469 |
+
return jsonify({
|
| 470 |
+
'trades': trades,
|
| 471 |
+
'total': total,
|
| 472 |
+
'limit': limit,
|
| 473 |
+
'offset': offset
|
| 474 |
+
})
|
| 475 |
+
|
| 476 |
+
@app.route("/orderbook/<symbol>")
|
| 477 |
+
def get_orderbook(symbol):
|
| 478 |
+
with lock:
|
| 479 |
+
book = order_books[symbol]
|
| 480 |
+
bids = sorted([o for o in book["bids"]], key=lambda x: -float(x["price"]) if x.get("price") is not None else 0)
|
| 481 |
+
asks = sorted([o for o in book["asks"]], key=lambda x: float(x["price"]) if x.get("price") is not None else float('inf'))
|
| 482 |
+
return jsonify({"symbol": symbol, "bids": bids, "asks": asks})
|
| 483 |
+
|
| 484 |
+
# Metrics tracking
|
| 485 |
+
_metrics = {
|
| 486 |
+
"orders_received": 0,
|
| 487 |
+
"trades_executed": 0,
|
| 488 |
+
"cancels_processed": 0,
|
| 489 |
+
"amends_processed": 0,
|
| 490 |
+
"start_time": time.time()
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
@app.route("/metrics")
|
| 494 |
+
def metrics():
|
| 495 |
+
"""Prometheus-compatible metrics endpoint."""
|
| 496 |
+
with lock:
|
| 497 |
+
total_bids = sum(len(b["bids"]) for b in order_books.values())
|
| 498 |
+
total_asks = sum(len(b["asks"]) for b in order_books.values())
|
| 499 |
+
|
| 500 |
+
try:
|
| 501 |
+
db_trades = get_trade_count()
|
| 502 |
+
except:
|
| 503 |
+
db_trades = 0
|
| 504 |
+
|
| 505 |
+
uptime = time.time() - _metrics["start_time"]
|
| 506 |
+
|
| 507 |
+
# Prometheus text format
|
| 508 |
+
lines = [
|
| 509 |
+
"# HELP matcher_orders_received_total Total orders received",
|
| 510 |
+
"# TYPE matcher_orders_received_total counter",
|
| 511 |
+
f"matcher_orders_received_total {_metrics['orders_received']}",
|
| 512 |
+
"",
|
| 513 |
+
"# HELP matcher_trades_executed_total Total trades executed",
|
| 514 |
+
"# TYPE matcher_trades_executed_total counter",
|
| 515 |
+
f"matcher_trades_executed_total {db_trades}",
|
| 516 |
+
"",
|
| 517 |
+
"# HELP matcher_cancels_processed_total Total cancel requests processed",
|
| 518 |
+
"# TYPE matcher_cancels_processed_total counter",
|
| 519 |
+
f"matcher_cancels_processed_total {_metrics['cancels_processed']}",
|
| 520 |
+
"",
|
| 521 |
+
"# HELP matcher_amends_processed_total Total amend requests processed",
|
| 522 |
+
"# TYPE matcher_amends_processed_total counter",
|
| 523 |
+
f"matcher_amends_processed_total {_metrics['amends_processed']}",
|
| 524 |
+
"",
|
| 525 |
+
"# HELP matcher_order_book_bids Current number of bid orders",
|
| 526 |
+
"# TYPE matcher_order_book_bids gauge",
|
| 527 |
+
f"matcher_order_book_bids {total_bids}",
|
| 528 |
+
"",
|
| 529 |
+
"# HELP matcher_order_book_asks Current number of ask orders",
|
| 530 |
+
"# TYPE matcher_order_book_asks gauge",
|
| 531 |
+
f"matcher_order_book_asks {total_asks}",
|
| 532 |
+
"",
|
| 533 |
+
"# HELP matcher_symbols_active Number of active trading symbols",
|
| 534 |
+
"# TYPE matcher_symbols_active gauge",
|
| 535 |
+
f"matcher_symbols_active {len(order_books)}",
|
| 536 |
+
"",
|
| 537 |
+
"# HELP matcher_uptime_seconds Time since service started",
|
| 538 |
+
"# TYPE matcher_uptime_seconds counter",
|
| 539 |
+
f"matcher_uptime_seconds {uptime:.2f}",
|
| 540 |
+
"",
|
| 541 |
+
]
|
| 542 |
+
|
| 543 |
+
return "\n".join(lines), 200, {"Content-Type": "text/plain; charset=utf-8"}
|
| 544 |
+
|
| 545 |
+
if __name__ == "__main__":
|
| 546 |
+
threading.Thread(target=consume_orders, daemon=True).start()
|
| 547 |
+
app.run(host="0.0.0.0", port=6000, debug=True)
|
matcher/requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
kafka-python==2.0.2
|
| 2 |
+
Flask==2.2.5
|
| 3 |
+
requests==2.31.0
|
| 4 |
+
|
matcher/test_matcher.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the matcher service.
|
| 2 |
+
|
| 3 |
+
Run with: python -m pytest matcher/test_matcher.py -v
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
import unittest
|
| 9 |
+
from unittest.mock import Mock, patch
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
|
| 12 |
+
# Add parent directory to path for imports
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
|
| 15 |
+
# Mock database before importing matcher
|
| 16 |
+
sys.modules['matcher.database'] = Mock()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestNormalizeOrder(unittest.TestCase):
|
| 20 |
+
"""Tests for order normalization."""
|
| 21 |
+
|
| 22 |
+
def setUp(self):
|
| 23 |
+
# Import after mocking
|
| 24 |
+
from matcher.matcher import normalize_order
|
| 25 |
+
self.normalize_order = normalize_order
|
| 26 |
+
|
| 27 |
+
def test_normalize_basic_order(self):
|
| 28 |
+
"""Test normalizing a basic order."""
|
| 29 |
+
raw = {
|
| 30 |
+
'symbol': 'ALPHA',
|
| 31 |
+
'side': 'BUY',
|
| 32 |
+
'quantity': 100,
|
| 33 |
+
'price': 50.5,
|
| 34 |
+
'cl_ord_id': 'order-1'
|
| 35 |
+
}
|
| 36 |
+
order = self.normalize_order(raw)
|
| 37 |
+
self.assertEqual(order['symbol'], 'ALPHA')
|
| 38 |
+
self.assertEqual(order['side'], 'BUY')
|
| 39 |
+
self.assertEqual(order['quantity'], 100)
|
| 40 |
+
self.assertEqual(order['price'], 50.5)
|
| 41 |
+
self.assertEqual(order['cl_ord_id'], 'order-1')
|
| 42 |
+
|
| 43 |
+
def test_normalize_fix_tags(self):
|
| 44 |
+
"""Test normalizing FIX-style order with tag numbers."""
|
| 45 |
+
raw = {
|
| 46 |
+
'55': 'BETA',
|
| 47 |
+
'54': '1', # Buy
|
| 48 |
+
'38': '200',
|
| 49 |
+
'44': '25.0',
|
| 50 |
+
'11': 'fix-order-1'
|
| 51 |
+
}
|
| 52 |
+
order = self.normalize_order(raw)
|
| 53 |
+
self.assertEqual(order['symbol'], 'BETA')
|
| 54 |
+
self.assertEqual(order['side'], 'BUY')
|
| 55 |
+
self.assertEqual(order['quantity'], 200)
|
| 56 |
+
self.assertEqual(order['price'], 25.0)
|
| 57 |
+
|
| 58 |
+
def test_normalize_sell_side(self):
|
| 59 |
+
"""Test normalizing sell orders."""
|
| 60 |
+
for side_value in ['2', 'sell', 'SELL', 's']:
|
| 61 |
+
raw = {'symbol': 'TEST', 'side': side_value, 'quantity': 50, 'price': 10}
|
| 62 |
+
order = self.normalize_order(raw)
|
| 63 |
+
self.assertEqual(order['side'], 'SELL', f"Failed for side={side_value}")
|
| 64 |
+
|
| 65 |
+
def test_normalize_market_order(self):
|
| 66 |
+
"""Test normalizing market order type."""
|
| 67 |
+
raw = {
|
| 68 |
+
'symbol': 'ALPHA',
|
| 69 |
+
'side': 'BUY',
|
| 70 |
+
'quantity': 100,
|
| 71 |
+
'ord_type': '1' # Market
|
| 72 |
+
}
|
| 73 |
+
order = self.normalize_order(raw)
|
| 74 |
+
self.assertEqual(order['ord_type'], 'MARKET')
|
| 75 |
+
|
| 76 |
+
def test_normalize_ioc_order(self):
|
| 77 |
+
"""Test normalizing IOC time-in-force."""
|
| 78 |
+
raw = {
|
| 79 |
+
'symbol': 'ALPHA',
|
| 80 |
+
'side': 'BUY',
|
| 81 |
+
'quantity': 100,
|
| 82 |
+
'price': 50,
|
| 83 |
+
'time_in_force': '3' # IOC
|
| 84 |
+
}
|
| 85 |
+
order = self.normalize_order(raw)
|
| 86 |
+
self.assertEqual(order['time_in_force'], 'IOC')
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class TestMatchOrder(unittest.TestCase):
|
| 90 |
+
"""Tests for order matching logic."""
|
| 91 |
+
|
| 92 |
+
def setUp(self):
|
| 93 |
+
"""Set up fresh order books for each test."""
|
| 94 |
+
# Import and reset order books
|
| 95 |
+
import matcher.matcher as matcher_module
|
| 96 |
+
self.matcher = matcher_module
|
| 97 |
+
self.matcher.order_books = defaultdict(lambda: {"bids": [], "asks": []})
|
| 98 |
+
self.matcher.trades_log.clear()
|
| 99 |
+
|
| 100 |
+
def test_no_match_empty_book(self):
|
| 101 |
+
"""Test that orders rest when book is empty."""
|
| 102 |
+
order = {
|
| 103 |
+
'cl_ord_id': 'buy-1',
|
| 104 |
+
'symbol': 'ALPHA',
|
| 105 |
+
'side': 'BUY',
|
| 106 |
+
'quantity': 100,
|
| 107 |
+
'price': 50.0,
|
| 108 |
+
'ord_type': 'LIMIT',
|
| 109 |
+
'time_in_force': 'DAY',
|
| 110 |
+
'timestamp': time.time()
|
| 111 |
+
}
|
| 112 |
+
self.matcher.match_order(order, producer=None)
|
| 113 |
+
|
| 114 |
+
# Order should be added to bids
|
| 115 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['bids']), 1)
|
| 116 |
+
self.assertEqual(self.matcher.order_books['ALPHA']['bids'][0]['quantity'], 100)
|
| 117 |
+
|
| 118 |
+
def test_full_match(self):
|
| 119 |
+
"""Test full match between buy and sell."""
|
| 120 |
+
# Add a resting sell order
|
| 121 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 122 |
+
'cl_ord_id': 'sell-1',
|
| 123 |
+
'symbol': 'ALPHA',
|
| 124 |
+
'side': 'SELL',
|
| 125 |
+
'quantity': 100,
|
| 126 |
+
'price': 50.0,
|
| 127 |
+
'timestamp': time.time()
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
# Incoming buy matches
|
| 131 |
+
buy_order = {
|
| 132 |
+
'cl_ord_id': 'buy-1',
|
| 133 |
+
'symbol': 'ALPHA',
|
| 134 |
+
'side': 'BUY',
|
| 135 |
+
'quantity': 100,
|
| 136 |
+
'price': 50.0,
|
| 137 |
+
'ord_type': 'LIMIT',
|
| 138 |
+
'time_in_force': 'DAY',
|
| 139 |
+
'timestamp': time.time()
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
with patch.object(self.matcher, 'save_trade'):
|
| 143 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 144 |
+
|
| 145 |
+
# Trade should be recorded
|
| 146 |
+
self.assertEqual(len(self.matcher.trades_log), 1)
|
| 147 |
+
trade = self.matcher.trades_log[0]
|
| 148 |
+
self.assertEqual(trade['quantity'], 100)
|
| 149 |
+
self.assertEqual(trade['price'], 50.0)
|
| 150 |
+
|
| 151 |
+
# Both sides of book should be empty
|
| 152 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['asks']), 0)
|
| 153 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['bids']), 0)
|
| 154 |
+
|
| 155 |
+
def test_partial_fill(self):
|
| 156 |
+
"""Test partial fill when order qty exceeds available."""
|
| 157 |
+
# Resting sell for 50
|
| 158 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 159 |
+
'cl_ord_id': 'sell-1',
|
| 160 |
+
'symbol': 'ALPHA',
|
| 161 |
+
'side': 'SELL',
|
| 162 |
+
'quantity': 50,
|
| 163 |
+
'price': 50.0,
|
| 164 |
+
'timestamp': time.time()
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
# Buy for 100
|
| 168 |
+
buy_order = {
|
| 169 |
+
'cl_ord_id': 'buy-1',
|
| 170 |
+
'symbol': 'ALPHA',
|
| 171 |
+
'side': 'BUY',
|
| 172 |
+
'quantity': 100,
|
| 173 |
+
'price': 50.0,
|
| 174 |
+
'ord_type': 'LIMIT',
|
| 175 |
+
'time_in_force': 'DAY',
|
| 176 |
+
'timestamp': time.time()
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
with patch.object(self.matcher, 'save_trade'), \
|
| 180 |
+
patch.object(self.matcher, 'save_order'):
|
| 181 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 182 |
+
|
| 183 |
+
# Trade for 50
|
| 184 |
+
self.assertEqual(len(self.matcher.trades_log), 1)
|
| 185 |
+
self.assertEqual(self.matcher.trades_log[0]['quantity'], 50)
|
| 186 |
+
|
| 187 |
+
# Remaining 50 rests on bid side
|
| 188 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['bids']), 1)
|
| 189 |
+
self.assertEqual(self.matcher.order_books['ALPHA']['bids'][0]['quantity'], 50)
|
| 190 |
+
|
| 191 |
+
def test_no_match_price_mismatch(self):
|
| 192 |
+
"""Test no match when prices don't cross."""
|
| 193 |
+
# Sell at 55
|
| 194 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 195 |
+
'cl_ord_id': 'sell-1',
|
| 196 |
+
'symbol': 'ALPHA',
|
| 197 |
+
'side': 'SELL',
|
| 198 |
+
'quantity': 100,
|
| 199 |
+
'price': 55.0,
|
| 200 |
+
'timestamp': time.time()
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
# Buy at 50 - won't match
|
| 204 |
+
buy_order = {
|
| 205 |
+
'cl_ord_id': 'buy-1',
|
| 206 |
+
'symbol': 'ALPHA',
|
| 207 |
+
'side': 'BUY',
|
| 208 |
+
'quantity': 100,
|
| 209 |
+
'price': 50.0,
|
| 210 |
+
'ord_type': 'LIMIT',
|
| 211 |
+
'time_in_force': 'DAY',
|
| 212 |
+
'timestamp': time.time()
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
with patch.object(self.matcher, 'save_order'):
|
| 216 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 217 |
+
|
| 218 |
+
# No trades
|
| 219 |
+
self.assertEqual(len(self.matcher.trades_log), 0)
|
| 220 |
+
|
| 221 |
+
# Both orders rest
|
| 222 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['asks']), 1)
|
| 223 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['bids']), 1)
|
| 224 |
+
|
| 225 |
+
def test_market_order_matches_any_price(self):
|
| 226 |
+
"""Test market order matches at any price."""
|
| 227 |
+
# Sell at 100
|
| 228 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 229 |
+
'cl_ord_id': 'sell-1',
|
| 230 |
+
'symbol': 'ALPHA',
|
| 231 |
+
'side': 'SELL',
|
| 232 |
+
'quantity': 100,
|
| 233 |
+
'price': 100.0,
|
| 234 |
+
'timestamp': time.time()
|
| 235 |
+
})
|
| 236 |
+
|
| 237 |
+
# Market buy
|
| 238 |
+
buy_order = {
|
| 239 |
+
'cl_ord_id': 'buy-1',
|
| 240 |
+
'symbol': 'ALPHA',
|
| 241 |
+
'side': 'BUY',
|
| 242 |
+
'quantity': 100,
|
| 243 |
+
'price': None, # No price for market order
|
| 244 |
+
'ord_type': 'MARKET',
|
| 245 |
+
'time_in_force': 'DAY',
|
| 246 |
+
'timestamp': time.time()
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
with patch.object(self.matcher, 'save_trade'):
|
| 250 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 251 |
+
|
| 252 |
+
# Should match at 100
|
| 253 |
+
self.assertEqual(len(self.matcher.trades_log), 1)
|
| 254 |
+
self.assertEqual(self.matcher.trades_log[0]['price'], 100.0)
|
| 255 |
+
|
| 256 |
+
def test_ioc_order_cancels_remainder(self):
|
| 257 |
+
"""Test IOC order cancels unfilled portion."""
|
| 258 |
+
# Sell only 30
|
| 259 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 260 |
+
'cl_ord_id': 'sell-1',
|
| 261 |
+
'symbol': 'ALPHA',
|
| 262 |
+
'side': 'SELL',
|
| 263 |
+
'quantity': 30,
|
| 264 |
+
'price': 50.0,
|
| 265 |
+
'timestamp': time.time()
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
# IOC buy for 100
|
| 269 |
+
buy_order = {
|
| 270 |
+
'cl_ord_id': 'buy-1',
|
| 271 |
+
'symbol': 'ALPHA',
|
| 272 |
+
'side': 'BUY',
|
| 273 |
+
'quantity': 100,
|
| 274 |
+
'price': 50.0,
|
| 275 |
+
'ord_type': 'LIMIT',
|
| 276 |
+
'time_in_force': 'IOC',
|
| 277 |
+
'timestamp': time.time()
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
with patch.object(self.matcher, 'save_trade'):
|
| 281 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 282 |
+
|
| 283 |
+
# Trade for 30
|
| 284 |
+
self.assertEqual(len(self.matcher.trades_log), 1)
|
| 285 |
+
self.assertEqual(self.matcher.trades_log[0]['quantity'], 30)
|
| 286 |
+
|
| 287 |
+
# Remaining 70 cancelled, not added to book
|
| 288 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['bids']), 0)
|
| 289 |
+
|
| 290 |
+
def test_fok_order_rejected_insufficient_qty(self):
|
| 291 |
+
"""Test FOK order rejected when full qty not available."""
|
| 292 |
+
# Only 30 available
|
| 293 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 294 |
+
'cl_ord_id': 'sell-1',
|
| 295 |
+
'symbol': 'ALPHA',
|
| 296 |
+
'side': 'SELL',
|
| 297 |
+
'quantity': 30,
|
| 298 |
+
'price': 50.0,
|
| 299 |
+
'timestamp': time.time()
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
# FOK buy for 100
|
| 303 |
+
buy_order = {
|
| 304 |
+
'cl_ord_id': 'buy-1',
|
| 305 |
+
'symbol': 'ALPHA',
|
| 306 |
+
'side': 'BUY',
|
| 307 |
+
'quantity': 100,
|
| 308 |
+
'price': 50.0,
|
| 309 |
+
'ord_type': 'LIMIT',
|
| 310 |
+
'time_in_force': 'FOK',
|
| 311 |
+
'timestamp': time.time()
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 315 |
+
|
| 316 |
+
# No trades - order rejected
|
| 317 |
+
self.assertEqual(len(self.matcher.trades_log), 0)
|
| 318 |
+
|
| 319 |
+
# Sell order still there
|
| 320 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['asks']), 1)
|
| 321 |
+
|
| 322 |
+
def test_price_time_priority(self):
|
| 323 |
+
"""Test orders match in price-time priority."""
|
| 324 |
+
# Two sell orders at different prices
|
| 325 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 326 |
+
'cl_ord_id': 'sell-high',
|
| 327 |
+
'symbol': 'ALPHA',
|
| 328 |
+
'side': 'SELL',
|
| 329 |
+
'quantity': 50,
|
| 330 |
+
'price': 52.0,
|
| 331 |
+
'timestamp': time.time()
|
| 332 |
+
})
|
| 333 |
+
self.matcher.order_books['ALPHA']['asks'].append({
|
| 334 |
+
'cl_ord_id': 'sell-low',
|
| 335 |
+
'symbol': 'ALPHA',
|
| 336 |
+
'side': 'SELL',
|
| 337 |
+
'quantity': 50,
|
| 338 |
+
'price': 50.0,
|
| 339 |
+
'timestamp': time.time() + 1
|
| 340 |
+
})
|
| 341 |
+
|
| 342 |
+
# Buy should match lower price first
|
| 343 |
+
buy_order = {
|
| 344 |
+
'cl_ord_id': 'buy-1',
|
| 345 |
+
'symbol': 'ALPHA',
|
| 346 |
+
'side': 'BUY',
|
| 347 |
+
'quantity': 50,
|
| 348 |
+
'price': 55.0,
|
| 349 |
+
'ord_type': 'LIMIT',
|
| 350 |
+
'time_in_force': 'DAY',
|
| 351 |
+
'timestamp': time.time()
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
with patch.object(self.matcher, 'save_trade'):
|
| 355 |
+
self.matcher.match_order(buy_order, producer=None)
|
| 356 |
+
|
| 357 |
+
# Matched at 50 (better price)
|
| 358 |
+
self.assertEqual(self.matcher.trades_log[0]['price'], 50.0)
|
| 359 |
+
self.assertEqual(self.matcher.trades_log[0]['sell_id'], 'sell-low')
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class TestCancelOrder(unittest.TestCase):
|
| 363 |
+
"""Tests for order cancellation."""
|
| 364 |
+
|
| 365 |
+
def setUp(self):
|
| 366 |
+
import matcher.matcher as matcher_module
|
| 367 |
+
self.matcher = matcher_module
|
| 368 |
+
self.matcher.order_books = defaultdict(lambda: {"bids": [], "asks": []})
|
| 369 |
+
|
| 370 |
+
def test_cancel_existing_order(self):
|
| 371 |
+
"""Test cancelling an existing order."""
|
| 372 |
+
# Add order to book
|
| 373 |
+
self.matcher.order_books['ALPHA']['bids'].append({
|
| 374 |
+
'cl_ord_id': 'order-1',
|
| 375 |
+
'symbol': 'ALPHA',
|
| 376 |
+
'side': 'BUY',
|
| 377 |
+
'quantity': 100,
|
| 378 |
+
'price': 50.0,
|
| 379 |
+
'timestamp': time.time()
|
| 380 |
+
})
|
| 381 |
+
|
| 382 |
+
cancel_msg = {
|
| 383 |
+
'type': 'cancel',
|
| 384 |
+
'orig_cl_ord_id': 'order-1',
|
| 385 |
+
'symbol': 'ALPHA'
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
with patch.object(self.matcher, 'cancel_order'):
|
| 389 |
+
self.matcher.handle_cancel(cancel_msg, producer=None)
|
| 390 |
+
|
| 391 |
+
# Order should be removed
|
| 392 |
+
self.assertEqual(len(self.matcher.order_books['ALPHA']['bids']), 0)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
class TestAmendOrder(unittest.TestCase):
|
| 396 |
+
"""Tests for order amendment."""
|
| 397 |
+
|
| 398 |
+
def setUp(self):
|
| 399 |
+
import matcher.matcher as matcher_module
|
| 400 |
+
self.matcher = matcher_module
|
| 401 |
+
self.matcher.order_books = defaultdict(lambda: {"bids": [], "asks": []})
|
| 402 |
+
|
| 403 |
+
def test_amend_price(self):
|
| 404 |
+
"""Test amending order price."""
|
| 405 |
+
# Add order
|
| 406 |
+
self.matcher.order_books['ALPHA']['bids'].append({
|
| 407 |
+
'cl_ord_id': 'order-1',
|
| 408 |
+
'symbol': 'ALPHA',
|
| 409 |
+
'side': 'BUY',
|
| 410 |
+
'quantity': 100,
|
| 411 |
+
'price': 50.0,
|
| 412 |
+
'timestamp': time.time()
|
| 413 |
+
})
|
| 414 |
+
|
| 415 |
+
amend_msg = {
|
| 416 |
+
'type': 'amend',
|
| 417 |
+
'orig_cl_ord_id': 'order-1',
|
| 418 |
+
'symbol': 'ALPHA',
|
| 419 |
+
'price': 55.0
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
self.matcher.handle_amend(amend_msg, producer=None)
|
| 423 |
+
|
| 424 |
+
# Price should be updated
|
| 425 |
+
self.assertEqual(self.matcher.order_books['ALPHA']['bids'][0]['price'], 55.0)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if __name__ == '__main__':
|
| 429 |
+
unittest.main()
|
md_feeder/Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY mdf_simulator.py .
|
| 5 |
+
|
| 6 |
+
RUN pip install --no-cache-dir kafka-python
|
| 7 |
+
|
| 8 |
+
CMD ["python", "-u", "mdf_simulator.py"]
|
md_feeder/mdf_simulator.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, "/app")
|
| 4 |
+
|
| 5 |
+
import json, time, random, os
|
| 6 |
+
|
| 7 |
+
from shared.config import Config
|
| 8 |
+
from shared.kafka_utils import create_producer
|
| 9 |
+
|
| 10 |
+
ORDER_INTERVAL = 60.0 / Config.ORDERS_PER_MIN
|
| 11 |
+
|
| 12 |
+
def load_securities():
|
| 13 |
+
"""Load securities from file: SYMBOL start_price current_price"""
|
| 14 |
+
securities = {}
|
| 15 |
+
if not os.path.exists(Config.SECURITIES_FILE):
|
| 16 |
+
raise FileNotFoundError(f"{Config.SECURITIES_FILE} not found")
|
| 17 |
+
|
| 18 |
+
with open(Config.SECURITIES_FILE) as f:
|
| 19 |
+
for line in f:
|
| 20 |
+
if not line.strip() or line.startswith("#"):
|
| 21 |
+
continue
|
| 22 |
+
parts = line.split()
|
| 23 |
+
if len(parts) >= 3:
|
| 24 |
+
symbol, start, current = parts[0], float(parts[1]), float(parts[2])
|
| 25 |
+
securities[symbol] = {"start": start, "current": current}
|
| 26 |
+
return securities
|
| 27 |
+
|
| 28 |
+
def save_securities(securities):
|
| 29 |
+
"""Persist securities with header line"""
|
| 30 |
+
with open(Config.SECURITIES_FILE, "w") as f:
|
| 31 |
+
f.write("#SYMBOL\t<start_price>\t<current_price>\n")
|
| 32 |
+
for sym, vals in securities.items():
|
| 33 |
+
f.write(f"{sym}\t{vals['start']:.2f}\t{vals['current']:.2f}\n")
|
| 34 |
+
|
| 35 |
+
_order_counter = 0
|
| 36 |
+
|
| 37 |
+
def make_order(symbol, side, price, qty):
|
| 38 |
+
global _order_counter
|
| 39 |
+
_order_counter += 1
|
| 40 |
+
return {
|
| 41 |
+
"symbol": symbol,
|
| 42 |
+
"side": side,
|
| 43 |
+
"price": round(price, 2),
|
| 44 |
+
"quantity": qty,
|
| 45 |
+
"cl_ord_id": f"MDF-{int(time.time()*1000)}-{_order_counter}",
|
| 46 |
+
"timestamp": time.time(),
|
| 47 |
+
"source": "MDF"
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
def make_snapshot(symbol, best_bid, best_ask, bid_size, ask_size):
|
| 51 |
+
return {
|
| 52 |
+
"symbol": symbol,
|
| 53 |
+
"best_bid": round(best_bid, 2),
|
| 54 |
+
"best_ask": round(best_ask, 2),
|
| 55 |
+
"bid_size": bid_size,
|
| 56 |
+
"ask_size": ask_size,
|
| 57 |
+
"timestamp": time.time(),
|
| 58 |
+
"source": "MDF"
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
producer = create_producer(component_name="MDF")
|
| 63 |
+
|
| 64 |
+
# Load securities and reset start=current
|
| 65 |
+
securities = load_securities()
|
| 66 |
+
for sym in securities:
|
| 67 |
+
securities[sym]["start"] = securities[sym]["current"]
|
| 68 |
+
save_securities(securities)
|
| 69 |
+
print(f"[MDF] Loaded securities: {list(securities.keys())}")
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
while True:
|
| 73 |
+
for sym, vals in securities.items():
|
| 74 |
+
mid = vals["current"]
|
| 75 |
+
# Define spread: bids below (mid - spread), asks above (mid + spread)
|
| 76 |
+
half_spread = 0.10 # 10 cents spread
|
| 77 |
+
|
| 78 |
+
# --- generate order ---
|
| 79 |
+
# 90% passive orders (build book), 10% aggressive (create trades)
|
| 80 |
+
side = random.choice(["BUY", "SELL"])
|
| 81 |
+
rand = random.random()
|
| 82 |
+
|
| 83 |
+
if rand < 0.90:
|
| 84 |
+
# Passive: place orders away from mid to rest on book
|
| 85 |
+
if side == "BUY":
|
| 86 |
+
# Bids: from (mid - spread) down to (mid - spread - 0.50)
|
| 87 |
+
base = mid - half_spread
|
| 88 |
+
offset = random.randint(1, 50) * Config.TICK_SIZE
|
| 89 |
+
price = round(base - offset, 2)
|
| 90 |
+
else:
|
| 91 |
+
# Asks: from (mid + spread) up to (mid + spread + 0.50)
|
| 92 |
+
base = mid + half_spread
|
| 93 |
+
offset = random.randint(1, 50) * Config.TICK_SIZE
|
| 94 |
+
price = round(base + offset, 2)
|
| 95 |
+
else:
|
| 96 |
+
# Aggressive: cross the spread to create trades (rare)
|
| 97 |
+
if side == "BUY":
|
| 98 |
+
# Buy into asks
|
| 99 |
+
price = round(mid + half_spread + random.randint(1, 5) * Config.TICK_SIZE, 2)
|
| 100 |
+
else:
|
| 101 |
+
# Sell into bids
|
| 102 |
+
price = round(mid - half_spread - random.randint(1, 5) * Config.TICK_SIZE, 2)
|
| 103 |
+
|
| 104 |
+
qty = random.choice([50, 100, 150, 200, 250])
|
| 105 |
+
|
| 106 |
+
order = make_order(sym, side, price, qty)
|
| 107 |
+
producer.send(Config.ORDERS_TOPIC, order)
|
| 108 |
+
print(f"[MDF] Order: {order}")
|
| 109 |
+
|
| 110 |
+
# --- simulate small price drift (10% chance, max 2 ticks) ---
|
| 111 |
+
if random.random() < 0.10:
|
| 112 |
+
drift = random.choice([-2, -1, 1, 2]) * Config.TICK_SIZE
|
| 113 |
+
new_price = vals["current"] + drift
|
| 114 |
+
# Keep price within reasonable bounds (min 1.00)
|
| 115 |
+
if new_price >= 1.00:
|
| 116 |
+
vals["current"] = round(new_price, 2)
|
| 117 |
+
save_securities(securities)
|
| 118 |
+
|
| 119 |
+
# --- snapshot around mid price ---
|
| 120 |
+
best_bid = mid - half_spread
|
| 121 |
+
best_ask = mid + half_spread
|
| 122 |
+
bid_size = random.choice([50, 100, 200])
|
| 123 |
+
ask_size = random.choice([50, 100, 200])
|
| 124 |
+
snap = make_snapshot(sym, best_bid, best_ask, bid_size, ask_size)
|
| 125 |
+
producer.send(Config.SNAPSHOTS_TOPIC, snap)
|
| 126 |
+
print(f"[MDF] Snapshot: {snap}")
|
| 127 |
+
|
| 128 |
+
time.sleep(ORDER_INTERVAL)
|
| 129 |
+
|
| 130 |
+
except KeyboardInterrupt:
|
| 131 |
+
pass
|
| 132 |
+
finally:
|
| 133 |
+
producer.flush()
|
oeg/Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY oeg_simulator.py .
|
| 5 |
+
|
| 6 |
+
# Install requests and any other deps
|
| 7 |
+
RUN pip install --no-cache-dir requests
|
| 8 |
+
RUN pip install --no-cache-dir kafka-python
|
| 9 |
+
|
| 10 |
+
CMD ["python", "oeg_simulator.py"]
|
oeg/oeg_simulator.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import time, json, random, requests, os
|
| 3 |
+
FRONTEND = os.environ.get("FRONTEND_URL", "http://frontend:5000")
|
| 4 |
+
SYMBOLS = ["FOO","AAA","BBB"]
|
| 5 |
+
SIDES = ["buy","sell"]
|
| 6 |
+
|
| 7 |
+
def post_order(order):
|
| 8 |
+
try:
|
| 9 |
+
r = requests.post(FRONTEND + "/submit", json=order, timeout=5)
|
| 10 |
+
print(json.dumps({"component":"oeg","event":"post_order","payload":{"order":order,"status":r.status_code}}))
|
| 11 |
+
except Exception as e:
|
| 12 |
+
print(json.dumps({"component":"oeg","event":"post_failed","payload":{"order":order,"error":str(e)}}))
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
try:
|
| 16 |
+
while True:
|
| 17 |
+
order = {
|
| 18 |
+
"order_id": str(int(time.time()*1000)),
|
| 19 |
+
"symbol": random.choice(SYMBOLS),
|
| 20 |
+
"type": random.choice(SIDES),
|
| 21 |
+
"quantity": random.choice([5,10,20]),
|
| 22 |
+
"price": round(100 + random.uniform(-1,1),2),
|
| 23 |
+
"timestamp": time.time(),
|
| 24 |
+
"source": "oeg-sim"
|
| 25 |
+
}
|
| 26 |
+
post_order(order)
|
| 27 |
+
time.sleep(2.0)
|
| 28 |
+
except KeyboardInterrupt:
|
| 29 |
+
pass
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
quickfix
|
| 2 |
+
flask
|
shared/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities and configuration for Kafka Trading System."""
|
| 2 |
+
|
| 3 |
+
from .config import Config
|
| 4 |
+
from .kafka_utils import create_producer, create_consumer
|
| 5 |
+
|
| 6 |
+
__all__ = ["Config", "create_producer", "create_consumer"]
|
shared/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized configuration for all services."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Config:
|
| 7 |
+
"""Configuration loaded from environment variables with sensible defaults."""
|
| 8 |
+
|
| 9 |
+
# Kafka connection
|
| 10 |
+
KAFKA_BOOTSTRAP: str = os.getenv("KAFKA_BOOTSTRAP", "kafka:9092")
|
| 11 |
+
|
| 12 |
+
# Topic names
|
| 13 |
+
ORDERS_TOPIC: str = os.getenv("ORDERS_TOPIC", "orders")
|
| 14 |
+
TRADES_TOPIC: str = os.getenv("TRADES_TOPIC", "trades")
|
| 15 |
+
SNAPSHOTS_TOPIC: str = os.getenv("SNAPSHOTS_TOPIC", "snapshots")
|
| 16 |
+
|
| 17 |
+
# Connection retry settings
|
| 18 |
+
KAFKA_RETRIES: int = int(os.getenv("KAFKA_RETRIES", "30"))
|
| 19 |
+
KAFKA_RETRY_DELAY: int = int(os.getenv("KAFKA_RETRY_DELAY", "2"))
|
| 20 |
+
|
| 21 |
+
# Service URLs
|
| 22 |
+
MATCHER_URL: str = os.getenv("MATCHER_URL", "http://matcher:6000")
|
| 23 |
+
FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://frontend:5000")
|
| 24 |
+
|
| 25 |
+
# Market data settings
|
| 26 |
+
SECURITIES_FILE: str = os.getenv("SECURITIES_FILE", "/app/data/securities.txt")
|
| 27 |
+
ORDER_ID_FILE: str = os.getenv("ORDER_ID_FILE", "/app/data/order_id.txt")
|
| 28 |
+
|
| 29 |
+
# Trading simulation
|
| 30 |
+
TICK_SIZE: float = float(os.getenv("TICK_SIZE", "0.05"))
|
| 31 |
+
ORDERS_PER_MIN: int = int(os.getenv("ORDERS_PER_MIN", "8"))
|
shared/kafka_utils.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reusable Kafka producer and consumer utilities."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, List, Union
|
| 7 |
+
|
| 8 |
+
from kafka import KafkaProducer, KafkaConsumer
|
| 9 |
+
from kafka.errors import NoBrokersAvailable
|
| 10 |
+
|
| 11 |
+
from .config import Config
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_producer(
|
| 17 |
+
bootstrap_servers: Optional[str] = None,
|
| 18 |
+
retries: Optional[int] = None,
|
| 19 |
+
delay: Optional[int] = None,
|
| 20 |
+
component_name: str = "Service",
|
| 21 |
+
) -> KafkaProducer:
|
| 22 |
+
"""
|
| 23 |
+
Create a Kafka producer with retry logic.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
bootstrap_servers: Kafka broker address (default: from Config)
|
| 27 |
+
retries: Number of connection attempts (default: from Config)
|
| 28 |
+
delay: Seconds between retries (default: from Config)
|
| 29 |
+
component_name: Name for logging purposes
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Connected KafkaProducer instance
|
| 33 |
+
|
| 34 |
+
Raises:
|
| 35 |
+
RuntimeError: If connection fails after all retries
|
| 36 |
+
"""
|
| 37 |
+
bootstrap_servers = bootstrap_servers or Config.KAFKA_BOOTSTRAP
|
| 38 |
+
retries = retries if retries is not None else Config.KAFKA_RETRIES
|
| 39 |
+
delay = delay if delay is not None else Config.KAFKA_RETRY_DELAY
|
| 40 |
+
|
| 41 |
+
for attempt in range(retries):
|
| 42 |
+
try:
|
| 43 |
+
producer = KafkaProducer(
|
| 44 |
+
bootstrap_servers=bootstrap_servers,
|
| 45 |
+
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
| 46 |
+
)
|
| 47 |
+
logger.info(f"{component_name}: Kafka producer connected")
|
| 48 |
+
print(f"{component_name}: Kafka producer connected")
|
| 49 |
+
return producer
|
| 50 |
+
except NoBrokersAvailable:
|
| 51 |
+
logger.warning(
|
| 52 |
+
f"{component_name}: Kafka not ready, retry {attempt + 1}/{retries}"
|
| 53 |
+
)
|
| 54 |
+
print(f"{component_name}: Kafka not ready, retry {attempt + 1}/{retries}")
|
| 55 |
+
time.sleep(delay)
|
| 56 |
+
|
| 57 |
+
raise RuntimeError(f"{component_name}: Cannot connect to Kafka after {retries} attempts")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def create_consumer(
|
| 61 |
+
topics: Union[str, List[str]],
|
| 62 |
+
bootstrap_servers: Optional[str] = None,
|
| 63 |
+
group_id: Optional[str] = None,
|
| 64 |
+
auto_offset_reset: str = "latest",
|
| 65 |
+
retries: Optional[int] = None,
|
| 66 |
+
delay: Optional[int] = None,
|
| 67 |
+
component_name: str = "Service",
|
| 68 |
+
) -> KafkaConsumer:
|
| 69 |
+
"""
|
| 70 |
+
Create a Kafka consumer with retry logic.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
topics: Topic name or list of topic names to subscribe to
|
| 74 |
+
bootstrap_servers: Kafka broker address (default: from Config)
|
| 75 |
+
group_id: Consumer group ID
|
| 76 |
+
auto_offset_reset: Where to start reading ('earliest' or 'latest')
|
| 77 |
+
retries: Number of connection attempts (default: from Config)
|
| 78 |
+
delay: Seconds between retries (default: from Config)
|
| 79 |
+
component_name: Name for logging purposes
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Connected KafkaConsumer instance
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
RuntimeError: If connection fails after all retries
|
| 86 |
+
"""
|
| 87 |
+
bootstrap_servers = bootstrap_servers or Config.KAFKA_BOOTSTRAP
|
| 88 |
+
retries = retries if retries is not None else Config.KAFKA_RETRIES
|
| 89 |
+
delay = delay if delay is not None else Config.KAFKA_RETRY_DELAY
|
| 90 |
+
|
| 91 |
+
# Ensure topics is a list
|
| 92 |
+
if isinstance(topics, str):
|
| 93 |
+
topics = [topics]
|
| 94 |
+
|
| 95 |
+
for attempt in range(retries):
|
| 96 |
+
try:
|
| 97 |
+
consumer = KafkaConsumer(
|
| 98 |
+
*topics,
|
| 99 |
+
bootstrap_servers=bootstrap_servers,
|
| 100 |
+
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
|
| 101 |
+
group_id=group_id,
|
| 102 |
+
auto_offset_reset=auto_offset_reset,
|
| 103 |
+
)
|
| 104 |
+
logger.info(f"{component_name}: Kafka consumer connected to {topics}")
|
| 105 |
+
print(f"{component_name}: Kafka consumer connected to {topics}")
|
| 106 |
+
return consumer
|
| 107 |
+
except NoBrokersAvailable:
|
| 108 |
+
logger.warning(
|
| 109 |
+
f"{component_name}: Kafka not ready, retry {attempt + 1}/{retries}"
|
| 110 |
+
)
|
| 111 |
+
print(f"{component_name}: Kafka not ready, retry {attempt + 1}/{retries}")
|
| 112 |
+
time.sleep(delay)
|
| 113 |
+
|
| 114 |
+
raise RuntimeError(f"{component_name}: Cannot connect to Kafka after {retries} attempts")
|
shared/logging_utils.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured logging utilities for the trading system.
|
| 2 |
+
|
| 3 |
+
Provides JSON-formatted logging with correlation IDs for tracing
|
| 4 |
+
requests across services.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
import threading
|
| 11 |
+
from functools import wraps
|
| 12 |
+
|
| 13 |
+
# Thread-local storage for correlation ID
|
| 14 |
+
_context = threading.local()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_correlation_id():
|
| 18 |
+
"""Get current correlation ID, or generate a new one."""
|
| 19 |
+
if not hasattr(_context, 'correlation_id') or _context.correlation_id is None:
|
| 20 |
+
_context.correlation_id = str(uuid.uuid4())[:8]
|
| 21 |
+
return _context.correlation_id
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def set_correlation_id(correlation_id):
|
| 25 |
+
"""Set correlation ID for current thread."""
|
| 26 |
+
_context.correlation_id = correlation_id
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def clear_correlation_id():
|
| 30 |
+
"""Clear correlation ID for current thread."""
|
| 31 |
+
_context.correlation_id = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class JSONFormatter(logging.Formatter):
|
| 35 |
+
"""JSON log formatter for structured logging."""
|
| 36 |
+
|
| 37 |
+
def format(self, record):
|
| 38 |
+
log_entry = {
|
| 39 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)),
|
| 40 |
+
"level": record.levelname,
|
| 41 |
+
"logger": record.name,
|
| 42 |
+
"message": record.getMessage(),
|
| 43 |
+
"correlation_id": get_correlation_id(),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Add component if set
|
| 47 |
+
if hasattr(record, 'component'):
|
| 48 |
+
log_entry["component"] = record.component
|
| 49 |
+
|
| 50 |
+
# Add extra fields
|
| 51 |
+
if hasattr(record, 'extra_data') and record.extra_data:
|
| 52 |
+
log_entry.update(record.extra_data)
|
| 53 |
+
|
| 54 |
+
# Add exception info if present
|
| 55 |
+
if record.exc_info:
|
| 56 |
+
log_entry["exception"] = self.formatException(record.exc_info)
|
| 57 |
+
|
| 58 |
+
# Add source location for errors
|
| 59 |
+
if record.levelno >= logging.ERROR:
|
| 60 |
+
log_entry["source"] = {
|
| 61 |
+
"file": record.filename,
|
| 62 |
+
"line": record.lineno,
|
| 63 |
+
"function": record.funcName
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return json.dumps(log_entry)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def setup_logging(component_name, level=logging.INFO, json_format=True):
|
| 70 |
+
"""Configure logging for a service component.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
component_name: Name of the service (e.g., "matcher", "dashboard")
|
| 74 |
+
level: Logging level
|
| 75 |
+
json_format: Use JSON formatting (True) or standard format (False)
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
Logger instance
|
| 79 |
+
"""
|
| 80 |
+
logger = logging.getLogger(component_name)
|
| 81 |
+
logger.setLevel(level)
|
| 82 |
+
|
| 83 |
+
# Remove existing handlers
|
| 84 |
+
logger.handlers = []
|
| 85 |
+
|
| 86 |
+
# Create console handler
|
| 87 |
+
handler = logging.StreamHandler()
|
| 88 |
+
handler.setLevel(level)
|
| 89 |
+
|
| 90 |
+
if json_format:
|
| 91 |
+
handler.setFormatter(JSONFormatter())
|
| 92 |
+
else:
|
| 93 |
+
handler.setFormatter(logging.Formatter(
|
| 94 |
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 95 |
+
))
|
| 96 |
+
|
| 97 |
+
logger.addHandler(handler)
|
| 98 |
+
|
| 99 |
+
return logger
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class StructuredLogger:
|
| 103 |
+
"""Convenience wrapper for structured logging."""
|
| 104 |
+
|
| 105 |
+
def __init__(self, component_name, json_format=True):
|
| 106 |
+
self.logger = setup_logging(component_name, json_format=json_format)
|
| 107 |
+
self.component = component_name
|
| 108 |
+
|
| 109 |
+
def _log(self, level, message, **extra):
|
| 110 |
+
"""Internal logging method with extra data."""
|
| 111 |
+
record = self.logger.makeRecord(
|
| 112 |
+
self.logger.name, level, "", 0, message, (), None
|
| 113 |
+
)
|
| 114 |
+
record.component = self.component
|
| 115 |
+
record.extra_data = extra if extra else None
|
| 116 |
+
self.logger.handle(record)
|
| 117 |
+
|
| 118 |
+
def info(self, message, **extra):
|
| 119 |
+
self._log(logging.INFO, message, **extra)
|
| 120 |
+
|
| 121 |
+
def debug(self, message, **extra):
|
| 122 |
+
self._log(logging.DEBUG, message, **extra)
|
| 123 |
+
|
| 124 |
+
def warning(self, message, **extra):
|
| 125 |
+
self._log(logging.WARNING, message, **extra)
|
| 126 |
+
|
| 127 |
+
def error(self, message, **extra):
|
| 128 |
+
self._log(logging.ERROR, message, **extra)
|
| 129 |
+
|
| 130 |
+
def order_received(self, order_id, symbol, side, quantity, price, source=None):
|
| 131 |
+
"""Log order received event."""
|
| 132 |
+
self.info("order_received", event="order_received",
|
| 133 |
+
order_id=order_id, symbol=symbol, side=side,
|
| 134 |
+
quantity=quantity, price=price, source=source)
|
| 135 |
+
|
| 136 |
+
def trade_executed(self, trade_id, symbol, price, quantity, buy_id, sell_id):
|
| 137 |
+
"""Log trade execution event."""
|
| 138 |
+
self.info("trade_executed", event="trade_executed",
|
| 139 |
+
trade_id=trade_id, symbol=symbol, price=price,
|
| 140 |
+
quantity=quantity, buy_id=buy_id, sell_id=sell_id)
|
| 141 |
+
|
| 142 |
+
def order_cancelled(self, order_id, reason=None):
|
| 143 |
+
"""Log order cancellation event."""
|
| 144 |
+
self.info("order_cancelled", event="order_cancelled",
|
| 145 |
+
order_id=order_id, reason=reason)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def with_correlation_id(func):
|
| 149 |
+
"""Decorator to set correlation ID for request handling."""
|
| 150 |
+
@wraps(func)
|
| 151 |
+
def wrapper(*args, **kwargs):
|
| 152 |
+
# Check for correlation ID in headers (for Flask)
|
| 153 |
+
try:
|
| 154 |
+
from flask import request
|
| 155 |
+
correlation_id = request.headers.get('X-Correlation-ID')
|
| 156 |
+
if correlation_id:
|
| 157 |
+
set_correlation_id(correlation_id)
|
| 158 |
+
else:
|
| 159 |
+
set_correlation_id(str(uuid.uuid4())[:8])
|
| 160 |
+
except:
|
| 161 |
+
set_correlation_id(str(uuid.uuid4())[:8])
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
return func(*args, **kwargs)
|
| 165 |
+
finally:
|
| 166 |
+
clear_correlation_id()
|
| 167 |
+
|
| 168 |
+
return wrapper
|
shared_data/securities.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#SYMBOL <start_price> <current_price>
|
| 2 |
+
ALPHA 24.90 24.95
|
| 3 |
+
PEIR 18.50 18.05
|
| 4 |
+
EXAE 42.00 42.05
|
| 5 |
+
QUEST 12.70 12.60
|
snapshot_viewer/Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
RUN pip install --no-cache-dir kafka-python
|
| 5 |
+
|
| 6 |
+
COPY snapshot_viewer.py .
|
| 7 |
+
|
| 8 |
+
CMD ["python", "snapshot_viewer.py"]
|
snapshot_viewer/snapshot_viewer.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, "/app")
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
from shared.config import Config
|
| 11 |
+
from shared.kafka_utils import create_consumer
|
| 12 |
+
|
| 13 |
+
# keep only last BBO per symbol
|
| 14 |
+
last_bbo = {}
|
| 15 |
+
|
| 16 |
+
def pretty_print(snapshot):
|
| 17 |
+
bids = snapshot.get("bids", [])
|
| 18 |
+
asks = snapshot.get("asks", [])
|
| 19 |
+
|
| 20 |
+
valid_bids = [b for b in bids if b["qty"] > 0]
|
| 21 |
+
valid_asks = [a for a in asks if a["qty"] > 0]
|
| 22 |
+
|
| 23 |
+
best_bid = max(valid_bids, key=lambda b: b["price"], default=None)
|
| 24 |
+
best_ask = min(valid_asks, key=lambda a: a["price"], default=None)
|
| 25 |
+
|
| 26 |
+
symbol = snapshot.get("symbol", "UNKNOWN")
|
| 27 |
+
now = datetime.utcnow().strftime("%H:%M:%S")
|
| 28 |
+
|
| 29 |
+
last_bbo[symbol] = {
|
| 30 |
+
"best_bid": best_bid,
|
| 31 |
+
"best_ask": best_ask,
|
| 32 |
+
"time": now,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
print(f"\n Market Data Snapshot: {symbol} {now}")
|
| 36 |
+
|
| 37 |
+
print("\n BIDS:")
|
| 38 |
+
if best_bid:
|
| 39 |
+
print(f" {best_bid['qty']:>6} @ {best_bid['price']:.2f}")
|
| 40 |
+
else:
|
| 41 |
+
print(" None")
|
| 42 |
+
|
| 43 |
+
print("\n ASKS:")
|
| 44 |
+
if best_ask:
|
| 45 |
+
print(f" {best_ask['qty']:>6} @ {best_ask['price']:.2f}")
|
| 46 |
+
else:
|
| 47 |
+
print(" None")
|
| 48 |
+
|
| 49 |
+
print("\nBest Bid :", best_bid["price"] if best_bid else "None")
|
| 50 |
+
print("Best Ask:", best_ask["price"] if best_ask else "None")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
consumer = create_consumer(
|
| 55 |
+
topics=Config.SNAPSHOTS_TOPIC,
|
| 56 |
+
group_id="snapshot-viewer",
|
| 57 |
+
component_name="SnapshotViewer"
|
| 58 |
+
)
|
| 59 |
+
print(f"Subscribed to {Config.SNAPSHOTS_TOPIC}, showing only latest BBO per symbol\n")
|
| 60 |
+
|
| 61 |
+
for msg in consumer:
|
| 62 |
+
snapshot = msg.value
|
| 63 |
+
if snapshot.get("type") == "snapshot":
|
| 64 |
+
pretty_print(snapshot)
|