├── .gitignore ├── README.md └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | # These are some examples of commonly ignored file patterns. 2 | # You should customize this list as applicable to your project. 3 | # Learn more about .gitignore: 4 | # https://www.atlassian.com/git/tutorials/saving-changes/gitignore 5 | 6 | # Node artifact files 7 | node_modules/ 8 | dist/ 9 | 10 | # Compiled Java class files 11 | *.class 12 | 13 | # Compiled Python bytecode 14 | *.py[cod] 15 | 16 | # Log files 17 | *.log 18 | 19 | # Package files 20 | *.jar 21 | 22 | # Maven 23 | target/ 24 | dist/ 25 | 26 | # JetBrains IDE 27 | .idea/ 28 | 29 | # Unit test reports 30 | TEST*.xml 31 | 32 | # Generated by MacOS 33 | .DS_Store 34 | 35 | # Generated by Windows 36 | Thumbs.db 37 | 38 | # Applications 39 | *.app 40 | *.exe 41 | *.war 42 | 43 | # Large media files 44 | *.mp4 45 | *.tiff 46 | *.avi 47 | *.flv 48 | *.mov 49 | *.wmv 50 | 51 | private_config.py 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README # 2 | 3 | Freqtrade as of today does not support DYDX, this repo contains an implementation of a minimal API endpoint to connect with [Freqtrade](https://freqtrade.io) webhooks. 4 | 5 | ### Setup ### 6 | 7 | Populate a file called `private_config.py` with your data. 8 | 9 | Change the values at the start of the `main.py` file to fit your needs. 10 | 11 | Start the API by running : 12 | 13 | ```console 14 | # python3 main.py 15 | ``` 16 | 17 | A logging file will be created at the same dir called log.txt. 18 | 19 | ### Freqtrade config ### 20 | 21 | First step is enable webhooks in your Freqtrade config. 22 | We are only using status, entry and exit webhooks for now. 23 | 24 | Freqtrade posts form encoded to our API. 25 | 26 | ```yaml 27 | "webhook": { 28 | "enabled": true, 29 | "url": "http://127.0.0.1:7000/api", 30 | "webhookentry": { 31 | "command": "Entry", 32 | "pair": "{pair}", 33 | "trade_id": "{trade_id}", 34 | "direction": "{direction}", 35 | "amount": "{amount}", 36 | "stake_amount": "{stake_amount}", 37 | "open_rate": "{open_rate}", 38 | }, 39 | "webhookexit": { 40 | "command": "Exit", 41 | "pair": "{pair}", 42 | "trade_id": "{trade_id}", 43 | "direction": "{direction}", 44 | "amount": "{amount}", 45 | "stake_amount": "{stake_amount}", 46 | "open_rate": "{open_rate}", 47 | "limit": "{limit}", 48 | }, 49 | "webhookstatus": { 50 | "command": "Status", 51 | "status": "{status}", 52 | }, 53 | }, 54 | 55 | ``` 56 | 57 | ### Testing with CURL ### 58 | 59 | Test the status command : 60 | ```console 61 | curl -X POST http://127.0.0.1:7000/api -F command=Status 62 | ``` 63 | 64 | Test an entry order : 65 | ```console 66 | curl -X POST http://127.0.0.1:7000/api -F command=Entry \ 67 | -F pair="BTC/USDT" \ 68 | -F trade_id=10 \ 69 | -F direction=Long \ 70 | -F amount=0.001 \ 71 | -F stake_amount=10 \ 72 | -F open_rate=100 73 | ``` 74 | ```console 75 | curl -X POST http://127.0.0.1:7000/api -F command=Exit \ 76 | -F pair="ETH/USDT" \ 77 | -F trade_id=10 \ 78 | -F direction=Long \ 79 | -F amount=0.1 \ 80 | -F stake_amount=20 \ 81 | -F limit=120.111 82 | ``` 83 | 84 | 85 | Note that data must be formatted as if it was sent by Freqtrade. 86 | Change the values according to what you want to test. 87 | 88 | ### Security ### 89 | 90 | lol 91 | 92 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request, send_file 2 | from dydx3.constants import ORDER_SIDE_BUY, ORDER_TYPE_LIMIT, ORDER_SIDE_SELL 3 | import decimal 4 | import time 5 | from dydx3 import Client 6 | 7 | from private_config import \ 8 | API_SECRET, API_KEY, API_PASSPHRASE, \ 9 | STARK_PRIVATE_KEY, \ 10 | ETHEREUM_ADDRESS, \ 11 | TELEGRAM_TOKEN, TELEGRAM_CHAT_ID 12 | import requests 13 | import logging 14 | 15 | app = Flask(__name__) 16 | 17 | PORT = 7000 18 | 19 | # Default API endpoint for DYDX Exchange 20 | DYDX_HOST = 'https://api.dydx.exchange/' 21 | 22 | # Stake currency, only USD is supported, do not change. 23 | STAKE_CURRENCY = 'USD' 24 | # FOK, GTT or IOK 25 | TIME_IN_FORCE = 'GTT' 26 | # Post only is used to make sure your order executes only as a maker 27 | POST_ONLY = False 28 | 29 | # Maximum Fee as a percentage 30 | # Tier 1 in DYDX is 0.05% 31 | LIMIT_FEE_PERCENT = '0.051' 32 | # LIVE for active trading DRY for testing 33 | MODE = 'DRY' 34 | # Order expiration in seconds 35 | ORDER_EXPIRATION = 86400 36 | # If margin fraction requirements for the market are higher than that, do not take the trade. 37 | INITIAL_MARGIN_FRACTION_LIMIT = '0.5' 38 | # TELEGRAM config (needs token and chat_id on private config) 39 | TELEGRAM_ENABLED = True 40 | TELEGRAM_SEND_URL = f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage' 41 | 42 | # If True, the trade will go through only if the asset is included in the allowed asset list. 43 | CHECK_ALLOWED_ASSET = False 44 | ALLOWED_ASSETS = ['BTC', 'ETH'] 45 | 46 | 47 | # Post message to telegram 48 | def send_telegram_message(message): 49 | if TELEGRAM_ENABLED: 50 | try: 51 | requests.post(TELEGRAM_SEND_URL, json={'chat_id': TELEGRAM_CHAT_ID, 'text': message}) 52 | except Exception as err: 53 | logging.error('Error sending telegram message. Exception : {}'.format(err)) 54 | 55 | # API endpoint listening on http://localhost:PORT/api 56 | @app.route('/api', methods=['POST']) 57 | def position(): 58 | logging.info('>> API hit, data dump follows : {}'.format(request.form)) 59 | command = request.form['command'] 60 | logging.info('Command : {}'.format(command)) 61 | if command in ['Entry', 'Exit']: 62 | try: 63 | pair = request.form['pair'] 64 | trade_id = request.form['trade_id'] 65 | direction = request.form['direction'] 66 | if direction not in ['Long', 'Short']: 67 | logging.error('Direction must be either Long or Short, but it was : {}'.format(direction)) 68 | return 'KO' 69 | amount = decimal.Decimal(request.form['amount']) 70 | open_rate = decimal.Decimal(request.form['open_rate']) 71 | if command == 'Exit': 72 | limit = decimal.Decimal(request.form['limit']) 73 | asset = pair.split("/")[0] 74 | if CHECK_ALLOWED_ASSET: 75 | if asset not in ALLOWED_ASSETS: 76 | logging.error('The asset is not in the allowed assets list.') 77 | return 'KO' 78 | market = asset + '-' + STAKE_CURRENCY 79 | except Exception as err: 80 | logging.error('Error getting parameters. Exception : {}'.format(err)) 81 | return 'KO' 82 | 83 | # Get our position ID. 84 | client = create_client() 85 | account_response = client.private.get_account() 86 | account = account_response.data['account'] 87 | position_id = account['positionId'] 88 | 89 | order_params = { 90 | 'position_id': position_id, 91 | 'market': market, 92 | 'order_type': ORDER_TYPE_LIMIT, 93 | 'post_only': POST_ONLY, 94 | 'price': str(open_rate) if command == 'Entry' else str(limit), 95 | 'time_in_force': TIME_IN_FORCE, 96 | 'expiration_epoch_seconds': int(time.time()) + ORDER_EXPIRATION, 97 | } 98 | logging.info('Order params before setting side: {}'.format(order_params)) 99 | # Check command and direction to properly set order side 100 | if command == 'Entry': 101 | if market in account['openPositions']: 102 | logging.error('There is already an open position for {}, ignoring order.'.format(market)) 103 | return 'KO' 104 | if direction == 'Short': 105 | order_params['side'] = ORDER_SIDE_SELL 106 | elif direction == 'Long': 107 | order_params['side'] = ORDER_SIDE_BUY 108 | elif command == 'Exit': 109 | if market not in account['openPositions']: 110 | logging.error('Trying to exit a position, but no open position found for {}, ignoring order.'.format(market)) 111 | return 'KO' 112 | elif account['openPositions'][market]['side'].lower() != direction.lower(): 113 | logging.error('Trying to exit a position on the wrong direction for {}, NGMI.'.format(market)) 114 | return 'KO' 115 | if direction == 'Short': 116 | order_params['side'] = ORDER_SIDE_BUY 117 | elif direction == 'Long': 118 | order_params['side'] = ORDER_SIDE_SELL 119 | 120 | try: 121 | # Get market data for pair 122 | market_data = client.public.get_markets(market) 123 | # Check Initial Margin Fraction requirementes are met while entering a position 124 | if command == 'Entry' and decimal.Decimal(INITIAL_MARGIN_FRACTION_LIMIT) < decimal.Decimal(market_data.data['markets'][market]['initialMarginFraction']): 125 | logging.info('Initial margin fraction limit is higher than the current market limit ({}), ' 126 | 'not taking the trade', market_data.data['markets'][market]['initialMarginFraction']) 127 | return 'KO' 128 | # Make sure order size is a multiple of stepSize for this market. 129 | step = decimal.Decimal(market_data.data['markets'][market]['stepSize']) 130 | newsize = step * round(decimal.Decimal(amount) / step) 131 | order_params['size'] = str(round(newsize, len(market_data.data['markets'][market]['assetResolution']))) 132 | order_params['limit_fee'] = str((amount * decimal.Decimal(LIMIT_FEE_PERCENT)) / decimal.Decimal('100')) 133 | # Make sure price is a multiple of tickSize for this market 134 | tick = decimal.Decimal(market_data.data['markets'][market]['tickSize']) 135 | newprice = tick * round(decimal.Decimal(order_params['price']) / tick) 136 | order_params['price'] = str(newprice) 137 | logging.info('[{} mode] Posting order with data :{}'.format(MODE, order_params)) 138 | if MODE == 'LIVE': 139 | order_response = client.private.create_order(**order_params) 140 | order_id = order_response.data['order']['id'] 141 | message = 'Order {} successfully posted, order response data : {}'.format(order_id, order_response.data['order']) 142 | logging.info(message) 143 | send_telegram_message(message) 144 | except Exception as err: 145 | message = 'Error posting order : {}'.format(err) 146 | logging.error(message) 147 | send_telegram_message(message) 148 | return 'KO' 149 | elif command == 'Status': 150 | logging.info('Status received : {}'.format(request.form['command'])) 151 | elif command == 'Account': 152 | logging.info('Account request received.') 153 | client = create_client() 154 | account_response = client.private.get_account() 155 | logging.info('Account data : {}'.format(account_response.data)) 156 | else: 157 | logging.info('Unknown command (or no command) received. Ignoring.') 158 | return 'KO' 159 | return 'OK' 160 | 161 | 162 | # Creates a DYDX API client 163 | def create_client() -> Client: 164 | client = Client( 165 | host=DYDX_HOST, 166 | api_key_credentials={ 167 | 'key': API_KEY, 168 | 'secret': API_SECRET, 169 | 'passphrase': API_PASSPHRASE}, 170 | stark_private_key=STARK_PRIVATE_KEY, 171 | default_ethereum_address=ETHEREUM_ADDRESS, 172 | ) 173 | return client 174 | 175 | 176 | if __name__ == '__main__': 177 | logging.basicConfig( 178 | filename='log.txt', 179 | encoding='utf-8', 180 | level=logging.DEBUG, 181 | format='%(asctime)s %(levelname)-8s %(message)s', 182 | datefmt='%Y-%m-%d %H:%M:%S' 183 | ) 184 | app.run(debug=False, port=PORT) 185 | --------------------------------------------------------------------------------