@icgio/icg-exchanges-wrapper 1.14.253 → 1.14.254

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -4,6 +4,7 @@ const get_exchanges_trades = require('./functions/get-exchanges-trades.js')
4
4
  const get_exchanges_open_orders = require('./functions/get-exchanges-open-orders.js')
5
5
  const get_exchanges_deposits = require('./functions/get-exchanges-deposits.js')
6
6
  const get_exchanges_withdrawals = require('./functions/get-exchanges-withdrawals.js')
7
+ const { ws_data_client } = require('./lib/ws-data-client.js')
7
8
 
8
9
  module.exports.functions = {
9
10
  get_exchanges_rates,
@@ -17,3 +18,7 @@ module.exports.functions = {
17
18
  }
18
19
 
19
20
  module.exports.orders = require('./middleware/orders.js')
21
+
22
+ // WebSocket data client for log streaming
23
+ module.exports.ws_data_client = ws_data_client
24
+ module.exports.send_log = (level, message) => ws_data_client.send_log(level, message)
@@ -0,0 +1,248 @@
1
+ const WebSocket = require('ws')
2
+ const { randomUUID: uuid } = require('crypto')
3
+
4
+ // Hardcoded backend URL for production
5
+ const WS_BACKEND_URL = 'wss://api-prod.icg.io/ws/data/ingest'
6
+
7
+ /**
8
+ * WebSocket client for streaming logs and order status to dashboard backend.
9
+ * Singleton instance shared across the application.
10
+ */
11
+ class WSDataClient {
12
+ constructor() {
13
+ this.backend_url = WS_BACKEND_URL
14
+ this.secret = null
15
+ this.client_id = null
16
+ this.ws = null
17
+ this.queue = []
18
+ this.max_queue = 100
19
+ this.reconnect_interval = 5000
20
+ this.enabled = false
21
+ this.connected = false
22
+ this.reconnect_timer = null
23
+ this.sync_interval = null
24
+ this.sync_interval_ms = 30 * 1000 // 30 seconds
25
+ this.order_info_ref = null // Reference to Orders.order_info for periodic sync
26
+ }
27
+
28
+ /**
29
+ * Initialize the WS client with connection details.
30
+ * @param {string} secret - Shared secret for authentication
31
+ * @param {string} client_id - Client identifier (e.g., "FREEZONE")
32
+ */
33
+ init(secret, client_id) {
34
+ if (!secret || !client_id) {
35
+ console.log('[WSDataClient] Missing config (secret or client_id), disabled')
36
+ return
37
+ }
38
+
39
+ this.secret = secret
40
+ this.client_id = client_id
41
+ this.enabled = true
42
+
43
+ console.log('[WSDataClient] Initializing for client:', client_id, 'URL:', this.backend_url)
44
+ this.connect()
45
+ }
46
+
47
+ /**
48
+ * Set reference to order_info for periodic sync.
49
+ * @param {object} order_info - Reference to Orders.order_info object
50
+ */
51
+ set_order_info_ref(order_info) {
52
+ this.order_info_ref = order_info
53
+
54
+ // Start periodic sync if not already started
55
+ if (!this.sync_interval && this.enabled) {
56
+ this.start_periodic_sync()
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Start periodic full sync of order_info (every 30 seconds).
62
+ */
63
+ start_periodic_sync() {
64
+ if (this.sync_interval) return
65
+
66
+ // Send initial reset on startup
67
+ this.send_reset('orders')
68
+
69
+ this.sync_interval = setInterval(() => {
70
+ if (this.order_info_ref && this.connected) {
71
+ this.send_order_sync(this.order_info_ref)
72
+ }
73
+ }, this.sync_interval_ms)
74
+
75
+ console.log('[WSDataClient] Periodic sync started (every %ds)', this.sync_interval_ms / 1000)
76
+ }
77
+
78
+ /**
79
+ * Connect to the dashboard backend WebSocket.
80
+ */
81
+ connect() {
82
+ if (!this.enabled) return
83
+
84
+ try {
85
+ this.ws = new WebSocket(this.backend_url, {
86
+ headers: {
87
+ 'x-client-secret': this.secret,
88
+ 'x-client-id': this.client_id,
89
+ },
90
+ })
91
+
92
+ this.ws.on('open', () => {
93
+ console.log('[WSDataClient] Connected to backend')
94
+ this.connected = true
95
+ this.flush_queue()
96
+
97
+ // Send reset on reconnect to clear stale state
98
+ this.send_reset('orders')
99
+
100
+ // Send full sync immediately after connect
101
+ if (this.order_info_ref) {
102
+ this.send_order_sync(this.order_info_ref)
103
+ }
104
+ })
105
+
106
+ this.ws.on('close', (code, reason) => {
107
+ console.log('[WSDataClient] Disconnected:', code, reason?.toString())
108
+ this.connected = false
109
+ this.schedule_reconnect()
110
+ })
111
+
112
+ this.ws.on('error', (err) => {
113
+ console.error('[WSDataClient] Error:', err.message)
114
+ this.connected = false
115
+ })
116
+
117
+ this.ws.on('message', (data) => {
118
+ // Handle any messages from backend (e.g., acknowledgments)
119
+ try {
120
+ const msg = JSON.parse(data.toString())
121
+ if (msg.type === 'error') {
122
+ console.error('[WSDataClient] Backend error:', msg.message)
123
+ }
124
+ } catch (e) {
125
+ // Ignore parse errors
126
+ }
127
+ })
128
+ } catch (err) {
129
+ console.error('[WSDataClient] Connection error:', err.message)
130
+ this.schedule_reconnect()
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Schedule reconnection attempt.
136
+ */
137
+ schedule_reconnect() {
138
+ if (this.reconnect_timer) return
139
+
140
+ this.reconnect_timer = setTimeout(() => {
141
+ this.reconnect_timer = null
142
+ console.log('[WSDataClient] Attempting reconnect...')
143
+ this.connect()
144
+ }, this.reconnect_interval)
145
+ }
146
+
147
+ /**
148
+ * Send a message to the backend.
149
+ * @param {string} type - Message type
150
+ * @param {object} data - Message data
151
+ */
152
+ send(type, data) {
153
+ if (!this.enabled) return
154
+
155
+ const msg = JSON.stringify({
156
+ type,
157
+ client: this.client_id,
158
+ ts: Date.now(),
159
+ ...data,
160
+ })
161
+
162
+ if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
163
+ this.ws.send(msg)
164
+ } else {
165
+ // Queue message for later
166
+ this.queue.push(msg)
167
+ if (this.queue.length > this.max_queue) {
168
+ this.queue.shift() // Drop oldest
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Flush queued messages after reconnection.
175
+ */
176
+ flush_queue() {
177
+ while (this.queue.length > 0 && this.connected && this.ws?.readyState === WebSocket.OPEN) {
178
+ const msg = this.queue.shift()
179
+ this.ws.send(msg)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Send reset event to clear state on backend.
185
+ * @param {string} scope - What to reset ('orders', 'logs', 'all')
186
+ */
187
+ send_reset(scope = 'orders') {
188
+ this.send('reset', { scope })
189
+ }
190
+
191
+ /**
192
+ * Send full order_info sync to backend.
193
+ * @param {object} order_info - Complete order_info object
194
+ */
195
+ send_order_sync(order_info) {
196
+ this.send('order_sync', { data: order_info })
197
+ }
198
+
199
+ /**
200
+ * Send order update event to backend.
201
+ * @param {object} event - Order event data
202
+ */
203
+ send_order(event) {
204
+ this.send('order', event)
205
+ }
206
+
207
+ /**
208
+ * Send log entry to backend.
209
+ * @param {string} level - Log level ('info', 'warn', 'error')
210
+ * @param {string} message - Log message
211
+ */
212
+ send_log(level, message) {
213
+ this.send('log', {
214
+ id: uuid(),
215
+ level,
216
+ msg: message,
217
+ })
218
+ }
219
+
220
+ /**
221
+ * Close the WebSocket connection.
222
+ */
223
+ close() {
224
+ this.enabled = false
225
+
226
+ if (this.sync_interval) {
227
+ clearInterval(this.sync_interval)
228
+ this.sync_interval = null
229
+ }
230
+
231
+ if (this.reconnect_timer) {
232
+ clearTimeout(this.reconnect_timer)
233
+ this.reconnect_timer = null
234
+ }
235
+
236
+ if (this.ws) {
237
+ this.ws.close()
238
+ this.ws = null
239
+ }
240
+
241
+ this.connected = false
242
+ }
243
+ }
244
+
245
+ // Singleton instance
246
+ const ws_data_client = new WSDataClient()
247
+
248
+ module.exports = { WSDataClient, ws_data_client }
@@ -7,7 +7,7 @@ const utils = require('@icgio/icg-utils').utils
7
7
  const { coinmarketcap: get_coinmarketcap_rates, observable: Observable } = require('@icgio/icg-utils')
8
8
 
9
9
  const Account = require('./account.js')
10
- const AllOrdersStore = require('../lib/order-status-store.js')
10
+ const { ws_data_client } = require('../lib/ws-data-client.js')
11
11
 
12
12
  // Default precision functions - will be overridden if provided in settings
13
13
  let round_price = (exchange, pair, price) => price
@@ -244,9 +244,10 @@ module.exports = class Orders {
244
244
  this.process_query_order(this.query_order)
245
245
  }, PROCESS_OPEN_ORDERS_TRADERS_INTERVAL_MS)
246
246
  }
247
- if (this.function_enabled.all_orders_store) {
248
- // Initialize all orders store for PostgreSQL persistence (async, non-blocking)
249
- this.all_orders_store = new AllOrdersStore(cd.all_orders_store_connection_string)
247
+ // Initialize WS data client for streaming to dashboard backend
248
+ if (settings.ws_backend_secret && settings.client_id) {
249
+ ws_data_client.init(settings.ws_backend_secret, settings.client_id)
250
+ ws_data_client.set_order_info_ref(this.order_info)
250
251
  }
251
252
  }
252
253
  init_account(balances, benchmarks) {
@@ -781,6 +782,17 @@ module.exports = class Orders {
781
782
  }
782
783
  _.setWith(this.order_info, `${exchange}.${pair}.${type}.${order_id}.${key}`, dict[key], Object)
783
784
  }
785
+
786
+ // Add trace entry for order history
787
+ const trace_entry = {
788
+ ts: Date.now(),
789
+ status: dict.status || _.get(this.order_info, [exchange, pair, type, order_id, 'status']),
790
+ note,
791
+ f_amount: dict.f_amount,
792
+ }
793
+ const existing_trace = _.get(this.order_info, [exchange, pair, type, order_id, 'trace'], [])
794
+ _.setWith(this.order_info, `${exchange}.${pair}.${type}.${order_id}.trace`, [...existing_trace, trace_entry], Object)
795
+
784
796
  console.log('UPDATE %s: %s %s %s %s %j', note, exchange, pair, type, order_id, dict)
785
797
  // start: log every f-filled trade for future analysis
786
798
  if (dict.status === 'F-FILLED') {
@@ -789,21 +801,38 @@ module.exports = class Orders {
789
801
  console.log(`f_filled_orders_info:${exchange},${pair},${type},${order_id},${price},${amount},${f_amount},${t_amount},${fees},${close_time},${note}`)
790
802
  }
791
803
  // end: log every f-filled trade for future analysis
792
- // Store order status to PostgreSQL asynchronously (non-blocking)
804
+
805
+ // Send order update via WebSocket to dashboard backend
793
806
  const order_data = _.get(this.order_info, [exchange, pair, type, order_id], {})
794
- if (this.function_enabled.all_orders_store) {
795
- this.all_orders_store.store(exchange, pair, type, order_id, order_data, note)
807
+ // Only send N-FILLED and later statuses (skip INIT which has temporary order_id)
808
+ if (dict.status !== 'INIT' && order_data.status !== 'INIT') {
809
+ ws_data_client.send_order({
810
+ action: 'update',
811
+ exchange,
812
+ pair,
813
+ side: type,
814
+ order_id,
815
+ data: order_data,
816
+ note,
817
+ })
796
818
  }
797
819
  }
798
820
  delete_order_info(exchange, pair, type, order_id, note) {
799
821
  const order_data = _.get(this.order_info, [exchange, pair, type, order_id], {})
800
822
  this.order_info[exchange][pair][type] = _.omit(this.order_info[exchange][pair][type], [order_id])
801
823
  console.log('DELETE %s: %s %s %s %s', note, exchange, pair, type, order_id)
802
- // Store deletion to PostgreSQL (non-blocking)
803
- // Keep final F-FILLED / P-FILLED states in DB (do NOT mark them as DELETED)
804
- if (order_data.status === 'F-FILLED' || order_data.status === 'P-FILLED') {
805
- } else if (this.function_enabled.all_orders_store) {
806
- this.all_orders_store.store(exchange, pair, type, order_id, { ...order_data, status: 'DELETED' }, note)
824
+
825
+ // Send delete event via WebSocket (only for non-INIT orders)
826
+ if (order_data.status !== 'INIT') {
827
+ ws_data_client.send_order({
828
+ action: 'delete',
829
+ exchange,
830
+ pair,
831
+ side: type,
832
+ order_id,
833
+ data: order_data,
834
+ note,
835
+ })
807
836
  }
808
837
  }
809
838
  check_balances() {
@@ -925,10 +954,6 @@ module.exports = class Orders {
925
954
  }
926
955
  // cancel previous orders
927
956
  else if (this.cancel_previous_orders && !this.previous_orders_canceled) {
928
- // remove all previous orders for this exchange/pair from all_orders (fire-and-forget)
929
- if (this.function_enabled.all_orders_store) {
930
- this.all_orders_store.clear_orders(exchange, pair).catch((err) => console.error('clear_all_orders error', exchange, pair, err.message))
931
- }
932
957
  if (this.cancel_previous_orders_with_delay) {
933
958
  setTimeout(() => {
934
959
  orders.map((order) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icgio/icg-exchanges-wrapper",
3
- "version": "1.14.253",
3
+ "version": "1.14.254",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,255 +0,0 @@
1
- const { Pool } = require('pg')
2
-
3
- /**
4
- * Async PostgreSQL store for all orders
5
- * Non-blocking: all writes are fire-and-forget to avoid blocking the main process
6
- */
7
- class AllOrdersStore {
8
- constructor(connection_string) {
9
- this.enabled = true
10
- this.pool = null
11
- this.table_ready = false
12
- this.queue = []
13
- this.max_queue_size = 2000
14
- this.flush_interval = null
15
- this.flush_interval_ms = 5000
16
-
17
- if (connection_string) {
18
- this.init(connection_string)
19
- }
20
- }
21
-
22
- async init(connection_string) {
23
- try {
24
- this.pool = new Pool({
25
- connectionString: connection_string,
26
- max: 5,
27
- idleTimeoutMillis: 30000,
28
- connectionTimeoutMillis: 5000,
29
- ssl: { rejectUnauthorized: false },
30
- })
31
-
32
- this.pool.on('error', (err) => {
33
- console.error('AllOrdersStore pool error:', err.message)
34
- })
35
-
36
- // Set timezone to UTC for all connections
37
- this.pool.on('connect', async (client) => {
38
- await client.query("SET timezone = 'UTC'")
39
- })
40
-
41
- await this.ensure_table()
42
- this.enabled = true
43
- this.start_flush_interval()
44
- console.log('AllOrdersStore initialized successfully')
45
- } catch (err) {
46
- console.error('AllOrdersStore init failed:', err.message)
47
- this.enabled = false
48
- }
49
- }
50
-
51
- async ensure_table() {
52
- const create_table_query = `
53
- -- All orders (persistent mapping of id/order_id to order_type & status)
54
- CREATE TABLE IF NOT EXISTS all_orders (
55
- id VARCHAR(128),
56
- exchange VARCHAR(64) NOT NULL,
57
- pair VARCHAR(32) NOT NULL,
58
- order_type VARCHAR(64),
59
- type VARCHAR(16) NOT NULL,
60
- order_id VARCHAR(128) NOT NULL,
61
- status VARCHAR(32) NOT NULL,
62
- price DECIMAL(32, 16),
63
- amount DECIMAL(32, 16),
64
- f_amount DECIMAL(32, 16),
65
- f_percentage DECIMAL(8, 4),
66
- t_amount DECIMAL(32, 16),
67
- fees JSONB,
68
- open_time TIMESTAMPTZ,
69
- close_time TIMESTAMPTZ,
70
- note VARCHAR(256),
71
- created_at TIMESTAMPTZ DEFAULT (NOW() AT TIME ZONE 'UTC'),
72
- updated_at TIMESTAMPTZ DEFAULT (NOW() AT TIME ZONE 'UTC'),
73
- PRIMARY KEY (exchange, pair, type, order_id)
74
- );
75
- CREATE INDEX IF NOT EXISTS idx_all_orders_exchange_pair_order ON all_orders(exchange, pair, order_id);
76
- `
77
-
78
- const client = await this.pool.connect()
79
- try {
80
- await client.query(create_table_query)
81
- this.table_ready = true
82
- } finally {
83
- client.release()
84
- }
85
- }
86
-
87
- /**
88
- * Remove all rows for a specific exchange/pair (fresh start).
89
- * @param {string} exchange
90
- * @param {string} pair
91
- */
92
- async clear_orders(exchange, pair) {
93
- if (!this.enabled || !this.table_ready) return
94
- const client = await this.pool.connect()
95
- try {
96
- await client.query('DELETE FROM all_orders WHERE exchange = $1 AND pair = $2', [exchange, pair])
97
- } finally {
98
- client.release()
99
- }
100
- }
101
-
102
- start_flush_interval() {
103
- if (this.flush_interval) return
104
-
105
- this.flush_interval = setInterval(() => {
106
- this.flush().catch((err) => {
107
- console.error('AllOrdersStore flush error:', err.message)
108
- })
109
- }, this.flush_interval_ms)
110
- }
111
-
112
- stop_flush_interval() {
113
- if (this.flush_interval) {
114
- clearInterval(this.flush_interval)
115
- this.flush_interval = null
116
- }
117
- }
118
-
119
- /**
120
- * Queue an order status update (non-blocking)
121
- * @param {string} exchange
122
- * @param {string} pair
123
- * @param {string} type - 'buy' or 'sell'
124
- * @param {string} order_id
125
- * @param {object} data - order data
126
- * @param {string} note - update note
127
- */
128
- store(exchange, pair, type, order_id, data, note) {
129
- if (!this.enabled) return
130
-
131
- // Convert Date objects to UTC ISO strings for consistent timezone handling
132
- const to_utc_iso = (date) => {
133
- if (!date) return null
134
- if (date instanceof Date) {
135
- return date.toISOString()
136
- }
137
- return date
138
- }
139
-
140
- const record = {
141
- id: data.id,
142
- exchange,
143
- pair,
144
- type,
145
- order_id: order_id,
146
- order_type: data.order_type || null,
147
- status: data.status || null,
148
- price: data.price || null,
149
- amount: data.amount || null,
150
- f_amount: data.f_amount || null,
151
- f_percentage: data.f_percentage || null,
152
- t_amount: data.t_amount || null,
153
- fees: data.fees ? JSON.stringify(data.fees) : null,
154
- open_time: to_utc_iso(data.open_time),
155
- close_time: to_utc_iso(data.close_time),
156
- note: note || null,
157
- }
158
-
159
- this.queue.push(record)
160
-
161
- // Prevent memory overflow
162
- if (this.queue.length > this.max_queue_size) {
163
- this.queue.shift()
164
- }
165
- }
166
-
167
- /**
168
- * Flush queued records to database (async, non-blocking to caller)
169
- */
170
- async flush() {
171
- if (!this.enabled || !this.table_ready || this.queue.length === 0) return
172
-
173
- const batch = this.queue.splice(0, 100) // Process up to 100 at a time
174
-
175
- const upsert_all_orders_query = `
176
- INSERT INTO all_orders (
177
- id, exchange, pair, type, order_id, order_type, status,
178
- price, amount, f_amount, f_percentage, t_amount,
179
- fees, open_time, close_time, note, created_at, updated_at
180
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC'))
181
- ON CONFLICT (exchange, pair, type, order_id) DO UPDATE SET
182
- id = EXCLUDED.id,
183
- order_type = EXCLUDED.order_type,
184
- status = EXCLUDED.status,
185
- price = EXCLUDED.price,
186
- amount = EXCLUDED.amount,
187
- f_amount = EXCLUDED.f_amount,
188
- f_percentage = EXCLUDED.f_percentage,
189
- t_amount = EXCLUDED.t_amount,
190
- fees = EXCLUDED.fees,
191
- open_time = EXCLUDED.open_time,
192
- close_time = EXCLUDED.close_time,
193
- note = EXCLUDED.note,
194
- updated_at = (NOW() AT TIME ZONE 'UTC')
195
- `
196
-
197
- const delete_all_orders_query = `
198
- DELETE FROM all_orders
199
- WHERE exchange = $1 AND pair = $2 AND type = $3 AND order_id = $4
200
- `
201
-
202
- const client = await this.pool.connect()
203
- try {
204
- for (const record of batch) {
205
- if (record.status === 'DELETED') {
206
- // Physically delete rows for deleted orders to limit DB size
207
- await client.query(delete_all_orders_query, [record.exchange, record.pair, record.type, record.order_id])
208
- } else {
209
- // Upsert active / updated orders
210
- await client.query(upsert_all_orders_query, [
211
- record.id,
212
- record.exchange,
213
- record.pair,
214
- record.type,
215
- record.order_id,
216
- record.order_type,
217
- record.status,
218
- record.price,
219
- record.amount,
220
- record.f_amount,
221
- record.f_percentage,
222
- record.t_amount,
223
- record.fees,
224
- record.open_time,
225
- record.close_time,
226
- record.note,
227
- ])
228
- }
229
- }
230
- } catch (err) {
231
- // Re-queue failed records
232
- this.queue.unshift(...batch)
233
- throw err
234
- } finally {
235
- client.release()
236
- }
237
- }
238
-
239
- async close() {
240
- this.stop_flush_interval()
241
- if (this.queue.length > 0) {
242
- try {
243
- await this.flush()
244
- } catch (err) {
245
- console.error('AllOrdersStore final flush error:', err.message)
246
- }
247
- }
248
- if (this.pool) {
249
- await this.pool.end()
250
- }
251
- this.enabled = false
252
- }
253
- }
254
-
255
- module.exports = AllOrdersStore