From de8acb3f8c20e478371136d3ff98457dabac3953 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Sun, 18 Apr 2021 16:30:33 -0400 Subject: [PATCH 01/11] Migrating to multi-exchange support --- CHANGELOG.md | 7 ++ README.md | 8 +- SourceCode/bot_internals.py | 123 +++++++++++++++++++++++++++- SourceCode/cryptodip_bot.py | 113 ++----------------------- SourceCode/gemini_exchange.py | 150 ++++++++++++++++++++++++++++++++++ TODO.md | 6 +- 6 files changed, 293 insertions(+), 114 deletions(-) create mode 100644 SourceCode/gemini_exchange.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc0a41..6061c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Crypto Dip Buying Bot: Changelog ================================ A list of all the changes made to this repo, and the bot it contains +Version 0.3.0 +------------- + +1. Shifted TODO versions +2. Added Gemini Support + + Version 0.2.0 ------------- diff --git a/README.md b/README.md index c6b04d0..c1dfad2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ Crypto Dip Buying Bot ===================== -This bot is designed to buy cryptocurrency on Coinbase Pro using a USD prefunded portfolio whenever it detects a significant dip in price. +This bot is designed to buy cryptocurrency on Coinbase Pro or Gemini using a USD prefunded portfolio whenever it detects a significant dip in price. PRE-RELEASE WARNING ------------------- -I tested this code in a sandbox for a few days, before releasing it. I am running this bot against my live Coinbase Pro account as well. +I tested this code in a sandbox for a few days, before releasing it. I am running this bot against my live Gemini account as well. This is still a very new bot with limited testing so **USE THIS BOT AT YOUR OWN RISK!** Dip Detection @@ -25,6 +25,7 @@ Config File You will need the following: 1. Coinbase Pro credentials tied to the portfolio you want to run the bot against + 1. Alternatively you can specify Gemini be used instead. 2. Dip logic parameters: 1. The cryptocurrency you want to transact in. (It must support being paired against USD in Coinbase Pro) 2. The buy amount you want in $USD. @@ -57,6 +58,9 @@ The file should look like this: "api_secret": "YOUR_API_SECRET", "passphrase": "YOUR_API_PASSPHRASE" }, + "gemini": { + + }, "aws": { "access_key": "YOUR_API_KEY", "secret_access_key": "YOUR_API_SECRET", diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index 0f73311..c47f19b 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -13,9 +13,49 @@ # See LICENSE # - +from itertools import count import json +import datetime +import time import boto3 +import coinbase_pro +import mongo + +# Constants that might be useful to adjust for debugging purposes +CYCLE_INTERVAL_MINUTES = 5 # TODO change to 60 before merging to main + + +def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool]: + """Open a JSON file and get the bot configuration + Args: + config_file: Path to the JSON file containing credentials and config options + + Returns: + crypto_currency: The cryptocurrency that will be monitored + buy_amount: The price in $USD that will be purchased when a dip is detected + dip_percentage: The percentage of the average price drop that means a dip occurred + average_period_days: The time period in days to average across + cool_down_period_days: The time period in days that you will wait before transacting + aws_loaded: A bool to determine if AWS configuration options exist + using_gemini: A bool to determine if the bot should use Gemini + """ + with open(config_file) as creds_file: + data = json.load(creds_file) + crypto_currency = data['bot']['currency'] + buy_amount = data['bot']['buy_amount'] + dip_percentage = data['bot']['dip_percentage'] + if 'average_period_days' in data['bot']: + average_period_days = data['bot']['average_period_days'] + else: + average_period_days = 7 + if 'cool_down_period_days' in data['bot']: + cool_down_period_days = data['bot']['cool_down_period_days'] + else: + cool_down_period_days = 7 + aws_loaded = bool('aws' in data) + using_gemini = bool('gemini' in data) + return crypto_currency, buy_amount, dip_percentage,\ + average_period_days, cool_down_period_days, aws_loaded, using_gemini def get_aws_creds_from_file(config_file: str) -> [str, str, str]: @@ -80,3 +120,84 @@ def dip_percent_value(price: float, percent: float) -> float: """ dip_price = price * (1 - percent / 100) return round(dip_price, 2) + + +def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: + """Perform bot cycles using Coinbase Pro as the exchange + + Args: + config_file: Path to the JSON file containing credentials + debug_mode: Are we running in debugging mode? + """ + # Load the configuration file + config_params = read_bot_config(config_file) + if config_params[5]: + aws_config = get_aws_creds_from_file(config_file) + message = "%s-Bot has been started" % config_params[0] + post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) + # Set API URLs + if debug_mode: + coinbase_pro_api_url = "https://api-public.sandbox.pro.coinbase.com/" + mongo_db_connection = "mongodb://localhost:27017/" + else: + coinbase_pro_api_url = "https://api.pro.coinbase.com/" + mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" + print("LOG: Starting bot...\n LOG: Monitoring %s on Coinbase Pro to buy $%s worth" + " when a %s%% dip occurs." % (config_params[0], config_params[1], config_params[2])) + print("LOG: Dips are checked against a %s day price" + " average with a %s day cool down period" % (config_params[3], config_params[4])) + for cycle in count(): + now = datetime.datetime.now().strftime("%m/%d/%Y-%H:%M:%S") + print("LOG: Cycle %s: %s" % (cycle, now)) + # Verify that there is enough money to transact, otherwise don't bother + if not coinbase_pro.verify_balance(coinbase_pro_api_url, config_file, config_params[1]): + message = "LOG: Not enough account balance" \ + " to buy $%s worth of %s" % (config_params[1], config_params[0]) + subject = "%s-Bot Funding Issue" % config_params[0] + if config_params[5]: + post_to_sns(aws_config[0], aws_config[1], aws_config[2], + subject, message) + print("LOG: %s" % message) + # Sleep for the specified cycle interval then end the cycle + time.sleep(CYCLE_INTERVAL_MINUTES * 60) + continue + coin_current_price = coinbase_pro.get_coin_price\ + (coinbase_pro_api_url, config_file, config_params[0]) + # Add the current price to the price database + mongo.add_price(mongo_db_connection, config_params[0], coin_current_price) + # Check if the a week has passed since the last dip buy + clear_to_proceed = mongo.check_last_buy_date(mongo_db_connection, + config_params[0], config_params[4]) + if clear_to_proceed is True: + print("LOG: Last buy date outside cool down period. Checking if a dip is occurring.") + average_price = mongo.average_pricing(mongo_db_connection, + config_params[0], config_params[3]) + dip_price = dip_percent_value(average_price, config_params[2]) + print("LOG: A %s%% dip at the average price of %s would be %s" + %(config_params[2], average_price, dip_price)) + if coin_current_price <= dip_price: + print("LOG: The current price of %s is <= %s. We are in a dip!" + % (coin_current_price, dip_price)) + did_buy = coinbase_pro.buy_currency(coinbase_pro_api_url, + config_file, config_params[0], config_params[1]) + message = "Buy success status is %s for %s worth of %s"\ + % (did_buy, config_params[1], config_params[0]) + subject = "%s-Bot Buy Status Alert" % config_params[0] + mongo.set_last_buy_date(mongo_db_connection, config_params[0]) + print("LOG: %s" % message) + if config_params[5]: + post_to_sns(aws_config[0], aws_config[1], aws_config[2], + subject, message) + else: + print("LOG: The current price of %s is > %s. We are not in a dip!" + % (coin_current_price, dip_price)) + else: + print("LOG: Last buy date inside cool down period. No buys will be attempted.") + + # Run a price history cleanup daily otherwise sleep the interval + if (cycle * CYCLE_INTERVAL_MINUTES) % 1440 == 0: + print("LOG: Cleaning up price history older than 30 days.") + mongo.cleanup_old_records(mongo_db_connection, config_params[0]) + else: + # Sleep for the specified cycle interval + time.sleep(CYCLE_INTERVAL_MINUTES * 60) diff --git a/SourceCode/cryptodip_bot.py b/SourceCode/cryptodip_bot.py index ad53ec1..b74d663 100644 --- a/SourceCode/cryptodip_bot.py +++ b/SourceCode/cryptodip_bot.py @@ -13,48 +13,8 @@ # See LICENSE # -from itertools import count import argparse -import datetime -import json -import time import bot_internals -import coinbase_pro -import mongo - -# Constants that might be useful to adjust for debugging purposes -CYCLE_INTERVAL_MINUTES = 60 - - -def read_bot_config(config_file: str) -> [str, float, int, int, int, bool]: - """Open a JSON file and get the bot configuration - Args: - config_file: Path to the JSON file containing credentials and config options - - Returns: - crypto_currency: The cryptocurrency that will be monitored - buy_amount: The price in $USD that will be purchased when a dip is detected - dip_percentage: The percentage of the average price drop that means a dip occurred - average_period_days: The time period in days to average across - cool_down_period_days: The time period in days that you will wait before transacting - aws_loaded: A bool to determine if AWS configuration options exist - """ - with open(config_file) as creds_file: - data = json.load(creds_file) - crypto_currency = data['bot']['currency'] - buy_amount = data['bot']['buy_amount'] - dip_percentage = data['bot']['dip_percentage'] - if 'average_period_days' in data['bot']: - average_period_days = data['bot']['average_period_days'] - else: - average_period_days = 7 - if 'cool_down_period_days' in data['bot']: - cool_down_period_days = data['bot']['cool_down_period_days'] - else: - cool_down_period_days = 7 - aws_loaded = bool('aws' in data) - return crypto_currency, buy_amount, dip_percentage,\ - average_period_days, cool_down_period_days, aws_loaded def main(config_file: str, debug_mode: bool): @@ -66,79 +26,16 @@ def main(config_file: str, debug_mode: bool): debug_mode: Use Sandbox APIs instead of production """ # Load the configuration file - config_params = read_bot_config(config_file) + config_params = bot_internals.read_bot_config(config_file) if config_params[5]: aws_config = bot_internals.get_aws_creds_from_file(config_file) message = "%s-Bot has been started" % config_params[0] bot_internals.post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) - # Set API URLs - if debug_mode: - coinbase_pro_api_url = "https://api-public.sandbox.pro.coinbase.com/" - mongo_db_connection = "mongodb://localhost:27017/" + # Start the correct cycle + if config_params[6]: + print("Gemini support coming") else: - coinbase_pro_api_url = "https://api.pro.coinbase.com/" - mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" - print("LOG: Starting bot...") - print("LOG: Monitoring %s to buy $%s worth when a %s%% dip occurs." - % (config_params[0], config_params[1], config_params[2])) - print("LOG: Dips are checked against a %s day price" - " average with a %s day cool down period" % (config_params[3], config_params[4])) - # Execute the bot every 10 seconds - for cycle in count(): - now = datetime.datetime.now().strftime("%m/%d/%Y-%H:%M:%S") - print("LOG: Cycle %s: %s" % (cycle, now)) - # Verify that there is enough money to transact, otherwise don't bother - if not coinbase_pro.verify_balance(coinbase_pro_api_url, config_file, config_params[1]): - message = "LOG: Not enough account balance" \ - " to buy $%s worth of %s" % (config_params[1], config_params[0]) - subject = "%s-Bot Funding Issue" % config_params[0] - if config_params[5]: - bot_internals.post_to_sns(aws_config[0], aws_config[1], aws_config[2], - subject, message) - print("LOG: %s" % message) - # Sleep for the specified cycle interval then end the cycle - time.sleep(CYCLE_INTERVAL_MINUTES * 60) - continue - coin_current_price = coinbase_pro.get_coin_price\ - (coinbase_pro_api_url, config_file, config_params[0]) - # Add the current price to the price database - mongo.add_price(mongo_db_connection, config_params[0], coin_current_price) - # Check if the a week has passed since the last dip buy - clear_to_proceed = mongo.check_last_buy_date(mongo_db_connection, - config_params[0], config_params[4]) - if clear_to_proceed is True: - print("LOG: Last buy date outside cool down period. Checking if a dip is occurring.") - average_price = mongo.average_pricing(mongo_db_connection, - config_params[0], config_params[3]) - dip_price = bot_internals.dip_percent_value(average_price, config_params[2]) - print("LOG: A %s%% dip at the average price of %s would be %s" - %(config_params[2], average_price, dip_price)) - if coin_current_price <= dip_price: - print("LOG: The current price of %s is <= %s. We are in a dip!" - % (coin_current_price, dip_price)) - did_buy = coinbase_pro.buy_currency(coinbase_pro_api_url, - config_file, config_params[0], config_params[1]) - message = "Buy success status is %s for %s worth of %s"\ - % (did_buy, config_params[1], config_params[0]) - subject = "%s-Bot Buy Status Alert" % config_params[0] - mongo.set_last_buy_date(mongo_db_connection, config_params[0]) - print("LOG: %s" % message) - if config_params[5]: - bot_internals.post_to_sns(aws_config[0], aws_config[1], aws_config[2], - subject, message) - else: - print("LOG: The current price of %s is > %s. We are not in a dip!" - % (coin_current_price, dip_price)) - else: - print("LOG: Last buy date inside cool down period. No buys will be attempted.") - - # Run a price history cleanup daily otherwise sleep the interval - if (cycle * CYCLE_INTERVAL_MINUTES) % 1440 == 0: - print("LOG: Cleaning up price history older than 30 days.") - mongo.cleanup_old_records(mongo_db_connection, config_params[0]) - else: - # Sleep for the specified cycle interval - time.sleep(CYCLE_INTERVAL_MINUTES * 60) + bot_internals.coinbase_pro_cycle(config_params, debug_mode) if __name__ == '__main__': diff --git a/SourceCode/gemini_exchange.py b/SourceCode/gemini_exchange.py new file mode 100644 index 0000000..002dd1c --- /dev/null +++ b/SourceCode/gemini_exchange.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Functions to use with the Gemini Exchange""" +# +# Python Script:: gemini_exchange.py +# +# Linter:: pylint +# +# Copyright 2021, Matthew Ahrenstein, All Rights Reserved. +# +# Maintainers: +# - Matthew Ahrenstein: matt@ahrenstein.com +# +# See LICENSE +# + +import base64 +import json +import time +import hmac +import hashlib +import requests +from requests.auth import AuthBase + + +# Create custom authentication for CoinbasePro +# as per https://docs.pro.coinbase.com/?python#creating-a-request +class CoinbaseProAuth(AuthBase): + """ + Coinbase Pro provided authentication method with minor fixes + """ + def __init__(self, api_key, secret_key, passphrase): + self.api_key = api_key + self.secret_key = secret_key + self.passphrase = passphrase + + def __call__(self, request): + timestamp = str(time.time()) + try: + message = timestamp + request.method + request.path_url + (request.body or b'').decode() + except: + message = timestamp + request.method + request.path_url + (request.body or b'') + hmac_key = base64.b64decode(self.secret_key) + signature = hmac.new(hmac_key, message.encode(), hashlib.sha256) + signature_b64 = base64.b64encode(signature.digest()).decode() + + request.headers.update({ + 'CB-ACCESS-SIGN': signature_b64, + 'CB-ACCESS-TIMESTAMP': timestamp, + 'CB-ACCESS-KEY': self.api_key, + 'CB-ACCESS-PASSPHRASE': self.passphrase, + 'Content-Type': 'application/json' + }) + return request + + +def get_cbpro_creds_from_file(config_file: str) -> [str, str, str]: + """Open a JSON file and get Coinbase Pro credentials out of it + Args: + config_file: Path to the JSON file containing credentials and config options + + Returns: + cbpro_api_key: An API key for Coinbase Pro + cbpro_api_secret: An API secret for Coinbase Pro + cbpro_api_passphrase: An API passphrase for Coinbase Pro + """ + with open(config_file) as creds_file: + data = json.load(creds_file) + cbpro_api_key = data['coinbase']['api_key'] + cbpro_api_secret = data['coinbase']['api_secret'] + cbpro_api_passphrase = data['coinbase']['passphrase'] + return cbpro_api_key, cbpro_api_secret, cbpro_api_passphrase + + +def get_coin_price(api_url: str, config_file: str, currency: str) -> float: + """ + Get the USD price of a coin from Coinbase Pro + + Args: + api_url: The API URL for Coinbase Pro + config_file: Path to the JSON file containing credentials and config options + currency: The cryptocurrency the bot is monitoring + + Returns: + coin_price: The price the coin currently holds in USD + """ + # Instantiate Coinbase API and query the price + coinbase_creds = get_cbpro_creds_from_file(config_file) + coinbase_auth = CoinbaseProAuth(coinbase_creds[0], coinbase_creds[1], coinbase_creds[2]) + api_query = "products/%s-USD/ticker" % currency + result = requests.get(api_url + api_query, auth=coinbase_auth) + coin_price = float(result.json()['price']) + return coin_price + + +def verify_balance(api_url: str, config_file: str, buy_amount: float) -> bool: + """Check if enough money exists in the account + Args: + api_url: The API URL for Coinbase Pro + config_file: Path to the JSON file containing credentials and config options + buy_amount: The amount of $USD the bot plans to spend + + Returns: + all_clear: A bool that returns true if there is enough money to transact + """ + # Instantiate Coinbase API and query the price + coinbase_creds = get_cbpro_creds_from_file(config_file) + coinbase_auth = CoinbaseProAuth(coinbase_creds[0], coinbase_creds[1], coinbase_creds[2]) + api_query = "accounts" + result = requests.get(api_url + api_query, auth=coinbase_auth).json() + try: + for account in result: + if account['currency'] == "USD": + if float(account['balance']) >= buy_amount: + return True + except Exception as err: + print("ERROR: Unable to current balance!") + print(err) + return False + # Return false by default + return False + + +def buy_currency(api_url: str, config_file: str, currency: str, buy_amount: float) -> bool: + """ + Conduct a trade on Coinbase Pro to trade a currency with USD + + Args: + api_url: The API URL for Coinbase Pro + config_file: Path to the JSON file containing credentials and config options + currency: The cryptocurrency the bot is monitoring + buy_amount: The amount of $USD the bot plans to spend + + Returns: + trade_success: A bool that is true if the trade succeeded + """ + coinbase_creds = get_cbpro_creds_from_file(config_file) + # Instantiate Coinbase API and query the price + coinbase_auth = CoinbaseProAuth(coinbase_creds[0], coinbase_creds[1], coinbase_creds[2]) + buy_query = 'orders' + order_config = json.dumps({'type': 'market', 'funds': buy_amount, + 'side': 'buy', 'product_id': '%s-USD' % currency}) + buy_result = requests.post(api_url + buy_query, data=order_config, auth=coinbase_auth).json() + if 'message' in buy_result: + print("LOG: Buy order failed.") + print("LOG: Reason: %s" % buy_result['message']) + return False + else: + print("LOG: Buy order succeeded.") + print("LOG: Buy Results: %s" % json.dumps(buy_result, indent=2)) + return True diff --git a/TODO.md b/TODO.md index 04a3b2c..b29d1bb 100644 --- a/TODO.md +++ b/TODO.md @@ -2,18 +2,18 @@ Crypto Dip Buying Bot: To Do List ================================= The versions below are future versions that will be released once those features have been completed. -Version 0.3.0 +Version 0.9.0 ------------- 1. Exception catching around DB functions 2. Cleaner Python code (The big O on this is probably shit) -Version 0.5.0 +Version 1.0.0 ------------- 1. Find a more secure way to store credentials than an on disk JSON file. -Version 1.0.0 +Version 1.5.0 ------------- 1. Python code is tested using pytest From 91f05851aea1316966e7b2d8d8a109f3ee82d6b2 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Sun, 18 Apr 2021 16:49:42 -0400 Subject: [PATCH 02/11] Coinbase is now an option --- README.md | 13 +++++++++---- SourceCode/bot_internals.py | 4 ++-- SourceCode/cryptodip_bot.py | 2 +- docker-compose.yml | 11 +++++++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c1dfad2..e3ac883 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,16 @@ To run the bot you will need Docker and docker-compose installed on your compute docker-compose up -d +Choosing An Exchange +-------------------- +If you specify Gemini credentials at all in the `config.json` file then the bot will use Gemini even if Coinbae Pro +credentials are also specified. + Config File ----------- You will need the following: -1. Coinbase Pro credentials tied to the portfolio you want to run the bot against - 1. Alternatively you can specify Gemini be used instead. +1. Coinbase Pro or Gemini credentials tied to the portfolio you want to run the bot against 2. Dip logic parameters: 1. The cryptocurrency you want to transact in. (It must support being paired against USD in Coinbase Pro) 2. The buy amount you want in $USD. @@ -59,8 +63,9 @@ The file should look like this: "passphrase": "YOUR_API_PASSPHRASE" }, "gemini": { - - }, + "api_key": "YOUR_API_KEY", + "api_secret": "YOUR_API_SECRET", + } "aws": { "access_key": "YOUR_API_KEY", "secret_access_key": "YOUR_API_SECRET", diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index c47f19b..a55cce8 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -55,7 +55,7 @@ def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool] aws_loaded = bool('aws' in data) using_gemini = bool('gemini' in data) return crypto_currency, buy_amount, dip_percentage,\ - average_period_days, cool_down_period_days, aws_loaded, using_gemini + average_period_days, cool_down_period_days, aws_loaded, using_gemini def get_aws_creds_from_file(config_file: str) -> [str, str, str]: @@ -138,7 +138,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: # Set API URLs if debug_mode: coinbase_pro_api_url = "https://api-public.sandbox.pro.coinbase.com/" - mongo_db_connection = "mongodb://localhost:27017/" + mongo_db_connection = "mongodb://bots:buythedip@localhost:27017/" else: coinbase_pro_api_url = "https://api.pro.coinbase.com/" mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" diff --git a/SourceCode/cryptodip_bot.py b/SourceCode/cryptodip_bot.py index b74d663..21a8cb2 100644 --- a/SourceCode/cryptodip_bot.py +++ b/SourceCode/cryptodip_bot.py @@ -35,7 +35,7 @@ def main(config_file: str, debug_mode: bool): if config_params[6]: print("Gemini support coming") else: - bot_internals.coinbase_pro_cycle(config_params, debug_mode) + bot_internals.coinbase_pro_cycle(config_file, debug_mode) if __name__ == '__main__': diff --git a/docker-compose.yml b/docker-compose.yml index e9c451d..23ede8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,19 +18,22 @@ services: MONGO_INITDB_ROOT_PASSWORD: buythedip expose: - 27017 + ports: + - "127.0.0.1:27017:27017" restart: always - eth-dips: + gemini-eth: links: - bots-db - container_name: eth-dips + container_name: gemini-eth image: ahrenstein/cryptodip-bot:latest volumes: - ./config:/config + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/gemini-config.json", "-d"] restart: always - btc-dips: + coinbase-btc: links: - bots-db - container_name: btc-dips + container_name: coinbase-btc image: ahrenstein/cryptodip-bot:latest volumes: - ./config:/config From 9da0c50b163efd3e2bc8ec9a089c198307f5e019 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Mon, 19 Apr 2021 23:33:36 -0400 Subject: [PATCH 03/11] Initial support for Gemini 1. Gemini added as an option. 2. Specifying Gemini and Coinbase creds results in using Gemini 3. Gemini not supporting market orders via API is annoying. To work around this I set a limit order 20% above the dip price. Dip price should still be triggered instantly since the order will instantly fill 4. DB name will now contain the exchange so this breaks previous price history unless you rename the DB manually 5. Fixed a bug where price data would not continue gathering if the bot was not funded 6. Super basic exception catching around DB functions --- CHANGELOG.md | 9 +- README.md | 5 +- SourceCode/bot_internals.py | 104 +++++++++++++-- SourceCode/coinbase_pro.py | 2 +- SourceCode/cryptodip_bot.py | 2 +- SourceCode/gemini_exchange.py | 231 +++++++++++++++++++++------------- SourceCode/mongo.py | 90 ++++++++----- TESTING.md | 8 ++ TODO.md | 3 +- docker-compose.yml | 6 +- test-compose.yml | 43 +++++++ 11 files changed, 361 insertions(+), 142 deletions(-) create mode 100644 test-compose.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6061c69..2bbd865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ A list of all the changes made to this repo, and the bot it contains Version 0.3.0 ------------- -1. Shifted TODO versions -2. Added Gemini Support - +1. Shifted TODO versions around to reflect new goals +2. **BREAKING CHANGE** Added Gemini Support + 1. The database name the bot uses is now exchange-currency-"bot", so a new DB will be created when + using the new version of the bot +3. Fixed a bug where price data would not continue gathering if the bot was not funded +4. Super basic exception catching around DB functions Version 0.2.0 ------------- diff --git a/README.md b/README.md index e3ac883..f1c1edf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ This bot is designed to buy cryptocurrency on Coinbase Pro or Gemini using a USD PRE-RELEASE WARNING ------------------- -I tested this code in a sandbox for a few days, before releasing it. I am running this bot against my live Gemini account as well. +I tested this code in a sandbox for a few days, before releasing it. I am running this bot against my live Coinbase Pro, and +Gemini accounts as well. This is still a very new bot with limited testing so **USE THIS BOT AT YOUR OWN RISK!** Dip Detection @@ -22,7 +23,7 @@ To run the bot you will need Docker and docker-compose installed on your compute Choosing An Exchange -------------------- -If you specify Gemini credentials at all in the `config.json` file then the bot will use Gemini even if Coinbae Pro +If you specify Gemini credentials at all in the `config.json` file then the bot will use Gemini even if Coinbase Pro credentials are also specified. Config File diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index a55cce8..347a0a0 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -19,10 +19,11 @@ import time import boto3 import coinbase_pro +import gemini_exchange import mongo # Constants that might be useful to adjust for debugging purposes -CYCLE_INTERVAL_MINUTES = 5 # TODO change to 60 before merging to main +CYCLE_INTERVAL_MINUTES = 1 # TODO change to 60 before merging to main def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool]: @@ -122,6 +123,89 @@ def dip_percent_value(price: float, percent: float) -> float: return round(dip_price, 2) +def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: + """Perform bot cycles using Gemini as the exchange + + Args: + config_file: Path to the JSON file containing credentials + debug_mode: Are we running in debugging mode? + """ + # Load the configuration file + config_params = read_bot_config(config_file) + if config_params[5]: + aws_config = get_aws_creds_from_file(config_file) + message = "%s-Bot has been started" % config_params[0] + post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) + # Set API URLs + if debug_mode: + gemini_exchange_api_url = "https://api.sandbox.gemini.com" + mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" + else: + gemini_exchange_api_url = "https://api.gemini.com" + mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" + print("LOG: Starting bot...\n LOG: Monitoring %s on Gemini to buy $%s worth" + " when a %s%% dip occurs." % (config_params[0], config_params[1], config_params[2])) + print("LOG: Dips are checked against a %s day price" + " average with a %s day cool down period" % (config_params[3], config_params[4])) + for cycle in count(): + now = datetime.datetime.now().strftime("%m/%d/%Y-%H:%M:%S") + print("LOG: Cycle %s: %s" % (cycle, now)) + coin_current_price = gemini_exchange.get_coin_price( + gemini_exchange_api_url, config_params[0]) + # Add the current price to the price database + mongo.add_price("Gemini", mongo_db_connection, config_params[0], coin_current_price) + # Verify that there is enough money to transact, otherwise don't bother + if not gemini_exchange.verify_balance(gemini_exchange_api_url, + config_file, config_params[1]): + message = "LOG: Not enough account balance" \ + " to buy $%s worth of %s" % (config_params[1], config_params[0]) + subject = "%s-Bot Funding Issue" % config_params[0] + if config_params[5]: + post_to_sns(aws_config[0], aws_config[1], aws_config[2], + subject, message) + print("LOG: %s" % message) + # Sleep for the specified cycle interval then end the cycle + time.sleep(CYCLE_INTERVAL_MINUTES * 60) + continue + # Check if the a week has passed since the last dip buy + clear_to_proceed = mongo.check_last_buy_date("Gemini", mongo_db_connection, + config_params[0], config_params[4]) + if clear_to_proceed is True: + print("LOG: Last buy date outside cool down period. Checking if a dip is occurring.") + average_price = mongo.average_pricing("Gemini", mongo_db_connection, + config_params[0], config_params[3]) + dip_price = dip_percent_value(average_price, config_params[2]) + print("LOG: A %s%% dip at the average price of %s would be %s" + % (config_params[2], average_price, dip_price)) + if coin_current_price <= dip_price: + print("LOG: The current price of %s is <= %s. We are in a dip!" + % (coin_current_price, dip_price)) + did_buy = gemini_exchange.buy_currency(gemini_exchange_api_url, + config_file, + config_params[0], config_params[1]) + message = "Buy success status is %s for %s worth of %s" \ + % (did_buy, config_params[1], config_params[0]) + subject = "%s-Bot Buy Status Alert" % config_params[0] + mongo.set_last_buy_date("Gemini", mongo_db_connection, config_params[0]) + print("LOG: %s" % message) + if config_params[5]: + post_to_sns(aws_config[0], aws_config[1], aws_config[2], + subject, message) + else: + print("LOG: The current price of %s is > %s. We are not in a dip!" + % (coin_current_price, dip_price)) + else: + print("LOG: Last buy date inside cool down period. No buys will be attempted.") + + # Run a price history cleanup daily otherwise sleep the interval + if (cycle * CYCLE_INTERVAL_MINUTES) % 1440 == 0: + print("LOG: Cleaning up price history older than 30 days.") + mongo.cleanup_old_records("Gemini", mongo_db_connection, config_params[0]) + else: + # Sleep for the specified cycle interval + time.sleep(CYCLE_INTERVAL_MINUTES * 60) + + def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: """Perform bot cycles using Coinbase Pro as the exchange @@ -138,7 +222,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: # Set API URLs if debug_mode: coinbase_pro_api_url = "https://api-public.sandbox.pro.coinbase.com/" - mongo_db_connection = "mongodb://bots:buythedip@localhost:27017/" + mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" else: coinbase_pro_api_url = "https://api.pro.coinbase.com/" mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" @@ -149,6 +233,10 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: for cycle in count(): now = datetime.datetime.now().strftime("%m/%d/%Y-%H:%M:%S") print("LOG: Cycle %s: %s" % (cycle, now)) + coin_current_price = coinbase_pro.get_coin_price\ + (coinbase_pro_api_url, config_file, config_params[0]) + # Add the current price to the price database + mongo.add_price("CoinbasePro", mongo_db_connection, config_params[0], coin_current_price) # Verify that there is enough money to transact, otherwise don't bother if not coinbase_pro.verify_balance(coinbase_pro_api_url, config_file, config_params[1]): message = "LOG: Not enough account balance" \ @@ -161,16 +249,12 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: # Sleep for the specified cycle interval then end the cycle time.sleep(CYCLE_INTERVAL_MINUTES * 60) continue - coin_current_price = coinbase_pro.get_coin_price\ - (coinbase_pro_api_url, config_file, config_params[0]) - # Add the current price to the price database - mongo.add_price(mongo_db_connection, config_params[0], coin_current_price) # Check if the a week has passed since the last dip buy - clear_to_proceed = mongo.check_last_buy_date(mongo_db_connection, + clear_to_proceed = mongo.check_last_buy_date("CoinbasePro", mongo_db_connection, config_params[0], config_params[4]) if clear_to_proceed is True: print("LOG: Last buy date outside cool down period. Checking if a dip is occurring.") - average_price = mongo.average_pricing(mongo_db_connection, + average_price = mongo.average_pricing("CoinbasePro", mongo_db_connection, config_params[0], config_params[3]) dip_price = dip_percent_value(average_price, config_params[2]) print("LOG: A %s%% dip at the average price of %s would be %s" @@ -183,7 +267,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: message = "Buy success status is %s for %s worth of %s"\ % (did_buy, config_params[1], config_params[0]) subject = "%s-Bot Buy Status Alert" % config_params[0] - mongo.set_last_buy_date(mongo_db_connection, config_params[0]) + mongo.set_last_buy_date("CoinbasePro", mongo_db_connection, config_params[0]) print("LOG: %s" % message) if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], @@ -197,7 +281,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: # Run a price history cleanup daily otherwise sleep the interval if (cycle * CYCLE_INTERVAL_MINUTES) % 1440 == 0: print("LOG: Cleaning up price history older than 30 days.") - mongo.cleanup_old_records(mongo_db_connection, config_params[0]) + mongo.cleanup_old_records("CoinbasePro", mongo_db_connection, config_params[0]) else: # Sleep for the specified cycle interval time.sleep(CYCLE_INTERVAL_MINUTES * 60) diff --git a/SourceCode/coinbase_pro.py b/SourceCode/coinbase_pro.py index 87a9316..c494ae7 100644 --- a/SourceCode/coinbase_pro.py +++ b/SourceCode/coinbase_pro.py @@ -113,7 +113,7 @@ def verify_balance(api_url: str, config_file: str, buy_amount: float) -> bool: if float(account['balance']) >= buy_amount: return True except Exception as err: - print("ERROR: Unable to current balance!") + print("ERROR: Unable to get current balance!") print(err) return False # Return false by default diff --git a/SourceCode/cryptodip_bot.py b/SourceCode/cryptodip_bot.py index 21a8cb2..268fb69 100644 --- a/SourceCode/cryptodip_bot.py +++ b/SourceCode/cryptodip_bot.py @@ -33,7 +33,7 @@ def main(config_file: str, debug_mode: bool): bot_internals.post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) # Start the correct cycle if config_params[6]: - print("Gemini support coming") + bot_internals.gemini_exchange_cycle(config_file, debug_mode) else: bot_internals.coinbase_pro_cycle(config_file, debug_mode) diff --git a/SourceCode/gemini_exchange.py b/SourceCode/gemini_exchange.py index 002dd1c..22f2535 100644 --- a/SourceCode/gemini_exchange.py +++ b/SourceCode/gemini_exchange.py @@ -14,137 +14,198 @@ # import base64 +import datetime import json import time import hmac import hashlib import requests -from requests.auth import AuthBase -# Create custom authentication for CoinbasePro -# as per https://docs.pro.coinbase.com/?python#creating-a-request -class CoinbaseProAuth(AuthBase): +# Create custom api call for Gemini +# as per https://docs.gemini.com/rest-api/#private-api-invocation +def gemini_api_call(api_url: str, gemini_api_key: str, + gemini_api_secret: str, api_query: str) -> dict: + """Make a post to the Gemini Exchange API + Args: + api_url: The API URL for the Gemini Exchange + gemini_api_key: An API key for Gemini Exhcange + gemini_api_secret: An API secret for Gemini Exhcange + api_query: The query to be posted to the API + + Returns: + api_response: The API response """ - Coinbase Pro provided authentication method with minor fixes - """ - def __init__(self, api_key, secret_key, passphrase): - self.api_key = api_key - self.secret_key = secret_key - self.passphrase = passphrase - - def __call__(self, request): - timestamp = str(time.time()) - try: - message = timestamp + request.method + request.path_url + (request.body or b'').decode() - except: - message = timestamp + request.method + request.path_url + (request.body or b'') - hmac_key = base64.b64decode(self.secret_key) - signature = hmac.new(hmac_key, message.encode(), hashlib.sha256) - signature_b64 = base64.b64encode(signature.digest()).decode() - - request.headers.update({ - 'CB-ACCESS-SIGN': signature_b64, - 'CB-ACCESS-TIMESTAMP': timestamp, - 'CB-ACCESS-KEY': self.api_key, - 'CB-ACCESS-PASSPHRASE': self.passphrase, - 'Content-Type': 'application/json' - }) - return request - - -def get_cbpro_creds_from_file(config_file: str) -> [str, str, str]: - """Open a JSON file and get Coinbase Pro credentials out of it + full_query_url = api_url + api_query + + # Using POSIX timestamps in UTC tp avoid repeating nonce issues. + # This avoids the bad design of the API reference sample code + current_time = datetime.datetime.now(datetime.timezone.utc) + epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) # use POSIX epoch + posix_timestamp_micros = (current_time - epoch) // datetime.timedelta(microseconds=1) + payload_nonce = str(posix_timestamp_micros) + + payload = {"request": api_query, "nonce": payload_nonce} + encoded_payload = json.dumps(payload).encode() + b64 = base64.b64encode(encoded_payload) + signature = hmac.new(gemini_api_secret.encode(), b64, hashlib.sha384).hexdigest() + + request_headers = { + 'Content-Type': "text/plain", + 'Content-Length': "0", + 'X-GEMINI-APIKEY': gemini_api_key, + 'X-GEMINI-PAYLOAD': b64, + 'X-GEMINI-SIGNATURE': signature, + 'Cache-Control': "no-cache" + } + + response = requests.post(full_query_url, headers=request_headers) + return response.json() + + +def get_gemini_creds_from_file(config_file: str) -> [str, str]: + """Open a JSON file and get Gemini credentials out of it Args: - config_file: Path to the JSON file containing credentials and config options + config_file: Path to the JSON file containing credentials and config options Returns: - cbpro_api_key: An API key for Coinbase Pro - cbpro_api_secret: An API secret for Coinbase Pro - cbpro_api_passphrase: An API passphrase for Coinbase Pro + gemini_api_key: An API key for Gemini Exhcange + gemini_api_secret: An API secret for Gemini Exhcange """ with open(config_file) as creds_file: data = json.load(creds_file) - cbpro_api_key = data['coinbase']['api_key'] - cbpro_api_secret = data['coinbase']['api_secret'] - cbpro_api_passphrase = data['coinbase']['passphrase'] - return cbpro_api_key, cbpro_api_secret, cbpro_api_passphrase + gemini_api_key = data['gemini']['api_key'] + gemini_api_secret = data['gemini']['api_secret'] + return gemini_api_key, gemini_api_secret -def get_coin_price(api_url: str, config_file: str, currency: str) -> float: +def get_coin_price(api_url: str, currency: str) -> float: """ - Get the USD price of a coin from Coinbase Pro + Get the USD price of a coin from Gemini Args: - api_url: The API URL for Coinbase Pro - config_file: Path to the JSON file containing credentials and config options - currency: The cryptocurrency the bot is monitoring + api_url: The API URL for Gemini + currency: The cryptocurrency the bot is monitoring Returns: - coin_price: The price the coin currently holds in USD + coin_price: The price the coin currently holds in USD """ - # Instantiate Coinbase API and query the price - coinbase_creds = get_cbpro_creds_from_file(config_file) - coinbase_auth = CoinbaseProAuth(coinbase_creds[0], coinbase_creds[1], coinbase_creds[2]) - api_query = "products/%s-USD/ticker" % currency - result = requests.get(api_url + api_query, auth=coinbase_auth) - coin_price = float(result.json()['price']) + # Instantiate Gemini and query the price + api_query = "/v1/pricefeed" + price_feeds = requests.get(api_url + api_query).json() + for feed in price_feeds: + if feed.get('pair') == currency + "USD": + coin_price = float(feed.get('price')) return coin_price def verify_balance(api_url: str, config_file: str, buy_amount: float) -> bool: """Check if enough money exists in the account Args: - api_url: The API URL for Coinbase Pro - config_file: Path to the JSON file containing credentials and config options - buy_amount: The amount of $USD the bot plans to spend + api_url: The API URL for Gemini + config_file: Path to the JSON file containing credentials and config options + buy_amount: The amount of $USD the bot plans to spend Returns: - all_clear: A bool that returns true if there is enough money to transact + all_clear: A bool that returns true if there is enough money to transact """ - # Instantiate Coinbase API and query the price - coinbase_creds = get_cbpro_creds_from_file(config_file) - coinbase_auth = CoinbaseProAuth(coinbase_creds[0], coinbase_creds[1], coinbase_creds[2]) - api_query = "accounts" - result = requests.get(api_url + api_query, auth=coinbase_auth).json() + # Instantiate Gemini and query the price + gemini_creds = get_gemini_creds_from_file(config_file) + api_query = "/v1/balances" + result = gemini_api_call(api_url,gemini_creds[0], gemini_creds[1], api_query) try: for account in result: - if account['currency'] == "USD": - if float(account['balance']) >= buy_amount: + if account.get('currency') == "USD": + balance = float(account.get('amount')) + if balance >= buy_amount: return True except Exception as err: - print("ERROR: Unable to current balance!") + print("ERROR: Unable to get current balance!") print(err) return False # Return false by default return False -def buy_currency(api_url: str, config_file: str, currency: str, buy_amount: float) -> bool: +def get_decimal_max(api_url: str, currency: str) -> int: + """Get the maximum amount of decimals permitted for a currency + + Args: + api_url: The API URL for the Gemini Exchange + currency: The cryptocurrency the bot is monitoring + + Returns: + tick_size: An integer of decimal places permitted """ - Conduct a trade on Coinbase Pro to trade a currency with USD + # Instantiate Gemini and query the price + api_query = "/v1/symbols/details/%s" % (currency + "usd").lower() + symbol_details = requests.get(api_url + api_query).json() + tick_size = str(symbol_details.get('tick_size'))[3:] + return int(tick_size) + +def buy_currency(api_url: str, config_file: str, + currency: str, buy_amount: float) -> bool: + """Conduct a trade on Gemini to trade a currency with USD Args: - api_url: The API URL for Coinbase Pro - config_file: Path to the JSON file containing credentials and config options - currency: The cryptocurrency the bot is monitoring - buy_amount: The amount of $USD the bot plans to spend + api_url: The API URL for the Gemini Exchange + config_file: Path to the JSON file containing credentials and config options + currency: The cryptocurrency the bot is monitoring + buy_amount: The amount of $USD the bot plans to spend Returns: - trade_success: A bool that is true if the trade succeeded + api_response: The API response """ - coinbase_creds = get_cbpro_creds_from_file(config_file) - # Instantiate Coinbase API and query the price - coinbase_auth = CoinbaseProAuth(coinbase_creds[0], coinbase_creds[1], coinbase_creds[2]) - buy_query = 'orders' - order_config = json.dumps({'type': 'market', 'funds': buy_amount, - 'side': 'buy', 'product_id': '%s-USD' % currency}) - buy_result = requests.post(api_url + buy_query, data=order_config, auth=coinbase_auth).json() - if 'message' in buy_result: - print("LOG: Buy order failed.") - print("LOG: Reason: %s" % buy_result['message']) - return False - else: + # Gemini's API doesn't support market orders in an effort to protect you from yourself + # So we just do a limit order at the current price multipled by 1.2 + coin_current_price = get_coin_price(api_url, currency) + # Gemini also denominates purchases in the coin amount not USD so we have to do math + tick_size = get_decimal_max(api_url, currency) + coin_amount = round(buy_amount / coin_current_price, tick_size) + market_price_fix = round(coin_current_price * 1.2, 2) + + # Instantiate Gemini and query the price + gemini_creds = get_gemini_creds_from_file(config_file) + full_query_url = api_url + "/v1/order/new" + + # Using POSIX timestamps in UTC tp avoid repeating nonce issues. + # This avoids the bad design of the API reference sample code + current_time = datetime.datetime.now(datetime.timezone.utc) + epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) # use POSIX epoch + posix_timestamp_micros = (current_time - epoch) // datetime.timedelta(microseconds=1) + payload_nonce = str(posix_timestamp_micros) + payload = { + "request": "/v1/order/new", + "nonce": payload_nonce, + "symbol": currency + "usd", + "amount": str(coin_amount), + "price": str(market_price_fix), + "side": "buy", + "type": "exchange limit", + "options": ["immediate-or-cancel"] + + } + + encoded_payload = json.dumps(payload).encode() + b64 = base64.b64encode(encoded_payload) + signature = hmac.new(gemini_creds[1].encode(), b64, hashlib.sha384).hexdigest() + + request_headers = { + 'Content-Type': "text/plain", + 'Content-Length': "0", + 'X-GEMINI-APIKEY': gemini_creds[0], + 'X-GEMINI-PAYLOAD': b64, + 'X-GEMINI-SIGNATURE': signature, + 'Cache-Control': "no-cache" + } + + response = requests.post(full_query_url, data=None, headers=request_headers) + order_result = response.json() + if 'executed_amount' in order_result.keys(): print("LOG: Buy order succeeded.") - print("LOG: Buy Results: %s" % json.dumps(buy_result, indent=2)) + print("LOG: Buy Results: %s" % json.dumps(order_result, indent=2)) return True + else: + print("LOG: Buy order failed.") + print("LOG: Reason: %s" % json.dumps(order_result, indent=2)) + return False diff --git a/SourceCode/mongo.py b/SourceCode/mongo.py index 31c56ff..0623f76 100644 --- a/SourceCode/mongo.py +++ b/SourceCode/mongo.py @@ -21,46 +21,52 @@ PURGE_OLDER_THAN_DAYS = 30 -def add_price(db_server: str, currency: str, current_price: float): +def add_price(exchange: str, db_server: str, currency: str, current_price: float): """Add a current price record to the database Args: + exchange: The Exchange the bot uses db_server: The MongoDB server to connect to currency: The cryptocurrency the bot is monitoring current_price: The current price of the currency """ timestamp = datetime.datetime.utcnow() - # Create a Mongo client to connect to - mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-bot" % currency] - prices_collection = bot_db["prices"] - record = {"time": timestamp, "price": current_price} try: + # Create a Mongo client to connect to + mongo_client = pymongo.MongoClient(db_server) + bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + prices_collection = bot_db["prices"] + record = {"time": timestamp, "price": current_price} prices_collection.insert_one(record) except Exception as err: print("Error creating price record: %s" % err) -def read_all_prices(db_server: str, currency: str): +def read_all_prices(exchange: str, db_server: str, currency: str): """Read all current price records in the database Args: + exchange: The Exchange the bot uses db_server: The MongoDB server to connect to currency: The cryptocurrency the bot is monitoring """ - # Create a Mongo client to connect to - mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-bot" % currency] - prices_collection = bot_db["prices"] - records = prices_collection.find() + try: + # Create a Mongo client to connect to + mongo_client = pymongo.MongoClient(db_server) + bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + prices_collection = bot_db["prices"] + records = prices_collection.find() + except Exception as err: + print("Error reading price records: %s" % err) for record in records: print(record) -def average_pricing(db_server: str, currency: str, average_period: int) -> float: +def average_pricing(exchange: str, db_server: str, currency: str, average_period: int) -> float: """Check the last week of prices and return the average Args: + exchange: The Exchange the bot uses db_server: The MongoDB server to connect to currency: The cryptocurrency the bot is monitoring average_period: The time period in days to average across @@ -69,11 +75,14 @@ def average_pricing(db_server: str, currency: str, average_period: int) -> float average_price: The average price of the last week """ price_history = [] - # Create a Mongo client to connect to - mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-bot" % currency] - prices_collection = bot_db["prices"] - records = prices_collection.find({}) + try: + # Create a Mongo client to connect to + mongo_client = pymongo.MongoClient(db_server) + bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + prices_collection = bot_db["prices"] + records = prices_collection.find({}) + except Exception as err: + print("Error reading price records for averaging: %s" % err) for record in records: record_age = datetime.datetime.utcnow() - record['time'] if record_age.days <= average_period: @@ -82,36 +91,44 @@ def average_pricing(db_server: str, currency: str, average_period: int) -> float return average_price -def cleanup_old_records(db_server: str, currency: str): +def cleanup_old_records(exchange: str, db_server: str, currency: str): """Remove all price history older than X days Args: + exchange: The Exchange the bot uses db_server: The MongoDB server to connect to currency: The cryptocurrency the bot is monitoring """ - # Create a Mongo client to connect to - mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-bot" % currency] - prices_collection = bot_db["prices"] - records = prices_collection.find() + try: + # Create a Mongo client to connect to + mongo_client = pymongo.MongoClient(db_server) + bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + prices_collection = bot_db["prices"] + records = prices_collection.find() + except Exception as err: + print("Error cleaning up old price records: %s" % err) for record in records: record_age = datetime.datetime.utcnow() - record['time'] if record_age.days >= PURGE_OLDER_THAN_DAYS: prices_collection.delete_one({"_id": record['_id']}) -def set_last_buy_date(db_server: str, currency: str): +def set_last_buy_date(exchange: str, db_server: str, currency: str): """Sets the date the last time the currency was bought Args: + exchange: The Exchange the bot uses db_server: The MongoDB server to connect to currency: The cryptocurrency the bot is monitoring """ timestamp = datetime.datetime.utcnow() - # Create a Mongo client to connect to - mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-bot" % currency] - buy_date = bot_db["buy-date"] + try: + # Create a Mongo client to connect to + mongo_client = pymongo.MongoClient(db_server) + bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + buy_date = bot_db["buy-date"] + except Exception as err: + print("Error connecting to buy-date collection: %s" % err) try: buy_date.find_one_and_update({"_id": 1}, {"$set": {"time": timestamp}}, upsert=True) @@ -119,11 +136,13 @@ def set_last_buy_date(db_server: str, currency: str): print("Error updating buy date record: %s" % err) -def check_last_buy_date(db_server: str, currency: str, cool_down_period: int) -> bool: +def check_last_buy_date(exchange: str, db_server: str, + currency: str, cool_down_period: int) -> bool: """Get the date of the last time the currency was bought and returns true if it >= cool down period Args: + exchange: The Exchange the bot uses db_server: The MongoDB server to connect to currency: The cryptocurrency the bot is monitoring cool_down_period: The time period in days that you will wait before transacting @@ -131,10 +150,13 @@ def check_last_buy_date(db_server: str, currency: str, cool_down_period: int) -> Returns: clear_to_buy: A bool that is true if we are clear to buy """ - # Create a Mongo client to connect to - mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-bot" % currency] - buy_date = bot_db["buy-date"] + try: + # Create a Mongo client to connect to + mongo_client = pymongo.MongoClient(db_server) + bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + buy_date = bot_db["buy-date"] + except Exception as err: + print("Error connecting to buy-date collection: %s" % err) # Create an initial record if the record doesn't exist yet if buy_date.find({'_id': 1}).count() == 0: print("Initializing new last buy date") diff --git a/TESTING.md b/TESTING.md index 9cfff95..570cb75 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,6 +17,14 @@ In order to use the hooks, make sure you have `pre-commit`, and `pylint` in your Once in your path you should run `pre-commit install` in order to configure it. If you push commits that fail pre-commit, your PR will not be merged. +Local Docker +------------ +You can run Docker using locally built containers via + + docker-compose -f test-compose.yml up -d + +Just make sure you create the proper config files first + poetry ------ This project uses [poetry](https://python-poetry.org/) for Python requirements diff --git a/TODO.md b/TODO.md index b29d1bb..319584d 100644 --- a/TODO.md +++ b/TODO.md @@ -5,8 +5,7 @@ The versions below are future versions that will be released once those features Version 0.9.0 ------------- -1. Exception catching around DB functions -2. Cleaner Python code (The big O on this is probably shit) +1. Cleaner Python code (The big O on this is probably shit) Version 1.0.0 ------------- diff --git a/docker-compose.yml b/docker-compose.yml index 23ede8e..fde6d1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,8 +18,6 @@ services: MONGO_INITDB_ROOT_PASSWORD: buythedip expose: - 27017 - ports: - - "127.0.0.1:27017:27017" restart: always gemini-eth: links: @@ -28,7 +26,7 @@ services: image: ahrenstein/cryptodip-bot:latest volumes: - ./config:/config - command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/gemini-config.json", "-d"] + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/gemini-config.json"] restart: always coinbase-btc: links: @@ -37,5 +35,5 @@ services: image: ahrenstein/cryptodip-bot:latest volumes: - ./config:/config - command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/btc-config.json", "-d"] + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/btc-config.json"] restart: always diff --git a/test-compose.yml b/test-compose.yml new file mode 100644 index 0000000..dbbe546 --- /dev/null +++ b/test-compose.yml @@ -0,0 +1,43 @@ +# +# Copyright 2021, Matthew Ahrenstein, All Rights Reserved. +# +# Maintainers: +# - Matthew Ahrenstein: matt@ahrenstein.com +# +# See LICENSE + +version: '2' +services: + bots-db: + container_name: bots-db + image: mongo:3.6 + volumes: + - ./config/db-data:/data/db + environment: + MONGO_INITDB_ROOT_USERNAME: bots + MONGO_INITDB_ROOT_PASSWORD: buythedip + expose: + - 27017 + ports: + - "127.0.0.1:27017:27017" + restart: always + gemini-eth: + links: + - bots-db + container_name: gemini-eth + image: local/dipbot:test + build: ./ + volumes: + - ./config:/config + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/gemini-config.json", "-d"] + restart: always + coinbase-btc: + links: + - bots-db + container_name: coinbase-btc + image: local/dipbot:test + build: ./ + volumes: + - ./config:/config + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/btc-config.json", "-d"] + restart: always From 555ec2ac41b6fdc3b4e728d28da8bb0a6d218454 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Mon, 19 Apr 2021 23:35:21 -0400 Subject: [PATCH 04/11] Adjusting cycle back to hourly --- SourceCode/bot_internals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index 347a0a0..dada12d 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -23,7 +23,7 @@ import mongo # Constants that might be useful to adjust for debugging purposes -CYCLE_INTERVAL_MINUTES = 1 # TODO change to 60 before merging to main +CYCLE_INTERVAL_MINUTES = 60 def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool]: From fdd6dd026ea4a5c72b859b7bcc0d5709428860f5 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Mon, 19 Apr 2021 23:40:35 -0400 Subject: [PATCH 05/11] Spacing fix --- SourceCode/bot_internals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index dada12d..5443a99 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -143,7 +143,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: else: gemini_exchange_api_url = "https://api.gemini.com" mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" - print("LOG: Starting bot...\n LOG: Monitoring %s on Gemini to buy $%s worth" + print("LOG: Starting bot...\nLOG: Monitoring %s on Gemini to buy $%s worth" " when a %s%% dip occurs." % (config_params[0], config_params[1], config_params[2])) print("LOG: Dips are checked against a %s day price" " average with a %s day cool down period" % (config_params[3], config_params[4])) @@ -226,7 +226,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: else: coinbase_pro_api_url = "https://api.pro.coinbase.com/" mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" - print("LOG: Starting bot...\n LOG: Monitoring %s on Coinbase Pro to buy $%s worth" + print("LOG: Starting bot...\nLOG: Monitoring %s on Coinbase Pro to buy $%s worth" " when a %s%% dip occurs." % (config_params[0], config_params[1], config_params[2])) print("LOG: Dips are checked against a %s day price" " average with a %s day cool down period" % (config_params[3], config_params[4])) From 2d9ffcc31b08625ca5f4cc0b054ca24542493bc7 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Thu, 22 Apr 2021 23:24:28 -0400 Subject: [PATCH 06/11] Doc fixes --- CHANGELOG.md | 1 + README.md | 13 +++++++------ SourceCode/bot_internals.py | 4 ++-- SourceCode/cryptodip_bot.py | 4 ---- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bbd865..885cda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Version 0.3.0 using the new version of the bot 3. Fixed a bug where price data would not continue gathering if the bot was not funded 4. Super basic exception catching around DB functions +5. `test-compose.yml` for local debugging/testing is now separate from the production example `docker-compose-yml` Version 0.2.0 ------------- diff --git a/README.md b/README.md index f1c1edf..e91bbc2 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ Crypto Dip Buying Bot ===================== This bot is designed to buy cryptocurrency on Coinbase Pro or Gemini using a USD prefunded portfolio whenever it detects a significant dip in price. -PRE-RELEASE WARNING -------------------- -I tested this code in a sandbox for a few days, before releasing it. I am running this bot against my live Coinbase Pro, and -Gemini accounts as well. -This is still a very new bot with limited testing so **USE THIS BOT AT YOUR OWN RISK!** +USE AT YOUR OWN RISK +-------------------- +I run this bot full time against my own personal Coinbase Pro and Gemini accounts, however I make no warranties that +the bot will function. It could crash and miss a dip, or it could detect and buy a dip before the floor. So far +it has done well for me but your mileage may vary. +As with any open source code: **USE THIS BOT AT YOUR OWN RISK!** Dip Detection ------------- @@ -66,7 +67,7 @@ The file should look like this: "gemini": { "api_key": "YOUR_API_KEY", "api_secret": "YOUR_API_SECRET", - } + }, "aws": { "access_key": "YOUR_API_KEY", "secret_access_key": "YOUR_API_SECRET", diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index 5443a99..630b42c 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -134,7 +134,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: config_params = read_bot_config(config_file) if config_params[5]: aws_config = get_aws_creds_from_file(config_file) - message = "%s-Bot has been started" % config_params[0] + message = "Gemini-%s-Bot has been started" % config_params[0] post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) # Set API URLs if debug_mode: @@ -217,7 +217,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: config_params = read_bot_config(config_file) if config_params[5]: aws_config = get_aws_creds_from_file(config_file) - message = "%s-Bot has been started" % config_params[0] + message = "CoinbasePro-%s-Bot has been started" % config_params[0] post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) # Set API URLs if debug_mode: diff --git a/SourceCode/cryptodip_bot.py b/SourceCode/cryptodip_bot.py index 268fb69..68e5fcb 100644 --- a/SourceCode/cryptodip_bot.py +++ b/SourceCode/cryptodip_bot.py @@ -27,10 +27,6 @@ def main(config_file: str, debug_mode: bool): """ # Load the configuration file config_params = bot_internals.read_bot_config(config_file) - if config_params[5]: - aws_config = bot_internals.get_aws_creds_from_file(config_file) - message = "%s-Bot has been started" % config_params[0] - bot_internals.post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) # Start the correct cycle if config_params[6]: bot_internals.gemini_exchange_cycle(config_file, debug_mode) From 6fc8fa9f44547626d10b723af935990082d8db33 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Fri, 23 Apr 2021 03:01:52 -0400 Subject: [PATCH 07/11] Quick logic fix for Gemini API being down --- SourceCode/bot_internals.py | 17 +++++++++++++---- SourceCode/gemini_exchange.py | 13 +++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index 630b42c..af65363 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -152,6 +152,15 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: print("LOG: Cycle %s: %s" % (cycle, now)) coin_current_price = gemini_exchange.get_coin_price( gemini_exchange_api_url, config_params[0]) + if coin_current_price == -1: + message = "ERROR: Coin price invalid. This could be an API issue. Ending cycle" + print(message) + subject = "Gemini-%s-Coin price invalid" % config_params[0] + if config_params[5]: + post_to_sns(aws_config[0], aws_config[1], aws_config[2], + subject, message) + time.sleep(CYCLE_INTERVAL_MINUTES * 60) + continue # Add the current price to the price database mongo.add_price("Gemini", mongo_db_connection, config_params[0], coin_current_price) # Verify that there is enough money to transact, otherwise don't bother @@ -159,7 +168,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: config_file, config_params[1]): message = "LOG: Not enough account balance" \ " to buy $%s worth of %s" % (config_params[1], config_params[0]) - subject = "%s-Bot Funding Issue" % config_params[0] + subject = "Gemini-%s-Bot Funding Issue" % config_params[0] if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) @@ -185,7 +194,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: config_params[0], config_params[1]) message = "Buy success status is %s for %s worth of %s" \ % (did_buy, config_params[1], config_params[0]) - subject = "%s-Bot Buy Status Alert" % config_params[0] + subject = "Gemini-%s-Bot Buy Status Alert" % config_params[0] mongo.set_last_buy_date("Gemini", mongo_db_connection, config_params[0]) print("LOG: %s" % message) if config_params[5]: @@ -241,7 +250,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: if not coinbase_pro.verify_balance(coinbase_pro_api_url, config_file, config_params[1]): message = "LOG: Not enough account balance" \ " to buy $%s worth of %s" % (config_params[1], config_params[0]) - subject = "%s-Bot Funding Issue" % config_params[0] + subject = "CoinbasePro-%s-Bot Funding Issue" % config_params[0] if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) @@ -266,7 +275,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: config_file, config_params[0], config_params[1]) message = "Buy success status is %s for %s worth of %s"\ % (did_buy, config_params[1], config_params[0]) - subject = "%s-Bot Buy Status Alert" % config_params[0] + subject = "CoinbasePro-%s-Bot Buy Status Alert" % config_params[0] mongo.set_last_buy_date("CoinbasePro", mongo_db_connection, config_params[0]) print("LOG: %s" % message) if config_params[5]: diff --git a/SourceCode/gemini_exchange.py b/SourceCode/gemini_exchange.py index 22f2535..660bb71 100644 --- a/SourceCode/gemini_exchange.py +++ b/SourceCode/gemini_exchange.py @@ -91,11 +91,16 @@ def get_coin_price(api_url: str, currency: str) -> float: coin_price: The price the coin currently holds in USD """ # Instantiate Gemini and query the price + coin_price = -1 api_query = "/v1/pricefeed" - price_feeds = requests.get(api_url + api_query).json() - for feed in price_feeds: - if feed.get('pair') == currency + "USD": - coin_price = float(feed.get('price')) + price_feeds = requests.get(api_url + api_query +"s").json() + try: + for feed in price_feeds: + if feed.get('pair') == currency + "USD": + coin_price = float(feed.get('price')) + except Exception as err: + print("ERROR: Unable to get price due to %s" % err) + print("Price feed: %s" % price_feeds) return coin_price From 4b6c7714afb216b0d734def8c95ac3949ec90d8d Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Fri, 23 Apr 2021 03:04:13 -0400 Subject: [PATCH 08/11] API string fix --- SourceCode/gemini_exchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SourceCode/gemini_exchange.py b/SourceCode/gemini_exchange.py index 660bb71..6837c30 100644 --- a/SourceCode/gemini_exchange.py +++ b/SourceCode/gemini_exchange.py @@ -93,7 +93,7 @@ def get_coin_price(api_url: str, currency: str) -> float: # Instantiate Gemini and query the price coin_price = -1 api_query = "/v1/pricefeed" - price_feeds = requests.get(api_url + api_query +"s").json() + price_feeds = requests.get(api_url + api_query).json() try: for feed in price_feeds: if feed.get('pair') == currency + "USD": From 71b123d8fac15041d5adb983b6b8d3fdc72f700c Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Mon, 26 Apr 2021 20:15:48 -0400 Subject: [PATCH 09/11] Extra space removed --- SourceCode/bot_internals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index af65363..7190445 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -201,7 +201,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) else: - print("LOG: The current price of %s is > %s. We are not in a dip!" + print("LOG: The current price of %s is > %s. We are not in a dip!" % (coin_current_price, dip_price)) else: print("LOG: Last buy date inside cool down period. No buys will be attempted.") @@ -282,7 +282,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) else: - print("LOG: The current price of %s is > %s. We are not in a dip!" + print("LOG: The current price of %s is > %s. We are not in a dip!" % (coin_current_price, dip_price)) else: print("LOG: Last buy date inside cool down period. No buys will be attempted.") From 1f4659eb4e2b1de48caed71fe4f241c90bd17f4e Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Tue, 27 Apr 2021 20:28:20 -0400 Subject: [PATCH 10/11] Some fixes --- CHANGELOG.md | 12 ++++-- Dockerfile | 2 +- README.md | 10 +++-- SourceCode/bot_internals.py | 81 +++++++++++++++++++++---------------- SourceCode/mongo.py | 44 +++++++++----------- docker-compose.yml | 6 +-- test-compose.yml | 6 +-- 7 files changed, 87 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 885cda9..23b3ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,17 @@ A list of all the changes made to this repo, and the bot it contains Version 0.3.0 ------------- -1. Shifted TODO versions around to reflect new goals +1. Shifted TODO versions around to reflect new goals. 2. **BREAKING CHANGE** Added Gemini Support 1. The database name the bot uses is now exchange-currency-"bot", so a new DB will be created when - using the new version of the bot -3. Fixed a bug where price data would not continue gathering if the bot was not funded -4. Super basic exception catching around DB functions + using the new version of the bot. + 2. Additionally, you have the option to give the bot a custom name so you can run more than one bot against the same + exchange/currency pair. +3. Fixed a bug where price data would not continue gathering if the bot was not funded. +4. Super basic exception catching around DB functions. 5. `test-compose.yml` for local debugging/testing is now separate from the production example `docker-compose-yml` +6. Added the ability to override the hourly cycle to a different cycle specified in minutes. +7. Fixed a mistake in the default starting config Version 0.2.0 ------------- diff --git a/Dockerfile b/Dockerfile index a1372a2..0a858cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,4 @@ RUN poetry install --no-dev ENV PYTHONUNBUFFERED=0 # Run the bot -CMD ["python", "-u", "/app/bankless_bot.py", "-c", "/config/config.json"] +CMD ["python", "-u", "/app/cryptodip_bot.py", "-c", "/config/config.json"] diff --git a/README.md b/README.md index e91bbc2..55a66a0 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ USE AT YOUR OWN RISK -------------------- I run this bot full time against my own personal Coinbase Pro and Gemini accounts, however I make no warranties that the bot will function. It could crash and miss a dip, or it could detect and buy a dip before the floor. So far -it has done well for me but your mileage may vary. +it has done well for me, but your mileage may vary. As with any open source code: **USE THIS BOT AT YOUR OWN RISK!** Dip Detection ------------- -The bot runs in hourly cycles. Each cycle the bot will check the price of the specified cryptocurrency. +The bot checks the price in a configurable cycle. Each cycle the bot will check the price of the specified cryptocurrency. It will then compare the average price of the previous 7 days worth of price history to the configured dip percentage. If the current price is the configured percentage lower than the price average it will buy the cryptocurrency in the specified amount of USD. @@ -42,9 +42,11 @@ The following sections are optional. 1. Time variables in the bot config 1. Period of days to average (Default: 7) 2. Cool down period before buying again (Default: 7) + 3. Check cycle frequency in minutes (Default: 60) 2. AWS credentials: 1. AWS API keys 2. SNS topic ARN (us-east-1 only for now) +3. Optionally you can override the bot name These settings should be in a configuration file named `config.json` and placed in `./config`. Additionally, you can override the volume mount to a new path if you prefer. @@ -57,7 +59,9 @@ The file should look like this: "buy_amount": 75.00, "dip_percentage": 10, "average_period_days": 3, - "cool_down_period_days": 5 + "cool_down_period_days": 5, + "cycle_time_minutes": 15, + "name": "Test-Bot" }, "coinbase": { "api_key": "YOUR_API_KEY", diff --git a/SourceCode/bot_internals.py b/SourceCode/bot_internals.py index 7190445..a6819b0 100644 --- a/SourceCode/bot_internals.py +++ b/SourceCode/bot_internals.py @@ -22,11 +22,8 @@ import gemini_exchange import mongo -# Constants that might be useful to adjust for debugging purposes -CYCLE_INTERVAL_MINUTES = 60 - -def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool]: +def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool, int, str]: """Open a JSON file and get the bot configuration Args: config_file: Path to the JSON file containing credentials and config options @@ -39,12 +36,16 @@ def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool] cool_down_period_days: The time period in days that you will wait before transacting aws_loaded: A bool to determine if AWS configuration options exist using_gemini: A bool to determine if the bot should use Gemini + cycle_time_minutes: The cycle interval in minutes + bot-name: The name of the bot """ with open(config_file) as creds_file: data = json.load(creds_file) crypto_currency = data['bot']['currency'] buy_amount = data['bot']['buy_amount'] dip_percentage = data['bot']['dip_percentage'] + aws_loaded = bool('aws' in data) + using_gemini = bool('gemini' in data) if 'average_period_days' in data['bot']: average_period_days = data['bot']['average_period_days'] else: @@ -53,10 +54,20 @@ def read_bot_config(config_file: str) -> [str, float, int, int, int, bool, bool] cool_down_period_days = data['bot']['cool_down_period_days'] else: cool_down_period_days = 7 - aws_loaded = bool('aws' in data) - using_gemini = bool('gemini' in data) + if 'cycle_time_minutes' in data['bot']: + cycle_time_minutes = data['bot']['cycle_time_minutes'] + else: + cycle_time_minutes = 60 + if 'name' in data['bot']: + bot_name = data['bot']['name'] + else: + if gemini_exchange: + bot_name = "Gemini-" + crypto_currency + "-bot" + else: + bot_name = "CoinbasePro-" + crypto_currency + "-bot" return crypto_currency, buy_amount, dip_percentage,\ - average_period_days, cool_down_period_days, aws_loaded, using_gemini + average_period_days, cool_down_period_days,\ + aws_loaded, using_gemini, cycle_time_minutes, bot_name def get_aws_creds_from_file(config_file: str) -> [str, str, str]: @@ -134,7 +145,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: config_params = read_bot_config(config_file) if config_params[5]: aws_config = get_aws_creds_from_file(config_file) - message = "Gemini-%s-Bot has been started" % config_params[0] + message = "%s has been started" % config_params[8] post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) # Set API URLs if debug_mode: @@ -144,7 +155,7 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: gemini_exchange_api_url = "https://api.gemini.com" mongo_db_connection = "mongodb://bots:buythedip@bots-db:27017/" print("LOG: Starting bot...\nLOG: Monitoring %s on Gemini to buy $%s worth" - " when a %s%% dip occurs." % (config_params[0], config_params[1], config_params[2])) + " when a %s%% dip occurs." % (config_params[0], config_params[1], config_params[2])) print("LOG: Dips are checked against a %s day price" " average with a %s day cool down period" % (config_params[3], config_params[4])) for cycle in count(): @@ -159,30 +170,30 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) - time.sleep(CYCLE_INTERVAL_MINUTES * 60) + time.sleep(config_params[7] * 60) continue # Add the current price to the price database - mongo.add_price("Gemini", mongo_db_connection, config_params[0], coin_current_price) + mongo.add_price(config_params[8], mongo_db_connection, coin_current_price) # Verify that there is enough money to transact, otherwise don't bother if not gemini_exchange.verify_balance(gemini_exchange_api_url, config_file, config_params[1]): message = "LOG: Not enough account balance" \ " to buy $%s worth of %s" % (config_params[1], config_params[0]) - subject = "Gemini-%s-Bot Funding Issue" % config_params[0] + subject = "%s Funding Issue" % config_params[8] if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) print("LOG: %s" % message) # Sleep for the specified cycle interval then end the cycle - time.sleep(CYCLE_INTERVAL_MINUTES * 60) + time.sleep(config_params[7] * 60) continue # Check if the a week has passed since the last dip buy - clear_to_proceed = mongo.check_last_buy_date("Gemini", mongo_db_connection, - config_params[0], config_params[4]) + clear_to_proceed = mongo.check_last_buy_date(config_params[8], mongo_db_connection, + config_params[4]) if clear_to_proceed is True: print("LOG: Last buy date outside cool down period. Checking if a dip is occurring.") - average_price = mongo.average_pricing("Gemini", mongo_db_connection, - config_params[0], config_params[3]) + average_price = mongo.average_pricing(config_params[8], mongo_db_connection, + config_params[3]) dip_price = dip_percent_value(average_price, config_params[2]) print("LOG: A %s%% dip at the average price of %s would be %s" % (config_params[2], average_price, dip_price)) @@ -194,8 +205,8 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: config_params[0], config_params[1]) message = "Buy success status is %s for %s worth of %s" \ % (did_buy, config_params[1], config_params[0]) - subject = "Gemini-%s-Bot Buy Status Alert" % config_params[0] - mongo.set_last_buy_date("Gemini", mongo_db_connection, config_params[0]) + subject = "%s Buy Status Alert" % config_params[8] + mongo.set_last_buy_date(config_params[8], mongo_db_connection) print("LOG: %s" % message) if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], @@ -207,12 +218,12 @@ def gemini_exchange_cycle(config_file: str, debug_mode: bool) -> None: print("LOG: Last buy date inside cool down period. No buys will be attempted.") # Run a price history cleanup daily otherwise sleep the interval - if (cycle * CYCLE_INTERVAL_MINUTES) % 1440 == 0: + if (cycle * config_params[7]) % 1440 == 0: print("LOG: Cleaning up price history older than 30 days.") - mongo.cleanup_old_records("Gemini", mongo_db_connection, config_params[0]) + mongo.cleanup_old_records(config_params[8], mongo_db_connection) else: # Sleep for the specified cycle interval - time.sleep(CYCLE_INTERVAL_MINUTES * 60) + time.sleep(config_params[7] * 60) def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: @@ -226,7 +237,7 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: config_params = read_bot_config(config_file) if config_params[5]: aws_config = get_aws_creds_from_file(config_file) - message = "CoinbasePro-%s-Bot has been started" % config_params[0] + message = "%s has been started" % config_params[8] post_to_sns(aws_config[0], aws_config[1], aws_config[2], message, message) # Set API URLs if debug_mode: @@ -245,26 +256,26 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: coin_current_price = coinbase_pro.get_coin_price\ (coinbase_pro_api_url, config_file, config_params[0]) # Add the current price to the price database - mongo.add_price("CoinbasePro", mongo_db_connection, config_params[0], coin_current_price) + mongo.add_price(config_params[8], mongo_db_connection, coin_current_price) # Verify that there is enough money to transact, otherwise don't bother if not coinbase_pro.verify_balance(coinbase_pro_api_url, config_file, config_params[1]): message = "LOG: Not enough account balance" \ " to buy $%s worth of %s" % (config_params[1], config_params[0]) - subject = "CoinbasePro-%s-Bot Funding Issue" % config_params[0] + subject = "%s Funding Issue" % config_params[8] if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], subject, message) print("LOG: %s" % message) # Sleep for the specified cycle interval then end the cycle - time.sleep(CYCLE_INTERVAL_MINUTES * 60) + time.sleep(config_params[7] * 60) continue # Check if the a week has passed since the last dip buy - clear_to_proceed = mongo.check_last_buy_date("CoinbasePro", mongo_db_connection, - config_params[0], config_params[4]) + clear_to_proceed = mongo.check_last_buy_date(config_params[8], mongo_db_connection, + config_params[4]) if clear_to_proceed is True: print("LOG: Last buy date outside cool down period. Checking if a dip is occurring.") - average_price = mongo.average_pricing("CoinbasePro", mongo_db_connection, - config_params[0], config_params[3]) + average_price = mongo.average_pricing(config_params[8], mongo_db_connection, + config_params[3]) dip_price = dip_percent_value(average_price, config_params[2]) print("LOG: A %s%% dip at the average price of %s would be %s" %(config_params[2], average_price, dip_price)) @@ -275,8 +286,8 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: config_file, config_params[0], config_params[1]) message = "Buy success status is %s for %s worth of %s"\ % (did_buy, config_params[1], config_params[0]) - subject = "CoinbasePro-%s-Bot Buy Status Alert" % config_params[0] - mongo.set_last_buy_date("CoinbasePro", mongo_db_connection, config_params[0]) + subject = "%s Buy Status Alert" % config_params[8] + mongo.set_last_buy_date(config_params[8], mongo_db_connection) print("LOG: %s" % message) if config_params[5]: post_to_sns(aws_config[0], aws_config[1], aws_config[2], @@ -288,9 +299,9 @@ def coinbase_pro_cycle(config_file: str, debug_mode: bool) -> None: print("LOG: Last buy date inside cool down period. No buys will be attempted.") # Run a price history cleanup daily otherwise sleep the interval - if (cycle * CYCLE_INTERVAL_MINUTES) % 1440 == 0: + if (cycle * config_params[7]) % 1440 == 0: print("LOG: Cleaning up price history older than 30 days.") - mongo.cleanup_old_records("CoinbasePro", mongo_db_connection, config_params[0]) + mongo.cleanup_old_records(config_params[8], mongo_db_connection) else: # Sleep for the specified cycle interval - time.sleep(CYCLE_INTERVAL_MINUTES * 60) + time.sleep(config_params[7] * 60) diff --git a/SourceCode/mongo.py b/SourceCode/mongo.py index 0623f76..b7103b8 100644 --- a/SourceCode/mongo.py +++ b/SourceCode/mongo.py @@ -21,20 +21,19 @@ PURGE_OLDER_THAN_DAYS = 30 -def add_price(exchange: str, db_server: str, currency: str, current_price: float): +def add_price(bot_name: str, db_server: str, current_price: float): """Add a current price record to the database Args: - exchange: The Exchange the bot uses + bot_name: The name of the bot db_server: The MongoDB server to connect to - currency: The cryptocurrency the bot is monitoring current_price: The current price of the currency """ timestamp = datetime.datetime.utcnow() try: # Create a Mongo client to connect to mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + bot_db = mongo_client[bot_name] prices_collection = bot_db["prices"] record = {"time": timestamp, "price": current_price} prices_collection.insert_one(record) @@ -42,18 +41,17 @@ def add_price(exchange: str, db_server: str, currency: str, current_price: float print("Error creating price record: %s" % err) -def read_all_prices(exchange: str, db_server: str, currency: str): +def read_all_prices(bot_name: str, db_server: str): """Read all current price records in the database Args: - exchange: The Exchange the bot uses + bot_name: The name of the bot db_server: The MongoDB server to connect to - currency: The cryptocurrency the bot is monitoring """ try: # Create a Mongo client to connect to mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + bot_db = mongo_client[bot_name] prices_collection = bot_db["prices"] records = prices_collection.find() except Exception as err: @@ -62,13 +60,12 @@ def read_all_prices(exchange: str, db_server: str, currency: str): print(record) -def average_pricing(exchange: str, db_server: str, currency: str, average_period: int) -> float: +def average_pricing(bot_name: str, db_server: str, average_period: int) -> float: """Check the last week of prices and return the average Args: - exchange: The Exchange the bot uses + bot_name: The name of the bot db_server: The MongoDB server to connect to - currency: The cryptocurrency the bot is monitoring average_period: The time period in days to average across Returns: @@ -78,7 +75,7 @@ def average_pricing(exchange: str, db_server: str, currency: str, average_period try: # Create a Mongo client to connect to mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + bot_db = mongo_client[bot_name] prices_collection = bot_db["prices"] records = prices_collection.find({}) except Exception as err: @@ -91,18 +88,17 @@ def average_pricing(exchange: str, db_server: str, currency: str, average_period return average_price -def cleanup_old_records(exchange: str, db_server: str, currency: str): +def cleanup_old_records(bot_name: str, db_server: str): """Remove all price history older than X days Args: - exchange: The Exchange the bot uses + bot_name: The name of the bot db_server: The MongoDB server to connect to - currency: The cryptocurrency the bot is monitoring """ try: # Create a Mongo client to connect to mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + bot_db = mongo_client[bot_name] prices_collection = bot_db["prices"] records = prices_collection.find() except Exception as err: @@ -113,19 +109,18 @@ def cleanup_old_records(exchange: str, db_server: str, currency: str): prices_collection.delete_one({"_id": record['_id']}) -def set_last_buy_date(exchange: str, db_server: str, currency: str): +def set_last_buy_date(bot_name: str, db_server: str): """Sets the date the last time the currency was bought Args: - exchange: The Exchange the bot uses + bot_name: The name of the bot db_server: The MongoDB server to connect to - currency: The cryptocurrency the bot is monitoring """ timestamp = datetime.datetime.utcnow() try: # Create a Mongo client to connect to mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + bot_db = mongo_client[bot_name] buy_date = bot_db["buy-date"] except Exception as err: print("Error connecting to buy-date collection: %s" % err) @@ -136,15 +131,14 @@ def set_last_buy_date(exchange: str, db_server: str, currency: str): print("Error updating buy date record: %s" % err) -def check_last_buy_date(exchange: str, db_server: str, - currency: str, cool_down_period: int) -> bool: +def check_last_buy_date(bot_name: str, db_server: str, + cool_down_period: int) -> bool: """Get the date of the last time the currency was bought and returns true if it >= cool down period Args: - exchange: The Exchange the bot uses + bot_name: The name of the bot db_server: The MongoDB server to connect to - currency: The cryptocurrency the bot is monitoring cool_down_period: The time period in days that you will wait before transacting Returns: @@ -153,7 +147,7 @@ def check_last_buy_date(exchange: str, db_server: str, try: # Create a Mongo client to connect to mongo_client = pymongo.MongoClient(db_server) - bot_db = mongo_client["%s-%s-bot" % (exchange, currency)] + bot_db = mongo_client[bot_name] buy_date = bot_db["buy-date"] except Exception as err: print("Error connecting to buy-date collection: %s" % err) diff --git a/docker-compose.yml b/docker-compose.yml index fde6d1f..658f269 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,12 +28,12 @@ services: - ./config:/config command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/gemini-config.json"] restart: always - coinbase-btc: + coinbase-eth: links: - bots-db - container_name: coinbase-btc + container_name: coinbase-eth image: ahrenstein/cryptodip-bot:latest volumes: - ./config:/config - command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/btc-config.json"] + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/coinbase-config.json"] restart: always diff --git a/test-compose.yml b/test-compose.yml index dbbe546..83515aa 100644 --- a/test-compose.yml +++ b/test-compose.yml @@ -31,13 +31,13 @@ services: - ./config:/config command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/gemini-config.json", "-d"] restart: always - coinbase-btc: + coinbase-eth: links: - bots-db - container_name: coinbase-btc + container_name: coinbase-eth image: local/dipbot:test build: ./ volumes: - ./config:/config - command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/btc-config.json", "-d"] + command: [ "python", "-u", "/app/cryptodip_bot.py", "-c", "/config/coinbase-config.json", "-d"] restart: always From 6e63814fef22f8fb6f31a95834f1828975779091 Mon Sep 17 00:00:00 2001 From: Matthew Ahrenstein Date: Tue, 4 May 2021 19:07:20 -0400 Subject: [PATCH 11/11] Formatting fix --- SourceCode/gemini_exchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SourceCode/gemini_exchange.py b/SourceCode/gemini_exchange.py index 6837c30..3841bd7 100644 --- a/SourceCode/gemini_exchange.py +++ b/SourceCode/gemini_exchange.py @@ -117,7 +117,7 @@ def verify_balance(api_url: str, config_file: str, buy_amount: float) -> bool: # Instantiate Gemini and query the price gemini_creds = get_gemini_creds_from_file(config_file) api_query = "/v1/balances" - result = gemini_api_call(api_url,gemini_creds[0], gemini_creds[1], api_query) + result = gemini_api_call(api_url, gemini_creds[0], gemini_creds[1], api_query) try: for account in result: if account.get('currency') == "USD":