-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy patharb.py
633 lines (546 loc) · 34.7 KB
/
arb.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
from ftx_rest import FtxRestClient
from ftx_ws import FtxWebsocketClient
from collections import defaultdict
from statistics import fmean
from datetime import datetime
from pathlib import Path
from time import sleep
import numpy as np
import subprocess
import keyboard
import logging
import json
import sys
import os
SUBACCOUNT = "SpotPerpAlgo" # Subaccount name
MARKET = ("GST/USD", "GST-PERP", 0.1) # (spot ticker, perp ticker, min size increment) Spot must be first and perp second
ACCOUNT_SIZE = 130 # Maximum combined size for both positions
ORDERS_PER_SIDE = 3 # Number of staggered orders used to reach max size when opening a position
DEFAULT_BASIS_THRESHOLD = 0.0005 # Smallest percentage basis that qualifies for an entry
MARGIN_FOR_ENTRY = 0.5 # Allowable percentage reduction from initial basis for subsequent entries
BASIS_FLOOR = 0.005 # If basis reaches or goes lower than this, convergence of spot and future price is considered to have ocurred.
BAD_ENTRY_CUTOFF = 2 # % distance past profitable at which an attempted entry is considered failed.
APR_EXIT_THRESHOLD = 50 # Exit a position if funding exceeds this value in the wrong direction
QUOTE_INDEX = 1 # Bid/ask index used for limit order pricing. 0 means 1st level, 1 means 2nd level and so on.
MOVE_ORDER_THRESHOLD = 2 # Move a limit order to follow price if it moves this many OB levels away from last price
DEBUG_OUTPUT = True # If True program actions print to console
# Return total size of all positions
def get_total_open_size(positions: dict) -> float:
size = 0
if positions:
for p in positions.values():
size += p['size'] * p['avgEntryPrice']
return size
# Return ticker and direction needing a size increase to zero out net exposure
def has_exposure(positions: dict) -> [str, str, str]:
p_list = list(positions.values())
p_count = len(p_list)
if p_list:
if p_count % 2 == 0:
must_hedge = [None, None, None]
if p_list[0]['fillCount'] > p_list[1]['fillCount'] or p_list[0]['fillCount'] < p_list[1]['fillCount']:
must_hedge[0] = p_list[1]['ticker'] if p_list[0]['fillCount'] > p_list[1]['fillCount'] else p_list[0]['ticker']
must_hedge[1] = p_list[1]['side'] if p_list[0]['fillCount'] > p_list[1]['fillCount'] else p_list[0]['side']
must_hedge[2] = "perp" if p_list[0]['fillCount'] > p_list[1]['fillCount'] else "spot"
else:
must_hedge = None
elif p_count == 1:
must_hedge = [None, None, None]
must_hedge[0] = MARKET[1] if p_list[0]['ticker'] == MARKET[0] else MARKET[0]
must_hedge[1] = 'buy' if p_list[0]['side'] == 'sell' else 'sell'
must_hedge[2] = 'spot' if p_list[0]['type'] == 'perp' else 'perp'
elif p_count == 0:
must_hedge = None
else:
must_hedge = None
return must_hedge
# Return total fills of all positions
def get_total_fills(positions: dict) -> float:
fills = 0
if positions:
for p in positions.values():
fills += p['fillCount']
return fills
def run():
# -----------------------------------------------------------------
# 1. Validate inputs and verify connection
# -----------------------------------------------------------------
# Set up logging
log_path = "logs"
Path(log_path).mkdir(parents=True, exist_ok=True)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s | %(message)s')
file_handler = logging.FileHandler(log_path + "/" + str(int(datetime.now().timestamp())) + ".log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Load keys
api_key = os.environ['BASIS_API_KEY_FTX']
api_secret = os.environ['BASIS_API_SECRET_FTX']
if api_key is None or api_secret is None:
err_msg = 'API keys not found.'
logger.info(err_msg)
raise ValueError(err_msg)
# Init connection clients
ws = FtxWebsocketClient(api_key, api_secret, SUBACCOUNT)
rest = FtxRestClient(api_key, api_secret, SUBACCOUNT)
if not ws or not rest:
err_msg = 'Websocket or REST client failed to init.'
logger.info(err_msg)
raise ModuleNotFoundError(err_msg)
# Validate instrument symbols
valid_tickers = [m['name'] for m in rest.get_markets()]
if MARKET[0] not in valid_tickers or MARKET[1] not in valid_tickers:
err_msg = 'Target market ticker invalid. Check ticker codes and restart program.'
logger.info(err_msg)
raise ValueError(err_msg)
# Validate account starting state
positions, orders = rest.get_positions(), rest.get_open_orders()
if positions or orders:
print(json.dumps(positions, indent=2))
print(json.dumps(orders, indent=2))
err_msg = "Existing positions or orders detected. Close all positions, orders and margin borrows, then restart program." \
" Ensure margin collateral is denominated in an asset you will not be trading e.g hold Tether if trading BTC spot and BTC perpetual, dont hold BTC or USD."
logger.info(err_msg)
raise Exception(err_msg)
# Verify websocket is subscribed and receiving data
ws_data_ready, wait_time = False, 0
while(not ws_data_ready):
order_updates = ws.get_orders()
ob_spot, ob_perp = ws.get_orderbook(MARKET[0]), ws.get_orderbook(MARKET[1])
last_price_spot, last_price_perp = ws.get_ticker(MARKET[0]), ws.get_ticker(MARKET[1])
if ob_spot and ob_perp and last_price_spot and last_price_perp:
ws_data_ready = True
else:
sleep(1)
wait_time += 1
if wait_time > 10:
err_msg = "Unable to subscribe to exchange websocket channels."
logger.info(err_msg)
raise Exception(err_msg)
try:
borrow = round(float([b['estimate'] for b in rest.get_borrow_rates() if b['coin'] == MARKET[0].split('/')[0]][0] * 100), 4)
except IndexError:
borrow = 0
funding = round(float(rest.get_funding_rates(MARKET[1])[0]['rate']) * 100, 4)
last_update_time = defaultdict(int)
should_run, should_update = True, False
basis, start_basis = None, None
fill_count, waiting_for_fill = 0, False
at_max_size = False
exposure = False
should_add_to_positions, should_unwind_positions = False, False
should_increase_spot, should_increase_perp = False, False
should_reduce_spot, should_reduce_perp = False, False
while(should_run):
if ws and rest:
# -----------------------------------------------------------------
# 2. Update order and position state with ws.get_orders() messages
# -----------------------------------------------------------------
order_updates = ws.get_orders()
if len(order_updates) > 0:
for oId in list(order_updates.keys()):
# Dont action updates older than last_actioned timestamp for a given order id
try:
should_update = True if order_updates[oId]['msg_time'] > last_update_time[oId] else False
except KeyError:
orders[oId] = order_updates[oId]
last_update_time[oId] = order_updates[oId]['msg_time']
should_update = True
# Match order updates to action case
if should_update:
# Placement
if order_updates[oId]['status'] == 'new' and order_updates[oId]['filledSize'] == 0.0:
logger.info("Created a new order")
orders[oId] = order_updates[oId]
last_update_time[oId] = order_updates[oId]['msg_time']
waiting_for_fill = True
# Cancellation
elif order_updates[oId]['status'] == 'closed' and order_updates[oId]['filledSize'] == 0.0:
try:
del orders[oId]
logger.info("Cancelled an existing order")
except KeyError:
last_update_time[oId] = order_updates[oId]['msg_time']
err_msg = "Warning: Unexpected cancellation detected. Small order rate limits may have been exceeded. Manually verify positions, orders and exposure are safe. Close all positions and orders and restart program. If unexpected limit order cancellation persists wait 1 hour before retrying."
logger.info(err_msg)
raise Exception(err_msg)
last_update_time[oId] = order_updates[oId]['msg_time']
waiting_for_fill = False
# Complete fill
elif order_updates[oId]['status'] == 'closed' and order_updates[oId]['size'] == order_updates[oId]['filledSize']:
# Save initial basis, this will be referenced when determine exit conditions.
if not start_basis:
start_basis = basis
# Balance against existing position
ticker = order_updates[oId]['market']
fill_count += 1
instrument_type = 'spot' if ticker == MARKET[0] else "perp"
if ticker in positions.keys():
if order_updates[oId]['side'] == positions[ticker]['side']:
logger.info("Increasing existing position")
positions[ticker]['size'] = round(positions[ticker]['size'] + order_updates[oId]['size'], 4)
positions[ticker]['fillCount'] += 1
w_pos = (positions[ticker]['fillCount'] - 1) / positions[ticker]['fillCount']
w_new = 1 / positions[ticker]['fillCount']
avg_entry = positions[ticker]['avgEntryPrice'] * w_pos + order_updates[oId]['avgFillPrice'] * w_new
positions[ticker]['avgEntryPrice'] = round(avg_entry, 2)
else:
logger.info("Decreasing existing postion")
positions[ticker]['size'] = round(positions[ticker]['size'] - order_updates[oId]['size'], 4)
positions[ticker]['fillCount'] -= 1
if positions[ticker]['size'] == 0.0:
del positions[ticker]
# Create new position record if none exists
else:
msg = "creating new " + instrument_type + " position"
logger.info(msg)
positions[ticker] = {'ticker': ticker, 'type': instrument_type, 'size': order_updates[oId]['filledSize'], 'side': order_updates[oId]['side'], 'avgEntryPrice': order_updates[oId]['avgFillPrice'], 'fillCount': 1}
try:
del orders[oId]
del last_update_time[oId]
except KeyError:
pass
last_update_time[oId] = order_updates[oId]['msg_time']
waiting_for_fill = False
exposure = has_exposure(positions)
should_update = False
# -----------------------------------------------------------------
# 3. Monitor price and funding changes for entry and exit conditions
# -----------------------------------------------------------------
# Refresh funding rates every 5 min
if int(datetime.now().timestamp()) % 300 == 0:
logger.info("Updating funding rates")
try:
borrow = round(float([b['estimate'] for b in rest.get_borrow_rates() if b['coin'] == MARKET[0].split('/')[0]][0] * 100), 4)
except IndexError:
borrow = 0
funding = round(float(rest.get_funding_rates(MARKET[1])[0]['rate']) * 100, 4)
# Update prices and calculate basis
ob_spot, ob_perp = ws.get_orderbook(MARKET[0]), ws.get_orderbook(MARKET[1])
spot_ask, spot_bid = ob_spot['asks'][0], ob_spot['bids'][0]
perp_ask, perp_bid = ob_perp['asks'][0], ob_perp['bids'][0]
last_price_spot, last_price_perp = ws.get_ticker(MARKET[0])['last'], ws.get_ticker(MARKET[1])['last']
if last_price_perp > last_price_spot:
basis = round(((perp_ask[0] - spot_bid[0]) / ((perp_ask[0] + spot_bid[0]) / 2)) * 100, 5)
perp_above_spot = True
above_below_message = "Perpetual is above Spot"
else:
basis = round(((spot_ask[0] - perp_bid[0]) / ((spot_ask[0] + perp_bid[0]) / 2)) * 100, 5)
perp_above_spot = False
above_below_message = "Spot is above Perpetual"
total_open_size = get_total_open_size(positions)
position_count, order_count = len(positions), len(orders)
total_fills = get_total_fills(positions)
basis_threshold = DEFAULT_BASIS_THRESHOLD if position_count == 0 else DEFAULT_BASIS_THRESHOLD * MARGIN_FOR_ENTRY
if total_open_size >= ACCOUNT_SIZE or total_fills == ORDERS_PER_SIDE * 2:
at_max_size = True
else:
at_max_size = False
if at_max_size:
should_add_to_positions = False
should_increase_spot = False
should_increase_perp = False
waiting_for_fill = False
if position_count == 2:
# Exit criteria 1: positioned, basis converges.
if (positions[MARKET[1]]['side'] == 'buy' and funding > 0) or (positions[MARKET[1]]['side'] == 'sell' and funding < 0):
print("-------------------------------------------------------")
print("START EXITING POSITIONS")
print("Basis convergence")
print("-------------------------------------------------------")
logger.info("Basis convergence exit condition detected")
should_unwind_positions = True
should_add_to_positions = False
waiting_for_fill = False
# Exit criteria 2: positioned, basis valid, but funding APR worse than acceptable.
if (positions[MARKET[1]]['side'] == 'buy' and funding > 0 and abs(funding) >= APR_EXIT_THRESHOLD) or (positions[MARKET[1]]['side'] == 'sell' and funding < 0 and abs(funding) >= APR_EXIT_THRESHOLD):
print("-------------------------------------------------------")
print("START EXITING POSITIONS")
print("Unfavourable funding APR")
print("-------------------------------------------------------")
logger.info("Unfavourable funding APR exit condition detected")
should_unwind_positions = True
should_add_to_positions = False
waiting_for_fill = False
# Exit criteria 3: arbitrary manual exit
if keyboard.is_pressed('ctrl+enter'):
print("-------------------------------------------------------")
print("START EXITING POSITIONS")
print("Manual exit signal")
print("-------------------------------------------------------")
logger.info("Manual exit signal detected")
should_unwind_positions = True
should_add_to_positions = False
waiting_for_fill = False
# For debug only - triggers position unwind as soon as max size is reached.
# if fill_count == ORDERS_PER_SIDE * 2 and not waiting_for_fill and order_count == 0 and not should_unwind_positions:
# print("-------------------------------------------------------")
# print("START EXITING POSITIONS")
# print("-------------------------------------------------------")
# should_unwind_positions = True
# should_add_to_positions = False
# waiting_for_fill = False
logger.info(str("waiting_for_fill: " + str(waiting_for_fill)))
logger.info(str("should_add_to_positions: " + str(should_add_to_positions)))
logger.info(str("should_unwind_positions: " + str(should_unwind_positions)))
logger.info(str("total open size: " + str(total_open_size)))
logger.info(str("account size: " + str(ACCOUNT_SIZE)))
logger.info(str("at_max_size: " + str(at_max_size)))
logger.info(str("exposure: " + str(exposure)))
# Add to positions
if not should_unwind_positions and not at_max_size:
if not waiting_for_fill:
if abs(basis) >= basis_threshold:
if (perp_above_spot and funding > 0) or (not perp_above_spot and funding < 0):
should_add_to_positions = True
else:
should_add_to_positions = False
logger.info("No entry conditions detected.")
print("No entry conditions detected.")
else:
should_add_to_positions = False
logger.info("Basis too small.")
print("Basis too small.")
if should_add_to_positions and not at_max_size:
# Place limit orders on both sides such that exposure is balanced by the next fill
try:
if order_count == 0:
if position_count == 0 or positions[MARKET[0]]["fillCount"] == positions[MARKET[1]]["fillCount"]:
should_increase_spot = True
should_increase_perp = True
elif positions[MARKET[0]]["fillCount"] > positions[MARKET[1]]["fillCount"]:
should_increase_spot = False
should_increase_perp = True
elif positions[MARKET[0]]["fillCount"] < positions[MARKET[1]]["fillCount"]:
should_increase_spot = True
should_increase_perp = False
elif order_count == 1:
try:
if positions[MARKET[0]]["fillCount"] > positions[MARKET[1]]["fillCount"]:
should_increase_spot = False
should_increase_perp = True
elif positions[MARKET[0]]["fillCount"] < positions[MARKET[1]]["fillCount"]:
should_increase_spot = True
should_increase_perp = False
elif positions[MARKET[0]]["fillCount"] == positions[MARKET[1]]["fillCount"]:
if list(orders.values())[0]['market'] == MARKET[0]:
should_increase_perp = True
else:
should_increase_spot = True
# Place order on side of missing position, if any.
except KeyError as missing_ticker:
if MARKET[0] == missing_ticker:
should_increase_perp = False
should_increase_spot = True
else:
should_increase_perp = True
should_increase_spot = False
# Do nothing if orders open for both positions, wait for fills on either side
elif order_count == 2:
waiting_for_fill = True
# Place order on side of missing position, if any.
except KeyError as missing_ticker:
if position_count == 0:
should_increase_perp = True
should_increase_spot = True
elif MARKET[0] == missing_ticker:
should_increase_perp = False
should_increase_spot = True
elif MARKET[1] == missing_ticker:
should_increase_perp = True
should_increase_spot = False
logger.info(str("should increase spot: " + str(should_increase_spot)))
logger.info(str("should increase perp: " + str(should_increase_perp)))
if should_increase_spot and MARKET[0] not in [o['market'] for o in orders.values()]:
logger.info("increase spot position")
base_size = ACCOUNT_SIZE / ORDERS_PER_SIDE / 2 / last_price_spot
size = round(MARKET[2] * round(float(base_size) / MARKET[2]), 4)
try:
side = 'sell' if positions[MARKET[0]]['side'] == 'sell' else 'buy'
except KeyError:
side = 'sell' if not perp_above_spot else 'buy'
price = ws.get_orderbook(MARKET[0])['bids'][QUOTE_INDEX][0] if side == 'buy' else ws.get_orderbook(MARKET[0])['asks'][QUOTE_INDEX][0]
logger.info("L420: Placing spot entry order:")
if DEBUG_OUTPUT:
print("Placing spot entry order:", size, side, price)
quotes = ws.get_orderbook(MARKET[0])['bids'][0:5] if side == 'buy' else ws.get_orderbook(MARKET[0])['asks'][0:5]
print("Spot quotes 0-5", quotes)
rest.place_order(MARKET[0], side, price, size, "limit", False, False, False, None, None)
should_increase_spot = False
if should_increase_perp and MARKET[1] not in [o['market'] for o in orders.values()]:
logger.info("increase perp position")
base_size = ACCOUNT_SIZE / ORDERS_PER_SIDE / 2 / last_price_perp
size = round(MARKET[2] * round(float(base_size) / MARKET[2]), 4)
try:
side = 'sell' if positions[MARKET[1]]['side'] == 'sell' else 'buy'
except KeyError:
side = 'sell' if perp_above_spot else 'buy'
price = ws.get_orderbook(MARKET[1])['bids'][QUOTE_INDEX][0] if side == 'buy' else ws.get_orderbook(MARKET[1])['asks'][QUOTE_INDEX][0]
logger.info("L437: Placing perp entry order:")
if DEBUG_OUTPUT:
print("Placing perp entry order:", size, side, price)
quotes = ws.get_orderbook(MARKET[1])['bids'][0:5] if side == 'buy' else ws.get_orderbook(MARKET[1])['asks'][0:5]
print("Perp quotes 0-5", quotes)
rest.place_order(MARKET[1], side, price, size, "limit", False, False, False, None, None)
should_increase_perp = False
# Unwind open positions
elif should_unwind_positions:
if not waiting_for_fill:
if position_count > 0:
# Place exit orders on both sides if exposure is balanced
try:
if order_count == 0 and positions[MARKET[0]]["fillCount"] == positions[MARKET[1]]["fillCount"]:
should_reduce_spot = True
should_reduce_perp = True
# Place one order on the side with greater exposure
elif order_count == 1:
try:
# If spot position larger than perp
if positions[MARKET[0]]["fillCount"] > positions[MARKET[1]]["fillCount"]:
should_reduce_spot = True
should_reduce_perp = False
# If perp position larger than spot
elif positions[MARKET[0]]["fillCount"] < positions[MARKET[1]]["fillCount"]:
should_reduce_spot = False
should_reduce_perp = True
except KeyError as missing_ticker:
if MARKET[0] == missing_ticker:
should_reduce_perp = True
should_reduce_spot = False
else:
should_reduce_perp = False
should_reduce_spot = True
# Do nothing if orders open for both positions, wait for fills on either side
elif order_count == 2:
waiting_for_fill = True
logger.info(str("should increase spot: " + str(should_increase_spot)))
logger.info(str("should increase perp: " + str(should_increase_perp)))
# Place order on side of missing position, if any.
except KeyError as missing_ticker:
if MARKET[0] == missing_ticker:
should_reduce_perp = True
should_reduce_spot = False
else:
should_reduce_perp = False
should_reduce_spot = True
if should_reduce_spot and MARKET[0] not in [o['market'] for o in orders.values()]:
try:
logger.info("reduce spot position")
size = positions[MARKET[0]]['size'] / positions[MARKET[0]]['fillCount']
side = 'buy' if positions[MARKET[0]]['side'] == 'sell' else 'sell'
price = ws.get_orderbook(MARKET[0])['bids'][QUOTE_INDEX][0] if side == 'buy' else ws.get_orderbook(MARKET[0])['asks'][QUOTE_INDEX][0]
logger.info("L498 Placing spot exit order")
if DEBUG_OUTPUT:
print("Placing spot exit order:", size, side, price)
print("Spot last price:", last_price_spot)
quotes = ws.get_orderbook(MARKET[0])['bids'][0:5] if side == 'buy' else ws.get_orderbook(MARKET[0])['asks'][0:5]
print("Spot quotes 0-5", quotes)
rest.place_order(MARKET[0], side, price, size, "limit", False, False, False, None, None)
waiting_for_fill = True
should_reduce_spot = False
except KeyError as already_closed:
if DEBUG_OUTPUT:
print("Position for", already_closed, "already_closed")
if should_reduce_perp and MARKET[1] not in [o['market'] for o in orders.values()]:
try:
logger.info("reduce perp position")
size = positions[MARKET[1]]['size'] / positions[MARKET[1]]['fillCount']
side = 'sell' if positions[MARKET[1]]['side'] == 'buy' else 'buy'
price = ws.get_orderbook(MARKET[1])['bids'][QUOTE_INDEX][0] if side == 'buy' else ws.get_orderbook(MARKET[1])['asks'][QUOTE_INDEX][0]
logger.info("L517 placing perp exit order")
if DEBUG_OUTPUT:
print("Placing perp exit order:", size, side, price)
print("Perp last price:", last_price_perp)
quotes = ws.get_orderbook(MARKET[1])['bids'][0:5] if side == 'buy' else ws.get_orderbook(MARKET[1])['asks'][0:5]
print("Perp quotes 0-5", quotes)
rest.place_order(MARKET[1], side, price, size, "limit", False, False, False, None, None)
waiting_for_fill = True
should_reduce_perp = False
except KeyError as already_closed:
if DEBUG_OUTPUT:
print("Position for", already_closed, "already_closed")
else:
print("\nTrade complete. Terminating.")
subprocess.run(["taskkill", "/IM", "python.exe", "/F"])
# -----------------------------------------------------------------
# 4. Check stop-loss conditions and move open orders to follow price
# -----------------------------------------------------------------
exposure = has_exposure(positions)
for o in orders.values():
within_risk_limit = True
last_price = last_price_perp if o['market'] == MARKET[1] else last_price_spot
ob = ws.get_orderbook(o['market'])['asks'] if side == 'sell' else ws.get_orderbook(o['market'])['bids']
ob_step = abs(fmean(np.diff([quote[0] for quote in ob[0:5]])))
new_price = ob[QUOTE_INDEX][0]
# Calculate stop distance from avg entry of opposing exposed position
if exposure:
market = MARKET[0] if exposure[0] == MARKET[1] else MARKET[1]
side = 'sell' if exposure[1] == 'buy' else 'buy'
entry = positions[market]['avgEntryPrice']
# In future suggest using the size of the basis as the stop distance. Fixed distance is disproportionate in most cases.
# Using a very small basis threshold is good for testing but will mean stopping out of an entry often.
stop_price = entry - (entry / 100 * BAD_ENTRY_CUTOFF) if side == 'buy' else entry + (entry / 100 * BAD_ENTRY_CUTOFF)
logger.info(str("cutoff price for open order: " + str(stop_price)))
if side == 'buy' and o['price'] <= stop_price:
within_risk_limit = False
elif side == 'sell' and o['price'] >= stop_price:
within_risk_limit = False
# Close exposed portion of trade and cancel open order
if not within_risk_limit:
if DEBUG_OUTPUT:
print("cutoff reached. closing exposed portion of trade and cancelling open order")
size = positions[market]['size'] / positions[market]['fillCount']
rest.cancel_order(o['id'])
logger.info("L568 placing market order to close exposure")
rest.place_order(market, exposure[1], None, size, "market", False, False, False, None, None)
should_add_to_positions = False
waiting_for_fill = False
# Move open limit orders closer to price if order price is more than MOVE_ORDER_THRESHOLD levels from last price.
if within_risk_limit and abs(o['price'] - last_price) > ob_step * MOVE_ORDER_THRESHOLD and new_price != o['price']:
logger.info("moving existing limit order")
rest.modify_order(o['id'], None, new_price, None, None)
msg_l1 = f"\n----------------- {MARKET[0]} : {MARKET[1]} -----------------"
msg_l2 = f"Spot margin borrow APR: {round(borrow * 8760, 5)}"
msg_l3 = f"Perpetual funding APR: {round(funding * 8760, 5)}"
msg_l4 = f"Spot/perp basis %: {round(basis, 5)}"
print(msg_l1)
print(msg_l2)
print(msg_l3)
print(msg_l4)
print(above_below_message)
logger.info(msg_l2)
logger.info(msg_l3)
logger.info(msg_l4)
logger.info(above_below_message)
msg_p1 = f"\nActive positions: {len(positions)}"
msg_p2 = f"Ticker ---- Direction ---- Avg. entry ---- Size ---- Fill count ---- "
msg_p3 = ""
for p in positions.values():
msg_p3 += f"{p['ticker']} {p['side']} {p['avgEntryPrice']} {p['size']} {p['fillCount']}\n"
print(msg_p1)
print(msg_p2)
print(msg_p3)
logger.info(msg_p1)
logger.info(msg_p2)
logger.info(msg_p3)
msg_o1 = f"\nOpen orders: {len(orders)}"
msg_o2 = f"Ticker ---- Direction ----- Price ---- Size ---- Status ----"
msg_o3 = ""
for o in orders.values():
msg_o3 += f"{o['market']} {o['side']} {o['price']} {o['size']} {o['status']}\n"
print(msg_o1)
print(msg_o2)
print(msg_o3)
logger.info(msg_o1)
logger.info(msg_o2)
logger.info(msg_o3)
print("\n\n")
sleep(4)
else:
if not ws:
ws = FtxWebsocketClient(api_key, api_secret, SUBACCOUNT)
if not rest:
rest = FtxRestClient(api_key, api_secret, SUBACCOUNT)
run()