@icgio/icg-exchanges 1.40.48 → 1.40.50

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.
@@ -2,9 +2,10 @@ const _ = require('lodash')
2
2
 
3
3
  const qs = require('qs')
4
4
  const websocket = require('ws')
5
- const { createHmac, randomBytes } = require('crypto')
5
+ const { createHmac, randomBytes, randomUUID } = require('crypto')
6
6
  const needle = require('needle')
7
7
 
8
+ const ExchangeFix = require('./exchange-fix.js')
8
9
  const ExchangeWs = require('./exchange-ws.js')
9
10
  const ExchangeBase = require('./exchange-base.js')
10
11
  const { filter_history_in_range, history_time_ms, sleep, sort_history_rows } = ExchangeBase.history_utils
@@ -15,6 +16,8 @@ const { error_codes } = require('../error_codes.json')
15
16
  const { utils, rate_limiter: Limiter } = require('@icgio/icg-utils')
16
17
  const { sign } = require('jsonwebtoken')
17
18
 
19
+ const FIX_SOH = '\x01'
20
+
18
21
  // Detect key type: advanced keys start with "organizations/"
19
22
  function is_advanced_key(api_key) {
20
23
  return typeof api_key === 'string' && api_key.startsWith('organizations/')
@@ -54,6 +57,63 @@ function coinbase_taker_side(side) {
54
57
  return undefined
55
58
  }
56
59
 
60
+ function build_fix_logon_fields(api_key, secret_key, passphrase, seq_num, sending_time, options = {}) {
61
+ const prehash = sending_time + FIX_SOH + 'A' + FIX_SOH + seq_num + FIX_SOH + api_key + FIX_SOH + 'Coinbase' + FIX_SOH + passphrase
62
+ const signature = createHmac('sha256', Buffer.from(secret_key, 'base64')).update(prehash).digest('base64')
63
+ const fields = [
64
+ [553, api_key], [554, passphrase],
65
+ [95, signature.length], [96, signature],
66
+ [98, 0], [108, 30], [141, 'Y'], [1137, 9],
67
+ ]
68
+ if (options.order_entry) fields.push([8013, 'S'])
69
+ return fields
70
+ }
71
+
72
+ function parse_fix_event_ts_ms(fix_time) {
73
+ if (!fix_time || fix_time.length < 17) return undefined
74
+ const iso = fix_time.slice(0, 4) + '-' + fix_time.slice(4, 6) + '-' + fix_time.slice(6, 8) + 'T' + fix_time.slice(9, 21) + 'Z'
75
+ const ms = Date.parse(iso)
76
+ return Number.isFinite(ms) ? ms : undefined
77
+ }
78
+
79
+ function parse_fix_md_entry(fields) {
80
+ const symbol = fields['55']
81
+ const entry_type = fields['269']
82
+ const action = fields['279']
83
+ const md_entry_id = fields['278']
84
+ const order_id = fields['37']
85
+ if (!symbol || entry_type === undefined || action === undefined) return null
86
+
87
+ const price = parseFloat(fields['270'])
88
+ const size = parseFloat(fields['271'])
89
+ if (!Number.isFinite(price)) return null
90
+
91
+ const rpt_seq = fields['83'] ? parseInt(fields['83'], 10) : undefined
92
+ if (entry_type === '2') {
93
+ return {
94
+ kind: 'trade', symbol, price, size,
95
+ time: fields['60'], trade_id: fields['1003'],
96
+ aggressor_side: fields['5797'], rpt_seq,
97
+ }
98
+ }
99
+ if (entry_type !== '0' && entry_type !== '1') return null
100
+ if (!md_entry_id && order_id) return null
101
+
102
+ let action_str
103
+ if (action === '0') action_str = 'new'
104
+ else if (action === '1') action_str = 'change'
105
+ else if (action === '2') action_str = 'delete'
106
+ else return null
107
+
108
+ return {
109
+ kind: 'l2', symbol,
110
+ side: entry_type === '0' ? 'bid' : 'offer',
111
+ action: action_str,
112
+ price, size,
113
+ time: fields['60'], md_entry_id, rpt_seq,
114
+ }
115
+ }
116
+
57
117
  // --- Legacy auth (HMAC + passphrase) ---
58
118
 
59
119
  function get_signature_legacy(api_key, secret_key, timestamp, method, request_path, body) {
@@ -138,18 +198,57 @@ module.exports = class Coinbase extends ExchangeBase {
138
198
  })
139
199
 
140
200
  // FIX protocol — default for legacy keys
141
- this.fix_ord = null
201
+ this.fix = null
142
202
  this._use_fix = false
203
+ this._fix_pending = new Map()
204
+ this._fix_md_pair_list = []
205
+ this._fix_md_symbols = []
206
+ this._fix_md_subscribed = false
207
+ this._fix_md_session_id = 0
143
208
  if (!this.advanced && this.passphrase && !this.settings.no_fix) {
144
- const { CoinbaseFixOrd, CoinbaseFixMd } = require('./coinbase-fix.js')
145
- this._CoinbaseFixMd = CoinbaseFixMd
146
- this.fix_ord = new CoinbaseFixOrd(
147
- Array.isArray(api_key) ? api_key[0] : api_key,
148
- Array.isArray(secret_key) ? secret_key[0] : secret_key,
149
- this.passphrase,
150
- { error_codes, pre_process_pair },
151
- )
152
- this.fix_ord.connect()
209
+ const fix_api_key = Array.isArray(api_key) ? api_key[0] : api_key
210
+ const fix_secret_key = Array.isArray(secret_key) ? secret_key[0] : secret_key
211
+ this.fix = new ExchangeFix(this.name, {
212
+ market: {
213
+ host: 'fix-md.exchange.coinbase.com',
214
+ port: 6121,
215
+ sender_comp_id: fix_api_key,
216
+ target_comp_id: 'Coinbase',
217
+ heartbeat_interval: 30,
218
+ build_logon_fields: (seq, time) => build_fix_logon_fields(fix_api_key, fix_secret_key, this.passphrase, seq, time),
219
+ on_market_data_batch: (batch) => this._handle_fix_market_batch_fields(batch),
220
+ on_market_data_snapshot: (fields) => this._handle_fix_market_fields(fields),
221
+ on_market_data_request_reject: (fields) => {
222
+ console.log('[coinbase FIX md] request reject:', fields['58'] || '')
223
+ },
224
+ on_status_change: (status) => this._on_fix_market_status(status),
225
+ },
226
+ trading: {
227
+ host: 'fix-ord.exchange.coinbase.com',
228
+ port: 6121,
229
+ sender_comp_id: fix_api_key,
230
+ target_comp_id: 'Coinbase',
231
+ heartbeat_interval: 30,
232
+ build_logon_fields: (seq, time) => build_fix_logon_fields(fix_api_key, fix_secret_key, this.passphrase, seq, time, { order_entry: true }),
233
+ on_execution_report: (fields) => this._handle_fix_exec_report(fields),
234
+ on_cancel_reject: (fields) => this._handle_fix_cancel_reject(fields),
235
+ on_reject: (fields) => this._handle_fix_reject(fields),
236
+ on_status_change: (status) => this._on_fix_trading_status(status),
237
+ },
238
+ })
239
+ this.ws.market.end = () => {
240
+ if (this.fix && this.fix.market) this.fix.market.end()
241
+ }
242
+ this.ws.market.close = () => {
243
+ if (this.fix && this.fix.market) this.fix.market.close()
244
+ }
245
+ this.ws.trading.end = () => {
246
+ if (this.fix && this.fix.trading) this.fix.trading.end()
247
+ }
248
+ this.ws.trading.close = () => {
249
+ if (this.fix && this.fix.trading) this.fix.trading.close()
250
+ }
251
+ this.fix.trading.start()
153
252
  this._use_fix = true
154
253
  }
155
254
  }
@@ -580,65 +679,250 @@ module.exports = class Coinbase extends ExchangeBase {
580
679
  this.ws.market.start(pair_list)
581
680
  }
582
681
 
583
- // FIX market data — L3 incremental with REST L3 snapshot bootstrap.
584
- _fix_market_data(pair_list, options = {}) {
585
- this.ws.ws_market_options = options
586
- const OB_WINDOW = options.fix_ob_window || 0.05
587
- const RESNAPSHOT_DRIFT = options.fix_resnapshot_drift || 0.03
588
- const CROSS_GRACE_MS = options.fix_cross_grace_ms || 500
589
- const [api_key, secret_key] = this.api_secret_key
682
+ _fix_trading_ready() {
683
+ return !!(this.fix && this.fix.trading && this.fix.trading.is_ready())
684
+ }
590
685
 
686
+ _send_fix_new_order(pair, method, price, amount, order_options, cb) {
687
+ if (typeof order_options === 'function') { cb = order_options; order_options = {} }
688
+ const cl_ord_id = randomUUID()
689
+ const side = method === 'buy' ? '1' : '2'
690
+ let tif = '1'
691
+ if (order_options && order_options.ioc) tif = '3'
692
+ else if (order_options && order_options.fok) tif = '4'
693
+ const fields = [
694
+ [11, cl_ord_id], [55, pre_process_pair(pair)], [54, side],
695
+ [44, price.toString()], [38, amount.toString()], [40, 2], [59, tif], [21, 1],
696
+ ]
697
+ if (order_options && order_options.post_only) fields.push([18, 'A'])
698
+ const timeout_id = setTimeout(() => this._resolve_fix_pending(cl_ord_id, { success: false, error: error_codes.timeout }), 15000)
699
+ this._fix_pending.set(cl_ord_id, { cb, timeout_id, type: 'new' })
700
+ this.fix.trading.send('D', fields)
701
+ }
702
+
703
+ _send_fix_cancel(pair, order_id, side, cb) {
704
+ const cl_ord_id = randomUUID()
705
+ const fix_side = side === 'buy' ? '1' : '2'
706
+ const fields = [
707
+ [11, cl_ord_id], [41, cl_ord_id], [37, order_id],
708
+ [55, pre_process_pair(pair)], [54, fix_side],
709
+ ]
710
+ const timeout_id = setTimeout(() => this._resolve_fix_pending(cl_ord_id, { success: false, error: error_codes.timeout }), 15000)
711
+ this._fix_pending.set(cl_ord_id, { cb, timeout_id, type: 'cancel', order_id })
712
+ this.fix.trading.send('F', fields)
713
+ }
714
+
715
+ _on_fix_trading_status(status) {
716
+ console.log('[coinbase FIX ord] status:', status)
717
+ if (status === 'connecting' || status === 'logging_in') {
718
+ this.ws.trading.status = 'opening'
719
+ } else if (status === 'ready') {
720
+ this.ws.trading.status = 'opened'
721
+ } else if (status === 'disconnected') {
722
+ this.ws.trading.status = this.fix && this.fix.trading && this.fix.trading.status === 'stopped' ? 'stopped' : 'closed'
723
+ this._flush_fix_pending()
724
+ }
725
+ }
726
+
727
+ _handle_fix_exec_report(fields) {
728
+ const cl_ord_id = fields['11']
729
+ const ord_status = fields['39']
730
+ const order_id = fields['37'] || ''
731
+ if (ord_status === '0') {
732
+ this._resolve_fix_pending(cl_ord_id, { success: true, body: order_id })
733
+ } else if (ord_status === '8') {
734
+ const text = fields['58'] || ''
735
+ let error = error_codes.other
736
+ if (text.includes('nsufficient') || text.includes('NSUFFICIENT')) error = error_codes.insufficient_funds
737
+ else if (fields['103'] === '2' || text.includes('too small') || text.includes('TOO_SMALL')) error = error_codes.amount_too_small
738
+ this._resolve_fix_pending(cl_ord_id, { success: false, error, body: text })
739
+ } else if (ord_status === '4') {
740
+ if (this._fix_pending.has(cl_ord_id)) this._resolve_fix_pending(cl_ord_id, { success: true, body: order_id })
741
+ else this._resolve_fix_pending_by_order_id(order_id, { success: true, body: order_id })
742
+ } else if (ord_status === '1' || ord_status === '2') {
743
+ if (this._fix_pending.has(cl_ord_id) && this._fix_pending.get(cl_ord_id).type === 'cancel') {
744
+ this._resolve_fix_pending(cl_ord_id, { success: false, error: error_codes.order_not_open, body: order_id })
745
+ } else {
746
+ this._resolve_fix_pending_by_order_id(order_id, { success: false, error: error_codes.order_not_open, body: order_id }, 'cancel')
747
+ }
748
+ }
749
+ }
750
+
751
+ _handle_fix_cancel_reject(fields) {
752
+ let error = error_codes.other
753
+ if (fields['102'] === '1') error = error_codes.order_not_open
754
+ this._resolve_fix_pending(fields['11'], { success: false, error, body: fields['58'] || '' })
755
+ }
756
+
757
+ _handle_fix_reject(fields) {
758
+ const cl_ord_id = fields['379'] || fields['11'] || ''
759
+ if (cl_ord_id && this._fix_pending.has(cl_ord_id)) {
760
+ this._resolve_fix_pending(cl_ord_id, { success: false, error: error_codes.other, body: fields['58'] || '' })
761
+ }
762
+ }
763
+
764
+ _resolve_fix_pending(cl_ord_id, result) {
765
+ const pending = this._fix_pending.get(cl_ord_id)
766
+ if (!pending) return
767
+ clearTimeout(pending.timeout_id)
768
+ this._fix_pending.delete(cl_ord_id)
769
+ pending.cb(result)
770
+ }
771
+
772
+ _resolve_fix_pending_by_order_id(order_id, result, type_filter) {
773
+ for (const [id, pending] of this._fix_pending) {
774
+ if (pending.order_id === order_id && (!type_filter || pending.type === type_filter)) {
775
+ this._resolve_fix_pending(id, result)
776
+ return
777
+ }
778
+ }
779
+ }
780
+
781
+ _flush_fix_pending() {
782
+ for (const [id, pending] of this._fix_pending) {
783
+ clearTimeout(pending.timeout_id)
784
+ pending.cb({ success: false, error: error_codes.timeout })
785
+ }
786
+ this._fix_pending.clear()
787
+ }
788
+
789
+ _prepare_fix_market_session() {
790
+ this._fix_md_session_id++
791
+ this._fix_md_subscribed = false
591
792
  this._fix_md_orders = {}
592
793
  this._fix_md_snapshot_mid = {}
593
794
  this._fix_md_snapshot_seq = {}
594
795
  this._fix_md_event_buffer = {}
595
796
  this._fix_md_pair_by_symbol = {}
596
797
  this._fix_md_cross_pending = {}
597
- this._fix_md_cross_grace_ms = CROSS_GRACE_MS
798
+ this._fix_md_resnapshot_pending = {}
799
+ this._fix_md_resnapshot_buffer = {}
800
+ this._fix_md_notify_pending = false
801
+ this.ws.market.status = 'opening'
802
+ this.ws.market.last_message_time = undefined
803
+
804
+ for (const pair of this._fix_md_pair_list) {
805
+ this.ws.market.pair_status[pair] = 'opening'
806
+ this.ws.market.asks_dict[pair] = sorted_book(true)
807
+ this.ws.market.bids_dict[pair] = sorted_book(false)
808
+ this.ws.market.market_history_dict[pair] = []
809
+ delete this.ws.market.market_ts_by_pair[pair]
810
+ delete this.ws.market.tickers[pair]
811
+ this._fix_md_orders[pair] = {}
812
+ this._fix_md_snapshot_mid[pair] = null
813
+ this._fix_md_snapshot_seq[pair] = null
814
+ this._fix_md_event_buffer[pair] = []
815
+ this._fix_md_pair_by_symbol[pre_process_pair(pair)] = pair
816
+ }
817
+ }
598
818
 
599
- this.ws.market.start_base = () => {
600
- pair_list.map((pair) => {
601
- this.ws.market.pair_status[pair] = 'opening'
602
- this.ws.market.asks_dict[pair] = sorted_book(true)
603
- this.ws.market.bids_dict[pair] = sorted_book(false)
604
- this.ws.market.market_history_dict[pair] = []
605
- this._fix_md_orders[pair] = {}
606
- this._fix_md_snapshot_seq[pair] = null
607
- this._fix_md_event_buffer[pair] = []
608
- this._fix_md_pair_by_symbol[pre_process_pair(pair)] = pair
609
- })
819
+ _send_fix_market_subscribe() {
820
+ if (!this.fix || !this.fix.market || !this._fix_md_symbols.length) return
821
+ const fields = [
822
+ [262, 'icg-md-' + Date.now()], [263, 1], [264, 0], [265, 1],
823
+ [267, 3], [269, 0], [269, 1], [269, 2],
824
+ [146, this._fix_md_symbols.length],
825
+ ]
826
+ for (const symbol of this._fix_md_symbols) fields.push([55, symbol])
827
+ if (this.fix.market.send('V', fields)) {
828
+ this._fix_md_subscribed = true
829
+ console.log('[coinbase FIX md] subscribed:', this._fix_md_symbols.join(','))
830
+ }
831
+ }
610
832
 
611
- const symbols = pair_list.map((pair) => pre_process_pair(pair))
612
-
613
- this.fix_md = new this._CoinbaseFixMd({
614
- api_key: Array.isArray(api_key) ? api_key[0] : api_key,
615
- secret_key: Array.isArray(secret_key) ? secret_key[0] : secret_key,
616
- passphrase: this.passphrase,
617
- symbols,
618
- on_status: (status) => {
619
- console.log('[coinbase FIX MD] status:', status)
620
- if (status === 'ready') {
621
- this.ws.market.status = 'opened'
622
- this.ws.market.last_message_time = Date.now()
623
- for (const pair of pair_list) this._fix_md_load_snapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT)
624
- }
625
- },
626
- on_batch: (entries) => this._fix_md_handle_batch(entries, OB_WINDOW, RESNAPSHOT_DRIFT),
627
- })
628
- this.fix_md.connect()
833
+ _on_fix_market_status(status) {
834
+ console.log('[coinbase FIX md] status:', status)
835
+ if (!this._fix_md_pair_list.length) return
836
+
837
+ if (status === 'connecting') {
838
+ this._prepare_fix_market_session()
839
+ return
629
840
  }
630
- this.ws.market.end_base = () => {
631
- if (this.fix_md) {
632
- try { this.fix_md.disconnect() } catch (e) {}
633
- this.fix_md = null
841
+ if (status === 'logging_in') {
842
+ this.ws.market.status = 'opening'
843
+ return
844
+ }
845
+ if (status === 'ready') {
846
+ if (!this._fix_md_subscribed) this._send_fix_market_subscribe()
847
+ this.ws.market.status = 'opened'
848
+ this.ws.market.last_message_time = Date.now()
849
+ const session_id = this._fix_md_session_id
850
+ for (const pair of this._fix_md_pair_list) {
851
+ this._fix_md_load_snapshot(pair, this._fix_md_ob_window, this._fix_md_resnapshot_drift, session_id)
634
852
  }
635
- this._fix_md_orders = {}
636
- this._fix_md_snapshot_mid = {}
637
- this._fix_md_snapshot_seq = {}
638
- this._fix_md_event_buffer = {}
639
- this._fix_md_cross_pending = {}
853
+ return
640
854
  }
641
- this.ws.market.start(pair_list)
855
+ if (status === 'disconnected') {
856
+ const final_status = this.fix && this.fix.market && this.fix.market.status === 'stopped' ? 'stopped' : 'closed'
857
+ this._fix_md_session_id++
858
+ this._fix_md_subscribed = false
859
+ this.ws.market.status = final_status
860
+ this.ws.market.last_message_time = undefined
861
+ for (const pair of this._fix_md_pair_list) this.ws.market.pair_status[pair] = final_status
862
+ if (final_status === 'stopped') {
863
+ clearInterval(this.ws.market.notify_heartbeat_session)
864
+ clearInterval(this.ws.market.stale_check_session)
865
+ clearInterval(this.ws.market.ping_session)
866
+ clearTimeout(this.ws.market.restart_timeout)
867
+ clearTimeout(this.ws.market.reconnecting_timeout)
868
+ }
869
+ }
870
+ }
871
+
872
+ _handle_fix_market_fields(fields) {
873
+ const hr_recv = process.hrtime()
874
+ const entry = parse_fix_md_entry(fields)
875
+ if (!entry) return
876
+ const hr_parsed = process.hrtime()
877
+ entry.ts_ms_venue_event = parse_fix_event_ts_ms(entry.time)
878
+ entry.ts_ms_local_received = Date.now()
879
+ entry.hr_recv = hr_recv
880
+ entry.hr_parsed = hr_parsed
881
+ this._fix_md_handle_batch([entry], this._fix_md_ob_window, this._fix_md_resnapshot_drift)
882
+ }
883
+
884
+ _handle_fix_market_batch_fields(batch) {
885
+ const entries = []
886
+ const now = Date.now()
887
+ for (const fields of batch) {
888
+ const hr_recv = process.hrtime()
889
+ const entry = parse_fix_md_entry(fields)
890
+ if (!entry) continue
891
+ const hr_parsed = process.hrtime()
892
+ entry.ts_ms_venue_event = parse_fix_event_ts_ms(entry.time)
893
+ entry.ts_ms_local_received = now
894
+ entry.hr_recv = hr_recv
895
+ entry.hr_parsed = hr_parsed
896
+ entries.push(entry)
897
+ }
898
+ if (entries.length) this._fix_md_handle_batch(entries, this._fix_md_ob_window, this._fix_md_resnapshot_drift)
899
+ }
900
+
901
+ // FIX market data — L3 incremental with REST L3 snapshot bootstrap.
902
+ _fix_market_data(pair_list, options = {}) {
903
+ if (!this.fix || !this.fix.market) return
904
+ if (this.fix.market.status === 'opening' || this.fix.market.status === 'opened') return
905
+
906
+ this.ws.ws_market_options = options
907
+ this._fix_md_ob_window = options.fix_ob_window || 0.05
908
+ this._fix_md_resnapshot_drift = options.fix_resnapshot_drift || 0.03
909
+ this._fix_md_cross_grace_ms = options.fix_cross_grace_ms || 500
910
+ this._fix_md_pair_list = pair_list.slice()
911
+ this._fix_md_symbols = pair_list.map((pair) => pre_process_pair(pair))
912
+
913
+ clearInterval(this.ws.market.ping_session)
914
+ clearInterval(this.ws.market.notify_heartbeat_session)
915
+ clearInterval(this.ws.market.stale_check_session)
916
+ clearTimeout(this.ws.market.restart_timeout)
917
+ clearTimeout(this.ws.market.reconnecting_timeout)
918
+
919
+ this.ws.market.clear()
920
+ this.ws.market.pair_list = pair_list.slice()
921
+ this.ws.market.status = 'closed'
922
+ pair_list.map((pair) => (this.ws.market.pair_status[pair] = 'closed'))
923
+ this.ws.market.start_notify_heartbeat()
924
+ this.ws.market.start_stale_check()
925
+ this.fix.market.start()
642
926
  }
643
927
 
644
928
  // Build book from L3 snapshot, apply to pair, replay buffered events.
@@ -709,11 +993,12 @@ module.exports = class Coinbase extends ExchangeBase {
709
993
  return snap_seq
710
994
  }
711
995
 
712
- _fix_md_load_snapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT) {
996
+ _fix_md_load_snapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT, session_id = this._fix_md_session_id) {
713
997
  this._fix_md_fetch_snapshot(pair, (res) => {
998
+ if (session_id !== this._fix_md_session_id) return
714
999
  if (!res.success) {
715
1000
  console.log('[coinbase FIX MD] snapshot fetch failed for %s, retrying in 1s', pair)
716
- setTimeout(() => this._fix_md_load_snapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT), 1000)
1001
+ setTimeout(() => this._fix_md_load_snapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT, session_id), 1000)
717
1002
  return
718
1003
  }
719
1004
  const buffered = this._fix_md_event_buffer[pair] || []
@@ -723,11 +1008,12 @@ module.exports = class Coinbase extends ExchangeBase {
723
1008
  }
724
1009
 
725
1010
  // Background resnapshot: old book stays live while new one is built, then atomic swap.
726
- _fix_md_resnapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT) {
1011
+ _fix_md_resnapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT, session_id = this._fix_md_session_id) {
727
1012
  this._fix_md_fetch_snapshot(pair, (res) => {
1013
+ if (session_id !== this._fix_md_session_id) return
728
1014
  if (!res.success) {
729
1015
  console.log('[coinbase FIX MD] resnapshot fetch failed for %s, retrying in 1s', pair)
730
- setTimeout(() => this._fix_md_resnapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT), 1000)
1016
+ setTimeout(() => this._fix_md_resnapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT, session_id), 1000)
731
1017
  return
732
1018
  }
733
1019
  const buffered = this._fix_md_resnapshot_buffer[pair] || []
@@ -931,7 +1217,7 @@ module.exports = class Coinbase extends ExchangeBase {
931
1217
  this._fix_md_resnapshot_buffer[pair] = []
932
1218
  console.log('[coinbase FIX MD] %s: %s snap_mid=%s cur_mid=%s bid=%s ask=%s, refetching',
933
1219
  reason, pair, snap_mid.toFixed(2), cur_mid.toFixed(2), best_bid, best_ask)
934
- this._fix_md_resnapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT)
1220
+ this._fix_md_resnapshot(pair, OB_WINDOW, RESNAPSHOT_DRIFT, this._fix_md_session_id)
935
1221
  }
936
1222
 
937
1223
  _fix_md_update_bbo(pair, OB_WINDOW, RESNAPSHOT_DRIFT, options = {}) {
@@ -987,7 +1273,7 @@ module.exports = class Coinbase extends ExchangeBase {
987
1273
  }
988
1274
 
989
1275
  ws_status() {
990
- return { market: this.ws.market.pair_status, trading: 'n-opened' }
1276
+ return { market: this.ws.market.pair_status, trading: this.ws.trading.status }
991
1277
  }
992
1278
  rate_ws(pair, cb) {
993
1279
  if (this.ws.market.pair_status[pair] === 'opened') {
@@ -1106,8 +1392,8 @@ module.exports = class Coinbase extends ExchangeBase {
1106
1392
  cb({ success: false, error: error_codes.amount_too_small })
1107
1393
  return
1108
1394
  }
1109
- if (this.fix_ord && this.fix_ord.is_ready()) {
1110
- this.fix_ord.send_new_order(pair, method, price, amount, order_options, cb)
1395
+ if (this._fix_trading_ready()) {
1396
+ this._send_fix_new_order(pair, method, price, amount, order_options, cb)
1111
1397
  return
1112
1398
  }
1113
1399
  if (this.advanced) {
@@ -1173,13 +1459,13 @@ module.exports = class Coinbase extends ExchangeBase {
1173
1459
  cb({ success: false, error: error_codes.amount_too_small })
1174
1460
  return
1175
1461
  }
1176
- if (this.fix_ord && this.fix_ord.is_ready()) {
1462
+ if (this._fix_trading_ready()) {
1177
1463
  const normalized = this.normalize_order_options(order_options)
1178
1464
  const t_order_sent = process.hrtime()
1179
1465
  if (normalized.on_sent) {
1180
1466
  normalized.on_sent({ t_order_sent })
1181
1467
  }
1182
- this.fix_ord.send_new_order(pair, method, price, amount, normalized.order_options, (res = {}) => {
1468
+ this._send_fix_new_order(pair, method, price, amount, normalized.order_options, (res = {}) => {
1183
1469
  res.meta = {
1184
1470
  ...(res.meta || {}),
1185
1471
  t_order_sent,
@@ -1230,9 +1516,9 @@ module.exports = class Coinbase extends ExchangeBase {
1230
1516
  }
1231
1517
  }
1232
1518
  cancel_order_by_order(order, cb) {
1233
- if (this.fix_ord && this.fix_ord.is_ready()) {
1519
+ if (this._fix_trading_ready()) {
1234
1520
  const t_cancel_sent = process.hrtime()
1235
- this.fix_ord.send_cancel(order.pair, order.order_id, order.type, (res = {}) => {
1521
+ this._send_fix_cancel(order.pair, order.order_id, order.type, (res = {}) => {
1236
1522
  res.meta = {
1237
1523
  ...(res.meta || {}),
1238
1524
  t_cancel_sent,
@@ -1,8 +1,8 @@
1
- // Generic FIX 5.0 protocol handler over TLS.
1
+ // Generic FIX 5.0 transport manager over TLS.
2
2
  //
3
- // Handles: TLS connection, message encode/decode, heartbeat, sequence numbers,
4
- // logon/logout lifecycle, auto-reconnect. Exchange-specific logic lives in
5
- // the caller (e.g. coinbase-fix.js).
3
+ // Exposes `market` and `trading` channels that each own their own FIX session
4
+ // lifecycle: connect, heartbeat, routine reconnect, disconnect, and restart.
5
+ // Exchange-specific message handling lives in the caller.
6
6
 
7
7
  const tls = require('tls')
8
8
 
@@ -67,126 +67,231 @@ function parse_fix_messages(recv_buffer) {
67
67
  // --- ExchangeFix ---
68
68
 
69
69
  module.exports = class ExchangeFix {
70
- constructor(name, options) {
70
+ constructor(name, options = {}) {
71
71
  this.name = name
72
- this.host = options.host
73
- this.port = options.port
74
- this.sender_comp_id = options.sender_comp_id
75
- this.target_comp_id = options.target_comp_id
76
- this.heartbeat_interval = (options.heartbeat_interval || 30) * 1000
77
- this.build_logon_fields = options.build_logon_fields
78
- this.on_execution_report = options.on_execution_report
79
- this.on_cancel_reject = options.on_cancel_reject
80
- this.on_reject = options.on_reject
81
- this.on_market_data_incremental = options.on_market_data_incremental
82
- this.on_market_data_batch = options.on_market_data_batch // called once per data chunk with array of fields
83
- this.on_market_data_snapshot = options.on_market_data_snapshot
84
- this.on_market_data_request_reject = options.on_market_data_request_reject
85
- this.on_status_change = options.on_status_change || (() => {})
86
-
87
- this.socket = null
88
- this.status = 'disconnected'
89
- this.seq_out = 1
90
- this.recv_buffer = Buffer.alloc(0)
91
- this.heartbeat_timer = null
92
- this.reconnect_timer = null
72
+ this.market = this._create_channel('market', options.market)
73
+ this.trading = this._create_channel('trading', options.trading)
93
74
  }
94
75
 
95
- connect() {
96
- if (this.status !== 'disconnected') return
97
- this._set_status('connecting')
98
- this.socket = tls.connect({ host: this.host, port: this.port }, () => {
99
- this._set_status('logging_in')
100
- let sending_time = fix_timestamp()
101
- let logon_fields = this.build_logon_fields(this.seq_out, sending_time)
102
- this.send('A', logon_fields, sending_time)
76
+ _create_channel(kind, options) {
77
+ if (!options) return null
78
+ const channel = {
79
+ kind,
80
+ name: `${this.name}_${kind}`,
81
+ host: options.host,
82
+ port: options.port,
83
+ sender_comp_id: options.sender_comp_id,
84
+ target_comp_id: options.target_comp_id,
85
+ heartbeat_interval: (options.heartbeat_interval || 30) * 1000,
86
+ reconnecting_time: options.reconnecting_time || 2 * 60 * 60 * 1000,
87
+ restart_after_close_time: options.restart_after_close_time || 3 * 1000,
88
+ build_logon_fields: options.build_logon_fields,
89
+ on_execution_report: options.on_execution_report,
90
+ on_cancel_reject: options.on_cancel_reject,
91
+ on_reject: options.on_reject,
92
+ on_market_data_incremental: options.on_market_data_incremental,
93
+ on_market_data_batch: options.on_market_data_batch,
94
+ on_market_data_snapshot: options.on_market_data_snapshot,
95
+ on_market_data_request_reject: options.on_market_data_request_reject,
96
+ on_status_change: options.on_status_change || (() => {}),
97
+ status: 'n-opened',
98
+ session_status: 'disconnected',
99
+ socket: null,
100
+ seq_out: 1,
101
+ recv_buffer: Buffer.alloc(0),
102
+ heartbeat_timer: null,
103
+ restart_timeout: null,
104
+ reconnecting_timeout: null,
105
+ start: () => this._start_channel(channel),
106
+ end: () => this._end_channel(channel),
107
+ close: () => this._close_channel(channel),
108
+ send: (msg_type, fields, sending_time) => this._send(channel, msg_type, fields, sending_time),
109
+ is_ready: () => channel.status === 'opened' && channel.session_status === 'ready',
110
+ }
111
+ return channel
112
+ }
113
+
114
+ _start_channel(channel) {
115
+ if (!channel) return
116
+ if (channel.status !== 'n-opened' && channel.status !== 'closed' && channel.status !== 'stopped') return
117
+
118
+ clearTimeout(channel.restart_timeout)
119
+ channel.restart_timeout = null
120
+ clearTimeout(channel.reconnecting_timeout)
121
+ channel.reconnecting_timeout = null
122
+ this._clear_heartbeat(channel)
123
+
124
+ channel.status = 'opening'
125
+ this._set_session_status(channel, 'connecting')
126
+
127
+ const socket = tls.connect({ host: channel.host, port: channel.port }, () => {
128
+ if (socket !== channel.socket) return
129
+ this._set_session_status(channel, 'logging_in')
130
+ const sending_time = fix_timestamp()
131
+ const logon_fields = channel.build_logon_fields(channel.seq_out, sending_time)
132
+ this._send(channel, 'A', logon_fields, sending_time)
133
+ })
134
+
135
+ channel.socket = socket
136
+ socket.on('data', (chunk) => {
137
+ if (socket !== channel.socket) return
138
+ this._on_data(channel, chunk)
103
139
  })
104
- this.socket.on('data', (chunk) => this._on_data(chunk))
105
- this.socket.on('error', (err) => {
106
- console.log('[FIX %s] error: %s', this.name, err.message)
107
- this._reconnect()
140
+ socket.on('error', (err) => {
141
+ if (socket !== channel.socket) return
142
+ console.log('[FIX %s] error: %s', channel.name, err.message)
143
+ this._handle_disconnect(channel, { restart: channel.status !== 'stopped' })
108
144
  })
109
- this.socket.on('close', () => {
110
- if (this.status !== 'disconnected') this._reconnect()
145
+ socket.on('close', () => {
146
+ if (socket !== channel.socket) return
147
+ this._handle_disconnect(channel, { restart: channel.status !== 'stopped' })
111
148
  })
112
149
  }
113
150
 
114
- disconnect() {
115
- this._clear_timers()
116
- this.status = 'disconnected'
117
- if (this.socket) {
118
- try { this.send('5', []) } catch (e) {}
119
- this.socket.destroy()
120
- this.socket = null
151
+ _end_channel(channel) {
152
+ if (!channel) return
153
+ if (channel.status !== 'opened' && channel.status !== 'opening' && !channel.socket) return
154
+ this._handle_disconnect(channel, { restart: true, graceful: true, next_status: 'closed' })
155
+ }
156
+
157
+ _close_channel(channel) {
158
+ if (!channel) return
159
+ clearTimeout(channel.restart_timeout)
160
+ channel.restart_timeout = null
161
+ clearTimeout(channel.reconnecting_timeout)
162
+ channel.reconnecting_timeout = null
163
+
164
+ if (channel.socket || channel.session_status !== 'disconnected') {
165
+ this._handle_disconnect(channel, { restart: false, graceful: true, next_status: 'stopped' })
166
+ } else {
167
+ channel.status = 'stopped'
168
+ channel.session_status = 'disconnected'
169
+ this._reset_session_state(channel)
121
170
  }
122
- this.on_status_change('disconnected')
123
171
  }
124
172
 
125
- send(msg_type, fields, sending_time) {
126
- if (!this.socket || this.socket.destroyed) return false
127
- this.socket.write(encode_fix_msg(msg_type, fields, this.seq_out, this.sender_comp_id, this.target_comp_id, sending_time))
128
- this.seq_out++
173
+ _send(channel, msg_type, fields, sending_time) {
174
+ return this._send_on_socket(channel, channel.socket, msg_type, fields, sending_time)
175
+ }
176
+
177
+ _send_on_socket(channel, socket, msg_type, fields, sending_time) {
178
+ if (!socket || socket.destroyed) return false
179
+ socket.write(encode_fix_msg(msg_type, fields, channel.seq_out, channel.sender_comp_id, channel.target_comp_id, sending_time))
180
+ channel.seq_out++
129
181
  return true
130
182
  }
131
183
 
132
- is_ready() { return this.status === 'ready' }
184
+ _set_session_status(channel, status) {
185
+ if (channel.session_status === status) return
186
+ channel.session_status = status
187
+ channel.on_status_change(status)
188
+ }
133
189
 
134
- _set_status(s) { this.status = s; this.on_status_change(s) }
190
+ _mark_ready(channel) {
191
+ channel.status = 'opened'
192
+ this._start_heartbeat(channel)
193
+ clearTimeout(channel.reconnecting_timeout)
194
+ channel.reconnecting_timeout = setTimeout(() => {
195
+ channel.reconnecting_timeout = null
196
+ console.log('[FIX %s] routine reconnecting', channel.name)
197
+ channel.end()
198
+ }, channel.reconnecting_time)
199
+ this._set_session_status(channel, 'ready')
200
+ }
135
201
 
136
- _on_data(chunk) {
137
- this.recv_buffer = Buffer.concat([this.recv_buffer, chunk])
138
- let { messages, remaining } = parse_fix_messages(this.recv_buffer)
139
- this.recv_buffer = remaining
202
+ _on_data(channel, chunk) {
203
+ channel.recv_buffer = Buffer.concat([channel.recv_buffer, chunk])
204
+ const { messages, remaining } = parse_fix_messages(channel.recv_buffer)
205
+ channel.recv_buffer = remaining
140
206
  let md_batch = null
141
- for (let msg of messages) {
142
- let t = msg.msg_type
207
+
208
+ for (const msg of messages) {
209
+ const t = msg.msg_type
143
210
  if (t === 'A') {
144
- this._set_status('ready')
145
- this._start_heartbeat()
211
+ this._mark_ready(channel)
146
212
  } else if (t === '0') {
147
213
  // Heartbeat
148
214
  } else if (t === '1') {
149
- this.send('0', [[112, msg.fields['112'] || '']])
215
+ channel.send('0', [[112, msg.fields['112'] || '']])
150
216
  } else if (t === '5') {
151
- this._reconnect()
217
+ this._handle_disconnect(channel, { restart: channel.status !== 'stopped' })
152
218
  } else if (t === '8') {
153
- if (this.on_execution_report) this.on_execution_report(msg.fields)
219
+ if (channel.on_execution_report) channel.on_execution_report(msg.fields)
154
220
  } else if (t === '9') {
155
- if (this.on_cancel_reject) this.on_cancel_reject(msg.fields)
221
+ if (channel.on_cancel_reject) channel.on_cancel_reject(msg.fields)
156
222
  } else if (t === 'W') {
157
- if (this.on_market_data_snapshot) this.on_market_data_snapshot(msg.fields)
223
+ if (channel.on_market_data_snapshot) channel.on_market_data_snapshot(msg.fields)
158
224
  } else if (t === 'X') {
159
- if (this.on_market_data_batch) {
225
+ if (channel.on_market_data_batch) {
160
226
  if (!md_batch) md_batch = []
161
227
  md_batch.push(msg.fields)
162
- } else if (this.on_market_data_incremental) {
163
- this.on_market_data_incremental(msg.fields)
228
+ } else if (channel.on_market_data_incremental) {
229
+ channel.on_market_data_incremental(msg.fields)
164
230
  }
165
231
  } else if (t === 'Y') {
166
- if (this.on_market_data_request_reject) this.on_market_data_request_reject(msg.fields)
232
+ if (channel.on_market_data_request_reject) channel.on_market_data_request_reject(msg.fields)
167
233
  } else if (t === '3' || t === 'j') {
168
- if (this.on_reject) this.on_reject(msg.fields)
234
+ if (channel.on_reject) channel.on_reject(msg.fields)
169
235
  }
170
236
  }
171
- if (md_batch) this.on_market_data_batch(md_batch)
237
+
238
+ if (md_batch && channel.on_market_data_batch) channel.on_market_data_batch(md_batch)
239
+ }
240
+
241
+ _start_heartbeat(channel) {
242
+ this._clear_heartbeat(channel)
243
+ channel.heartbeat_timer = setInterval(() => channel.send('0', []), channel.heartbeat_interval)
172
244
  }
173
245
 
174
- _start_heartbeat() {
175
- this._clear_timers()
176
- this.heartbeat_timer = setInterval(() => this.send('0', []), this.heartbeat_interval)
246
+ _clear_heartbeat(channel) {
247
+ if (channel.heartbeat_timer) {
248
+ clearInterval(channel.heartbeat_timer)
249
+ channel.heartbeat_timer = null
250
+ }
177
251
  }
178
252
 
179
- _clear_timers() {
180
- if (this.heartbeat_timer) { clearInterval(this.heartbeat_timer); this.heartbeat_timer = null }
181
- if (this.reconnect_timer) { clearTimeout(this.reconnect_timer); this.reconnect_timer = null }
253
+ _reset_session_state(channel) {
254
+ channel.recv_buffer = Buffer.alloc(0)
255
+ channel.seq_out = 1
182
256
  }
183
257
 
184
- _reconnect() {
185
- this._clear_timers()
186
- if (this.socket) { this.socket.destroy(); this.socket = null }
187
- this.recv_buffer = Buffer.alloc(0)
188
- this.seq_out = 1
189
- this._set_status('disconnected')
190
- this.reconnect_timer = setTimeout(() => { this.reconnect_timer = null; this.connect() }, 3000)
258
+ _handle_disconnect(channel, options = {}) {
259
+ const restart = options.restart === true
260
+ const next_status = options.next_status || (restart ? 'closed' : 'stopped')
261
+ const socket = channel.socket
262
+ const should_notify = !!socket || channel.session_status !== 'disconnected'
263
+
264
+ if (!socket && !should_notify && channel.status === next_status) return
265
+
266
+ channel.socket = null
267
+
268
+ if (socket && options.graceful) {
269
+ try { this._send_on_socket(channel, socket, '5', []) } catch (e) {}
270
+ }
271
+
272
+ this._clear_heartbeat(channel)
273
+ clearTimeout(channel.reconnecting_timeout)
274
+ channel.reconnecting_timeout = null
275
+ clearTimeout(channel.restart_timeout)
276
+ channel.restart_timeout = null
277
+
278
+ this._reset_session_state(channel)
279
+ channel.status = next_status
280
+ channel.session_status = 'disconnected'
281
+
282
+ if (socket) {
283
+ try { socket.destroy() } catch (e) {}
284
+ }
285
+
286
+ if (should_notify) {
287
+ process.nextTick(() => channel.on_status_change('disconnected'))
288
+ }
289
+
290
+ if (restart && next_status !== 'stopped') {
291
+ channel.restart_timeout = setTimeout(() => {
292
+ channel.restart_timeout = null
293
+ channel.start()
294
+ }, channel.restart_after_close_time)
295
+ }
191
296
  }
192
297
  }
@@ -560,6 +560,7 @@ module.exports = class Kucoin extends ExchangeBase {
560
560
  fees: {
561
561
  [post_process_cur(fill.feeCurrency)]: parseFloat(fill.fee),
562
562
  },
563
+ role: fill.liquidity,
563
564
  }
564
565
  }
565
566
  let dedupe_history_trades = (trades) => {
@@ -567,9 +568,18 @@ module.exports = class Kucoin extends ExchangeBase {
567
568
  trades.forEach((trade) => {
568
569
  let time = trade.open_time instanceof Date ? trade.open_time.getTime() : new Date(trade.open_time).getTime()
569
570
  let fees = trade.fees && typeof trade.fees === 'object' ? JSON.stringify(trade.fees) : ''
570
- let key =
571
- trade.trade_id ||
572
- [trade.order_id || '', pair, trade.type || '', trade.price || 0, trade.amount || 0, trade.total || 0, Number.isFinite(time) ? time : 0, fees].join('|')
571
+ let key = [
572
+ trade.trade_id || '',
573
+ trade.order_id || '',
574
+ pair,
575
+ trade.type || '',
576
+ trade.role || '',
577
+ trade.price || 0,
578
+ trade.amount || 0,
579
+ trade.total || 0,
580
+ Number.isFinite(time) ? time : 0,
581
+ fees,
582
+ ].join('|')
573
583
  if (!by_fill_identity.has(key)) by_fill_identity.set(key, trade)
574
584
  })
575
585
  return [...by_fill_identity.values()]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icgio/icg-exchanges",
3
- "version": "1.40.48",
3
+ "version": "1.40.50",
4
4
  "description": "icgio exchanges package",
5
5
  "main": "./exchanges.js",
6
6
  "scripts": {
@@ -1,267 +0,0 @@
1
- // Coinbase FIX session wrappers.
2
- //
3
- // CoinbaseFixOrd — order entry (fix-ord.exchange.coinbase.com:6121)
4
- // CoinbaseFixMd — market data (fix-md.exchange.coinbase.com:6121)
5
-
6
- const { createHmac, randomUUID } = require('crypto')
7
- const ExchangeFix = require('./exchange-fix.js')
8
-
9
- const SOH = '\x01'
10
-
11
- // --- Shared helpers ---
12
-
13
- function build_logon_fields(api_key, secret_key, passphrase, seq_num, sending_time) {
14
- let prehash = sending_time + SOH + 'A' + SOH + seq_num + SOH + api_key + SOH + 'Coinbase' + SOH + passphrase
15
- let signature = createHmac('sha256', Buffer.from(secret_key, 'base64')).update(prehash).digest('base64')
16
- return [
17
- [553, api_key], [554, passphrase],
18
- [95, signature.length], [96, signature],
19
- [98, 0], [108, 30], [141, 'Y'], [1137, 9],
20
- ]
21
- }
22
-
23
- function parse_event_ts_ms(fix_time) {
24
- if (!fix_time || fix_time.length < 17) return undefined
25
- let iso = fix_time.slice(0, 4) + '-' + fix_time.slice(4, 6) + '-' + fix_time.slice(6, 8) + 'T' + fix_time.slice(9, 21) + 'Z'
26
- let ms = Date.parse(iso)
27
- return Number.isFinite(ms) ? ms : undefined
28
- }
29
-
30
- function parse_md_entry(fields) {
31
- let symbol = fields['55']
32
- let entry_type = fields['269']
33
- let action = fields['279']
34
- let md_entry_id = fields['278']
35
- let order_id = fields['37']
36
- if (!symbol || entry_type === undefined || action === undefined) return null
37
- let price = parseFloat(fields['270'])
38
- let size = parseFloat(fields['271'])
39
- if (!Number.isFinite(price)) return null
40
- let rpt_seq = fields['83'] ? parseInt(fields['83'], 10) : undefined
41
-
42
- if (entry_type === '2') {
43
- return {
44
- kind: 'trade', symbol, price, size,
45
- time: fields['60'], trade_id: fields['1003'],
46
- aggressor_side: fields['5797'], rpt_seq,
47
- }
48
- }
49
- if (entry_type !== '0' && entry_type !== '1') return null
50
- if (!md_entry_id && order_id) return null
51
-
52
- let action_str
53
- if (action === '0') action_str = 'new'
54
- else if (action === '1') action_str = 'change'
55
- else if (action === '2') action_str = 'delete'
56
- else return null
57
-
58
- return {
59
- kind: 'l2', symbol,
60
- side: entry_type === '0' ? 'bid' : 'offer',
61
- action: action_str,
62
- price, size,
63
- time: fields['60'], md_entry_id, rpt_seq,
64
- }
65
- }
66
-
67
- // --- CoinbaseFixOrd ---
68
-
69
- class CoinbaseFixOrd {
70
- constructor(api_key, secret_key, passphrase, options = {}) {
71
- this.error_codes = options.error_codes || {}
72
- this.pre_process_pair = options.pre_process_pair || ((p) => p)
73
- this.pending = new Map()
74
-
75
- this.fix = new ExchangeFix('coinbase_ord', {
76
- host: options.host || 'fix-ord.exchange.coinbase.com',
77
- port: options.port || 6121,
78
- sender_comp_id: api_key,
79
- target_comp_id: 'Coinbase',
80
- heartbeat_interval: 30,
81
- build_logon_fields: (seq, time) => {
82
- let fields = build_logon_fields(api_key, secret_key, passphrase, seq, time)
83
- fields.push([8013, 'S'])
84
- return fields
85
- },
86
- on_execution_report: (fields) => this._handle_exec_report(fields),
87
- on_cancel_reject: (fields) => this._handle_cancel_reject(fields),
88
- on_reject: (fields) => this._handle_reject(fields),
89
- on_status_change: (status) => {
90
- console.log('[coinbase FIX ord] status:', status)
91
- if (status === 'disconnected') this._flush_pending()
92
- },
93
- })
94
- }
95
-
96
- connect() { this.fix.connect() }
97
- disconnect() { this.fix.disconnect() }
98
- is_ready() { return this.fix.is_ready() }
99
-
100
- send_new_order(pair, method, price, amount, order_options, cb) {
101
- if (typeof order_options === 'function') { cb = order_options; order_options = {} }
102
- let cl_ord_id = randomUUID()
103
- let side = method === 'buy' ? '1' : '2'
104
- let tif = '1'
105
- if (order_options && order_options.ioc) tif = '3'
106
- else if (order_options && order_options.fok) tif = '4'
107
- let fields = [
108
- [11, cl_ord_id], [55, this.pre_process_pair(pair)], [54, side],
109
- [44, price.toString()], [38, amount.toString()], [40, 2], [59, tif], [21, 1],
110
- ]
111
- if (order_options && order_options.post_only) fields.push([18, 'A'])
112
- let timeout_id = setTimeout(() => this._resolve_pending(cl_ord_id, { success: false, error: this.error_codes.timeout }), 15000)
113
- this.pending.set(cl_ord_id, { cb, timeout_id, type: 'new' })
114
- this.fix.send('D', fields)
115
- }
116
-
117
- send_cancel(pair, order_id, side, cb) {
118
- let cl_ord_id = randomUUID()
119
- let fix_side = side === 'buy' ? '1' : '2'
120
- let fields = [
121
- [11, cl_ord_id], [41, cl_ord_id], [37, order_id],
122
- [55, this.pre_process_pair(pair)], [54, fix_side],
123
- ]
124
- let timeout_id = setTimeout(() => this._resolve_pending(cl_ord_id, { success: false, error: this.error_codes.timeout }), 15000)
125
- this.pending.set(cl_ord_id, { cb, timeout_id, type: 'cancel', order_id })
126
- this.fix.send('F', fields)
127
- }
128
-
129
- _handle_exec_report(fields) {
130
- let cl_ord_id = fields['11'], ord_status = fields['39'], order_id = fields['37'] || '', ec = this.error_codes
131
- if (ord_status === '0') {
132
- this._resolve_pending(cl_ord_id, { success: true, body: order_id })
133
- } else if (ord_status === '8') {
134
- let text = fields['58'] || '', error = ec.other
135
- if (text.includes('nsufficient') || text.includes('NSUFFICIENT')) error = ec.insufficient_funds
136
- else if (fields['103'] === '2' || text.includes('too small') || text.includes('TOO_SMALL')) error = ec.amount_too_small
137
- this._resolve_pending(cl_ord_id, { success: false, error, body: text })
138
- } else if (ord_status === '4') {
139
- if (this.pending.has(cl_ord_id)) this._resolve_pending(cl_ord_id, { success: true, body: order_id })
140
- else this._resolve_pending_by_order_id(order_id, { success: true, body: order_id })
141
- } else if (ord_status === '1' || ord_status === '2') {
142
- if (this.pending.has(cl_ord_id) && this.pending.get(cl_ord_id).type === 'cancel')
143
- this._resolve_pending(cl_ord_id, { success: false, error: ec.order_not_open, body: order_id })
144
- else this._resolve_pending_by_order_id(order_id, { success: false, error: ec.order_not_open, body: order_id }, 'cancel')
145
- }
146
- }
147
-
148
- _handle_cancel_reject(fields) {
149
- let error = this.error_codes.other
150
- if (fields['102'] === '1') error = this.error_codes.order_not_open
151
- this._resolve_pending(fields['11'], { success: false, error, body: fields['58'] || '' })
152
- }
153
-
154
- _handle_reject(fields) {
155
- let cl_ord_id = fields['379'] || fields['11'] || ''
156
- if (cl_ord_id && this.pending.has(cl_ord_id))
157
- this._resolve_pending(cl_ord_id, { success: false, error: this.error_codes.other, body: fields['58'] || '' })
158
- }
159
-
160
- _resolve_pending(cl_ord_id, result) {
161
- let p = this.pending.get(cl_ord_id)
162
- if (!p) return
163
- clearTimeout(p.timeout_id)
164
- this.pending.delete(cl_ord_id)
165
- p.cb(result)
166
- }
167
-
168
- _resolve_pending_by_order_id(order_id, result, type_filter) {
169
- for (let [id, p] of this.pending) {
170
- if (p.order_id === order_id && (!type_filter || p.type === type_filter)) {
171
- this._resolve_pending(id, result)
172
- return
173
- }
174
- }
175
- }
176
-
177
- _flush_pending() {
178
- for (let [id, p] of this.pending) { clearTimeout(p.timeout_id); p.cb({ success: false, error: this.error_codes.timeout }) }
179
- this.pending.clear()
180
- }
181
- }
182
-
183
- // --- CoinbaseFixMd ---
184
-
185
- class CoinbaseFixMd {
186
- constructor(options) {
187
- this.symbols = options.symbols || []
188
- this.on_event = options.on_event || (() => {})
189
- this.on_batch = options.on_batch || null // called with array of parsed entries per data chunk
190
- this.on_status = options.on_status || (() => {})
191
- this.subscribed = false
192
-
193
- this.fix = new ExchangeFix('coinbase_md', {
194
- host: options.host || 'fix-md.exchange.coinbase.com',
195
- port: options.port || 6121,
196
- sender_comp_id: options.api_key,
197
- target_comp_id: 'Coinbase',
198
- heartbeat_interval: 30,
199
- build_logon_fields: (seq, time) => build_logon_fields(options.api_key, options.secret_key, options.passphrase, seq, time),
200
- on_market_data_batch: this.on_batch ? (batch) => this._handle_md_batch(batch) : undefined,
201
- on_market_data_incremental: this.on_batch ? undefined : (fields) => this._handle_md(fields),
202
- on_market_data_snapshot: (fields) => this._handle_md(fields),
203
- on_market_data_request_reject: (fields) => {
204
- console.log('[coinbase FIX md] request reject:', fields['58'] || '')
205
- },
206
- on_status_change: (status) => {
207
- if (status === 'ready' && !this.subscribed) this._send_subscribe()
208
- if (status === 'disconnected') this.subscribed = false
209
- this.on_status(status)
210
- },
211
- })
212
- }
213
-
214
- connect() { this.fix.connect() }
215
- disconnect() { this.fix.disconnect() }
216
- is_ready() { return this.fix.is_ready() }
217
-
218
- _send_subscribe() {
219
- if (!this.symbols.length) return
220
- let fields = [
221
- [262, 'icg-md-' + Date.now()], [263, 1], [264, 0], [265, 1],
222
- [267, 3], [269, 0], [269, 1], [269, 2],
223
- [146, this.symbols.length],
224
- ]
225
- for (let s of this.symbols) fields.push([55, s])
226
- this.fix.send('V', fields)
227
- this.subscribed = true
228
- console.log('[coinbase FIX md] subscribed:', this.symbols.join(','))
229
- }
230
-
231
- _handle_md(fields) {
232
- const hr_recv = process.hrtime()
233
- let entry = parse_md_entry(fields)
234
- if (!entry) return
235
- const hr_parsed = process.hrtime()
236
- entry.ts_ms_venue_event = parse_event_ts_ms(entry.time)
237
- entry.ts_ms_local_received = Date.now()
238
- entry.hr_recv = hr_recv
239
- entry.hr_parsed = hr_parsed
240
- this.on_event(entry)
241
- }
242
-
243
- _handle_md_batch(batch) {
244
- let entries = []
245
- let now = Date.now()
246
- for (let fields of batch) {
247
- const hr_recv = process.hrtime()
248
- let entry = parse_md_entry(fields)
249
- if (!entry) continue
250
- const hr_parsed = process.hrtime()
251
- entry.ts_ms_venue_event = parse_event_ts_ms(entry.time)
252
- entry.ts_ms_local_received = now
253
- entry.hr_recv = hr_recv
254
- entry.hr_parsed = hr_parsed
255
- entries.push(entry)
256
- }
257
- if (entries.length) this.on_batch(entries)
258
- }
259
- }
260
-
261
- module.exports = {
262
- CoinbaseFixOrd,
263
- CoinbaseFixMd,
264
- build_logon_fields,
265
- parse_md_entry,
266
- parse_event_ts_ms,
267
- }