├── .gitignore ├── LICENSE ├── README.md ├── logging.conf ├── requirements.txt ├── whtrader ├── __init__.py ├── wht_config-sample.py └── wht_core.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # webhook-trader local ignores 132 | whtrader/wht_config.py 133 | .idea/ 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ivan Montilla Miralles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webhook Trader 2 | Webhook crypto trader. Intended to be used along with TradingView and any crypto exchange. 3 | 4 | ## Current state of the project 5 | It receives a webhook and places a Limit Order on the cryptocurrency exchange you configure. Only tested with Binance so far. 6 | 7 | You can post it a `BUY` or `SELL` to the webhook like this. Example: 8 | ```json 9 | { 10 | "exchange_id": "XXXXXXXXXX", 11 | "symbol": "BTC/USDT", 12 | "order_type": "LIMIT", 13 | "side": "BUY", 14 | "price": "11220", 15 | "quantity": 0.002 16 | } 17 | ``` 18 | If you don't set `quantity`, you might want to set `amount_pc` which is a calculated percentage from your wallet for the base or quote currency. Example: 19 | ```json 20 | { 21 | "exchange_id": "XXXXXXXXXX", 22 | "symbol": "BTC/USDT", 23 | "order_type": "LIMIT", 24 | "side": "BUY", 25 | "price": "11220", 26 | "amount_pc": 80 27 | } 28 | ``` 29 | In this example, the `BUY` operation will take 80% of my `USDT` Spot wallet to buy `BTC` at the specified price above. 30 | 31 | The below example uses your entire `USDT` wallet to buy `BTC` at the market price. These are the minimum required fields for it to work. 32 | ```json 33 | { 34 | "exchange_id": "wht-binance", 35 | "symbol": "BTC/USDT", 36 | "order_type": "MARKET", 37 | "side": "BUY" 38 | } 39 | ``` 40 | 41 | Please note the following: 42 | - You can have any number of exchanges set up in `wht_config.py`, but the `id` field is the one you'll be calling from the webhook as `exchange_id`. 43 | - The input values from the JSON get normalized, so if you mix uppercase with lowercase or mix a type string value with a type float, don't worry. This rule doesn't apply to the `exchange_id` field. 44 | - If you don't set the `price` field, it will just retrieve the most recent close price for the `symbol` specified. 45 | - If you don't set the `stop_price` field, it will take the value from the previously set `price` field. 46 | - Valid `order_type` values are: `market`, `limit` and `stop_limit`. Any other `order_type` will just get ignored. 47 | - Setting `quantity` and `amount_pc` at the same time will make `quantity` override `amount_pc`. 48 | - If you don't set either `quantity` or `amount_pc`, `amount_pc` will take the default value of 100% from your Spot wallet. 49 | 50 | ## Setup instructions 51 | 1. Navigate to the cloned repository directory. 52 | 2. Create a virtual environment (e.g. `$ python -m venv venv`). 53 | 3. Activate the virtual environment (e.g. `$ source venv/bin/activate`). 54 | 4. Install package requirements (e.g. `(venv) $ pip install -r requirements.txt`). 55 | 5. Duplicate file `wht_config-sample.py` into `wht_config.py` editing it with your custom values. 56 | 6. \#TODO: Write Flask instructions here. 57 | 58 | Developed using `Flask` and `ccxt`. 59 | -------------------------------------------------------------------------------- /logging.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root 3 | 4 | [handlers] 5 | keys=consoleHandler 6 | 7 | [formatters] 8 | keys=simpleFormatter 9 | 10 | [logger_root] 11 | level=DEBUG 12 | handlers=consoleHandler 13 | 14 | [handler_consoleHandler] 15 | class=StreamHandler 16 | level=DEBUG 17 | formatter=simpleFormatter 18 | args=(sys.stdout,) 19 | 20 | [formatter_simpleFormatter] 21 | format=%(asctime)s %(message)s 22 | datefmt= -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiodns==2.0.0 2 | aiohttp==3.7.4.post0 3 | aiohttp-cors==0.7.0 4 | appdirs==1.4.4 5 | astroid==2.6.2 6 | async-timeout==3.0.1 7 | attrs==21.2.0 8 | black==21.6b0 9 | ccxt==1.52.31 10 | certifi==2021.5.30 11 | cffi==1.14.5 12 | chardet==4.0.0 13 | click==8.0.1 14 | colorama==0.4.4 15 | cryptography==3.4.7 16 | Flask==2.0.1 17 | idna==2.10 18 | isort==5.9.1 19 | itsdangerous==2.0.1 20 | Jinja2==3.0.1 21 | lazy-object-proxy==1.6.0 22 | MarkupSafe==2.0.1 23 | mccabe==0.6.1 24 | multidict==5.1.0 25 | mypy-extensions==0.4.3 26 | pathspec==0.8.1 27 | pycares==4.0.0 28 | pycparser==2.20 29 | pylint==2.9.3 30 | pylint-flask==0.6 31 | pylint-plugin-utils==0.6 32 | regex==2021.7.1 33 | requests==2.25.1 34 | toml==0.10.2 35 | typing-extensions==3.10.0.0 36 | urllib3==1.26.6 37 | Werkzeug==2.0.1 38 | wrapt==1.12.1 39 | yarl==1.6.3 40 | -------------------------------------------------------------------------------- /whtrader/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Standard library imports 4 | import logging.config 5 | 6 | logging.config.fileConfig("logging.conf", disable_existing_loggers=False) 7 | logger = logging.getLogger(__name__) 8 | import json 9 | import os 10 | from concurrent.futures import ThreadPoolExecutor 11 | 12 | # Third party imports 13 | from flask import Flask, request, make_response # , abort 14 | import ccxt 15 | 16 | # Local application imports 17 | import wht_core 18 | import wht_config 19 | 20 | # Create exchange instances from config file and grab other settings 21 | wh_key = wht_config.wh_key 22 | exchanges = wht_config.exchanges 23 | instances = {} 24 | 25 | for i in exchanges: 26 | exchange_id = i["id"] 27 | exchange_type = i["type"] 28 | exchange_params = i["params"] 29 | exchange_class = getattr(ccxt, exchange_type) 30 | instances[exchange_id] = exchange_class(exchange_params) 31 | 32 | # Set threading pool 33 | e = ThreadPoolExecutor() 34 | 35 | 36 | def create_app(): 37 | # create and configure the app 38 | app = Flask(__name__, instance_relative_config=True) 39 | 40 | # main route 41 | @app.route("/") 42 | def index(): 43 | return wht_core.wh_index(), 301 44 | 45 | # ip-addr route for troubleshooting purposes 46 | @app.route("/ip-addr") 47 | def ip_addr(): 48 | ip_address = request.environ.get("HTTP_X_REAL_IP", request.remote_addr) 49 | return ip_address, 200 50 | 51 | # main webhook 52 | @app.route("/" + wh_key + "/webhook", methods=["POST"]) 53 | def webhook(): 54 | if request.method == "POST": 55 | msg_posted = request.data.decode("utf-8") 56 | instruction = json.loads(msg_posted) 57 | logger.info("POST Request received: %s", instruction) 58 | instance_ref = instruction["exchange_id"] 59 | exchange = instances[instance_ref] 60 | symbol = instruction["symbol"].upper() 61 | order_type = instruction["order_type"].lower() 62 | side = instruction["side"].lower() 63 | try: 64 | price = instruction["price"] 65 | except KeyError: 66 | price = exchange.fetchTicker(symbol)["close"] 67 | try: 68 | stop_price = instruction["stop_price"] 69 | except KeyError: 70 | stop_price = price 71 | try: 72 | amount_pc = instruction["amount_pc"] 73 | except KeyError: 74 | amount_pc = 100 75 | quantity = 0 76 | try: 77 | quantity = instruction["quantity"] 78 | except KeyError: 79 | if side == "buy": 80 | balance_quote = wht_core.fetch_asset_balance( 81 | exchange, symbol.split("/")[1].upper() 82 | ) 83 | quantity = wht_core.determine_quantity( 84 | side, amount_pc, balance_quote, price 85 | ) 86 | elif side == "sell": 87 | balance_base = wht_core.fetch_asset_balance( 88 | exchange, symbol.split("/")[0].upper() 89 | ) 90 | quantity = wht_core.determine_quantity( 91 | side, amount_pc, balance_base, price 92 | ) 93 | 94 | # Submit order for execution 95 | e.submit( 96 | wht_core.place_order, 97 | exchange=exchange, 98 | symbol=symbol, 99 | order_type=order_type, 100 | side=side, 101 | quantity=quantity, 102 | price=price, 103 | stop_price=stop_price, 104 | ) 105 | return "POST OK", 200 106 | 107 | return app 108 | -------------------------------------------------------------------------------- /whtrader/wht_config-sample.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # WHTrader specifics 4 | wh_key = "" 5 | 6 | # API Keys 7 | exchanges = [ 8 | { 9 | "id": "wht-binance", # Used for identifying this instance, this can be anything 10 | "type": "binance", # Type of exchange in CCXT 11 | "params": { 12 | "apiKey": "", 13 | "secret": "", 14 | }, 15 | }, 16 | { 17 | "id": "wht-bitfinex", # Used for identifying this instance, this can be anything 18 | "type": "bitfinex", # Type of exchange in CCXT 19 | "params": { 20 | "apiKey": "", 21 | "secret": "", 22 | }, 23 | }, 24 | ] 25 | -------------------------------------------------------------------------------- /whtrader/wht_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Standard library imports 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def wh_index(): 10 | redirect = '' 11 | return redirect 12 | 13 | 14 | def fetch_asset_balance(exchange, asset): 15 | balances = exchange.fetch_balance() 16 | return balances["free"][asset] 17 | 18 | 19 | def determine_quantity(side, amount_pc, balance, price): 20 | amount_pc = float(amount_pc) 21 | balance = float(balance) 22 | price = float(price) 23 | quantity = 0 24 | if side == "buy": 25 | quantity = amount_pc / 100 * balance / price 26 | if side == "sell": 27 | quantity = amount_pc / 100 * balance 28 | return quantity 29 | 30 | 31 | def place_order( 32 | exchange, symbol, order_type, side, quantity, price=None, stop_price=None 33 | ): 34 | exchange = exchange 35 | # exchange_id = exchange.id 36 | symbol = symbol.upper() 37 | order_type = order_type.lower() 38 | side = side.lower() 39 | amount = quantity 40 | try: 41 | price = float(price) 42 | except TypeError: 43 | price = None 44 | try: 45 | stop_price = float(stop_price) 46 | except TypeError: 47 | stop_price = None 48 | ref_price = None 49 | order = None 50 | 51 | possible_orders = [ 52 | "market", 53 | "limit", 54 | "stop_limit", 55 | ] 56 | 57 | if order_type in possible_orders: 58 | if order_type == "stop_limit": 59 | params = { 60 | "stopPrice": stop_price, 61 | "type": "stopLimit", 62 | } 63 | 64 | ref_price = exchange.fetchTicker(symbol)["close"] 65 | 66 | if side == "buy" and ref_price >= stop_price: 67 | order = exchange.create_order( 68 | symbol, "TAKE_PROFIT_LIMIT", side, amount, price, params 69 | ) 70 | elif side == "buy" and ref_price < stop_price: 71 | order = exchange.create_order( 72 | symbol, "STOP_LOSS_LIMIT", side, amount, price, params 73 | ) 74 | elif side == "sell" and ref_price >= stop_price: 75 | order = exchange.create_order( 76 | symbol, "STOP_LOSS_LIMIT", side, amount, price, params 77 | ) 78 | elif side == "sell" and ref_price < stop_price: 79 | order = exchange.create_order( 80 | symbol, "TAKE_PROFIT_LIMIT", side, amount, price, params 81 | ) 82 | 83 | elif order_type == "market": 84 | order = exchange.create_order(symbol, order_type, side, amount) 85 | else: 86 | order = exchange.create_order(symbol, order_type, side, amount, price) 87 | 88 | print( 89 | f"{order_type.upper()} ORDER", 90 | symbol, 91 | order_type, 92 | side, 93 | amount, 94 | price, 95 | stop_price, 96 | ref_price, 97 | ) 98 | 99 | return order 100 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import whtrader 2 | 3 | application = whtrader.create_app() 4 | 5 | if __name__ == "__main__": 6 | application.debug = True 7 | application.run() 8 | --------------------------------------------------------------------------------