· 6 years ago · Mar 24, 2020, 12:46 AM
1 require("dotenv").config();
2 const crypto = require("crypto");
3 const vorpal = require("vorpal")();
4 const fetch = require("node-fetch");
5 const qs = require("qs");
6 const BMexWS = require("bitmex-realtime-api");
7
8 // TODO: Use NODE env?
9 const debug = false;
10 const network = debug ? "testnet" : "mainnet";
11
12 if (
13 !process.env[network.toUpperCase() + "_KEY"] ||
14 !process.env[network.toUpperCase() + "_SECRET"]
15 )
16 throw new Error("Misconfigured environment (Missing API key).");
17
18 const config = {
19 apiKeyID: process.env[network.toUpperCase() + "_KEY"],
20 apiKeySecret: process.env[network.toUpperCase() + "_SECRET"],
21 instrument: "XBTUSD",
22 stopPerc: 0.002,
23 tickSize: 0.5,
24 maxOrderQty: 300,
25 minFrontQty: 1000,
26 minPnL: 250
27 };
28
29 // Testnet has smaller volumes.
30 if (debug) config.minFrontQty = 10;
31
32 let state = {
33 direction: 0, // [0=long, 1=short]
34 frontrun: false,
35 orderID: "", // orderID
36 price: 0, // Order price
37 qty: 0, // Order quantity
38 quote: [0, 0], // [bidPrice, askPrice]
39 stoploss: 0, // Price of related stop
40 stoplossID: "", // orderID
41 wins: 0 // Number of wins for position sizing
42 };
43
44 vorpal.log = console.log;
45 vorpal.log = console.error;
46
47 let position = 0;
48 function initPosition() {
49 return new Promise((resolve, reject) => {
50 makeRequest("GET", "position", {
51 symbol: config.instrument
52 })
53 .then(function(res) {
54 const item = res[0];
55 if (!item) {
56 console.error("Position not found.");
57 resolve();
58 return;
59 }
60
61 position = item.currentQty;
62 console.log("Position initialised.", position);
63 resolve();
64 })
65 .catch(function(err) {
66 console.error(err.message);
67 reject();
68 });
69 });
70 }
71
72 let ws;
73 function initWS() {
74 return new Promise((resolve, reject) => {
75 const client = new BMexWS({
76 testnet: debug,
77 apiKeyID: config.apiKeyID,
78 apiKeySecret: config.apiKeySecret
79 });
80 client.on("error", () => {
81 console.error(err);
82 reject();
83 });
84 client.on("open", () => console.log("Connection opened."));
85 client.on("close", () => console.log("Connection closed."));
86 client.on("initialize", () => {
87 console.log("Websockets initialised.");
88
89 let lastWalletBalance = 0;
90 client.addStream(config.instrument, "margin", function(
91 data,
92 symbol,
93 tableName
94 ) {
95 if (!data.length) return;
96 const item = data[0];
97 if (lastWalletBalance > 0 && item.walletBalance !== lastWalletBalance) {
98 console.log("PnL", item.walletBalance - lastWalletBalance);
99 if (item.walletBalance - config.minPnL > lastWalletBalance) {
100 state.wins++;
101 console.log("WIN", state.wins);
102 } else if (item.walletBalance + config.minPnL < lastWalletBalance) {
103 state.wins--;
104 state.wins = Math.max(0, state.wins);
105 console.log("LOSS", state.wins);
106 }
107 }
108 lastWalletBalance = item.walletBalance;
109 });
110
111 client.addStream(config.instrument, "quote", function(
112 data,
113 symbol,
114 tableName
115 ) {
116 if (!data.length) return;
117 const item = data[data.length - 1];
118 // Ignore thresholds for initialisation.
119 if (state.quote[0] === 0 && state.quote[1] === 0) {
120 state.quote = [item.bidPrice, item.askPrice];
121 } else {
122 if (item.bidSize > config.minFrontQty) state.quote[0] = item.bidPrice;
123 if (item.askSize > config.minFrontQty) state.quote[1] = item.askPrice;
124 }
125 rebalance();
126 });
127
128 /*
129 client.addStream(config.instrument, "position", function(
130 data,
131 symbol,
132 tableName
133 ) {
134 if (!data.length) return;
135 const item = data[data.length - 1];
136 //console.log("position", item);
137 if (item.isOpen) {
138 position = item.currentQty;
139 } else {
140 position = 0;
141 }
142 });
143 */
144
145 /*
146 // `bitmex-realtime-api/lib/deltaParser.js`
147 // FIXME: https://github.com/BitMEX/api-connectors/issues/166#issuecomment-404088343
148 if (
149 tableName === "execution" &&
150 action === "insert" &&
151 !isInitialized(tableName, symbol, client)
152 ) {
153 data.keys = ["execID"];
154 return this._partial(tableName, symbol, client, data);
155 }
156 */
157 client.addStream(config.instrument, "execution", function(
158 data,
159 symbol,
160 tableName
161 ) {
162 if (!data.length) return;
163 const item = data[data.length - 1];
164
165 // http://www.anotherbot.info/
166 // ![short/long] [trading pair] [opening value] [leverage] [stack %] -sl stop loss
167 // ![short/long] [trading pair] [add/profit] [value] [leverage/stack %]
168 // ![short/long] [trading pair] close [value]
169 if (item.execType === "Trade" && item.ordStatus === "Filled") {
170 const previous = position;
171 if (item.side === "Buy") {
172 position += item.orderQty;
173 } else if (item.side === "Sell") {
174 position -= item.orderQty;
175 }
176
177 function logOpen() {
178 const desc = position > 0 ? "long" : "short";
179 console.log(
180 "!" +
181 desc +
182 " " +
183 item.symbol +
184 " " +
185 item.price.toFixed(2) +
186 " 25x " +
187 getStackPerc(item.orderQty) +
188 "%"
189 );
190 }
191
192 function logClose() {
193 const desc = previous > 0 ? "long" : "short";
194 console.log(
195 "!" + desc + " " + item.symbol + " close " + item.price.toFixed(2)
196 );
197 }
198
199 function logAdjust() {
200 const desc = previous > 0 ? "long" : "short";
201 const type =
202 (previous > 0 && previous > position) ||
203 (previous < 0 && previous < position)
204 ? "profit"
205 : "add";
206
207 console.log(
208 "!" +
209 desc +
210 " " +
211 item.symbol +
212 " " +
213 type +
214 " " +
215 item.price.toFixed(2) +
216 " " +
217 getStackPerc(item.orderQty) +
218 "%"
219 );
220 }
221
222 if (previous !== 0) {
223 // Close
224 if (Math.sign(previous) !== Math.sign(position)) {
225 logClose();
226 if (position !== 0) logOpen();
227 }
228 // Add/Profit
229 else if (Math.abs(previous) !== Math.abs(position)) {
230 logAdjust();
231 } else {
232 console.error("Unknown position state.");
233 }
234 }
235 // Open
236 else if (position !== 0) {
237 logOpen();
238 }
239 }
240
241 if (item.ordStatus === "Canceled" && item.orderID === state.orderID) {
242 console.log("Canceled", item.orderID);
243 resetState();
244 }
245 });
246
247 ws = client;
248 resolve();
249 });
250 });
251 }
252
253 function makeRequest(verb, endpoint, data = {}) {
254 const apiRoot = "/api/v1/";
255 const expires = new Date().getTime() + 60 * 1000; // 1 min in the future
256
257 let query = "",
258 postBody = "";
259 if (verb === "GET") query = "?" + qs.stringify(data);
260 // Pre-compute the reqBody so we can be sure that we're using *exactly* the same body in the request
261 // and in the signature. If you don't do this, you might get differently-sorted keys and blow the signature.
262 else postBody = JSON.stringify(data);
263
264 const signature = crypto
265 .createHmac("sha256", config.apiKeySecret)
266 .update(verb + apiRoot + endpoint + query + expires + postBody)
267 .digest("hex");
268
269 const headers = {
270 "content-type": "application/json",
271 accept: "application/json",
272 // This example uses the 'expires' scheme. You can also use the 'nonce' scheme. See
273 // https://www.bitmex.com/app/apiKeysUsage for more details.
274 "api-expires": expires,
275 "api-key": config.apiKeyID,
276 "api-signature": signature
277 };
278
279 const requestOptions = {
280 method: verb,
281 headers
282 };
283 if (verb !== "GET") requestOptions.body = postBody; // GET/HEAD requests can't have body
284
285 let url = "https://";
286 url += debug ? "testnet" : "www";
287 url += ".bitmex.com" + apiRoot + endpoint + query;
288
289 return fetch(url, requestOptions)
290 .then(response => response.json())
291 .then(
292 response => {
293 if ("error" in response) throw new Error(response.error.message);
294 return response;
295 },
296 error => console.error("Network error", error)
297 );
298 }
299
300 function rebalance() {
301 const stoploss = parseFloat(
302 (state.quote[state.direction] * (1 - config.stopPerc)).toFixed(2)
303 );
304
305 // Move stop into profit
306 /*
307 if (
308 state &&
309 state.stoplossID !== "" &&
310 state.stoploss > 0 &&
311 ((state.stoploss < stoploss && state.direction === 1) ||
312 (state.stoploss > stoploss && state.direction === 0))
313 ) {
314 makeRequest("PUT", "order", {
315 orderID: state.stoplossID,
316 price: state.stoploss
317 })
318 .then(function(res) {
319 console.log("stoplossID:", state.stoplossID, state.stoploss);
320 state.stoploss = stoploss;
321 })
322 .catch(function(err) {
323 if (err.message === "Invalid ordStatus") {
324 state.stoplossID = "";
325 } else {
326 console.log("PUT", state);
327 console.error(err.message);
328 }
329 });
330 }
331 */
332
333 // Frontrun
334 if (
335 state &&
336 state.frontrun &&
337 state.orderID !== "" &&
338 state.price > 0 &&
339 state.price !== state.quote[state.direction]
340 ) {
341 state.price = state.quote[state.direction];
342
343 // TODO: Identify when left high and dry. Move order back again.
344 // Gap detection algo?
345
346 makeRequest("PUT", "order", {
347 orderID: state.orderID,
348 price: state.price
349 })
350 /*
351 .then(function(res) {
352 console.log("orderID:", state.orderID, state.price);
353 })
354 */
355 .catch(function(err) {
356 if (err.message === "Invalid ordStatus") {
357 state.orderID = "";
358 } else {
359 console.log("PUT", state);
360 console.error(err.message);
361 }
362 });
363 }
364 }
365
366 function getStackPerc(qty) {
367 return parseInt((Math.abs(qty) / config.maxOrderQty) * 100);
368 }
369
370 function getQty(multiplier) {
371 // TODO: `5` in config
372 return Math.min(config.maxOrderQty, (state.wins + 1) * 5 * multiplier);
373 }
374
375 function resetState() {
376 state.frontrun = false;
377 state.orderID = "";
378 state.price = 0;
379 state.qty = 0;
380 state.stoploss = 0;
381 state.stoplossID = "";
382 }
383
384 function logTrade(direction, instrument, price, qty) {
385 const desc = direction === 1 ? "short" : "long";
386 console.log(desc.toUpperCase(), instrument, price.toFixed(2), Math.abs(qty));
387 }
388
389 vorpal.command("r", "Rebalance.").action(function(args, callback) {
390 rebalance();
391 callback();
392 });
393
394 vorpal
395 .command("l [multiplier]", "Enter long (Limit + Stop).")
396 .option("-f, --frontrun", "Keep order at front of queue.")
397 .action(function(args, callback) {
398 const multiplier = args.multiplier || 1;
399 //const exists = state.direction === 0 && state.price > 0;
400 state.frontrun = args.options.frontrun === true;
401 state.direction = 0;
402 state.price = state.quote[state.direction];
403 state.stoploss = parseFloat(
404 (state.price * (1 - config.stopPerc)).toFixed(2)
405 );
406 state.stoploss -= state.stoploss % config.tickSize;
407
408 if (state.price === 0) {
409 console.error("Quote not initialised.");
410 callback();
411 return;
412 }
413
414 const qty = getQty(multiplier);
415
416 let requests = [];
417 requests.push(
418 makeRequest("POST", "order", {
419 execInst: "ParticipateDoNotInitiate",
420 ordType: "Limit",
421 orderQty: qty,
422 price: state.price,
423 symbol: config.instrument
424 })
425 .then(function(res) {
426 //console.log("Limit", "orderID:", res.orderID);
427 state.orderID = res.orderID;
428 })
429 .catch(function(err) {
430 console.error("Limit", err);
431 })
432 );
433
434 requests.push(
435 makeRequest("POST", "order", {
436 execInst: "LastPrice,ReduceOnly",
437 ordType: "Stop",
438 orderQty: -qty,
439 stopPx: state.stoploss,
440 symbol: config.instrument
441 })
442 .then(function(res) {
443 //console.log("Stop", "orderID:", res.orderID);
444 state.stoplossID = res.orderID;
445 })
446 .catch(function(err) {
447 console.error("Stop", err);
448 })
449 );
450
451 state.qty = Math.abs(qty);
452
453 logTrade(state.direction, config.instrument, state.price, state.qty);
454 Promise.all(requests).then(function() {
455 callback();
456 });
457 });
458
459 vorpal
460 .command("s [multiplier]", "Enter short (Limit + Stop).")
461 .option("-f, --frontrun", "Keep order at front of queue.")
462 .action(function(args, callback) {
463 const multiplier = args.multiplier || 1;
464 //const exists = state.direction === 1 && state.price > 0;
465 state.frontrun = args.options.frontrun === true;
466 state.direction = 1;
467 state.price = state.quote[state.direction];
468 state.stoploss = parseFloat(
469 (state.price * (1 + config.stopPerc)).toFixed(2)
470 );
471 state.stoploss -= state.stoploss % config.tickSize;
472 state.stoploss++;
473
474 if (state.price === 0) {
475 console.error("Quote not initialised.");
476 callback();
477 return;
478 }
479
480 const qty = -getQty(multiplier);
481
482 let requests = [];
483 requests.push(
484 makeRequest("POST", "order", {
485 execInst: "ParticipateDoNotInitiate",
486 ordType: "Limit",
487 orderQty: qty,
488 price: state.price,
489 symbol: config.instrument
490 })
491 .then(function(res) {
492 //console.log("Limit", "orderID:", res.orderID, res);
493 state.orderID = res.orderID;
494 })
495 .catch(function(err) {
496 console.error("Limit", err);
497 })
498 );
499
500 requests.push(
501 makeRequest("POST", "order", {
502 execInst: "LastPrice,ReduceOnly",
503 ordType: "Stop",
504 orderQty: -qty,
505 stopPx: state.stoploss,
506 symbol: config.instrument
507 })
508 .then(function(res) {
509 //console.log("Stop", "orderID:", res.orderID);
510 state.stoplossID = res.orderID;
511 })
512 .catch(function(err) {
513 console.error("Stop", err);
514 })
515 );
516
517 state.qty = Math.abs(qty);
518
519 logTrade(state.direction, config.instrument, state.price, state.qty);
520 Promise.all(requests).then(function() {
521 callback();
522 });
523 });
524
525 vorpal.command("t", "Trail stop into profit.").action(function(args, callback) {
526 if (state.stoploss === 0) {
527 console.log("No stoploss.");
528 callback();
529 return;
530 }
531
532 // Flip stoploss to other side of initial order price.
533 let stoploss = state.stoploss;
534 if (state.direction === 1) {
535 stoploss = parseFloat((state.price * (1 - config.stopPerc)).toFixed(2));
536 stoploss -= stoploss % config.tickSize;
537 if (stoploss <= state.quote[state.direction]) {
538 console.log("Price would trigger stoploss.", stoploss);
539 callback();
540 return;
541 }
542 } else {
543 stoploss = parseFloat((state.price * (1 + config.stopPerc)).toFixed(2));
544 stoploss += stoploss % config.tickSize;
545 if (stoploss >= state.quote[state.direction]) {
546 console.log("Price would trigger stoploss.", stoploss);
547 callback();
548 return;
549 }
550 }
551
552 if (stoploss !== state.stoploss) {
553 makeRequest("PUT", "order", {
554 orderID: state.stoplossID,
555 price: stoploss
556 })
557 .then(function(res) {
558 state.stoploss = stoploss;
559 console.log("stoplossID:", state.stoplossID, state.stoploss);
560 callback();
561 })
562 .catch(function(err) {
563 if (err.message === "Invalid ordStatus") {
564 state.stoplossID = "";
565 callback();
566 } else if (err.message === "Invalid price") {
567 callback();
568 } else {
569 console.log("PUT", state);
570 console.error(err.message);
571 }
572 });
573 }
574
575 this.log("!trail " + config.instrument, state.stoploss, stoploss);
576 });
577
578 vorpal
579 .command("c", "Cancel last set of orders.")
580 .action(function(args, callback) {
581 let requests = [];
582 requests.push(
583 makeRequest("DELETE", "order", {
584 orderID: state.orderID
585 })
586 .then(function(res) {
587 console.log("Cancel", "orderID:", res.ordType, res.ordStatus);
588 })
589 .catch(function(err) {
590 console.error("Cancel", err);
591 })
592 );
593
594 requests.push(
595 makeRequest("DELETE", "order", {
596 orderID: state.stoplossID
597 })
598 .then(function(res) {
599 console.log("Cancel", "stoplossID:", res.ordType, res.ordStatus);
600 })
601 .catch(function(err) {
602 console.error("Cancel", err);
603 })
604 );
605
606 this.log("!cancel " + config.instrument);
607 Promise.all(requests).then(function() {
608 resetState();
609 callback();
610 });
611 });
612
613 vorpal
614 .command("x", "Exit position (Market, Close).")
615 .action(function(args, callback) {
616 // TODO: Cancel pending orders.
617 makeRequest("POST", "order", {
618 execInst: "Close",
619 ordType: "Market",
620 symbol: config.instrument
621 })
622 .then(function(res) {
623 //console.log("Market", "orderID:", res.orderID);
624 resetState();
625 callback();
626 })
627 .catch(function(err) {
628 console.error("Market", err);
629 });
630
631 this.log("!exit " + config.instrument);
632 });
633
634 function init() {
635 Promise.all([initPosition(), initWS()])
636 .then(function() {
637 vorpal.delimiter("bitmex(" + network + ")$").show();
638 })
639 .catch(function(err) {
640 console.error("Failed initalisation");
641 process.exit(1);
642 });
643 }
644 init();