@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.
- package/lib/exchanges/coinbase.js +356 -70
- package/lib/exchanges/exchange-fix.js +191 -86
- package/lib/exchanges/kucoin.js +13 -3
- package/package.json +1 -1
- package/lib/exchanges/coinbase-fix.js +0 -267
|
@@ -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.
|
|
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
|
|
145
|
-
|
|
146
|
-
this.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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.
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
1110
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
1519
|
+
if (this._fix_trading_ready()) {
|
|
1234
1520
|
const t_cancel_sent = process.hrtime()
|
|
1235
|
-
this.
|
|
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
|
|
1
|
+
// Generic FIX 5.0 transport manager over TLS.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the caller
|
|
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.
|
|
73
|
-
this.
|
|
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
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
console.log('[FIX %s] error: %s',
|
|
107
|
-
this.
|
|
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
|
-
|
|
110
|
-
if (
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
207
|
+
|
|
208
|
+
for (const msg of messages) {
|
|
209
|
+
const t = msg.msg_type
|
|
143
210
|
if (t === 'A') {
|
|
144
|
-
this.
|
|
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
|
-
|
|
215
|
+
channel.send('0', [[112, msg.fields['112'] || '']])
|
|
150
216
|
} else if (t === '5') {
|
|
151
|
-
this.
|
|
217
|
+
this._handle_disconnect(channel, { restart: channel.status !== 'stopped' })
|
|
152
218
|
} else if (t === '8') {
|
|
153
|
-
if (
|
|
219
|
+
if (channel.on_execution_report) channel.on_execution_report(msg.fields)
|
|
154
220
|
} else if (t === '9') {
|
|
155
|
-
if (
|
|
221
|
+
if (channel.on_cancel_reject) channel.on_cancel_reject(msg.fields)
|
|
156
222
|
} else if (t === 'W') {
|
|
157
|
-
if (
|
|
223
|
+
if (channel.on_market_data_snapshot) channel.on_market_data_snapshot(msg.fields)
|
|
158
224
|
} else if (t === 'X') {
|
|
159
|
-
if (
|
|
225
|
+
if (channel.on_market_data_batch) {
|
|
160
226
|
if (!md_batch) md_batch = []
|
|
161
227
|
md_batch.push(msg.fields)
|
|
162
|
-
} else if (
|
|
163
|
-
|
|
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 (
|
|
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 (
|
|
234
|
+
if (channel.on_reject) channel.on_reject(msg.fields)
|
|
169
235
|
}
|
|
170
236
|
}
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
253
|
+
_reset_session_state(channel) {
|
|
254
|
+
channel.recv_buffer = Buffer.alloc(0)
|
|
255
|
+
channel.seq_out = 1
|
|
182
256
|
}
|
|
183
257
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
}
|
package/lib/exchanges/kucoin.js
CHANGED
|
@@ -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
|
-
|
|
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,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
|
-
}
|