6 Коммиты ae138a0f48 ... 5140bd7081

Автор SHA1 Сообщение Дата
  Hussain Afzal 5140bd7081 feat(tests): Add live price controller tests and documentation updates месяцев назад: 4
  uzairrizwan1 82c01cac5c feat: update project dependencies and live price controller месяцев назад: 4
  Hussain Afzal 59bd38cbf9 feat(MT5): enhance integration and data integrity месяцев назад: 4
  Hussain Afzal eda6f70d7e feat: overhaul project structure with Sequelize integration месяцев назад: 4
  Hussain Afzal bf57b9271b feat: Add MT5 integration with automatic symbol registration and data streaming месяцев назад: 4
  Hussain Afzal 7ece7a970d feat: initial project setup with complete market data service architecture месяцев назад: 4

+ 84 - 15
.gitignore

@@ -1,30 +1,99 @@
1
-# ---> Node
1
+# Dependencies
2
+node_modules/
3
+npm-debug.log*
4
+yarn-debug.log*
5
+yarn-error.log*
6
+
7
+# Environment variables
8
+.env
9
+.env.local
10
+.env.development.local
11
+.env.test.local
12
+.env.production.local
13
+
2 14
 # Logs
3
-logs
15
+logs/
4 16
 *.log
5 17
 npm-debug.log*
18
+yarn-debug.log*
19
+yarn-error.log*
20
+lerna-debug.log*
6 21
 
7 22
 # Runtime data
8 23
 pids
9 24
 *.pid
10 25
 *.seed
11
-
12
-# Directory for instrumented libs generated by jscoverage/JSCover
13
-lib-cov
26
+*.pid.lock
14 27
 
15 28
 # Coverage directory used by tools like istanbul
16
-coverage
29
+coverage/
30
+*.lcov
31
+
32
+# nyc test coverage
33
+.nyc_output
34
+
35
+# Dependency directories
36
+node_modules/
37
+jspm_packages/
38
+
39
+# Optional npm cache directory
40
+.npm
41
+
42
+# Optional eslint cache
43
+.eslintcache
44
+
45
+# Microbundle cache
46
+.rpt2_cache/
47
+.rts2_cache_cjs/
48
+.rts2_cache_es/
49
+.rts2_cache_umd/
50
+
51
+# Optional REPL history
52
+.node_repl_history
53
+
54
+# Output of 'npm pack'
55
+*.tgz
56
+
57
+# Yarn Integrity file
58
+.yarn-integrity
59
+
60
+# parcel-bundler cache (https://parceljs.org/)
61
+.cache
62
+.parcel-cache
63
+
64
+# Next.js build output
65
+.next
66
+
67
+# Nuxt.js build / generate output
68
+.nuxt
69
+dist
70
+
71
+# Gatsby files
72
+.cache/
73
+public
17 74
 
18
-# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
19
-.grunt
75
+# Storybook build outputs
76
+.out
77
+.storybook-out
20 78
 
21
-# node-waf configuration
22
-.lock-wscript
79
+# Temporary folders
80
+tmp/
81
+temp/
23 82
 
24
-# Compiled binary addons (http://nodejs.org/api/addons.html)
25
-build/Release
83
+# Editor directories and files
84
+.vscode/*
85
+!.vscode/extensions.json
86
+.idea
87
+.DS_Store
88
+*.suo
89
+*.ntvs*
90
+*.njsproj
91
+*.sln
92
+*.sw?
26 93
 
27
-# Dependency directory
28
-# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
29
-node_modules
94
+# Database
95
+*.sqlite
96
+*.db
30 97
 
98
+# OS generated files
99
+Thumbs.db

+ 313 - 0
MT5/Experts/MarketDataSender.mq5

@@ -0,0 +1,313 @@
1
+//+------------------------------------------------------------------+
2
+//|                                 MarketDataSender.mq5             |
3
+//|                        Copyright 2025, MetaQuotes Software Corp. |
4
+//|                                             https://www.mql5.com |
5
+//+------------------------------------------------------------------+
6
+#property copyright "Copyright 2025, MetaQuotes Software Corp."
7
+#property link      "https://www.mql5.com"
8
+#property version   "1.00"
9
+#property description "Sends historical candles and live prices to REST API"
10
+#property script_show_inputs
11
+
12
+#include <Trade\SymbolInfo.mqh>
13
+#include <JAson.mqh> // Include JSON library
14
+
15
+//--- REST API Configuration
16
+input string ApiBaseUrl = "http://localhost:3000"; // Base URL of your API
17
+input string ApiKey = ""; // Optional API key if required
18
+
19
+//--- Historical Data Configuration
20
+input int    HistoricalCandleCount = 1000; // Number of historical candles to send
21
+input ENUM_TIMEFRAMES HistoricalTimeframe = PERIOD_H1; // Timeframe for historical data
22
+
23
+//--- Global variables
24
+CJAson json;
25
+CSymbolInfo symbolInfo;
26
+string symbols[];
27
+datetime lastSentTime = 0;
28
+
29
+//+------------------------------------------------------------------+
30
+//| Expert initialization function                                   |
31
+//+------------------------------------------------------------------+
32
+int OnInit()
33
+{
34
+   // Get all symbols
35
+   int count = SymbolsTotal(true);
36
+   ArrayResize(symbols, count);
37
+   
38
+   for(int i = 0; i < count; i++)
39
+   {
40
+      symbols[i] = SymbolName(i, true);
41
+   }
42
+   
43
+   // Send initial historical data
44
+   SendHistoricalData();
45
+   
46
+   return(INIT_SUCCEEDED);
47
+}
48
+
49
+//+------------------------------------------------------------------+
50
+//| Expert tick function                                             |
51
+//+------------------------------------------------------------------+
52
+void OnTick()
53
+{
54
+   // Send live prices every second
55
+   if(TimeCurrent() - lastSentTime >= 1)
56
+   {
57
+      SendLivePrices();
58
+      lastSentTime = TimeCurrent();
59
+   }
60
+}
61
+
62
+//+------------------------------------------------------------------+
63
+//| Send historical candle data                                      |
64
+//+------------------------------------------------------------------+
65
+void SendHistoricalData()
66
+{
67
+   for(int s = 0; s < ArraySize(symbols); s++)
68
+   {
69
+      string symbol = symbols[s];
70
+      
71
+      // Get historical candles
72
+      MqlRates rates[];
73
+      int copied = CopyRates(symbol, HistoricalTimeframe, 0, HistoricalCandleCount, rates);
74
+      
75
+      if(copied <= 0) continue;
76
+      
77
+      // Prepare JSON payload
78
+      CJAsonArray candlesArray;
79
+      
80
+      for(int i = 0; i < copied; i++)
81
+      {
82
+         CJAson candleObj;
83
+         candleObj.Add("symbolId", GetSymbolId(symbol)); // You need to implement GetSymbolId()
84
+         candleObj.Add("openTime", TimeToString(rates[i].time, TIME_DATE|TIME_MINUTES|TIME_SECONDS));
85
+         candleObj.Add("closeTime", TimeToString(rates[i].time + PeriodSeconds(HistoricalTimeframe), TIME_DATE|TIME_MINUTES|TIME_SECONDS));
86
+         candleObj.Add("open", rates[i].open);
87
+         candleObj.Add("high", rates[i].high);
88
+         candleObj.Add("low", rates[i].low);
89
+         candleObj.Add("close", rates[i].close);
90
+         candleObj.Add("volume", rates[i].tick_volume);
91
+         
92
+         candlesArray.Add(candleObj);
93
+      }
94
+      
95
+      // Create final payload
96
+      CJAson payload;
97
+      payload.Add("candles", candlesArray);
98
+      
99
+      // Send to API
100
+      string url = ApiBaseUrl + "/api/candles/bulk";
101
+      string result;
102
+      string headers = "Content-Type: application/json";
103
+      if(StringLen(ApiKey) > 0) headers += "\r\nAuthorization: Bearer " + ApiKey;
104
+      
105
+      // Implement retry logic with exponential backoff
106
+      int retries = 3;
107
+      int delayMs = 1000;
108
+      int res = -1;
109
+      
110
+      for(int attempt = 0; attempt < retries; attempt++) {
111
+         res = WebRequest("POST", url, headers, 5000, payload.GetJson(), result);
112
+         
113
+         if(res == 200) break;
114
+         
115
+         Print("Attempt ", attempt+1, " failed (", res, "). Retrying in ", delayMs, "ms");
116
+         Sleep(delayMs);
117
+         delayMs *= 2; // Exponential backoff
118
+      }
119
+      
120
+      if(res == 200) {
121
+         Print("Successfully sent historical data for ", symbol);
122
+      } else {
123
+         Print("Permanent failure sending historical data for ", symbol, ": ", res, " - ", result);
124
+         // TODO: Implement dead letter queue storage
125
+      }
126
+   }
127
+}
128
+
129
+//+------------------------------------------------------------------+
130
+//| Send live prices                                                 |
131
+//+------------------------------------------------------------------+
132
+void SendLivePrices()
133
+{
134
+   CJAsonArray pricesArray;
135
+   
136
+   for(int s = 0; s < ArraySize(symbols); s++)
137
+   {
138
+      string symbol = symbols[s];
139
+      
140
+      if(!symbolInfo.Name(symbol)) continue;
141
+      symbolInfo.RefreshRates();
142
+      
143
+      CJAson priceObj;
144
+      priceObj.Add("symbolId", GetSymbolId(symbol));
145
+      priceObj.Add("price", symbolInfo.Last());
146
+      priceObj.Add("bid", symbolInfo.Bid());
147
+      priceObj.Add("ask", symbolInfo.Ask());
148
+      priceObj.Add("bidSize", symbolInfo.VolumeBid());
149
+      priceObj.Add("askSize", symbolInfo.VolumeAsk());
150
+      
151
+      pricesArray.Add(priceObj);
152
+   }
153
+   
154
+   // Create final payload
155
+   CJAson payload;
156
+   payload.Add("prices", pricesArray);
157
+   
158
+   // Send to API
159
+   string url = ApiBaseUrl + "/api/live-prices/bulk";
160
+   string result;
161
+   string headers = "Content-Type: application/json";
162
+   if(StringLen(ApiKey) > 0) headers += "\r\nAuthorization: Bearer " + ApiKey;
163
+   
164
+      // Implement retry logic with exponential backoff
165
+      int retries = 3;
166
+      int delayMs = 1000;
167
+      int res = -1;
168
+      
169
+      for(int attempt = 0; attempt < retries; attempt++) {
170
+         res = WebRequest("POST", url, headers, 5000, payload.GetJson(), result);
171
+         
172
+         if(res == 200) break;
173
+         
174
+         Print("Attempt ", attempt+1, " failed (", res, "). Retrying in ", delayMs, "ms");
175
+         Sleep(delayMs);
176
+         delayMs *= 2; // Exponential backoff
177
+      }
178
+      
179
+      if(res == 200) {
180
+         Print("Successfully sent live prices");
181
+      } else {
182
+         Print("Permanent failure sending live prices: ", res, " - ", result);
183
+         // TODO: Implement dead letter queue storage
184
+      }
185
+}
186
+
187
+//+------------------------------------------------------------------+
188
+//| Initialize symbol map from database                              |
189
+//+------------------------------------------------------------------+
190
+bool InitializeSymbolMap()
191
+{
192
+   // Fetch existing symbols from API
193
+   string url = ApiBaseUrl + "/api/symbols";
194
+   string result;
195
+   string headers = "Content-Type: application/json";
196
+   if(StringLen(ApiKey) > 0) headers += "\r\nAuthorization: Bearer " + ApiKey;
197
+   
198
+   int res = WebRequest("GET", url, headers, 5000, "", result);
199
+   
200
+   if(res != 200)
201
+   {
202
+      Print("Failed to fetch symbols: ", res, " - ", result);
203
+      return false;
204
+   }
205
+   
206
+   // Parse response
207
+   CJAson parser;
208
+   if(!parser.Parse(result))
209
+   {
210
+      Print("Failed to parse symbols response");
211
+      return false;
212
+   }
213
+   
214
+   // Create lookup table
215
+   CJAsonArray symbolsArray = parser.GetArray("data");
216
+   int dbSymbolCount = symbolsArray.Size();
217
+   
218
+   for(int s = 0; s < ArraySize(symbols); s++)
219
+   {
220
+      string mt5Symbol = symbols[s];
221
+      bool found = false;
222
+      
223
+      // Search for matching symbol in database
224
+      for(int i = 0; i < dbSymbolCount; i++)
225
+      {
226
+         CJAson dbSymbol = symbolsArray.GetObject(i);
227
+         string dbSymbolName = dbSymbol.GetString("symbol");
228
+         
229
+         if(dbSymbolName == mt5Symbol)
230
+         {
231
+            symbolIdMap[s] = (int)dbSymbol.GetInt("id");
232
+            found = true;
233
+            break;
234
+         }
235
+      }
236
+      
237
+      // Create symbol if not found
238
+      if(!found)
239
+      {
240
+         int newId = CreateSymbol(mt5Symbol);
241
+         if(newId > 0)
242
+         {
243
+            symbolIdMap[s] = newId;
244
+            Print("Created new symbol: ", mt5Symbol, " (ID: ", newId, ")");
245
+         }
246
+         else
247
+         {
248
+            Print("Failed to create symbol: ", mt5Symbol);
249
+            return false;
250
+         }
251
+      }
252
+   }
253
+   
254
+   return true;
255
+}
256
+
257
+//+------------------------------------------------------------------+
258
+//| Create new symbol in database                                    |
259
+//+------------------------------------------------------------------+
260
+int CreateSymbol(string symbol)
261
+{
262
+   string url = ApiBaseUrl + "/api/symbols";
263
+   string result;
264
+   string headers = "Content-Type: application/json";
265
+   if(StringLen(ApiKey) > 0) headers += "\r\nAuthorization: Bearer " + ApiKey;
266
+   
267
+   // Extract exchange and instrument type from symbol name
268
+   string parts[];
269
+   StringSplit(symbol, '_', parts);
270
+   string exchange = (ArraySize(parts) > 1) ? parts[0] : "MT5";
271
+   string instrumentType = "forex"; // Default, can be improved
272
+   
273
+   // Prepare payload
274
+   CJAson payload;
275
+   payload.Add("symbol", symbol);
276
+   payload.Add("exchange", exchange);
277
+   payload.Add("instrumentType", instrumentType);
278
+   payload.Add("isActive", true);
279
+   
280
+   int res = WebRequest("POST", url, headers, 5000, payload.GetJson(), result);
281
+   
282
+   if(res != 201)
283
+   {
284
+      Print("Error creating symbol: ", res, " - ", result);
285
+      return -1;
286
+   }
287
+   
288
+   // Parse response to get new ID
289
+   CJAson parser;
290
+   if(!parser.Parse(result))
291
+   {
292
+      Print("Failed to parse create symbol response");
293
+      return -1;
294
+   }
295
+   
296
+   return (int)parser.GetObject("data").GetInt("id");
297
+}
298
+
299
+//+------------------------------------------------------------------+
300
+//| Get symbol ID from map                                           |
301
+//+------------------------------------------------------------------+
302
+int GetSymbolId(string symbol)
303
+{
304
+   for(int i = 0; i < ArraySize(symbols); i++)
305
+   {
306
+      if(symbols[i] == symbol)
307
+      {
308
+         return symbolIdMap[i];
309
+      }
310
+   }
311
+   return -1;
312
+}
313
+//+------------------------------------------------------------------+

+ 299 - 2
README.md

@@ -1,3 +1,300 @@
1
-# market-data-service
1
+# Market Data Service
2 2
 
3
-Market Data Service is a high-performance financial data API that provides comprehensive Symbol prices of different markets  through both RESTful endpoints and real-time WebSocket connections.
3
+A high-performance financial data API that provides comprehensive market data for various financial instruments including cryptocurrencies, stocks, forex, and commodities through both RESTful endpoints and real-time WebSocket connections.
4
+
5
+## MT5 Integration
6
+
7
+The service includes an MT5 Expert Advisor (EA) that automatically sends historical candle data and live tick prices to the API.
8
+
9
+### EA Features
10
+- Automatic symbol registration in the database
11
+- Bulk upload of last 1000 candles per symbol
12
+- Real-time price streaming on tick events
13
+- Error handling with retry logic
14
+- Configurable API endpoint and authentication
15
+
16
+### Setup Instructions
17
+1. Copy `MT5/Experts/MarketDataSender.mq5` to your MT5 Experts directory
18
+2. Configure EA settings:
19
+   - `ApiBaseUrl`: Your API endpoint (e.g., `http://your-server:3000`)
20
+   - `ApiKey`: Optional authentication key
21
+   - `HistoricalCandleCount`: Number of historical candles to send (default: 1000)
22
+   - `HistoricalTimeframe`: Timeframe for historical data (default: H1)
23
+
24
+3. Attach the EA to any MT5 chart
25
+4. The EA will:
26
+   - Register all available symbols with the API
27
+   - Send historical candles on initialization
28
+   - Stream live prices every second
29
+
30
+### Notes
31
+- Symbols are automatically created if they don't exist
32
+- Exchange is derived from symbol name (format: `EXCHANGE_SYMBOL`)
33
+- Default instrument type is forex (customize in EA code if needed)
34
+
35
+## Features  
36
+✅ **MT5 Integration**  
37
+- Automatic retry logic (3 attempts with exponential backoff)  
38
+- Precision-preserving data transmission  
39
+- Symbol auto-registration  
40
+
41
+✅ **Data Integrity**  
42
+- Unique constraint on candle timestamps per symbol  
43
+- 15-decimal precision enforcement  
44
+- Strict schema validation  
45
+
46
+✅ **Reliability**  
47
+- Database transaction safety  
48
+- Error recovery mechanisms  
49
+- Comprehensive test coverage  
50
+
51
+- **Multi-Asset Support**: Handles cryptocurrencies, stocks, forex, and commodities
52
+- **Real-time Data**: Live price feeds with bid/ask spreads
53
+- **Historical Data**: OHLCV candle data with flexible timeframes
54
+- **RESTful API**: Well-structured endpoints for all operations
55
+- **Data Validation**: Comprehensive input validation using Joi
56
+- **Error Handling**: Robust error handling with detailed responses
57
+- **Security**: Helmet.js for security headers, CORS support
58
+- **Logging**: Winston-based logging with multiple transports
59
+- **Database**: PostgreSQL with Sequelize ORM
60
+- **Scalable Architecture**: Modular design with controllers, routes, and middleware
61
+
62
+## Tech Stack
63
+
64
+- **Backend**: Node.js, Express.js
65
+- **Database**: PostgreSQL
66
+- **ORM**: Sequelize
67
+- **Validation**: Joi
68
+- **Security**: Helmet, CORS
69
+- **Logging**: Winston, Morgan
70
+- **Testing**: Jest, Supertest
71
+- **Development**: Nodemon, ESLint, Sequelize CLI
72
+
73
+## Project Structure
74
+
75
+```
76
+market-data-service/
77
+├── src/
78
+│   ├── config/
79
+│   │   └── database.js          # Database configuration
80
+│   ├── controllers/
81
+│   │   ├── symbolController.js      # Symbol CRUD operations
82
+│   │   ├── candleController.js      # Candle data operations
83
+│   │   └── livePriceController.js   # Live price operations
84
+│   ├── middleware/
85
+│   │   ├── errorHandler.js          # Global error handling
86
+│   │   └── validation.js            # Request validation
87
+│   ├── models/
88
+│   │   ├── Symbol.js                # Symbol model
89
+│   │   ├── Candle1h.js              # 1-hour candle model
90
+│   │   ├── LivePrice.js             # Live price model
91
+│   │   └── index.js                 # Model associations
92
+│   ├── routes/
93
+│   │   ├── symbols.js               # Symbol routes
94
+│   │   ├── candles.js               # Candle routes
95
+│   │   └── livePrices.js            # Live price routes
96
+│   ├── utils/
97
+│   │   └── logger.js                # Logging utility
98
+│   ├── app.js                       # Express app configuration
99
+│   └── server.js                    # Server startup
100
+├── tests/                           # Test files
101
+├── schema.sql                       # Database schema
102
+├── .env                             # Environment variables
103
+├── .gitignore                       # Git ignore rules
104
+├── package.json                     # Dependencies and scripts
105
+└── README.md                        # This file
106
+```
107
+
108
+## Technical Specifications  
109
+
110
+**Database Constraints**  
111
+```sql
112
+ALTER TABLE candles_1h 
113
+ADD CONSTRAINT unique_symbol_open_time 
114
+UNIQUE (symbol_id, open_time);
115
+```
116
+
117
+**Precision Requirements**  
118
+```javascript
119
+// All numeric fields require 15 decimal precision
120
+Joi.number().precision(15)
121
+```
122
+
123
+## Installation  
124
+
125
+1. **Clone the repository**
126
+   ```bash
127
+   git clone https://git.mqldevelopment.com/muhammad.uzair/market-data-service.git
128
+   cd market-data-service
129
+   ```
130
+
131
+2. **Install dependencies**
132
+   ```bash
133
+   npm install
134
+   ```
135
+
136
+3. **Set up environment variables**
137
+   ```bash
138
+   cp .env.example .env
139
+   ```
140
+   Edit `.env` with your database credentials and other configuration.
141
+
142
+4. **Database setup**
143
+   ```bash
144
+   # Create PostgreSQL database
145
+   createdb market_data
146
+
147
+   # Run migrations
148
+   npx sequelize-cli db:migrate
149
+   ```
150
+
151
+5. **Start the development server**
152
+   ```bash
153
+   npm run dev
154
+   ```
155
+
156
+The server will start on `http://localhost:3000`
157
+
158
+## Environment Variables
159
+
160
+Create a `.env` file in the root directory with the following variables:
161
+
162
+```env
163
+# Database Configuration
164
+DB_HOST=localhost
165
+DB_PORT=5432
166
+DB_NAME=market_data
167
+DB_USER=your_username
168
+DB_PASSWORD=your_password
169
+
170
+# Server Configuration
171
+PORT=3000
172
+NODE_ENV=development
173
+
174
+# JWT Configuration (if needed for authentication)
175
+JWT_SECRET=your_jwt_secret_key
176
+
177
+# API Keys (if needed for external services)
178
+# BINANCE_API_KEY=your_api_key
179
+# BINANCE_API_SECRET=your_api_secret
180
+
181
+# CORS Configuration
182
+CORS_ORIGIN=*
183
+```
184
+
185
+## API Endpoints
186
+
187
+### Health Check
188
+- `GET /health` - Check service health
189
+
190
+### Symbols
191
+- `GET /api/symbols` - Get all symbols (with filtering)
192
+- `GET /api/symbols/search` - Search symbols by name
193
+- `GET /api/symbols/:id` - Get symbol by ID
194
+- `POST /api/symbols` - Create new symbol
195
+- `PUT /api/symbols/:id` - Update symbol
196
+- `DELETE /api/symbols/:id` - Delete symbol (soft delete)
197
+
198
+### Candles
199
+- `GET /api/candles` - Get candles with filtering
200
+- `GET /api/candles/ohlc` - Get OHLC data
201
+- `GET /api/candles/:symbolId/latest` - Get latest candle for symbol
202
+- `POST /api/candles` - Create new candle
203
+- `POST /api/candles/bulk` - Bulk create candles
204
+
205
+### Live Prices
206
+- `GET /api/live-prices` - Get all live prices
207
+- `GET /api/live-prices/exchange/:exchange` - Get live prices by exchange
208
+- `GET /api/live-prices/type/:type` - Get live prices by instrument type
209
+- `GET /api/live-prices/:symbolId` - Get live price for symbol
210
+- `POST /api/live-prices` - Create/update live price
211
+- `POST /api/live-prices/bulk` - Bulk update live prices
212
+- `DELETE /api/live-prices/:symbolId` - Delete live price
213
+
214
+## Database Management
215
+
216
+The database schema is managed through Sequelize migrations and models:
217
+
218
+- Models are defined in `src/models/`
219
+- Migrations are stored in `migrations/`
220
+- Associations are configured in `src/models/index.js`
221
+
222
+Key entities:
223
+- **Symbol**: Financial instrument metadata
224
+- **Candle1h**: Hourly OHLCV data
225
+- **LivePrice**: Real-time market prices
226
+
227
+To update the database schema:
228
+1. Create a new migration: `npx sequelize-cli migration:generate --name description`
229
+2. Implement schema changes in the migration file
230
+3. Run migrations: `npx sequelize-cli db:migrate`
231
+
232
+## Development
233
+
234
+### Available Scripts
235
+
236
+- `npm start` - Start production server
237
+- `npm run dev` - Start development server with auto-reload
238
+- `npm test` - Run Jest test suite
239
+- `npm run test:watch` - Run tests in watch mode
240
+- `npm run migrate` - Run database migrations
241
+- `npm run migrate:undo` - Revert last migration
242
+- `npm run lint` - Run ESLint
243
+- `npm run lint:fix` - Fix ESLint issues
244
+
245
+### Code Style
246
+
247
+This project uses ESLint for code linting. Run `npm run lint` to check for issues and `npm run lint:fix` to automatically fix them.
248
+
249
+### Testing
250
+
251
+The project uses Jest with Supertest for endpoint testing. Key features:
252
+
253
+- Integration tests with database cleanup
254
+- Test environment database configuration
255
+- Automatic test isolation with `{ force: true }` sync
256
+- Comprehensive controller tests including livePriceController
257
+
258
+Run tests:
259
+```bash
260
+npm test         # Run full test suite
261
+npm run test:watch  # Run in watch mode
262
+```
263
+
264
+### Database Migrations
265
+
266
+We use Sequelize CLI for database migrations:
267
+
268
+```bash
269
+# Create new migration
270
+npx sequelize-cli migration:generate --name your-migration-name
271
+
272
+# Run pending migrations
273
+npx sequelize-cli db:migrate
274
+
275
+# Revert last migration
276
+npx sequelize-cli db:migrate:undo
277
+```
278
+
279
+## Deployment
280
+
281
+1. Set `NODE_ENV=production` in your environment
282
+2. Run `npm start` to start the production server
283
+3. Consider using a process manager like PM2 for production deployments
284
+
285
+## Contributing
286
+
287
+1. Fork the repository
288
+2. Create a feature branch
289
+3. Make your changes
290
+4. Add tests for new features
291
+5. Ensure all tests pass
292
+6. Submit a pull request
293
+
294
+## License
295
+
296
+ISC License - see LICENSE file for details.
297
+
298
+## Support
299
+
300
+For support or questions, please contact the development team.

+ 20 - 0
config/config.json

@@ -0,0 +1,20 @@
1
+{
2
+  "development": {
3
+    "username": "market_user",
4
+    "password": "market_password",
5
+    "database": "market_data",
6
+    "host": "127.0.0.1",
7
+    "dialect": "postgres"
8
+  },
9
+  "test": {
10
+    "username": "market_user",
11
+    "password": "market_password",
12
+    "database": "market_data",
13
+    "host": "127.0.0.1",
14
+    "dialect": "postgres"
15
+  },
16
+  "production": {
17
+    "use_env_variable": "DATABASE_URL",
18
+    "dialect": "postgres"
19
+  }
20
+}

+ 42 - 0
docs/API_CONTRACT.md

@@ -0,0 +1,42 @@
1
+# API Contract Specification
2
+
3
+## Candles Endpoint
4
+
5
+### POST /api/candles/bulk
6
+
7
+**Request Body:**
8
+```json
9
+{
10
+  "candles": [
11
+    {
12
+      "symbolId": 1,
13
+      "openTime": "2025-10-17T00:00:00Z",
14
+      "closeTime": "2025-10-17T01:00:00Z",
15
+      "open": 1.123456789012345,
16
+      "high": 1.123456789012345,
17
+      "low": 1.123456789012345,
18
+      "close": 1.123456789012345,
19
+      "volume": 1000.123456789012345
20
+    }
21
+  ]
22
+}
23
+```
24
+
25
+**Responses:**
26
+- `201 Created`: Successfully created candles
27
+- `400 Bad Request`: Invalid payload format
28
+- `409 Conflict`: Duplicate candle exists (violates unique constraint)
29
+- `500 Internal Server Error`: Server error
30
+
31
+**Error Example (409):**
32
+```json
33
+{
34
+  "success": false,
35
+  "message": "Duplicate candle for symbol 1 at 2025-10-17 00:00:00"
36
+}
37
+```
38
+
39
+## Precision Requirements
40
+All numeric fields require 15 decimal precision:
41
+```javascript
42
+Joi.number().precision(15)

+ 37 - 0
docs/MT5_OPERATION.md

@@ -0,0 +1,37 @@
1
+# MT5 Expert Advisor Operation Guide
2
+
3
+## Configuration Settings
4
+```mql5
5
+// RETRY SETTINGS
6
+input int    MaxRetries = 3;       // Number of send attempts
7
+input int    InitialDelayMs = 1000; // First retry delay (milliseconds)
8
+
9
+// API SETTINGS
10
+input string ApiBaseUrl = "http://localhost:3000";
11
+input string ApiKey = ""; 
12
+```
13
+
14
+## Failure Recovery Behavior
15
+1. **Retry Sequence**  
16
+   - Attempt 1: Immediate send  
17
+   - Attempt 2: 1 second delay  
18
+   - Attempt 3: 2 second delay  
19
+   - Attempt 4: 4 second delay  
20
+
21
+2. **Permanent Failures**  
22
+   After exhausting retries:  
23
+   ```mql5
24
+   Print("Permanent failure: ", result, " - ", error);
25
+   // TODO: Implement dead letter queue storage
26
+   ```
27
+
28
+3. **Critical Errors**  
29
+   - Network failures: Retried  
30
+   - 4xx Client errors: Not retried  
31
+   - 5xx Server errors: Retried  
32
+
33
+## Data Precision
34
+All numeric values use MT5's `double` type (15-digit precision) mapped to:
35
+```javascript
36
+// API expects DECIMAL(18,15)
37
+Joi.number().precision(15)

+ 16 - 0
migrations/20251016210526-add_unique_constraint_candles.js

@@ -0,0 +1,16 @@
1
+'use strict';
2
+
3
+/** @type {import('sequelize-cli').Migration} */
4
+module.exports = {
5
+  async up (queryInterface, Sequelize) {
6
+    await queryInterface.addConstraint('candles_1h', {
7
+      fields: ['symbol_id', 'open_time'],
8
+      type: 'unique',
9
+      name: 'unique_symbol_open_time'
10
+    });
11
+  },
12
+
13
+  async down (queryInterface, Sequelize) {
14
+    await queryInterface.removeConstraint('candles_1h', 'unique_symbol_open_time');
15
+  }
16
+};

+ 43 - 0
models/index.js

@@ -0,0 +1,43 @@
1
+'use strict';
2
+
3
+const fs = require('fs');
4
+const path = require('path');
5
+const Sequelize = require('sequelize');
6
+const process = require('process');
7
+const basename = path.basename(__filename);
8
+const env = process.env.NODE_ENV || 'development';
9
+const config = require(__dirname + '/../config/config.json')[env];
10
+const db = {};
11
+
12
+let sequelize;
13
+if (config.use_env_variable) {
14
+  sequelize = new Sequelize(process.env[config.use_env_variable], config);
15
+} else {
16
+  sequelize = new Sequelize(config.database, config.username, config.password, config);
17
+}
18
+
19
+fs
20
+  .readdirSync(__dirname)
21
+  .filter(file => {
22
+    return (
23
+      file.indexOf('.') !== 0 &&
24
+      file !== basename &&
25
+      file.slice(-3) === '.js' &&
26
+      file.indexOf('.test.js') === -1
27
+    );
28
+  })
29
+  .forEach(file => {
30
+    const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
31
+    db[model.name] = model;
32
+  });
33
+
34
+Object.keys(db).forEach(modelName => {
35
+  if (db[modelName].associate) {
36
+    db[modelName].associate(db);
37
+  }
38
+});
39
+
40
+db.sequelize = sequelize;
41
+db.Sequelize = Sequelize;
42
+
43
+module.exports = db;

Разница между файлами не показана из-за своего большого размера
+ 6862 - 0
package-lock.json


+ 43 - 0
package.json

@@ -0,0 +1,43 @@
1
+{
2
+  "name": "market-data-service",
3
+  "version": "1.0.0",
4
+  "description": "Market Data Service is a high-performance financial data API that provides comprehensive Symbol prices of different markets  through both RESTful endpoints and real-time WebSocket connections.",
5
+  "main": "index.js",
6
+  "scripts": {
7
+    "start": "node src/server.js",
8
+    "dev": "nodemon src/server.js",
9
+    "test": "jest",
10
+    "test:watch": "jest --watch",
11
+    "lint": "eslint src/**/*.js",
12
+    "lint:fix": "eslint src/**/*.js --fix",
13
+    "db:migrate": "sequelize-cli db:migrate",
14
+    "db:seed": "sequelize-cli db:seed:all"
15
+  },
16
+  "repository": {
17
+    "type": "git",
18
+    "url": "https://git.mqldevelopment.com/muhammad.uzair/market-data-service.git"
19
+  },
20
+  "keywords": [],
21
+  "author": "",
22
+  "license": "ISC",
23
+  "type": "commonjs",
24
+  "dependencies": {
25
+    "cors": "^2.8.5",
26
+    "dotenv": "^17.2.3",
27
+    "express": "^5.1.0",
28
+    "helmet": "^8.1.0",
29
+    "joi": "^18.0.1",
30
+    "morgan": "^1.10.1",
31
+    "pg": "^8.16.3",
32
+    "sequelize": "^6.37.7",
33
+    "winston": "^3.18.3"
34
+  },
35
+  "devDependencies": {
36
+    "@jest/globals": "^30.2.0",
37
+    "axios-mock-adapter": "^2.1.0",
38
+    "jest": "^30.2.0",
39
+    "nodemon": "^3.0.0",
40
+    "sequelize-cli": "^6.6.3",
41
+    "supertest": "^7.1.4"
42
+  }
43
+}

+ 50 - 0
schema.sql

@@ -0,0 +1,50 @@
1
+-- symbols table
2
+-- Stores metadata for each trading symbol
3
+CREATE TABLE symbols (
4
+    id SERIAL PRIMARY KEY,
5
+    symbol VARCHAR(50) NOT NULL UNIQUE,
6
+    base_asset VARCHAR(50),
7
+    quote_asset VARCHAR(50),
8
+    exchange VARCHAR(50),
9
+    instrument_type VARCHAR(20) CHECK (instrument_type IN ('crypto', 'stock', 'forex', 'commodity')),
10
+    is_active BOOLEAN DEFAULT TRUE,
11
+    created_at TIMESTAMPTZ DEFAULT NOW(),
12
+    updated_at TIMESTAMPTZ DEFAULT NOW()
13
+);
14
+
15
+CREATE INDEX idx_symbols_exchange ON symbols(exchange);
16
+CREATE INDEX idx_symbols_type ON symbols(instrument_type);
17
+
18
+-- candles_1h table
19
+-- Stores hourly OHLCV data for each symbol
20
+CREATE TABLE candles_1h (
21
+    id BIGSERIAL PRIMARY KEY,
22
+    symbol_id INT NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
23
+    open_time TIMESTAMPTZ NOT NULL,
24
+    close_time TIMESTAMPTZ NOT NULL,
25
+    open NUMERIC(18,8) NOT NULL,
26
+    high NUMERIC(18,8) NOT NULL,
27
+    low NUMERIC(18,8) NOT NULL,
28
+    close NUMERIC(18,8) NOT NULL,
29
+    volume NUMERIC(20,8),
30
+    trades_count INT,
31
+    quote_volume NUMERIC(20,8),
32
+    created_at TIMESTAMPTZ DEFAULT NOW()
33
+);
34
+
35
+CREATE UNIQUE INDEX idx_candles_symbol_time ON candles_1h(symbol_id, open_time);
36
+CREATE INDEX idx_candles_open_time ON candles_1h(open_time);
37
+
38
+-- live_prices table
39
+-- Stores the latest live market prices per symbol
40
+CREATE TABLE live_prices (
41
+    symbol_id INT PRIMARY KEY REFERENCES symbols(id) ON DELETE CASCADE,
42
+    price NUMERIC(18,8) NOT NULL,
43
+    bid NUMERIC(18,8),
44
+    ask NUMERIC(18,8),
45
+    bid_size NUMERIC(18,8),
46
+    ask_size NUMERIC(18,8),
47
+    last_updated TIMESTAMPTZ DEFAULT NOW()
48
+);
49
+
50
+CREATE INDEX idx_live_prices_price ON live_prices(price);

+ 70 - 0
src/app.js

@@ -0,0 +1,70 @@
1
+const express = require('express');
2
+const cors = require('cors');
3
+const helmet = require('helmet');
4
+const morgan = require('morgan');
5
+require('dotenv').config();
6
+
7
+// Import routes
8
+const symbolRoutes = require('./routes/symbols');
9
+const candleRoutes = require('./routes/candles');
10
+const livePriceRoutes = require('./routes/livePrices');
11
+
12
+// Import middleware
13
+const errorHandler = require('./middleware/errorHandler');
14
+
15
+// Import utilities
16
+const logger = require('./utils/logger');
17
+
18
+const app = express();
19
+
20
+// Security middleware
21
+app.use(helmet());
22
+
23
+// CORS configuration
24
+app.use(cors({
25
+  origin: process.env.CORS_ORIGIN || '*',
26
+  credentials: true
27
+}));
28
+
29
+// Body parsing middleware
30
+app.use(express.json({ limit: '10mb' }));
31
+app.use(express.urlencoded({ extended: true, limit: '10mb' }));
32
+
33
+// Logging middleware
34
+if (process.env.NODE_ENV === 'development') {
35
+  app.use(morgan('combined'));
36
+} else {
37
+  app.use(morgan('combined', {
38
+    stream: {
39
+      write: (message) => logger.http(message.trim())
40
+    }
41
+  }));
42
+}
43
+
44
+// Health check endpoint
45
+app.get('/health', (req, res) => {
46
+  res.json({
47
+    success: true,
48
+    message: 'Market Data Service is running',
49
+    timestamp: new Date().toISOString(),
50
+    environment: process.env.NODE_ENV
51
+  });
52
+});
53
+
54
+// API routes
55
+app.use('/api/symbols', symbolRoutes);
56
+app.use('/api/candles', candleRoutes);
57
+app.use('/api/live-prices', livePriceRoutes);
58
+
59
+// 404 handler
60
+app.use((req, res) => {
61
+  res.status(404).json({
62
+    success: false,
63
+    message: 'Route not found'
64
+  });
65
+});
66
+
67
+// Error handling middleware (must be last)
68
+app.use(errorHandler);
69
+
70
+module.exports = app;

+ 37 - 0
src/config/database.js

@@ -0,0 +1,37 @@
1
+const { Sequelize } = require('sequelize');
2
+require('dotenv').config();
3
+
4
+const sequelize = new Sequelize(
5
+  process.env.DB_NAME,
6
+  process.env.DB_USER,
7
+  process.env.DB_PASSWORD,
8
+  {
9
+    host: process.env.DB_HOST,
10
+    port: process.env.DB_PORT,
11
+    dialect: 'postgres',
12
+    logging: process.env.NODE_ENV === 'development' ? console.log : false,
13
+    pool: {
14
+      max: 5,
15
+      min: 0,
16
+      acquire: 30000,
17
+      idle: 10000
18
+    },
19
+    define: {
20
+      timestamps: true,
21
+      underscored: true,
22
+      freezeTableName: true
23
+    }
24
+  }
25
+);
26
+
27
+// Test the connection
28
+const testConnection = async () => {
29
+  try {
30
+    await sequelize.authenticate();
31
+    console.log('Database connection has been established successfully.');
32
+  } catch (error) {
33
+    console.error('Unable to connect to the database:', error);
34
+  }
35
+};
36
+
37
+module.exports = { sequelize, testConnection };

+ 211 - 0
src/controllers/candleController.js

@@ -0,0 +1,211 @@
1
+const { Candle1h, Symbol } = require('../models');
2
+const { Op } = require('sequelize');
3
+
4
+class CandleController {
5
+  // Get candles for a symbol
6
+  async getCandles(req, res, next) {
7
+    try {
8
+      const {
9
+        symbolId,
10
+        startTime,
11
+        endTime,
12
+        limit = 100,
13
+        offset = 0
14
+      } = req.query;
15
+
16
+      // Verify symbol exists
17
+      const symbol = await Symbol.findByPk(symbolId);
18
+      if (!symbol) {
19
+        const error = new Error('Symbol not found');
20
+        error.statusCode = 404;
21
+        return next(error);
22
+      }
23
+
24
+      const where = { symbolId: parseInt(symbolId) };
25
+
26
+      if (startTime) {
27
+        where.openTime = {
28
+          ...where.openTime,
29
+          [Op.gte]: new Date(startTime)
30
+        };
31
+      }
32
+
33
+      if (endTime) {
34
+        where.openTime = {
35
+          ...where.openTime,
36
+          [Op.lte]: new Date(endTime)
37
+        };
38
+      }
39
+
40
+      const candles = await Candle1h.findAndCountAll({
41
+        where,
42
+        limit: parseInt(limit),
43
+        offset: parseInt(offset),
44
+        order: [['openTime', 'DESC']],
45
+        include: [{
46
+          model: Symbol,
47
+          as: 'symbol',
48
+          attributes: ['symbol', 'exchange', 'instrumentType']
49
+        }]
50
+      });
51
+
52
+      res.json({
53
+        success: true,
54
+        data: candles.rows,
55
+        pagination: {
56
+          total: candles.count,
57
+          limit: parseInt(limit),
58
+          offset: parseInt(offset),
59
+          hasMore: offset + candles.rows.length < candles.count
60
+        }
61
+      });
62
+    } catch (error) {
63
+      next(error);
64
+    }
65
+  }
66
+
67
+  // Get latest candle for a symbol
68
+  async getLatestCandle(req, res, next) {
69
+    try {
70
+      const { symbolId } = req.params;
71
+
72
+      // Verify symbol exists
73
+      const symbol = await Symbol.findByPk(symbolId);
74
+      if (!symbol) {
75
+        const error = new Error('Symbol not found');
76
+        error.statusCode = 404;
77
+        return next(error);
78
+      }
79
+
80
+      const candle = await Candle1h.findOne({
81
+        where: { symbolId: parseInt(symbolId) },
82
+        order: [['openTime', 'DESC']],
83
+        include: [{
84
+          model: Symbol,
85
+          as: 'symbol',
86
+          attributes: ['symbol', 'exchange', 'instrumentType']
87
+        }]
88
+      });
89
+
90
+      if (!candle) {
91
+        const error = new Error('No candle data found for this symbol');
92
+        error.statusCode = 404;
93
+        return next(error);
94
+      }
95
+
96
+      res.json({
97
+        success: true,
98
+        data: candle
99
+      });
100
+    } catch (error) {
101
+      next(error);
102
+    }
103
+  }
104
+
105
+  // Create new candle
106
+  async createCandle(req, res, next) {
107
+    try {
108
+      const candleData = {
109
+        ...req.body,
110
+        symbolId: parseInt(req.body.symbolId)
111
+      };
112
+
113
+      // Verify symbol exists
114
+      const symbol = await Symbol.findByPk(candleData.symbolId);
115
+      if (!symbol) {
116
+        const error = new Error('Symbol not found');
117
+        error.statusCode = 404;
118
+        return next(error);
119
+      }
120
+
121
+      const candle = await Candle1h.create(candleData);
122
+
123
+      res.status(201).json({
124
+        success: true,
125
+        data: candle,
126
+        message: 'Candle created successfully'
127
+      });
128
+    } catch (error) {
129
+      next(error);
130
+    }
131
+  }
132
+
133
+  // Bulk create candles
134
+  async bulkCreateCandles(req, res, next) {
135
+    try {
136
+      const { candles } = req.body;
137
+
138
+      if (!Array.isArray(candles) || candles.length === 0) {
139
+        const error = new Error('Candles array is required');
140
+        error.statusCode = 400;
141
+        return next(error);
142
+      }
143
+
144
+      // Verify all symbols exist
145
+      const symbolIds = [...new Set(candles.map(c => c.symbolId))];
146
+      const existingSymbols = await Symbol.findAll({
147
+        where: { id: symbolIds },
148
+        attributes: ['id']
149
+      });
150
+
151
+      const existingSymbolIds = existingSymbols.map(s => s.id);
152
+      const invalidSymbolIds = symbolIds.filter(id => !existingSymbolIds.includes(id));
153
+
154
+      if (invalidSymbolIds.length > 0) {
155
+        const error = new Error(`Invalid symbol IDs: ${invalidSymbolIds.join(', ')}`);
156
+        error.statusCode = 400;
157
+        return next(error);
158
+      }
159
+
160
+      const createdCandles = await Candle1h.bulkCreate(candles);
161
+
162
+      res.status(201).json({
163
+        success: true,
164
+        data: createdCandles,
165
+        message: `${createdCandles.length} candles created successfully`
166
+      });
167
+    } catch (error) {
168
+      next(error);
169
+    }
170
+  }
171
+
172
+  // Get OHLC data aggregated by time period
173
+  async getOHLC(req, res, next) {
174
+    try {
175
+      const { symbolId, period = '1h', limit = 100 } = req.query;
176
+
177
+      // Verify symbol exists
178
+      const symbol = await Symbol.findByPk(symbolId);
179
+      if (!symbol) {
180
+        const error = new Error('Symbol not found');
181
+        error.statusCode = 404;
182
+        return next(error);
183
+      }
184
+
185
+      // For now, only support 1h period since we only have candles_1h table
186
+      if (period !== '1h') {
187
+        const error = new Error('Only 1h period is currently supported');
188
+        error.statusCode = 400;
189
+        return next(error);
190
+      }
191
+
192
+      const candles = await Candle1h.findAll({
193
+        where: { symbolId: parseInt(symbolId) },
194
+        limit: parseInt(limit),
195
+        order: [['openTime', 'DESC']],
196
+        attributes: ['openTime', 'open', 'high', 'low', 'close', 'volume']
197
+      });
198
+
199
+      res.json({
200
+        success: true,
201
+        data: candles,
202
+        period,
203
+        symbol: symbol.symbol
204
+      });
205
+    } catch (error) {
206
+      next(error);
207
+    }
208
+  }
209
+}
210
+
211
+module.exports = new CandleController();

+ 251 - 0
src/controllers/livePriceController.js

@@ -0,0 +1,251 @@
1
+const { LivePrice, Symbol } = require('../models');
2
+
3
+class LivePriceController {
4
+  // Get all live prices
5
+  async getAllLivePrices(req, res, next) {
6
+    try {
7
+      const { limit = 100, offset = 0 } = req.query;
8
+
9
+      const livePrices = await LivePrice.findAndCountAll({
10
+        limit: parseInt(limit),
11
+        offset: parseInt(offset),
12
+        order: [['lastUpdated', 'DESC']],
13
+        include: [{
14
+          model: Symbol,
15
+          as: 'livePriceSymbol',
16
+          attributes: ['symbol', 'exchange', 'instrumentType', 'isActive']
17
+        }]
18
+      });
19
+
20
+      res.json({
21
+        success: true,
22
+        data: livePrices.rows,
23
+        pagination: {
24
+          total: livePrices.count,
25
+          limit: parseInt(limit),
26
+          offset: parseInt(offset),
27
+          hasMore: offset + livePrices.rows.length < livePrices.count
28
+        }
29
+      });
30
+    } catch (error) {
31
+      next(error);
32
+    }
33
+  }
34
+
35
+  // Get live price for a specific symbol
36
+  async getLivePrice(req, res, next) {
37
+    try {
38
+      const { symbolId } = req.params;
39
+
40
+      const livePrice = await LivePrice.findOne({
41
+        where: { symbolId: parseInt(symbolId) },
42
+        include: [{
43
+          model: Symbol,
44
+          as: 'livePriceSymbol',
45
+          attributes: ['symbol', 'exchange', 'instrumentType', 'isActive']
46
+        }]
47
+      });
48
+
49
+      if (!livePrice) {
50
+        const error = new Error('Live price not found for this symbol');
51
+        error.statusCode = 404;
52
+        return next(error);
53
+      }
54
+
55
+      res.json({
56
+        success: true,
57
+        data: livePrice
58
+      });
59
+    } catch (error) {
60
+      next(error);
61
+    }
62
+  }
63
+
64
+  // Update or create live price
65
+  async upsertLivePrice(req, res, next) {
66
+    try {
67
+      const { symbolId, price, bid, ask, bidSize, askSize } = req.body;
68
+
69
+      // Verify symbol exists and is active
70
+      const symbol = await Symbol.findByPk(symbolId);
71
+      if (!symbol) {
72
+        const error = new Error('Symbol not found');
73
+        error.statusCode = 404;
74
+        return next(error);
75
+      }
76
+
77
+      if (!symbol.isActive) {
78
+        const error = new Error('Cannot update live price for inactive symbol');
79
+        error.statusCode = 400;
80
+        return next(error);
81
+      }
82
+
83
+      const [livePrice, created] = await LivePrice.upsert({
84
+        symbolId: parseInt(symbolId),
85
+        price,
86
+        bid,
87
+        ask,
88
+        bidSize,
89
+        askSize,
90
+        lastUpdated: new Date()
91
+      });
92
+
93
+      res.status(created ? 201 : 200).json({
94
+        success: true,
95
+        data: livePrice,
96
+        message: created ? 'Live price created successfully' : 'Live price updated successfully'
97
+      });
98
+    } catch (error) {
99
+      next(error);
100
+    }
101
+  }
102
+
103
+  // Bulk update live prices
104
+  async bulkUpdateLivePrices(req, res, next) {
105
+    try {
106
+      const { prices } = req.body;
107
+
108
+      if (!Array.isArray(prices) || prices.length === 0) {
109
+        const error = new Error('Prices array is required');
110
+        error.statusCode = 400;
111
+        return next(error);
112
+      }
113
+
114
+      // Verify all symbols exist and are active
115
+      const symbolIds = prices.map(p => p.symbolId);
116
+      const existingSymbols = await Symbol.findAll({
117
+        where: {
118
+          id: symbolIds,
119
+          isActive: true
120
+        },
121
+        attributes: ['id']
122
+      });
123
+
124
+      const existingSymbolIds = existingSymbols.map(s => s.id);
125
+      const invalidSymbolIds = symbolIds.filter(id => !existingSymbolIds.includes(id));
126
+
127
+      if (invalidSymbolIds.length > 0) {
128
+        const error = new Error(`Invalid or inactive symbol IDs: ${invalidSymbolIds.join(', ')}`);
129
+        error.statusCode = 400;
130
+        return next(error);
131
+      }
132
+
133
+      // Prepare data for bulk upsert
134
+      const upsertData = prices.map(price => ({
135
+        symbolId: parseInt(price.symbolId),
136
+        price: price.price,
137
+        bid: price.bid,
138
+        ask: price.ask,
139
+        bidSize: price.bidSize,
140
+        askSize: price.askSize,
141
+        lastUpdated: new Date()
142
+      }));
143
+
144
+      const updatedPrices = [];
145
+      for (const data of upsertData) {
146
+        const [livePrice] = await LivePrice.upsert(data);
147
+        updatedPrices.push(livePrice);
148
+      }
149
+
150
+      res.json({
151
+        success: true,
152
+        data: updatedPrices,
153
+        message: `${updatedPrices.length} live prices updated successfully`
154
+      });
155
+    } catch (error) {
156
+      next(error);
157
+    }
158
+  }
159
+
160
+  // Get live prices by exchange
161
+  async getLivePricesByExchange(req, res, next) {
162
+    try {
163
+      const { exchange } = req.params;
164
+      const { limit = 100, offset = 0 } = req.query;
165
+
166
+      const livePrices = await LivePrice.findAndCountAll({
167
+        limit: parseInt(limit),
168
+        offset: parseInt(offset),
169
+        order: [['lastUpdated', 'DESC']],
170
+        include: [{
171
+          model: Symbol,
172
+          as: 'livePriceSymbol',
173
+          where: { exchange, isActive: true },
174
+          attributes: ['symbol', 'exchange', 'instrumentType']
175
+        }]
176
+      });
177
+
178
+      res.json({
179
+        success: true,
180
+        data: livePrices.rows,
181
+        pagination: {
182
+          total: livePrices.count,
183
+          limit: parseInt(limit),
184
+          offset: parseInt(offset),
185
+          hasMore: offset + livePrices.rows.length < livePrices.count
186
+        }
187
+      });
188
+    } catch (error) {
189
+      next(error);
190
+    }
191
+  }
192
+
193
+  // Get live prices by instrument type
194
+  async getLivePricesByType(req, res, next) {
195
+    try {
196
+      const { type } = req.params;
197
+      const { limit = 100, offset = 0 } = req.query;
198
+
199
+      const livePrices = await LivePrice.findAndCountAll({
200
+        limit: parseInt(limit),
201
+        offset: parseInt(offset),
202
+        order: [['lastUpdated', 'DESC']],
203
+        include: [{
204
+          model: Symbol,
205
+          as: 'livePriceSymbol',
206
+          where: { instrumentType: type, isActive: true },
207
+          attributes: ['symbol', 'exchange', 'instrumentType']
208
+        }]
209
+      });
210
+
211
+      res.json({
212
+        success: true,
213
+        data: livePrices.rows,
214
+        pagination: {
215
+          total: livePrices.count,
216
+          limit: parseInt(limit),
217
+          offset: parseInt(offset),
218
+          hasMore: offset + livePrices.rows.length < livePrices.count
219
+        }
220
+      });
221
+    } catch (error) {
222
+      next(error);
223
+    }
224
+  }
225
+
226
+  // Delete live price for a symbol
227
+  async deleteLivePrice(req, res, next) {
228
+    try {
229
+      const { symbolId } = req.params;
230
+
231
+      const deletedRowsCount = await LivePrice.destroy({
232
+        where: { symbolId: parseInt(symbolId) }
233
+      });
234
+
235
+      if (deletedRowsCount === 0) {
236
+        const error = new Error('Live price not found for this symbol');
237
+        error.statusCode = 404;
238
+        return next(error);
239
+      }
240
+
241
+      res.json({
242
+        success: true,
243
+        message: 'Live price deleted successfully'
244
+      });
245
+    } catch (error) {
246
+      next(error);
247
+    }
248
+  }
249
+}
250
+
251
+module.exports = new LivePriceController();

+ 164 - 0
src/controllers/symbolController.js

@@ -0,0 +1,164 @@
1
+const { Symbol } = require('../models');
2
+const { Op } = require('sequelize');
3
+
4
+class SymbolController {
5
+  // Get all symbols with optional filtering
6
+  async getAllSymbols(req, res, next) {
7
+    try {
8
+      const {
9
+        exchange,
10
+        instrumentType,
11
+        isActive = true,
12
+        limit = 100,
13
+        offset = 0
14
+      } = req.query;
15
+
16
+      const where = {};
17
+      if (exchange) where.exchange = exchange;
18
+      if (instrumentType) where.instrumentType = instrumentType;
19
+      if (isActive !== undefined) where.isActive = isActive === 'true';
20
+
21
+      const symbols = await Symbol.findAndCountAll({
22
+        where,
23
+        limit: parseInt(limit),
24
+        offset: parseInt(offset),
25
+        order: [['symbol', 'ASC']]
26
+      });
27
+
28
+      res.json({
29
+        success: true,
30
+        data: symbols.rows,
31
+        pagination: {
32
+          total: symbols.count,
33
+          limit: parseInt(limit),
34
+          offset: parseInt(offset),
35
+          hasMore: offset + symbols.rows.length < symbols.count
36
+        }
37
+      });
38
+    } catch (error) {
39
+      next(error);
40
+    }
41
+  }
42
+
43
+  // Get symbol by ID
44
+  async getSymbolById(req, res, next) {
45
+    try {
46
+      const { id } = req.params;
47
+
48
+      const symbol = await Symbol.findByPk(id);
49
+
50
+      if (!symbol) {
51
+        const error = new Error('Symbol not found');
52
+        error.statusCode = 404;
53
+        return next(error);
54
+      }
55
+
56
+      res.json({
57
+        success: true,
58
+        data: symbol
59
+      });
60
+    } catch (error) {
61
+      next(error);
62
+    }
63
+  }
64
+
65
+  // Create new symbol
66
+  async createSymbol(req, res, next) {
67
+    try {
68
+      const symbol = await Symbol.create(req.body);
69
+
70
+      res.status(201).json({
71
+        success: true,
72
+        data: symbol,
73
+        message: 'Symbol created successfully'
74
+      });
75
+    } catch (error) {
76
+      next(error);
77
+    }
78
+  }
79
+
80
+  // Update symbol
81
+  async updateSymbol(req, res, next) {
82
+    try {
83
+      const { id } = req.params;
84
+
85
+      const [updatedRowsCount] = await Symbol.update(req.body, {
86
+        where: { id }
87
+      });
88
+
89
+      if (updatedRowsCount === 0) {
90
+        const error = new Error('Symbol not found');
91
+        error.statusCode = 404;
92
+        return next(error);
93
+      }
94
+
95
+      const updatedSymbol = await Symbol.findByPk(id);
96
+
97
+      res.json({
98
+        success: true,
99
+        data: updatedSymbol,
100
+        message: 'Symbol updated successfully'
101
+      });
102
+    } catch (error) {
103
+      next(error);
104
+    }
105
+  }
106
+
107
+  // Delete symbol (soft delete by setting isActive to false)
108
+  async deleteSymbol(req, res, next) {
109
+    try {
110
+      const { id } = req.params;
111
+
112
+      const [updatedRowsCount] = await Symbol.update(
113
+        { isActive: false },
114
+        { where: { id } }
115
+      );
116
+
117
+      if (updatedRowsCount === 0) {
118
+        const error = new Error('Symbol not found');
119
+        error.statusCode = 404;
120
+        return next(error);
121
+      }
122
+
123
+      res.json({
124
+        success: true,
125
+        message: 'Symbol deactivated successfully'
126
+      });
127
+    } catch (error) {
128
+      next(error);
129
+    }
130
+  }
131
+
132
+  // Search symbols by symbol name
133
+  async searchSymbols(req, res, next) {
134
+    try {
135
+      const { q, limit = 20 } = req.query;
136
+
137
+      if (!q) {
138
+        const error = new Error('Search query is required');
139
+        error.statusCode = 400;
140
+        return next(error);
141
+      }
142
+
143
+      const symbols = await Symbol.findAll({
144
+        where: {
145
+          symbol: {
146
+            [Op.iLike]: `%${q}%`
147
+          },
148
+          isActive: true
149
+        },
150
+        limit: parseInt(limit),
151
+        order: [['symbol', 'ASC']]
152
+      });
153
+
154
+      res.json({
155
+        success: true,
156
+        data: symbols
157
+      });
158
+    } catch (error) {
159
+      next(error);
160
+    }
161
+  }
162
+}
163
+
164
+module.exports = new SymbolController();

+ 60 - 0
src/middleware/errorHandler.js

@@ -0,0 +1,60 @@
1
+const errorHandler = (err, req, res, next) => {
2
+  console.error(err.stack);
3
+
4
+  // Sequelize validation error
5
+  if (err.name === 'SequelizeValidationError') {
6
+    const errors = err.errors.map(error => ({
7
+      field: error.path,
8
+      message: error.message,
9
+      value: error.value
10
+    }));
11
+
12
+    return res.status(400).json({
13
+      success: false,
14
+      message: 'Validation error',
15
+      errors
16
+    });
17
+  }
18
+
19
+  // Sequelize unique constraint error
20
+  if (err.name === 'SequelizeUniqueConstraintError') {
21
+    return res.status(409).json({
22
+      success: false,
23
+      message: 'Duplicate entry',
24
+      error: err.errors[0].message
25
+    });
26
+  }
27
+
28
+  // Sequelize foreign key constraint error
29
+  if (err.name === 'SequelizeForeignKeyConstraintError') {
30
+    return res.status(400).json({
31
+      success: false,
32
+      message: 'Foreign key constraint error',
33
+      error: err.message
34
+    });
35
+  }
36
+
37
+  // Joi validation error
38
+  if (err.isJoi) {
39
+    return res.status(400).json({
40
+      success: false,
41
+      message: 'Validation error',
42
+      details: err.details.map(detail => ({
43
+        field: detail.path.join('.'),
44
+        message: detail.message
45
+      }))
46
+    });
47
+  }
48
+
49
+  // Default error
50
+  const statusCode = err.statusCode || 500;
51
+  const message = err.message || 'Internal server error';
52
+
53
+  res.status(statusCode).json({
54
+    success: false,
55
+    message,
56
+    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
57
+  });
58
+};
59
+
60
+module.exports = errorHandler;

+ 101 - 0
src/middleware/validation.js

@@ -0,0 +1,101 @@
1
+const Joi = require('joi');
2
+
3
+// Symbol validation schemas
4
+const symbolSchema = Joi.object({
5
+  symbol: Joi.string().max(50).required(),
6
+  baseAsset: Joi.string().max(50),
7
+  quoteAsset: Joi.string().max(50),
8
+  exchange: Joi.string().max(50),
9
+  instrumentType: Joi.string().valid('crypto', 'stock', 'forex', 'commodity').required(),
10
+  isActive: Joi.boolean().default(true)
11
+});
12
+
13
+const symbolIdSchema = Joi.object({
14
+  id: Joi.number().integer().positive().required()
15
+});
16
+
17
+// Candle validation schemas
18
+const candleSchema = Joi.object({
19
+  symbolId: Joi.number().integer().positive().required(),
20
+  openTime: Joi.date().iso().required(),
21
+  closeTime: Joi.date().iso().required(),
22
+  open: Joi.number().precision(15).required(),
23
+  high: Joi.number().precision(15).required(),
24
+  low: Joi.number().precision(15).required(),
25
+  close: Joi.number().precision(15).required(),
26
+  volume: Joi.number().precision(15).required()
27
+});
28
+
29
+const candleQuerySchema = Joi.object({
30
+  symbolId: Joi.number().integer().positive().required(),
31
+  startTime: Joi.date().iso(),
32
+  endTime: Joi.date().iso().when('startTime', {
33
+    is: Joi.exist(),
34
+    then: Joi.date().iso().greater(Joi.ref('startTime'))
35
+  }),
36
+  limit: Joi.number().integer().min(1).max(1000).default(100),
37
+  offset: Joi.number().integer().min(0).default(0)
38
+});
39
+
40
+// Live price validation schemas
41
+const livePriceSchema = Joi.object({
42
+  symbolId: Joi.number().integer().positive().required(),
43
+  price: Joi.number().precision(15).positive().required(),
44
+  bid: Joi.number().precision(15).positive(),
45
+  ask: Joi.number().precision(15).positive(),
46
+  bidSize: Joi.number().precision(15).positive(),
47
+  askSize: Joi.number().precision(15).positive()
48
+});
49
+
50
+// Middleware functions
51
+const validate = (schema) => {
52
+  return (req, res, next) => {
53
+    const { error, value } = schema.validate(req.body, { abortEarly: false });
54
+
55
+    if (error) {
56
+      error.isJoi = true;
57
+      return next(error);
58
+    }
59
+
60
+    req.body = value;
61
+    next();
62
+  };
63
+};
64
+
65
+const validateQuery = (schema) => {
66
+  return (req, res, next) => {
67
+    const { error, value } = schema.validate(req.query, { abortEarly: false });
68
+
69
+    if (error) {
70
+      error.isJoi = true;
71
+      return next(error);
72
+    }
73
+
74
+    req.query = value;
75
+    next();
76
+  };
77
+};
78
+
79
+const validateParams = (schema) => {
80
+  return (req, res, next) => {
81
+    const { error, value } = schema.validate(req.params, { abortEarly: false });
82
+
83
+    if (error) {
84
+      error.isJoi = true;
85
+      return next(error);
86
+    }
87
+
88
+    req.params = value;
89
+    next();
90
+  };
91
+};
92
+
93
+module.exports = {
94
+  symbolSchema,
95
+  symbolIdSchema,
96
+  candleQuerySchema,
97
+  livePriceSchema,
98
+  validate,
99
+  validateQuery,
100
+  validateParams
101
+};

+ 70 - 0
src/models/Candle1h.js

@@ -0,0 +1,70 @@
1
+const { DataTypes } = require('sequelize');
2
+const { sequelize } = require('../config/database');
3
+const Symbol = require('./Symbol');
4
+
5
+const Candle1h = sequelize.define('Candle1h', {
6
+  id: {
7
+    type: DataTypes.BIGINT,
8
+    primaryKey: true,
9
+    autoIncrement: true
10
+  },
11
+  symbolId: {
12
+    type: DataTypes.INTEGER,
13
+    field: 'symbol_id',
14
+    allowNull: false,
15
+    references: {
16
+      model: Symbol,
17
+      key: 'id'
18
+    }
19
+  },
20
+  openTime: {
21
+    type: DataTypes.DATE,
22
+    field: 'open_time',
23
+    allowNull: false
24
+  },
25
+  closeTime: {
26
+    type: DataTypes.DATE,
27
+    field: 'close_time',
28
+    allowNull: false
29
+  },
30
+  open: {
31
+    type: DataTypes.DECIMAL(18, 8),
32
+    allowNull: false
33
+  },
34
+  high: {
35
+    type: DataTypes.DECIMAL(18, 8),
36
+    allowNull: false
37
+  },
38
+  low: {
39
+    type: DataTypes.DECIMAL(18, 8),
40
+    allowNull: false
41
+  },
42
+  close: {
43
+    type: DataTypes.DECIMAL(18, 8),
44
+    allowNull: false
45
+  },
46
+  volume: {
47
+    type: DataTypes.DECIMAL(20, 8)
48
+  },
49
+  tradesCount: {
50
+    type: DataTypes.INTEGER,
51
+    field: 'trades_count'
52
+  },
53
+  quoteVolume: {
54
+    type: DataTypes.DECIMAL(20, 8),
55
+    field: 'quote_volume'
56
+  },
57
+  createdAt: {
58
+    type: DataTypes.DATE,
59
+    field: 'created_at'
60
+  }
61
+}, {
62
+  tableName: 'candles_1h',
63
+  indexes: [
64
+    { unique: true, fields: ['symbol_id', 'open_time'] },
65
+    { fields: ['open_time'] }
66
+  ]
67
+});
68
+
69
+
70
+module.exports = Candle1h;

+ 46 - 0
src/models/LivePrice.js

@@ -0,0 +1,46 @@
1
+const { DataTypes } = require('sequelize');
2
+const { sequelize } = require('../config/database');
3
+const Symbol = require('./Symbol');
4
+
5
+const LivePrice = sequelize.define('LivePrice', {
6
+  symbolId: {
7
+    type: DataTypes.INTEGER,
8
+    primaryKey: true,
9
+    field: 'symbol_id',
10
+    references: {
11
+      model: Symbol,
12
+      key: 'id'
13
+    }
14
+  },
15
+  price: {
16
+    type: DataTypes.DECIMAL(18, 8),
17
+    allowNull: false
18
+  },
19
+  bid: {
20
+    type: DataTypes.DECIMAL(18, 8)
21
+  },
22
+  ask: {
23
+    type: DataTypes.DECIMAL(18, 8)
24
+  },
25
+  bidSize: {
26
+    type: DataTypes.DECIMAL(18, 8),
27
+    field: 'bid_size'
28
+  },
29
+  askSize: {
30
+    type: DataTypes.DECIMAL(18, 8),
31
+    field: 'ask_size'
32
+  },
33
+  lastUpdated: {
34
+    type: DataTypes.DATE,
35
+    field: 'last_updated',
36
+    defaultValue: DataTypes.NOW
37
+  }
38
+}, {
39
+  tableName: 'live_prices',
40
+  indexes: [
41
+    { fields: ['price'] }
42
+  ]
43
+});
44
+
45
+
46
+module.exports = LivePrice;

+ 52 - 0
src/models/Symbol.js

@@ -0,0 +1,52 @@
1
+const { DataTypes } = require('sequelize');
2
+const { sequelize } = require('../config/database');
3
+
4
+const Symbol = sequelize.define('Symbol', {
5
+  id: {
6
+    type: DataTypes.INTEGER,
7
+    primaryKey: true,
8
+    autoIncrement: true
9
+  },
10
+  symbol: {
11
+    type: DataTypes.STRING(50),
12
+    allowNull: false,
13
+    unique: true
14
+  },
15
+  baseAsset: {
16
+    type: DataTypes.STRING(50),
17
+    field: 'base_asset'
18
+  },
19
+  quoteAsset: {
20
+    type: DataTypes.STRING(50),
21
+    field: 'quote_asset'
22
+  },
23
+  exchange: {
24
+    type: DataTypes.STRING(50)
25
+  },
26
+  instrumentType: {
27
+    type: DataTypes.ENUM('crypto', 'stock', 'forex', 'commodity'),
28
+    field: 'instrument_type',
29
+    allowNull: false
30
+  },
31
+  isActive: {
32
+    type: DataTypes.BOOLEAN,
33
+    field: 'is_active',
34
+    defaultValue: true
35
+  },
36
+  createdAt: {
37
+    type: DataTypes.DATE,
38
+    field: 'created_at'
39
+  },
40
+  updatedAt: {
41
+    type: DataTypes.DATE,
42
+    field: 'updated_at'
43
+  }
44
+}, {
45
+  tableName: 'symbols',
46
+  indexes: [
47
+    { fields: ['exchange'] },
48
+    { fields: ['instrument_type'] }
49
+  ]
50
+});
51
+
52
+module.exports = Symbol;

+ 29 - 0
src/models/index.js

@@ -0,0 +1,29 @@
1
+const { sequelize } = require('../config/database');
2
+const Symbol = require('./Symbol');
3
+const Candle1h = require('./Candle1h');
4
+const LivePrice = require('./LivePrice');
5
+
6
+// Define associations
7
+Symbol.hasMany(Candle1h, { foreignKey: 'symbolId', as: 'candles1h' });
8
+Candle1h.belongsTo(Symbol, { foreignKey: 'symbolId', as: 'symbol' });
9
+
10
+Symbol.hasOne(LivePrice, { foreignKey: 'symbolId', as: 'livePrice' });
11
+LivePrice.belongsTo(Symbol, { foreignKey: 'symbolId', as: 'livePriceSymbol' });
12
+
13
+// Sync database (only in development)
14
+if (process.env.NODE_ENV === 'development') {
15
+  sequelize.sync({ alter: true })
16
+    .then(() => {
17
+      console.log('Database synchronized successfully.');
18
+    })
19
+    .catch((error) => {
20
+      console.error('Error synchronizing database:', error);
21
+    });
22
+}
23
+
24
+module.exports = {
25
+  sequelize,
26
+  Symbol,
27
+  Candle1h,
28
+  LivePrice
29
+};

+ 61 - 0
src/routes/candles.js

@@ -0,0 +1,61 @@
1
+const express = require('express');
2
+const router = express.Router();
3
+const candleController = require('../controllers/candleController');
4
+const { validate, validateQuery, validateParams } = require('../middleware/validation');
5
+const Joi = require('joi');
6
+
7
+// GET /api/candles - Get candles with filtering
8
+router.get('/', validateQuery(Joi.object({
9
+  symbolId: Joi.number().integer().positive().required(),
10
+  startTime: Joi.date().iso(),
11
+  endTime: Joi.date().iso().when('startTime', {
12
+    is: Joi.exist(),
13
+    then: Joi.date().iso().greater(Joi.ref('startTime'))
14
+  }),
15
+  limit: Joi.number().integer().min(1).max(1000).default(100),
16
+  offset: Joi.number().integer().min(0).default(0)
17
+})), candleController.getCandles);
18
+
19
+// GET /api/candles/ohlc - Get OHLC data
20
+router.get('/ohlc', validateQuery(Joi.object({
21
+  symbolId: Joi.number().integer().positive().required(),
22
+  period: Joi.string().valid('1h').default('1h'),
23
+  limit: Joi.number().integer().min(1).max(1000).default(100)
24
+})), candleController.getOHLC);
25
+
26
+// GET /api/candles/:symbolId/latest - Get latest candle for a symbol
27
+router.get('/:symbolId/latest', validateParams(Joi.object({
28
+  symbolId: Joi.number().integer().positive().required()
29
+})), candleController.getLatestCandle);
30
+
31
+// POST /api/candles - Create new candle
32
+router.post('/', validate(Joi.object({
33
+  symbolId: Joi.number().integer().positive().required(),
34
+  openTime: Joi.date().iso().required(),
35
+  closeTime: Joi.date().iso().required(),
36
+  open: Joi.number().precision(8).positive().required(),
37
+  high: Joi.number().precision(8).positive().required(),
38
+  low: Joi.number().precision(8).positive().required(),
39
+  close: Joi.number().precision(8).positive().required(),
40
+  volume: Joi.number().precision(8).positive(),
41
+  tradesCount: Joi.number().integer().min(0),
42
+  quoteVolume: Joi.number().precision(8).positive()
43
+})), candleController.createCandle);
44
+
45
+// POST /api/candles/bulk - Bulk create candles
46
+router.post('/bulk', validate(Joi.object({
47
+  candles: Joi.array().items(Joi.object({
48
+    symbolId: Joi.number().integer().positive().required(),
49
+    openTime: Joi.date().iso().required(),
50
+    closeTime: Joi.date().iso().required(),
51
+    open: Joi.number().precision(8).positive().required(),
52
+    high: Joi.number().precision(8).positive().required(),
53
+    low: Joi.number().precision(8).positive().required(),
54
+    close: Joi.number().precision(8).positive().required(),
55
+    volume: Joi.number().precision(8).positive(),
56
+    tradesCount: Joi.number().integer().min(0),
57
+    quoteVolume: Joi.number().precision(8).positive()
58
+  })).min(1).required()
59
+})), candleController.bulkCreateCandles);
60
+
61
+module.exports = router;

+ 47 - 0
src/routes/livePrices.js

@@ -0,0 +1,47 @@
1
+const express = require('express');
2
+const router = express.Router();
3
+const livePriceController = require('../controllers/livePriceController');
4
+const { validate, validateQuery, validateParams, livePriceSchema } = require('../middleware/validation');
5
+const Joi = require('joi');
6
+
7
+// GET /api/live-prices - Get all live prices
8
+router.get('/', validateQuery(Joi.object({
9
+  limit: Joi.number().integer().min(1).max(1000).default(100),
10
+  offset: Joi.number().integer().min(0).default(0)
11
+})), livePriceController.getAllLivePrices);
12
+
13
+// GET /api/live-prices/exchange/:exchange - Get live prices by exchange
14
+router.get('/exchange/:exchange', validateParams(Joi.object({
15
+  exchange: Joi.string().max(50).required()
16
+})), validateQuery(Joi.object({
17
+  limit: Joi.number().integer().min(1).max(1000).default(100),
18
+  offset: Joi.number().integer().min(0).default(0)
19
+})), livePriceController.getLivePricesByExchange);
20
+
21
+// GET /api/live-prices/type/:type - Get live prices by instrument type
22
+router.get('/type/:type', validateParams(Joi.object({
23
+  type: Joi.string().valid('crypto', 'stock', 'forex', 'commodity').required()
24
+})), validateQuery(Joi.object({
25
+  limit: Joi.number().integer().min(1).max(1000).default(100),
26
+  offset: Joi.number().integer().min(0).default(0)
27
+})), livePriceController.getLivePricesByType);
28
+
29
+// GET /api/live-prices/:symbolId - Get live price for a specific symbol
30
+router.get('/:symbolId', validateParams(Joi.object({
31
+  symbolId: Joi.number().integer().positive().required()
32
+})), livePriceController.getLivePrice);
33
+
34
+// POST /api/live-prices - Create or update live price
35
+router.post('/', validate(livePriceSchema), livePriceController.upsertLivePrice);
36
+
37
+// POST /api/live-prices/bulk - Bulk update live prices
38
+router.post('/bulk', validate(Joi.object({
39
+  prices: Joi.array().items(livePriceSchema).min(1).required()
40
+})), livePriceController.bulkUpdateLivePrices);
41
+
42
+// DELETE /api/live-prices/:symbolId - Delete live price for a symbol
43
+router.delete('/:symbolId', validateParams(Joi.object({
44
+  symbolId: Joi.number().integer().positive().required()
45
+})), livePriceController.deleteLivePrice);
46
+
47
+module.exports = router;

+ 33 - 0
src/routes/symbols.js

@@ -0,0 +1,33 @@
1
+const express = require('express');
2
+const router = express.Router();
3
+const symbolController = require('../controllers/symbolController');
4
+const { validate, validateQuery, validateParams, symbolSchema, symbolIdSchema } = require('../middleware/validation');
5
+
6
+// GET /api/symbols - Get all symbols with optional filtering
7
+router.get('/', validateQuery(require('joi').object({
8
+  exchange: require('joi').string().max(50),
9
+  instrumentType: require('joi').string().valid('crypto', 'stock', 'forex', 'commodity'),
10
+  isActive: require('joi').string().valid('true', 'false'),
11
+  limit: require('joi').number().integer().min(1).max(1000).default(100),
12
+  offset: require('joi').number().integer().min(0).default(0)
13
+})), symbolController.getAllSymbols);
14
+
15
+// GET /api/symbols/search - Search symbols by name
16
+router.get('/search', validateQuery(require('joi').object({
17
+  q: require('joi').string().required(),
18
+  limit: require('joi').number().integer().min(1).max(100).default(20)
19
+})), symbolController.searchSymbols);
20
+
21
+// GET /api/symbols/:id - Get symbol by ID
22
+router.get('/:id', validateParams(symbolIdSchema), symbolController.getSymbolById);
23
+
24
+// POST /api/symbols - Create new symbol
25
+router.post('/', validate(symbolSchema), symbolController.createSymbol);
26
+
27
+// PUT /api/symbols/:id - Update symbol
28
+router.put('/:id', validateParams(symbolIdSchema), validate(symbolSchema), symbolController.updateSymbol);
29
+
30
+// DELETE /api/symbols/:id - Delete symbol (soft delete)
31
+router.delete('/:id', validateParams(symbolIdSchema), symbolController.deleteSymbol);
32
+
33
+module.exports = router;

+ 55 - 0
src/server.js

@@ -0,0 +1,55 @@
1
+const app = require('./app');
2
+const { testConnection } = require('./config/database');
3
+const logger = require('./utils/logger');
4
+
5
+const PORT = process.env.PORT || 3000;
6
+
7
+// Test database connection and start server
8
+const startServer = async () => {
9
+  try {
10
+    // Test database connection
11
+    await testConnection();
12
+
13
+    // Start the server
14
+    const server = app.listen(PORT, () => {
15
+      logger.info(`Market Data Service is running on port ${PORT}`);
16
+      logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
17
+      logger.info(`Health check available at: http://localhost:${PORT}/health`);
18
+    });
19
+
20
+    // Graceful shutdown
21
+    process.on('SIGTERM', () => {
22
+      logger.info('SIGTERM received, shutting down gracefully');
23
+      server.close(() => {
24
+        logger.info('Process terminated');
25
+        process.exit(0);
26
+      });
27
+    });
28
+
29
+    process.on('SIGINT', () => {
30
+      logger.info('SIGINT received, shutting down gracefully');
31
+      server.close(() => {
32
+        logger.info('Process terminated');
33
+        process.exit(0);
34
+      });
35
+    });
36
+
37
+  } catch (error) {
38
+    logger.error('Failed to start server:', error);
39
+    process.exit(1);
40
+  }
41
+};
42
+
43
+// Handle uncaught exceptions
44
+process.on('uncaughtException', (error) => {
45
+  logger.error('Uncaught Exception:', error);
46
+  process.exit(1);
47
+});
48
+
49
+// Handle unhandled promise rejections
50
+process.on('unhandledRejection', (reason, promise) => {
51
+  logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
52
+  process.exit(1);
53
+});
54
+
55
+startServer();

+ 54 - 0
src/utils/logger.js

@@ -0,0 +1,54 @@
1
+const winston = require('winston');
2
+
3
+// Define log levels
4
+const levels = {
5
+  error: 0,
6
+  warn: 1,
7
+  info: 2,
8
+  http: 3,
9
+  debug: 4,
10
+};
11
+
12
+// Define colors for each level
13
+const colors = {
14
+  error: 'red',
15
+  warn: 'yellow',
16
+  info: 'green',
17
+  http: 'magenta',
18
+  debug: 'white',
19
+};
20
+
21
+// Tell winston that we want to link the colors
22
+winston.addColors(colors);
23
+
24
+// Define the format for logs
25
+const format = winston.format.combine(
26
+  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
27
+  winston.format.colorize({ all: true }),
28
+  winston.format.printf(
29
+    (info) => `${info.timestamp} ${info.level}: ${info.message}`,
30
+  ),
31
+);
32
+
33
+// Define which transports the logger must use
34
+const transports = [
35
+  // Allow the use the console to print the messages
36
+  new winston.transports.Console(),
37
+  // Allow to print all the error level messages inside the error.log file
38
+  new winston.transports.File({
39
+    filename: 'logs/error.log',
40
+    level: 'error',
41
+  }),
42
+  // Allow to print all the error message inside the all.log file
43
+  new winston.transports.File({ filename: 'logs/all.log' }),
44
+];
45
+
46
+// Create the logger instance
47
+const logger = winston.createLogger({
48
+  level: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
49
+  levels,
50
+  format,
51
+  transports,
52
+});
53
+
54
+module.exports = logger;

+ 103 - 0
tests/candleController.test.js

@@ -0,0 +1,103 @@
1
+const request = require('supertest');
2
+const app = require('../src/app');
3
+const { Candle1h, Symbol, sequelize } = require('../src/models');
4
+
5
+describe('Candle Controller Integration Tests', () => {
6
+  let testSymbol;
7
+
8
+  beforeAll(async () => {
9
+    // Sync database and cleanup
10
+    await sequelize.sync({ force: true });
11
+    
12
+    // Create fresh test symbol
13
+    testSymbol = await Symbol.create({
14
+      symbol: 'TEST_SYMBOL',
15
+      exchange: 'TEST',
16
+      instrumentType: 'forex'
17
+    });
18
+  });
19
+
20
+  afterAll(async () => {
21
+    // Cleanup test data
22
+    await Candle1h.destroy({ where: {} });
23
+    await Symbol.destroy({ where: {} });
24
+    await sequelize.close();
25
+  });
26
+
27
+  describe('POST /api/candles/bulk', () => {
28
+    it('should create multiple candles from MT5 payload', async () => {
29
+      const mockCandles = [
30
+        {
31
+          symbolId: testSymbol.id,
32
+          openTime: '2025-10-17 00:00:00',
33
+          closeTime: '2025-10-17 01:00:00',
34
+          open: 1.1000,
35
+          high: 1.1050,
36
+          low: 1.0990,
37
+          close: 1.1025,
38
+          volume: 1000
39
+        },
40
+        {
41
+          symbolId: testSymbol.id,
42
+          openTime: '2025-10-17 01:00:00',
43
+          closeTime: '2025-10-17 02:00:00',
44
+          open: 1.1025,
45
+          high: 1.1075,
46
+          low: 1.1005,
47
+          close: 1.1060,
48
+          volume: 1200
49
+        }
50
+      ];
51
+
52
+      const response = await request(app)
53
+        .post('/api/candles/bulk')
54
+        .send({ candles: mockCandles })
55
+        .expect(201);
56
+
57
+      expect(response.body.success).toBe(true);
58
+      expect(response.body.message).toBe('2 candles created successfully');
59
+      expect(response.body.data.length).toBe(2);
60
+
61
+      // Verify database persistence
62
+      const dbCandles = await Candle1h.findAll({
63
+        where: { symbolId: testSymbol.id },
64
+        order: [['openTime', 'ASC']]
65
+      });
66
+
67
+      expect(dbCandles.length).toBe(2);
68
+      expect(Number(dbCandles[0].open)).toBeCloseTo(1.1000);
69
+      expect(Number(dbCandles[1].close)).toBeCloseTo(1.1060);
70
+    });
71
+
72
+    it('should reject invalid payload format', async () => {
73
+      const response = await request(app)
74
+        .post('/api/candles/bulk')
75
+        .send({ invalid: 'payload' })
76
+        .expect(400);
77
+
78
+      expect(response.body.success).toBe(false);
79
+      expect(response.body.message).toContain('Validation error');
80
+    });
81
+
82
+    it('should handle invalid symbol IDs', async () => {
83
+      const invalidCandles = [{
84
+        symbolId: 999,
85
+        openTime: '2025-10-17 00:00:00',
86
+        closeTime: '2025-10-17 01:00:00',
87
+        open: 1.1000,
88
+        high: 1.1050,
89
+        low: 1.0990,
90
+        close: 1.1025,
91
+        volume: 1000
92
+      }];
93
+
94
+      const response = await request(app)
95
+        .post('/api/candles/bulk')
96
+        .send({ candles: invalidCandles })
97
+        .expect(400);
98
+
99
+      expect(response.body.success).toBe(false);
100
+      expect(response.body.message).toContain('Invalid symbol IDs');
101
+    });
102
+  });
103
+});

+ 105 - 0
tests/livePriceController.test.js

@@ -0,0 +1,105 @@
1
+const request = require('supertest');
2
+const app = require('../src/app');
3
+const { LivePrice, Symbol, sequelize } = require('../src/models');
4
+
5
+describe('LivePrice Controller Integration Tests', () => {
6
+  beforeAll(async () => {
7
+    // Verify database connection
8
+    await sequelize.authenticate();
9
+    
10
+    // Sync models
11
+    await sequelize.sync({ force: true });
12
+    
13
+    // Create test symbols
14
+    await Symbol.bulkCreate([
15
+      { symbol: 'TEST1', exchange: 'TEST', instrumentType: 'forex' },
16
+      { symbol: 'TEST2', exchange: 'TEST', instrumentType: 'stock' }
17
+    ]);
18
+
19
+    // Get created symbols
20
+    const symbols = await Symbol.findAll();
21
+    
22
+    // Create test live prices using actual symbol IDs
23
+    await LivePrice.bulkCreate([
24
+      {
25
+        symbolId: symbols[0].id,
26
+        price: 1.12348, // Added required price field
27
+        bid: 1.12345,
28
+        ask: 1.12350,
29
+        lastUpdated: new Date()
30
+      },
31
+      {
32
+        symbolId: symbols[1].id,
33
+        price: 100.55, // Added required price field
34
+        bid: 100.50,
35
+        ask: 100.60,
36
+        lastUpdated: new Date()
37
+      }
38
+    ]);
39
+  });
40
+
41
+  afterAll(async () => {
42
+    await LivePrice.destroy({ where: {} });
43
+    await Symbol.destroy({ where: {} });
44
+    await sequelize.close();
45
+  });
46
+
47
+  describe('GET /api/live-prices', () => {
48
+    it('should return all live prices with symbol associations', async () => {
49
+      const response = await request(app)
50
+        .get('/api/live-prices')
51
+        .expect(200);
52
+
53
+      expect(response.body.success).toBe(true);
54
+      expect(response.body.data.length).toBe(2);
55
+      
56
+      // Verify association alias change
57
+      expect(response.body.data[0].livePriceSymbol).toBeDefined();
58
+      expect(response.body.data[0].livePriceSymbol.symbol).toBe('TEST1');
59
+      expect(response.body.data[1].livePriceSymbol.symbol).toBe('TEST2');
60
+    });
61
+  });
62
+
63
+  describe('GET /api/live-prices/:symbolId', () => {
64
+    it('should return live price for specific symbol', async () => {
65
+      const response = await request(app)
66
+        .get('/api/live-prices/1')
67
+        .expect(200);
68
+
69
+      expect(response.body.success).toBe(true);
70
+      expect(response.body.data.livePriceSymbol.symbol).toBe('TEST1');
71
+    });
72
+
73
+    it('should return 404 for invalid symbol ID', async () => {
74
+      await request(app)
75
+        .get('/api/live-prices/999')
76
+        .expect(404);
77
+    });
78
+  });
79
+
80
+  describe('GET /api/live-prices/exchange/:exchange', () => {
81
+    it('should return live prices filtered by exchange', async () => {
82
+      const response = await request(app)
83
+        .get('/api/live-prices/exchange/TEST')
84
+        .expect(200);
85
+
86
+      expect(response.body.success).toBe(true);
87
+      expect(response.body.data.length).toBe(2);
88
+      response.body.data.forEach(price => {
89
+        expect(price.livePriceSymbol.exchange).toBe('TEST');
90
+      });
91
+    });
92
+  });
93
+
94
+  describe('GET /api/live-prices/type/:type', () => {
95
+    it('should return live prices filtered by instrument type', async () => {
96
+      const response = await request(app)
97
+        .get('/api/live-prices/type/forex')
98
+        .expect(200);
99
+
100
+      expect(response.body.success).toBe(true);
101
+      expect(response.body.data.length).toBe(1);
102
+      expect(response.body.data[0].livePriceSymbol.instrumentType).toBe('forex');
103
+    });
104
+  });
105
+});