RayMelius Claude Sonnet 4.6 commited on
Commit
9e5fa5b
·
0 Parent(s):

Initial commit: StockEx trading platform

Browse files

Kafka-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>

Files changed (50) hide show
  1. .gitignore +50 -0
  2. Dockerfile.base +13 -0
  3. IMPROVEMENT_PLAN.md +271 -0
  4. README_OPTIQ.md +73 -0
  5. StockEx_Developer_Guide.html +569 -0
  6. StockEx_Technical_Guide.md +814 -0
  7. StockEx_User_Guide.html +428 -0
  8. consumer/Dockerfile +6 -0
  9. consumer/consumer.py +22 -0
  10. consumer/requirements.txt +2 -0
  11. dashboard/Dockerfile +10 -0
  12. dashboard/dashboard.py +249 -0
  13. dashboard/templates/index - Copy (6).html +198 -0
  14. dashboard/templates/index.html +964 -0
  15. dashboard/templates/index_Matcher.html +194 -0
  16. docker-compose.yml +164 -0
  17. fix-ui-client/Dockerfile +12 -0
  18. fix-ui-client/FIX44.xml +0 -0
  19. fix-ui-client/client1.cfg +21 -0
  20. fix-ui-client/client2.cfg +22 -0
  21. fix-ui-client/fix-ui-client.py +187 -0
  22. fix-ui-client/requirements.txt +0 -0
  23. fix-ui-client/templates/index.html +162 -0
  24. fix_oeg/Dockerfile +45 -0
  25. fix_oeg/FIX44.xml +0 -0
  26. fix_oeg/fix_oeg_server.py +341 -0
  27. fix_oeg/fix_server.cfg +27 -0
  28. fix_oeg/requirements.txt +2 -0
  29. frontend/Dockerfile +7 -0
  30. frontend/frontend.py +98 -0
  31. frontend/requirements.txt +4 -0
  32. frontend/templates/index.html +312 -0
  33. matcher/Dockerfile +6 -0
  34. matcher/database.py +242 -0
  35. matcher/matcher - Copy.py +135 -0
  36. matcher/matcher.py +547 -0
  37. matcher/requirements.txt +4 -0
  38. matcher/test_matcher.py +429 -0
  39. md_feeder/Dockerfile +8 -0
  40. md_feeder/mdf_simulator.py +133 -0
  41. oeg/Dockerfile +10 -0
  42. oeg/oeg_simulator.py +29 -0
  43. requirements.txt +2 -0
  44. shared/__init__.py +6 -0
  45. shared/config.py +31 -0
  46. shared/kafka_utils.py +114 -0
  47. shared/logging_utils.py +168 -0
  48. shared_data/securities.txt +5 -0
  49. snapshot_viewer/Dockerfile +8 -0
  50. 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
+ ![Trading Dashboard](screenshots/dashboard.png)
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/&lt;symbol&gt;</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/&lt;symbol&gt;</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)