├── .gitattributes ├── .gitignore ├── README.md ├── app.py ├── bot.py ├── data ├── Binance_ADAUSDT_1h.csv ├── Binance_BCHUSDT_1h.csv ├── Binance_BNBUSDT_1h.csv ├── Binance_BTCUSDT_1h.csv ├── Binance_BTTUSDT_1h.csv ├── Binance_DASHUSDT_1h.csv ├── Binance_DOGEUSDT_1h.csv ├── Binance_ETHUSDT_1h.csv ├── Binance_LTCUSDT_1h.csv ├── Binance_NANOUSDT_1h.csv ├── Binance_XLMUSDT_1h.csv └── Binance_XMRUSDT_1h.csv ├── models └── model │ └── LSTM-2021-08-23.tflite ├── requirements_rpi.txt ├── requirements_win.txt ├── screenshot.png ├── setup.py ├── src ├── config.py ├── data_backup.py ├── data_update.py ├── orders.py ├── predict.py ├── process_logfile.py └── utils.py ├── static ├── Chart.bundle.min.js ├── bootstrap.min.css ├── favicon.ico ├── main.css └── script.js ├── templates └── index.html ├── tests.py ├── tools ├── data_add_new_pair.py └── simulate.py ├── training-google-colab ├── convert_model.py ├── model3.ipynb └── model3_kfold.ipynb ├── whl ├── tflite_runtime-2.3.1-cp37-cp37m-linux_armv6l.whl └── tflite_runtime-2.5.0-cp37-cp37m-linux_armv7l.whl └── wsgi.py /.gitattributes: -------------------------------------------------------------------------------- 1 | training-google-colab/* linguist-documentation 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rar 2 | *.zip 3 | 4 | old/ 5 | __pycache__/ 6 | src/__pycache__/ 7 | tools/__pycache__/ 8 | training-google-colab/__pycache__/ 9 | venv/ 10 | .idea/ 11 | 12 | secrets.ini 13 | config.ini 14 | logfile.log 15 | logfile.csv 16 | 17 | todos.txt 18 | t.py 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # crypto-bot 3 | 4 | ![Screenshot](screenshot.png) 5 | 6 | ## Contents 7 | 1. About 8 | 2. crypto-bot (bot.py) 9 | - Installation 10 | - Setup 11 | - Running 12 | - [OPTIONAL] GitHub backups 13 | 3. info-site (app.py) 14 | 4. Usage 15 | 5. Training 16 | 6. Tools and tests 17 | 7. Issues 18 | 19 | ## About 20 | This archive contains the source code of "crypto-bot", which is a cryptocurrency trading bot (`bot.py`) together with info-site (`app.py`) created with Flask. 21 | 22 | It is designed to work in 1h intervals (data gathering, making predictions, placing orders) on Binance, the biggest cryptocurrency exchange. It is configured to predict BTC, basing on hourly data of 12 selected cryptocurrencies. Its features: 23 | 24 | - data gathering and trading using binance.com API 25 | - neural network (LSTM) predictor trained on Google Colab 26 | - automatic data backups using Git 27 | - info-site with logs and stats using Flask 28 | - implemented on Raspberry Pi Zero W (armv6l) 29 | 30 | 31 | 32 | ## crypto-bot (`bot.py`) 33 | 34 | ### Installation 35 | 1. Install **Raspberry PI OS Lite** (tested on 2020-02-14 release) and setup internet access. Make sure to have **Python 3.7** and **git** installed. The default working directory is assumed to be ```/home/pi/```. 36 | 2. Clone this repository 37 | ```sh 38 | git clone https://github.com/kamsec/crypto-bot.git 39 | ``` 40 | 3. Install requirements 41 | ```sh 42 | cd crypto-bot 43 | ``` 44 | Linux: 45 | ``` 46 | pip3 install -r requirements_rpi.txt 47 | ``` 48 | Windows: 49 | ``` 50 | pip install -r requirements_win.txt 51 | ``` 52 | ### Setup 53 | 4. Run `setup.py` with command: 54 | ```sh 55 | python3 setup.py 56 | ``` 57 | It will create the files `config.ini`, `secrets.ini`, `logfile.log`. 58 | 5. In order to check the balances and operate Binance account, bot needs to have access to API keys. In `secrets.ini` you will find the following content: 59 | ``` 60 | [DEFAULT] 61 | testnet_api_key = *** 62 | testnet_api_secret = *** 63 | mainnet_api_key = *** 64 | mainnet_api_secret = *** 65 | ``` 66 | In place of `***` placeholders provide your binance.com API keys (testnet or/and mainnet) without any quotation marks. Visit https://www.binance.com/en/support/faq/360002502072 for more information. 67 | [OPTIONAL] To enable orders, in `config.ini` you can set: 68 | ``` 69 | orders_allowed = True 70 | ``` 71 | In `config.ini` you can also change working binance.com enviroment between `testnet` and `mainnet` according to provided keys. 72 | ``` 73 | enviroment = testnet # can be changed to mainnet 74 | ``` 75 | ### Running 76 | 6. Run the bot with: 77 | ```sh 78 | python3 bot.py 79 | ``` 80 | If everything works correctly it will produce the output in logfile.log of the following format: 81 | ```sh 82 | 2021-08-23 09:32:45 [INFO] Bot started 83 | 2021-08-23 09:32:47 [INFO] [BALANCE] 0.29 USD, 0.0010283 BTC 84 | 2021-08-23 09:32:47 [INFO] [PREDICTION] DOWN: 42%, UP: 58% 85 | ``` 86 | If an hour haven't passed from last record in `data/` folder, the message 87 | ```sh 88 | "[WARNING] [UPDATE] No new records available yet. Try again in: 0:47:50.464589" 89 | ``` 90 | will appear and `[PREDICTION]` will not be made this hour, but this is correct. Bot will also create `logfile.csv` which is used only by Flask info-site (`app.py`). 91 | 7. In order to make the bot working at the beginning of every hour (1st minute), add the cron job. Enter the command: 92 | ``` 93 | crontab -e 94 | ``` 95 | and at the end of the opened file add the following line: 96 | ```sh 97 | 1 * * * * cd /home/pi/crypto-bot && python3 /home/pi/crypto-bot/bot.py 98 | ``` 99 | 100 | ### [OPTIONAL] GitHub backups 101 | 8. In order to create GitHub backups bot requires a remote repository and SSH key for authentication. Having remote repository defined, create new ssh key: 102 | ```sh 103 | ssh-keygen -t rsa -b 4096 -C "your_email@example.com" 104 | ``` 105 | When the prompts appear, press enter three times so the file will be saved in default location `/home/pi/.ssh/` under default name `id_rsa` with no passphrase. 106 | Open the ssh agent with command 107 | ```shell 108 | eval "$(ssh-agent -s)" 109 | ``` 110 | and add created key 111 | ```shell 112 | ssh-add ~/.ssh/id_rsa 113 | ``` 114 | Now add the SSH key to your account on GitHub, and create repository for this bot. For more information, see https://docs.github.com/en/github/authenticating-to-github/adding-a-new-ssh-key-to-your-github-account. 115 | In `config.ini` set: 116 | ``` 117 | backups_allowed = True 118 | ``` 119 | Instructions from this section will make `bot.py` script running once in the first minute of every hour. 120 | 121 | ## info-site (`app.py`) 122 | After running crypto-bot for the first time, you can view the results on Flask info-site, by running: 123 | ```sh 124 | python3 app.py 125 | ``` 126 | and visiting the `http://IP:5000/` in your browser (port is always 5000, but you need to check the IP assigned to your Raspberry Pi), for example http://192.168.0.107:5000/. 127 | If you want to host the Flask info site on Raspbery Pi as service, you can use Gunicorn with nginx. To do this, take the folliwing steps: 128 | 1. Install nginx. 129 | ```sh 130 | sudo apt-get install nginx 131 | ``` 132 | 2. Create `crypto-bot` file in `/etc/nginx/sites-available/` with the following comand: 133 | ```sh 134 | sudo nano /etc/nginx/sites-available/crypto-bot 135 | ``` 136 | and write the following content (assumed localhost and port 5000): 137 | ```sh 138 | server { 139 | listen 80; 140 | server_name 123.123.123.123; 141 | 142 | location / { 143 | proxy_pass http://127.0.0.1:5000; 144 | proxy_set_header Host $host; 145 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 146 | proxy_set_header X-Scheme $scheme; 147 | } 148 | } 149 | ``` 150 | 3. Create `crypto-bot.service` file in `/etc/systemd/system/` with the following comand: 151 | ```sh 152 | sudo nano /etc/systemd/system/crypto-bot.service 153 | ``` 154 | and write the following content (assumed localhost and port 5000): 155 | ```sh 156 | [Unit] 157 | Description=Gunicorn instance to serve crypto-bot 158 | After=network.target 159 | 160 | [Service] 161 | User=pi 162 | Group=pi 163 | WorkingDirectory=/home/pi/crypto-bot 164 | # Environment="PATH=/home/pi/crypto-bot/myprojectenv/bin" # if using virtual env 165 | ExecStart=/home/pi/.local/bin/gunicorn --bind 0.0.0.0:5000 --workers 3 wsgi:app 166 | Restart=always 167 | 168 | [Install] 169 | WantedBy=multi-user.target 170 | ``` 171 | Add and start the service with commands: 172 | ``` 173 | sudo systemctl daemon-reload 174 | sudo systemctl enable crypto-bot.service 175 | sudo systemctl start crypto-bot.service 176 | ``` 177 | You can now visit information site on your RaspberryPi IP on port 5000, e.g. http://192.168.0.107:5000/, and this site will be available whenever Raspberry Pi is running and has internet connection. 178 | To access the site outside the local network, you should perform port forwarding in your router settings. 179 | 180 | ## Usage 181 | - To control the configuration of the bot, you can edit constants in`config.ini`, for example: 182 | ```sh 183 | status_active = True # set to False to make the bot inactive 184 | orders_allowed = False # set to True to allow placing orders 185 | backups_allowed = False # set to True to allow GitHub backups 186 | enviroment = testnet # set to mainnet to change binance.com enviroment 187 | ``` 188 | 189 | - To stop the bot completely, type: 190 | ```sh 191 | crontab -e 192 | ``` 193 | and remove or comment the line that defines cron job: 194 | ```sh 195 | 1 * * * * cd /home/pi/crypto-bot && python3 /home/pi/crypto-bot/bot.py 196 | ``` 197 | - To restart the bot (erase log history and reset balances), delete `logfile.log`, `config.ini`, `logfile.csv` (if exists) and run : 198 | ```sh 199 | python3 setup.py 200 | python3 bot.py 201 | ``` 202 | - To control Flask info-site, you can use commands: 203 | ```sh 204 | sudo systemctl stop crypto-bot.service # to stop the app 205 | sudo systemctl restart crypto-bot.service # to restart the app 206 | ``` 207 | - To change the prediction model place new model in `models/model/`, and in `config.cfg` set: 208 | ```sh 209 | model_path = models/model/*** 210 | ``` 211 | and in `setup.py` set the variable 212 | ```sh 213 | MODEL_NAME = '***' 214 | ``` 215 | with `***` as new model name. 216 | 217 | ## Training 218 | Training neural network models is performed manually on Google Colab, and files used for it are located in `training-google-colab` directory. 219 | 220 | ## Tools and tests 221 | Tools for adding new data pairs and running bot simulation (placing orders during using past data) are located in `tools/` directory. 222 | In main directory, file `tests.py` contains some manual tests and allows to run&test individial functions from the project. 223 | 224 | ## Issues 225 | - Recent version of Firefox sometimes display parts of info-page incorrectly - just use Chrome in that case. 226 | - If you encounter Binance error "Timestamp for this request is 1000ms ahead of the server's" you should sync your system time with Binance. More information: https://github.com/ccxt/ccxt/issues/773 227 | 228 | 229 | 230 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import csv 4 | from json import dumps 5 | from flask import Flask, render_template 6 | from datetime import datetime, timedelta 7 | 8 | import src.config # also reloaded with every page refresh 9 | from src.data_backup import get_last_commit_time 10 | from src.utils import shift_hour_exact, UTC_OFFSET 11 | 12 | # disabling Flask werkzeug logger because in config we use basicConfig for loggers, which is propagated to werkzeug, 13 | # and we don't want app.run() logs in logfile.log 14 | flask_logger = logging.getLogger("werkzeug") 15 | flask_logger.disabled = True 16 | 17 | app = Flask(__name__) 18 | 19 | @app.route("/") 20 | def generate_page(): 21 | # all hours are given in UTC+0. Times are adjusted in process_logfile.py -> logfile.csv 22 | # logfile.log timestamps also are in UTC+0 23 | 24 | # config needs to be reloaded on every page refresh, so importing constants here 25 | importlib.reload(src.config) 26 | from src.config import CONFIG_NAME, cfg, STATUS_ACTIVE, ORDERS_ALLOWED, ENVIROMENT, LOG_FILE_ABS, MODEL_PATH, \ 27 | STARTING_TIMESTAMP, STARTING_BALANCE_USD, STARTING_BALANCE_BTC, CURRENT_BALANCE_USD, CURRENT_BALANCE_BTC, \ 28 | BACKUPS_ALLOWED 29 | 30 | # for Log textarea 31 | with open(LOG_FILE_ABS, 'rt') as f: 32 | logs_data = f.read() 33 | if logs_data[-1] == '\n': 34 | logs_data = logs_data[:-1] # stripping ending blank line, if it's there 35 | 36 | # time from line '2021-08-12 16:40:49 [INFO] Bot started.' 37 | exact_starting_timestamp = logs_data[:19] # '2021-08-12 16:40:49' 38 | 39 | def interpret_string_from_csv(x): 40 | if x == 'True': 41 | return True 42 | elif x == 'False': 43 | return False 44 | elif x == '': 45 | return None 46 | else: 47 | return x 48 | 49 | columns = {'date': [], 'BTCUSDT_close': [], 'future': [], 'target': [], 50 | 'predictions_shifted': [], 'accurate_prediction': [], 'orders_shifted': [], 51 | 'balance_USD': [], 'balance_BTC': []} 52 | with open('logfile.csv', 'r') as file: 53 | reader = csv.reader(file, delimiter=',') 54 | next(reader) # skipping header 55 | for row in reader: 56 | for item, key in zip(row, columns): 57 | columns[key].append(interpret_string_from_csv(item)) 58 | 59 | # accuracy from start 60 | accuracy_from_start = 50 # default value if no predictions were made 61 | num_of_true_preds = columns['accurate_prediction'].count(True) 62 | num_of_false_preds = columns['accurate_prediction'].count(False) # None predictions are not counted 63 | if num_of_true_preds + num_of_false_preds > 0: # prevents zero division 64 | accuracy_from_start = f'{(num_of_true_preds / (num_of_true_preds + num_of_false_preds) * 100):.1f}' 65 | 66 | # slicing last 72 hours for chart 67 | columns_72h = {} 68 | for key in columns: 69 | columns_72h[key] = columns[key][-72:] 70 | 71 | # accuracy from last 72h 72 | accuracy_72h = 50 # default value if no predictions were made 73 | num_of_true_preds_72h = columns_72h['accurate_prediction'].count(True) 74 | num_of_false_preds_72h = columns_72h['accurate_prediction'].count(False) # None predictions are not counted 75 | if num_of_true_preds_72h + num_of_false_preds_72h > 0: # prevents zero division 76 | accuracy_72h = f'{(num_of_true_preds_72h / (num_of_true_preds_72h + num_of_false_preds_72h) * 100):.1f}' 77 | 78 | # values directly used to plot graph 79 | data_values_72h = columns_72h['BTCUSDT_close'] 80 | data_index_72h = [x[:-6] for x in columns_72h['date']] # [:-6] makes time in format "%Y-%m-%d %H" 81 | predictions_72h = dumps(columns_72h['predictions_shifted']) # also converts python None to js null 82 | orders_72h = dumps(columns_72h['orders_shifted']) # also converts python None to js null 83 | 84 | # lifetime profit string 85 | profit_from_start_text = '+0 USD (0.00%)' # default text 86 | # [-1] because we want to see what is our starting balance worth currently 87 | starting_total_value_USD = STARTING_BALANCE_USD + STARTING_BALANCE_BTC * float(columns['BTCUSDT_close'][-1]) 88 | if columns['BTCUSDT_close'][-1] is not None: 89 | current_total_value_USD = CURRENT_BALANCE_USD + CURRENT_BALANCE_BTC * float(columns['BTCUSDT_close'][-1]) 90 | # these are TOTAL values (USD + BTC * BTCUSD_close) 91 | # Profit from start. Displayed string: Runtime accuracy: 52.8%, +1.62 USD (+1.6%) 92 | profit_from_start = current_total_value_USD - starting_total_value_USD 93 | profit_from_start_pct = ((current_total_value_USD / starting_total_value_USD) - 1) * 100 94 | if profit_from_start > 0: 95 | profit_from_start_text = f'+{profit_from_start:.2f} USD (+{profit_from_start_pct:.2f}%)' 96 | elif profit_from_start < 0: 97 | profit_from_start_text = f'{profit_from_start:.2f} USD ({profit_from_start_pct:.2f}%)' 98 | 99 | # 72h profit string 100 | _72h_profit_text = '+0 USD (0.00%)' # default text 101 | # these are TOTAL values (USD + BTC * BTCUSD_close) 102 | # if we just started bot, all balances in columns_72h['balance_USD'] will be None, we want to avoid that 103 | if (columns_72h['balance_USD'][0] is not None) and (columns_72h['balance_BTC'][0] is not None): 104 | starting_timestamp_dt = datetime.strptime(STARTING_TIMESTAMP, '%Y-%m-%d %H:%M:%S') 105 | seconds_from_starting_timestamp = (datetime.utcnow() - starting_timestamp_dt).total_seconds() 106 | # if 72h havent passed, the profits are the same 107 | # +5 minutes just to be sure 108 | if seconds_from_starting_timestamp < timedelta(hours=72, minutes=5).total_seconds(): 109 | _72h_profit_text = profit_from_start_text # if 72h have not passed yet, both strings are the same 110 | else: 111 | _72h_total_value_USD = float(columns_72h['balance_USD'][0]) + float(columns_72h['balance_BTC'][0]) * float(columns_72h['BTCUSDT_close'][0]) 112 | _72h_profit = current_total_value_USD - _72h_total_value_USD 113 | _72h_profit_pct = ((current_total_value_USD / _72h_total_value_USD) - 1) * 100 114 | if _72h_profit > 0: 115 | _72h_profit_text = f'+{_72h_profit:.2f} USD (+{_72h_profit_pct:.2f}%)' 116 | elif _72h_profit < 0: 117 | _72h_profit_text = f'{_72h_profit:.2f} USD ({_72h_profit_pct:.2f}%)' 118 | 119 | runtime_price_change_text = f'0.00% , +0.00 USD/BTC' 120 | runtime_price_change = float(columns['BTCUSDT_close'][-1]) - float(columns['BTCUSDT_close'][71]) 121 | runtime_price_change_pct = (float(columns['BTCUSDT_close'][-1]) / float(columns['BTCUSDT_close'][71]) - 1) * 100 122 | if runtime_price_change > 0: 123 | runtime_price_change_text = f'+{runtime_price_change_pct:.2f}% (+{runtime_price_change:.2f} USD/BTC)' 124 | elif runtime_price_change < 0: 125 | runtime_price_change_text = f'{runtime_price_change_pct:.2f}% ({runtime_price_change:.2f} USD/BTC)' 126 | 127 | # conversion from booleans to displayed strings 128 | if STATUS_ACTIVE is True: 129 | STATUS_ACTIVE_text = 'Active' 130 | else: 131 | STATUS_ACTIVE_text = 'Disabled' 132 | if ORDERS_ALLOWED is True: 133 | ORDERS_ALLOWED_text = 'Allowed' 134 | else: 135 | ORDERS_ALLOWED_text = 'Disallowed' 136 | if BACKUPS_ALLOWED is True: 137 | BACKUPS_ALLOWED_text = 'Allowed' 138 | else: 139 | BACKUPS_ALLOWED_text = 'Disallowed' 140 | 141 | # Github backup time, shifted to UTC+0. Offset sometimes is -1 and sometimes -2, but it is returned by that command 142 | last_commit_time = shift_hour_exact(*get_last_commit_time()) # unpacking string timestamp and integer offset 143 | 144 | return render_template('index.html', 145 | data_values=data_values_72h, data_index=data_index_72h, 146 | predictions=predictions_72h, orders=orders_72h, 147 | logs_data=logs_data, 148 | exact_starting_timestamp=exact_starting_timestamp, 149 | STATUS_ACTIVE_text=STATUS_ACTIVE_text, 150 | ORDERS_ALLOWED_text=ORDERS_ALLOWED_text, 151 | BACKUPS_ALLOWED_text=BACKUPS_ALLOWED_text, 152 | ENVIROMENT=ENVIROMENT, 153 | MODEL_PATH=MODEL_PATH, # relative, just name 154 | STARTING_BALANCE_USD=f'{STARTING_BALANCE_USD:.2f}', 155 | STARTING_BALANCE_BTC=f'{STARTING_BALANCE_BTC:.7f}', 156 | starting_total_value_USD=f'{starting_total_value_USD:.2f}', 157 | CURRENT_BALANCE_USD=f'{CURRENT_BALANCE_USD:.2f}', 158 | CURRENT_BALANCE_BTC=f'{CURRENT_BALANCE_BTC:.7f}', 159 | current_total_value_USD=f'{current_total_value_USD:.2f}', 160 | accuracy_from_start=accuracy_from_start, 161 | profit_from_start_text=profit_from_start_text, 162 | accuracy_72h=accuracy_72h, 163 | _72h_profit_text=_72h_profit_text, 164 | runtime_price_change_text=runtime_price_change_text, 165 | last_commit_time=last_commit_time 166 | ) 167 | 168 | 169 | if __name__ == '__main__': 170 | app.run(host='0.0.0.0', port=5000, debug=True) # on RPi it's set by gunicorn, which gets app from wsgi.py 171 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | 2 | from src.config import STATUS_ACTIVE, ORDERS_ALLOWED, BACKUPS_ALLOWED, set_bot_config, logger, LOG_FILE_ABS,\ 3 | STARTING_BALANCE_USD, STARTING_BALANCE_BTC 4 | from src.data_update import update_all_data 5 | from src.predict import predict_next 6 | from src.data_backup import backup_data_from_RPi, pull_changes_from_remote 7 | from src.orders import place_order, get_account_balances, convert_balances_to_USD 8 | from src.process_logfile import process_logfile 9 | from src.utils import shift_hour_trunc 10 | from datetime import datetime 11 | 12 | 13 | # bot.py is independent from app.py, however app.py requires processed logfile (done by bot.py) to draw the chart 14 | def main(): 15 | # Performed on PC: 16 | # training and evaluation of the model 17 | # simulation 18 | 19 | # Performed on RPi: 20 | if STATUS_ACTIVE is True: 21 | 22 | # Runs once at start (when logfile.log is empty), writes first entries in logfile.log from config.ini 23 | with open(LOG_FILE_ABS, 'rt') as f: 24 | logfile_content = f.read().strip('\n').strip(' ') 25 | if logfile_content == '': 26 | logger.info(f'Bot started') 27 | starting_balances = get_account_balances() # this has try except inside when sending requests 28 | current_utc_timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') 29 | if starting_balances is not False: 30 | set_bot_config(STARTING_BALANCE_USD=starting_balances['USD'], 31 | STARTING_BALANCE_BTC=starting_balances['BTC'], 32 | CURRENT_BALANCE_USD=starting_balances['USD'], 33 | CURRENT_BALANCE_BTC=starting_balances['BTC'], 34 | STARTING_TIMESTAMP=shift_hour_trunc(current_utc_timestamp, h=0) # ..17:03:58 -> ..17:00:00 35 | ) 36 | logger.info(f'[BALANCE] {starting_balances["USD"]:.2f} USD, {starting_balances["BTC"]:.7f} BTC') 37 | else: 38 | print('Cannot load starting balances. Check the provided API keys in secrets.ini') 39 | return 40 | 41 | # missing values happens once per few months, it's better to check manually, so fill_missing=False 42 | # if missing values appear, error will be raised and logged, and bot will be set inactive 43 | update_successful = update_all_data(fill_missing=True) 44 | prediction_result = 'EVEN' # default value if no prediction is made 45 | if update_successful is True: # False in case if no new records available 46 | prediction_result = predict_next() # 'UP' 'DOWN' 'EVEN' 47 | 48 | if ORDERS_ALLOWED is True: 49 | balances = get_account_balances() # returns a dict with balances or False 50 | if balances is not False: 51 | # bot buys and sells alternately, and side depends from if he has more BTC or USD - checking that here 52 | balances_in_USD = convert_balances_to_USD(balances) 53 | if (balances_in_USD['USD'] > balances_in_USD['BTC']) and (prediction_result == 'UP'): 54 | quantity = round(balances['USD'] * 0.995, 6) # fixes "no sufficient amount", "precision err" 55 | order_filled = place_order(side='BUY', quantity=quantity, symbol='BTCUSDT', order_type='MARKET') 56 | if order_filled is not False: 57 | balances = get_account_balances() 58 | logger.info(f'[BALANCE] {balances["USD"]:.2f} USD, {balances["BTC"]:.7f} BTC') 59 | set_bot_config(CURRENT_BALANCE_USD=balances['USD'], CURRENT_BALANCE_BTC=balances['BTC']) 60 | 61 | elif (balances_in_USD['USD'] <= balances_in_USD['BTC']) and (prediction_result == 'DOWN'): 62 | quantity = round(balances['BTC'] * 0.995, 6) 63 | order_filled = place_order(side='SELL', quantity=quantity, symbol='BTCUSDT', order_type='MARKET') 64 | if order_filled is not False: 65 | balances = get_account_balances() 66 | logger.info(f'[BALANCE] {balances["USD"]:.2f} USD, {balances["BTC"]:.7f} BTC') 67 | set_bot_config(CURRENT_BALANCE_USD=balances['USD'], CURRENT_BALANCE_BTC=balances['BTC']) 68 | else: 69 | pass # bot alternates between BUY/SELL state so no need more orders conditions 70 | 71 | process_logfile() # process logfile to get accuracies and information for chart and save data as logfile.csv 72 | 73 | if (update_successful is True) and (BACKUPS_ALLOWED is True): # False in case if no new records available 74 | pull_changes_from_remote() 75 | backup_data_from_RPi() # use with ssh key for github in default location without passphrase 76 | 77 | else: # when STATUS_ACTIVE is False, bot does nothing 78 | pass 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | 84 | -------------------------------------------------------------------------------- /models/model/LSTM-2021-08-23.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamsec/crypto-bot/4d247efe76a71ca7a8e7a638f48f791a06f428b5/models/model/LSTM-2021-08-23.tflite -------------------------------------------------------------------------------- /requirements_rpi.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.4.post0 2 | async-timeout==3.0.1 3 | attrs==21.2.0 4 | backports.zoneinfo==0.2.1 5 | certifi==2021.5.30 6 | chardet==4.0.0 7 | charset-normalizer==2.0.4 8 | click==8.0.1 9 | dateparser==1.0.0 10 | Flask==2.0.1 11 | gunicorn==20.1.0 12 | idna==3.2 13 | importlib-metadata==4.6.4 14 | itsdangerous==2.0.1 15 | Jinja2==3.0.1 16 | joblib==1.0.1 17 | MarkupSafe==2.0.1 18 | multidict==5.1.0 19 | numpy==1.21.2 20 | pandas==1.3.2 21 | pybind11==2.7.1 22 | python-binance==1.0.12 23 | python-dateutil==2.8.2 24 | pytz==2021.1 25 | regex==2021.8.3 26 | requests==2.26.0 27 | scikit-learn==0.24.2 28 | scipy==1.7.1 29 | six==1.16.0 30 | threadpoolctl==2.2.0 31 | typing-extensions==3.10.0.0 32 | tzlocal==3.0 33 | ujson==4.1.0 34 | urllib3==1.26.6 35 | websockets==9.1 36 | Werkzeug==2.0.1 37 | yarl==1.6.3 38 | zipp==3.5.0 39 | ./whl/tflite_runtime-2.3.1-cp37-cp37m-linux_armv6l.whl; platform_machine == 'armv6l' 40 | ./whl/tflite_runtime-2.5.0-cp37-cp37m-linux_armv7l.whl; platform_machine == 'armv7l' 41 | -------------------------------------------------------------------------------- /requirements_win.txt: -------------------------------------------------------------------------------- 1 | --extra-index-url=https://google-coral.github.io/py-repo/ 2 | aiohttp==3.7.4.post0 3 | async-timeout==3.0.1 4 | attrs==21.2.0 5 | backports.zoneinfo==0.2.1 6 | certifi==2021.5.30 7 | chardet==4.0.0 8 | charset-normalizer==2.0.4 9 | click==8.0.1 10 | dateparser==1.0.0 11 | Flask==2.0.1 12 | gunicorn==20.1.0 13 | idna==3.2 14 | importlib-metadata==4.6.4 15 | itsdangerous==2.0.1 16 | Jinja2==3.0.1 17 | joblib==1.0.1 18 | MarkupSafe==2.0.1 19 | multidict==5.1.0 20 | numpy==1.21.2 21 | pandas==1.3.2 22 | pybind11==2.7.1 23 | python-binance==1.0.12 24 | python-dateutil==2.8.2 25 | pytz==2021.1 26 | regex==2021.8.3 27 | requests==2.26.0 28 | scikit-learn==0.24.2 29 | scipy==1.7.1 30 | six==1.16.0 31 | threadpoolctl==2.2.0 32 | typing-extensions==3.10.0.0 33 | tzlocal==3.0 34 | ujson==4.1.0 35 | urllib3==1.26.6 36 | websockets==9.1 37 | Werkzeug==2.0.1 38 | yarl==1.6.3 39 | zipp==3.5.0 40 | tflite_runtime==2.5.0; platform_machine == 'AMD64' 41 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamsec/crypto-bot/4d247efe76a71ca7a8e7a638f48f791a06f428b5/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import configparser 3 | import os 4 | from datetime import datetime 5 | 6 | from src.utils import shift_hour_trunc 7 | 8 | 9 | SECRETS_FILE = 'secrets.ini' 10 | CONFIG_NAME = 'config.ini' 11 | LOG_FILE = 'logfile.log' 12 | 13 | MODEL_NAME = 'LSTM-2021-08-23.tflite' 14 | PROJECT_PATH_ABS = os.path.abspath(os.path.join(os.path.dirname(__file__))) 15 | 16 | def setup(): 17 | # SECRETS 18 | cfg_secrets = configparser.ConfigParser() 19 | if not os.path.exists(SECRETS_FILE): 20 | cfg_secrets['DEFAULT'] = {'TESTNET_API_KEY': '***', 21 | 'TESTNET_API_SECRET': '***', 22 | 'MAINNET_API_KEY': '***', 23 | 'MAINNET_API_SECRET': '***', 24 | } 25 | with open(SECRETS_FILE, 'w') as secretsfile: 26 | cfg_secrets.write(secretsfile) 27 | print('Created secrets.ini.') 28 | 29 | # CONFIG 30 | cfg = configparser.ConfigParser() 31 | if not os.path.exists(CONFIG_NAME): 32 | current_utc_timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') 33 | cfg['DEFAULT'] = {'SEQ_LEN': '48', 34 | 'RATIOS': '["BTCUSDT", "LTCUSDT", "ETHUSDT", "BNBUSDT", "ADAUSDT", "BTTUSDT", "DASHUSDT",' 35 | '"XMRUSDT", "NANOUSDT", "DOGEUSDT", "XLMUSDT", "BCHUSDT"]', 36 | 'RATIO_TO_PREDICT': 'BTCUSDT', 37 | 'MODEL_PATH': f'models/model/{MODEL_NAME}', 38 | 'DATA_PATH': 'data/', 39 | 'STATUS_ACTIVE': True, 40 | 'ORDERS_ALLOWED': False, 41 | 'BACKUPS_ALLOWED ': False, 42 | 'ENVIROMENT': 'testnet', 43 | 'STARTING_BALANCE_USD': 0, 44 | 'STARTING_BALANCE_BTC': 0, 45 | 'STARTING_TIMESTAMP': shift_hour_trunc(current_utc_timestamp, h=0), # ..17:03:58 ->..17:00:00 46 | 'CURRENT_BALANCE_USD': 0, 47 | 'CURRENT_BALANCE_BTC': 0 48 | } 49 | with open(CONFIG_NAME, 'w') as configfile: 50 | cfg.write(configfile) 51 | print('Created config.ini') 52 | 53 | # LOGGING 54 | if not os.path.exists(LOG_FILE): 55 | with open(LOG_FILE, 'w') as f: 56 | f.write('') 57 | print('Created logfile.log') 58 | 59 | 60 | if __name__ == "__main__": 61 | setup() 62 | 63 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | 2 | import time 3 | import json 4 | import logging 5 | import configparser 6 | import os 7 | 8 | from setup import CONFIG_NAME, LOG_FILE, SECRETS_FILE, PROJECT_PATH_ABS 9 | 10 | # assumed config.py in crypto-bot/src, config.ini in crypto-bot/, setup.py in crypto-bot/ 11 | CONFIG_PATH_ABS = os.path.abspath(os.path.join(PROJECT_PATH_ABS, CONFIG_NAME)) 12 | 13 | cfg = configparser.ConfigParser() 14 | 15 | cfg.read(CONFIG_PATH_ABS) 16 | 17 | SEQ_LEN = cfg['DEFAULT'].getint('SEQ_LEN') 18 | RATIOS = json.loads(cfg['DEFAULT']['RATIOS']) 19 | RATIO_TO_PREDICT = cfg['DEFAULT']['RATIO_TO_PREDICT'] 20 | 21 | # for tensorflow 2.5 'models/model', for TFLite you need to specify 'models/model/model.tflite' 22 | MODEL_PATH = cfg['DEFAULT']['MODEL_PATH'] 23 | MODEL_PATH_ABS = os.path.abspath(os.path.join(PROJECT_PATH_ABS, MODEL_PATH)) 24 | 25 | DATA_PATH = cfg['DEFAULT']['DATA_PATH'] 26 | DATA_PATH_ABS = os.path.abspath(os.path.join(PROJECT_PATH_ABS, DATA_PATH)) + os.path.sep # ending with / 27 | 28 | STATUS_ACTIVE = cfg['DEFAULT'].getboolean('STATUS_ACTIVE') 29 | ORDERS_ALLOWED = cfg['DEFAULT'].getboolean('ORDERS_ALLOWED') 30 | BACKUPS_ALLOWED = cfg['DEFAULT'].getboolean('BACKUPS_ALLOWED') 31 | ENVIROMENT = cfg['DEFAULT']['ENVIROMENT'] # testnet or mainnet 32 | 33 | # logging 34 | # Level Numeric value 35 | # CRITICAL 50 36 | # ERROR 40 37 | # WARNING 30 38 | # INFO 20 39 | # DEBUG 10 40 | # NOTSET 0 41 | LOG_FILE_ABS = os.path.abspath(os.path.join(PROJECT_PATH_ABS, LOG_FILE)) 42 | 43 | logging.basicConfig(filename=LOG_FILE_ABS, 44 | level=logging.INFO, # DEBUG is the lowest - the most information 45 | format="%(asctime)s [%(levelname)s] %(message)s", 46 | datefmt='%Y-%m-%d %H:%M:%S') 47 | logging.Formatter.converter = time.gmtime # logs will be in gmt (UTC+0) 48 | logger = logging.getLogger(__name__) 49 | 50 | STARTING_BALANCE_USD = cfg['DEFAULT'].getfloat('starting_balance_USD') 51 | STARTING_BALANCE_BTC = cfg['DEFAULT'].getfloat('starting_balance_BTC') 52 | STARTING_TIMESTAMP = cfg['DEFAULT']['starting_timestamp'] 53 | 54 | CURRENT_BALANCE_USD = cfg['DEFAULT'].getfloat('current_balance_USD') 55 | CURRENT_BALANCE_BTC = cfg['DEFAULT'].getfloat('current_balance_BTC') 56 | 57 | def set_bot_config(**kwargs): # e.g. set_bot_config(STATUS_ACTIVE=False) 58 | for key, value in kwargs.items(): 59 | if key in cfg['DEFAULT']: 60 | cfg.set('DEFAULT', key, str(value)) 61 | if (key == 'STATUS_ACTIVE') or (key == 'ORDERS_ALLOWED') or (key == 'BACKUPS_ALLOWED'): 62 | logger.info(f'[CONFIG] {key} set to {value}') 63 | else: 64 | raise Exception(f'Config has no {key} setting') 65 | with open(CONFIG_PATH_ABS, 'w') as configfile: 66 | cfg.write(configfile) 67 | -------------------------------------------------------------------------------- /src/data_backup.py: -------------------------------------------------------------------------------- 1 | 2 | import subprocess 3 | from src.config import logger, set_bot_config 4 | 5 | 6 | def git_error_check(proc): 7 | proc_returncode = proc.returncode 8 | if proc_returncode is not 0: 9 | proc_stderr = proc.stderr 10 | error = str(proc_stderr)[2:-3].replace(r'\n', '\n') # 2 removes b', -3 removes \n' 11 | logger.error(f'[BACKUP] Git backup error. Message: {error}') 12 | set_bot_config(BACKUPS_ALLOWED=False) 13 | raise Exception(f'Git backup error. Message: {error}') 14 | 15 | def pull_changes_from_remote(): 16 | # git pull 17 | 18 | proc = subprocess.run(["git", "pull"], capture_output=True) 19 | git_error_check(proc) 20 | 21 | def backup_data_from_RPi(): 22 | # git add . 23 | # git commit -m "data update" 24 | # git push 25 | 26 | proc = subprocess.run(["git", "add", "."], capture_output=True) 27 | git_error_check(proc) 28 | proc = subprocess.run(["git", "commit", "-m", "Data update by RPi"], capture_output=True) 29 | git_error_check(proc) 30 | proc = subprocess.run(["git", "push"], capture_output=True) 31 | git_error_check(proc) 32 | return 33 | 34 | # commit, no push, but whatever 35 | def get_last_commit_time(): 36 | # git --no-pager log -1 --format=%ai 37 | 38 | proc = subprocess.run(["git", "--no-pager", "log", "-1", "--format=%ai"], capture_output=True) 39 | # output is b'2021-08-23 19:27:51' +0200 or and sometimes +0100, soi need to grab that offset as well 40 | last_commit_time = str(proc.stdout)[2:21] 41 | last_commit_offset = -int(str(proc.stdout)[24]) # -2 or -1 so can be passed directly as h= utils functions 42 | return last_commit_time, last_commit_offset 43 | 44 | -------------------------------------------------------------------------------- /src/data_update.py: -------------------------------------------------------------------------------- 1 | import time 2 | import pandas as pd 3 | import numpy as np 4 | from datetime import datetime, timedelta 5 | from requests import get 6 | from json import loads 7 | 8 | from src.utils import UTC_OFFSET, unix_to_datetime, datetime_to_unix 9 | from src.config import RATIOS, logger, DATA_PATH_ABS, set_bot_config 10 | 11 | 12 | def update_ratio(ratio, filename, request_records=500, fill_missing=False): 13 | old_df = pd.read_csv(filename, nrows=None, skiprows=None, parse_dates=['date'], usecols=[0, 1, 2]) 14 | old_df.set_index("date", inplace=True) 15 | last_record_dt = old_df.tail(1).index.item() # get last record (1 element of tail) in old csv that is being updated 16 | last_record_unix = datetime_to_unix(str(last_record_dt), h=-UTC_OFFSET) # -UTC_OFFSET because of timezones 17 | 18 | ''' 19 | logger.debug(f'Existing records: {len(old_df)} | ' 20 | f'Last close: {old_df.tail(1).index.item()} {old_df[f"{ratio}_close"][-1]}') 21 | ''' 22 | seconds_from_last_update = (datetime.utcnow() - last_record_dt).total_seconds() 23 | # e.g. 18:00 close is like 18:59:59 so we can get 18:00 close price (UTC+0) at 18:59:59 so 19:01 24 | # so we can request new price after constant 2 hours passed from last record (+ few seconds just to be sure) 25 | # also thats why cron job is set to 1 * * * * 26 | seconds_remaining = timedelta(hours=2, seconds=10).seconds - seconds_from_last_update 27 | if seconds_remaining > 0: 28 | logger.warning(f'[UPDATE] No new records available yet. Try again in: {str(timedelta(seconds=seconds_remaining))}') 29 | return False 30 | 31 | # example: 32 | # curl -X GET "https://api.binance.com/api/v1/klines?&symbol=BTCUSDT&interval=3m" 33 | # https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data 34 | times_repeat_download = int((-seconds_remaining // (request_records * 3600))) + 1 35 | new_df = pd.DataFrame(columns=[f'{ratio}_close', f'{ratio}_volume']) 36 | for i in range(0, times_repeat_download): 37 | temp_df = pd.DataFrame(columns=['date', f'{ratio}_close', f'{ratio}_volume']) 38 | response = get('https://api.binance.com/api/v1/klines', 39 | params={'symbol': ratio, 40 | 'interval': '1h', 41 | 'startTime': last_record_unix + (i * request_records * 3600 * 1000), 42 | 'limit': request_records}) # last_record_unix also limits number of records, if it's recent 43 | raw_data = loads(response.content) 44 | raw_data_df = pd.DataFrame(raw_data) 45 | 46 | # we receive data in UTC+2 timezone, so need to set h=-2, so we pass list of [-2] as second argument 47 | temp_df['date'] = list(map(unix_to_datetime, raw_data_df[0], [UTC_OFFSET] * len(raw_data_df[0]))) 48 | temp_df[f'{ratio}_close'] = list(map(float, raw_data_df[4])) # df[4] # Close 49 | temp_df[f'{ratio}_volume'] = list(map(float, raw_data_df[7])) # df[7] # Quote asset volume (second assets unit, so in BTC/USDT its volume_USDT) # noqa 50 | temp_df.set_index("date", inplace=True) 51 | if i == 0: # in first iteration we drop temp_df[0] because its the same as in old_df[-1] 52 | temp_df = temp_df[1:] 53 | new_df = pd.concat([new_df, temp_df]) 54 | # if we send request at 15:27 (UTC-0 13:27) it will return data with last entry 13:00 which will change over time 55 | new_df = new_df[:-1] # dropping that entry 56 | 57 | ''' 58 | logger.debug(f'Received {len(new_df)} records. | ' 59 | f'Last: {new_df.head(1).index.item()} {new_df[f"{ratio}_close"][0]} | ' 60 | f'First: {new_df.tail(1).index.item()} {new_df[f"{ratio}_close"][-1]}') 61 | # python floats (and interpolation) might cause that old_df[-1] is not equal new_df[0] but thats ok, 62 | # e.g.: old_df[-1] is not equal new_df[0]: 0.23510999999999999 != 0.23511 but this won't be a problem 63 | ''' 64 | 65 | # check for gaps in timestamps and interpolate if found 66 | new_df.index = pd.to_datetime(new_df.index) 67 | expected_range = pd.date_range(start=new_df.index.min(), end=new_df.index.max(), freq='H') 68 | 69 | missing_ts = list(expected_range.difference(new_df.index)) 70 | if missing_ts and (fill_missing is True): 71 | new_df = new_df.resample('60min').mean() 72 | new_df.replace(to_replace=0, value=np.nan, inplace=True) # converting 0s to NaNs 73 | new_df.interpolate(method='linear', inplace=True) # interpolating missing NaNs 74 | 75 | logger.warning(f'[UPDATE] Missing {len(missing_ts)} timestamps in {ratio} pair. Performed interpolation.') 76 | 77 | logger.debug(f'{ratio} updated succesfully. All new records: {len(new_df)} | ' 78 | f'First: {new_df.tail(1).index.item()} {new_df[f"{ratio}_close"][-1]}\n') 79 | return new_df 80 | elif missing_ts and (fill_missing is False): 81 | logger.error(f'[UPDATE] Missing {len(missing_ts)} timestamps. Update skipped, check manually.') 82 | set_bot_config(STATUS_ACTIVE=False) 83 | raise Exception(f'Missing {len(missing_ts)} timestamps. Update skipped, bot set inactive.') 84 | else: 85 | return new_df 86 | 87 | 88 | def update_all_data(fill_missing=False): 89 | dict_name_ratio_df = {} 90 | for ratio in RATIOS: 91 | filename = f"{DATA_PATH_ABS}Binance_{ratio}_1h.csv" 92 | time.sleep(0.2) # to not spam the exchange 93 | # 336 hourly records = 14 days, if files havent been updated for longer time, 94 | # just update them again until "No new records available yet." encountered 95 | new_df = update_ratio(ratio, filename, request_records=336, fill_missing=fill_missing) 96 | dict_name_ratio_df[f'{filename}'] = new_df 97 | if new_df is False: # some new_df will be False if not enough time have passed from last update 98 | return False 99 | 100 | for filename, _df in dict_name_ratio_df.items(): 101 | _df.to_csv(filename, mode='a', header=False) 102 | return True 103 | 104 | 105 | if __name__ == "__main__": 106 | update_all_data() 107 | -------------------------------------------------------------------------------- /src/orders.py: -------------------------------------------------------------------------------- 1 | 2 | import configparser 3 | import os 4 | import time 5 | from json import loads 6 | from requests import get 7 | from binance.client import Client 8 | 9 | from src.config import RATIOS, logger, set_bot_config, ENVIROMENT, PROJECT_PATH_ABS, DATA_PATH_ABS, SECRETS_FILE 10 | 11 | # doing it here since only functions in this file use authentication on Binance. E.g. Flask doesnt need that 12 | cfg_secrets = configparser.ConfigParser() 13 | SECRETS_FILE_ABS = os.path.abspath(os.path.join(PROJECT_PATH_ABS, SECRETS_FILE)) 14 | cfg_secrets.read(SECRETS_FILE_ABS) 15 | 16 | if ENVIROMENT == 'testnet': 17 | TESTNET_API_KEY = cfg_secrets['DEFAULT']['TESTNET_API_KEY'] 18 | TESTNET_API_SECRET = cfg_secrets['DEFAULT']['TESTNET_API_SECRET'] 19 | client = Client(TESTNET_API_KEY, TESTNET_API_SECRET, testnet=True) 20 | elif ENVIROMENT == 'mainnet': 21 | MAINNET_API_KEY = cfg_secrets['DEFAULT']['MAINNET_API_KEY'] 22 | MAINNET_API_SECRET = cfg_secrets['DEFAULT']['MAINNET_API_SECRET'] 23 | client = Client(MAINNET_API_KEY, MAINNET_API_SECRET, testnet=False) 24 | 25 | def get_account_balances(): 26 | try: 27 | BTC = client.get_asset_balance(asset='BTC')['free'] 28 | USD = client.get_asset_balance(asset='USDT')['free'] # assumed 1:1 USD/USDT ratio, using 'USD' name 29 | except Exception as e: 30 | logger.error(f'[get_account_balances] {e}') 31 | return False 32 | # print(f'{USD} USD, {BTC} BTC') 33 | return {'BTC': float(BTC), 'USD': float(USD)} 34 | 35 | 36 | def get_last_price(): 37 | try: 38 | # 'https://testnet.binance.vision/api/v3/ticker/price?symbol=BTCUSDT' # for testnet 39 | # 'https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT' # for mainnet 40 | if ENVIROMENT == 'testnet': 41 | url = 'https://testnet.binance.vision/api/v3/ticker/price' 42 | elif ENVIROMENT == 'mainnet': 43 | url = 'https://api.binance.com/api/v3/ticker/price' 44 | response = get(url, params={'symbol': 'BTCUSDT'}) 45 | except Exception as e: 46 | logger.error(f'[get_last_price] {e}') 47 | return False 48 | return float(loads(response.text)['price']) 49 | 50 | 51 | def get_last_data_price(): 52 | with open(f"{DATA_PATH_ABS}Binance_BTCUSDT_1h.csv", 'r') as file: 53 | last_data_price = float(file.readlines()[-1].split(',')[1]) # -1 bcs last line and 1 bcs (date, close, volume) 54 | return last_data_price 55 | 56 | 57 | def convert_balances_to_USD(balances_dict): 58 | new_balances_dict = balances_dict.copy() 59 | last_data_price = get_last_data_price() 60 | new_balances_dict['BTC'] = balances_dict['BTC'] * last_data_price 61 | new_balances_dict['USD'] = balances_dict['USD'] 62 | return new_balances_dict # {'BTC': BTC, 'USD': USD} and USD is not changed 63 | 64 | 65 | # quantity is translated to quantity or quoteOrderQty. quantity is always known, so no additional calculations needed 66 | def place_order(side, quantity, symbol, order_type): # side='SELL', quantity=0.1, symbol='BTCUSDT', order_type='MARKET' 67 | 68 | # checking actual price, to prevent error (if no enough liquidity in the market, etc) 69 | last_data_price = get_last_data_price() # last price from data/ folder 70 | max_diff = last_data_price * 0.02 # for 45000 it is 900, very unlikely to make 2% change in few minutes 71 | price_confirmed = False 72 | for i in range(0, 10): 73 | last_price = get_last_price() # last price from the exchange ticker 74 | if abs(last_data_price - last_price) > max_diff: 75 | time.sleep(5) # sleep 5 seconds, wait for price change 76 | continue 77 | else: 78 | price_confirmed = True 79 | break 80 | if price_confirmed is False: # function returns False in this case 81 | logger.warning(f'[ORDER CANCELLED] Market price changed too much, {last_data_price:.7f} -> {last_price:.7f}') 82 | return False 83 | 84 | # placing order 85 | if side == 'BUY': # e.g buy x BTC for 80 USD so quoteOrderQty=80 86 | try: 87 | order = client.create_order(side=side, quoteOrderQty=quantity, symbol=symbol, type=order_type) 88 | except Exception as e: 89 | set_bot_config(ORDERS_ALLOWED=False) 90 | logger.error(f'[place_order] side={side}, quoteOrderQty={quantity} {e}') 91 | return False 92 | elif side == 'SELL': # e.g sell 0.0003BTC for 80 USD so quantity=0.0003 93 | try: 94 | order = client.create_order(side=side, quantity=quantity, symbol=symbol, type=order_type) 95 | except Exception as e: 96 | set_bot_config(ORDERS_ALLOWED=False) 97 | logger.error(f'[place_order] side={side}, quantity={quantity} {e}') 98 | return False 99 | 100 | # e.g. in BTCUSDT, BTC is base asset and USDT is quote asset 101 | quote_amount = float(order["cummulativeQuoteQty"]) # USD (USDT but we assume 1:1) 102 | base_amount = float(order['executedQty']) # BTC 103 | fills = order["fills"] 104 | status = order["status"] 105 | # fee = 0.001 # not dealing with fees, we check the balances later from the exchange anyway 106 | 107 | # order that is partially filled is still in order book and waiting for filling so no need to renew it 108 | if len(fills) > 0: # its better than status == 'FILLED': because EXPIRED can also be partially filled :/ 109 | avg_price = quote_amount / base_amount 110 | # format of logs e.g.: 111 | # 2021-07-27 05:05:51 [INFO] [ORDER] [FILLED] BUY 0.0003523 BTC for 23.12 USD at 33123.12 USD/BTC in 1 FILL 112 | # 2021-07-27 09:05:53 [INFO] [ORDER] [FILLED] SELL 0.0003523 BTC for 23.89 USD at 33242.43 USD/BTC in 2 FILLS 113 | if side == 'BUY': 114 | logger.info(f'[ORDER] [{status}] {side} {base_amount:.7f} BTC for {quote_amount:.2f} USD' 115 | f' at {avg_price:.2f} USD/BTC in {len(fills)} FILL{"S" if len(fills) > 1 else ""}') 116 | elif side == 'SELL': 117 | logger.info(f'[ORDER] [{status}] {side} {base_amount:.7f} BTC for {quote_amount:.2f} USD' 118 | f' at {avg_price:.2f} USD/BTC in {len(fills)} FILL{"S" if len(fills) > 1 else ""}') 119 | else: 120 | logger.warning(f'[ORDER] [{status}] Response: {order}') 121 | set_bot_config(ORDERS_ALLOWED=False) 122 | return False 123 | return True 124 | -------------------------------------------------------------------------------- /src/predict.py: -------------------------------------------------------------------------------- 1 | 2 | import numpy as np 3 | import pandas as pd 4 | import tflite_runtime.interpreter as tflite 5 | from sklearn import preprocessing 6 | from collections import deque 7 | from src.config import RATIOS, RATIO_TO_PREDICT, SEQ_LEN, MODEL_PATH_ABS, DATA_PATH_ABS, logger 8 | 9 | # windows: C:\Users\Admin\Desktop\python37\Scripts>pip3 install --index-url https://google-coral.gith 10 | # rpi: tflite_runtime-2.3.1-cp37-cp37m-linux_armv6l.whl 11 | 12 | 13 | def construct_df_for_pred(ratios): 14 | main_df = pd.DataFrame() 15 | # 'unix,date,symbol,open,high,low,close,Volume LTC,Volume USDT,tradecount' 16 | for ratio in ratios: 17 | dataset = f"{DATA_PATH_ABS}Binance_{ratio}_1h.csv" 18 | df = pd.read_csv(dataset, nrows=None, skiprows=None, parse_dates=['date'], usecols=[0, 1, 2]) 19 | df.set_index("date", inplace=True) 20 | 21 | # later we use pct_change so we lose 1 row and need 1 more (-49 for single pred) 22 | # but there is no future and target column, so we dont waste one last row, so removing "+1" 23 | num_of_preds = 1 24 | short_df = df[-SEQ_LEN - num_of_preds:] 25 | 26 | if len(main_df) == 0: 27 | main_df = short_df 28 | else: 29 | main_df = main_df.join(short_df) 30 | 31 | # in this case it reduces main_df length to the length of pair with the smallest data 32 | main_df.dropna(how='any', inplace=True) 33 | return main_df 34 | 35 | 36 | # percentage change and scaling 37 | def preprocess_data_for_pred(df): 38 | df = df.copy() # fixes random SettingWithCopyWarning appearing with pct_change and preprocessing.scale 39 | for col in df.columns: 40 | # here we lose 1 row 41 | df[col] = df[col].pct_change() # converted to percentage change 42 | # example: 43 | # hour 1:00 close = 93.13, hour 2:00 close = 92.41, so 44 | # 93.13 + 93.13 * x = 92.41, x = -0.007731 45 | # so percentage change of 1.0 means doubling the price 46 | # first row (the oldest values) is being filled with NaN percentage, but we don't want to drop whole row yet 47 | # so we wait until loop ends (we will have whole row filled with nans) 48 | df.dropna(inplace=True) 49 | 50 | for col in df.columns: 51 | if col != "target": 52 | # scaling should be used after splitting to training and test set - leaks data from test set to training 53 | df[col] = preprocessing.scale(df[col].values) # normalizes to [-1, +1] 54 | # df[col] = preprocessing.StandardScaler().fit_transform(df[col].values.reshape(-1, 1)) # it does the same 55 | # scales the data so it has mean = 0, standard deviation = 1, variance = 1 56 | # it can be checked before and after with e.g. print(df['BTCUSDT_close'].mean()) .std() .var() 57 | 58 | # here we always will have sequential_data with one array 1x48x24 59 | prev_days = deque(maxlen=SEQ_LEN) # list of SEQ_LEN items, we append to it, as it exceed, it pops old values 60 | sequential_data = [] 61 | for i in df.values: 62 | prev_days.append([n for n in i]) # set of price and volume features added (-1 so without target) 63 | if len(prev_days) == SEQ_LEN: 64 | sequential_data.append(np.array([prev_days])) # [] is necessary to be compatible with model input 65 | # the sequences are like this ([a, b, 1] is single prev_days): 66 | # [([[a, b] [c, d], [e, f]], 1) ([[c, d] [e, f] [g, h]], 1) ... ([[g, h], [i, j], [k, l]], 0)] 67 | return sequential_data 68 | 69 | 70 | def predict_next(): 71 | main_df = construct_df_for_pred(RATIOS) 72 | preprocessed_sequences = preprocess_data_for_pred(main_df) 73 | 74 | interpreter = tflite.Interpreter(model_path=MODEL_PATH_ABS) 75 | interpreter.allocate_tensors() 76 | input_details = interpreter.get_input_details() 77 | output_details = interpreter.get_output_details() 78 | data_for_tflite = np.array(preprocessed_sequences[0], dtype=np.float32) # doesnt work without dtype=np.float32 79 | interpreter.set_tensor(input_details[0]['index'], data_for_tflite) 80 | interpreter.invoke() 81 | 82 | predictions = interpreter.get_tensor(output_details[0]['index']) 83 | probabilities = predictions[0] 84 | 85 | def classifier(prob): 86 | if round(prob, 2) > 0.50: 87 | return 'UP' 88 | elif round(prob, 2) == 0.50: 89 | # when bot is inactive it can be treated as 'EVEN' prediction as well, because it has no influence on orders 90 | return 'EVEN' 91 | elif round(prob, 2) < 0.50: 92 | return 'DOWN' 93 | 94 | prediction = classifier(probabilities[1]) 95 | logger.info(f'[PREDICTION] DOWN: {probabilities[0]*100:.0f}%, UP: {probabilities[1]*100:.0f}%') 96 | return prediction 97 | 98 | 99 | if __name__ == "__main__": 100 | predict_next() 101 | -------------------------------------------------------------------------------- /src/process_logfile.py: -------------------------------------------------------------------------------- 1 | 2 | import numpy as np 3 | import pandas as pd 4 | from src.utils import datetime_to_unix, unix_to_datetime, shift_hour_trunc 5 | from src.config import LOG_FILE_ABS, DATA_PATH_ABS, STARTING_TIMESTAMP, STARTING_BALANCE_USD, STARTING_BALANCE_BTC 6 | 7 | 8 | def process_logfile(): 9 | def classifier(current, future): 10 | if current < future: 11 | return 'UP' 12 | elif current == future: 13 | return np.nan 14 | elif current > future: 15 | return 'DOWN' 16 | 17 | df = pd.read_csv(f'{DATA_PATH_ABS}Binance_BTCUSDT_1h.csv', parse_dates=['date'], usecols=[0, 1]) # volume not required 18 | df.set_index("date", inplace=True) 19 | # shifted by 72h for 72h chart, so if we pass current date as STARTING_TIMESTAMP, it will still plot full chart 20 | df = df.truncate(before=shift_hour_trunc(STARTING_TIMESTAMP, h=-72)) 21 | df['future'] = df[f"BTCUSDT_close"].shift(-1) 22 | df['target'] = list(map(classifier, df[f"BTCUSDT_close"], df["future"])) 23 | # df.drop('future', 1, inplace=True) # future is not necessary from now but no need to drop it 24 | 25 | df['predictions_shifted'] = None 26 | df['accurate_prediction'] = None 27 | df['orders_shifted'] = None 28 | df['balance_USD'] = np.nan # np.nan instead of None so we can use interpolate (fill nans with nearest vals) later 29 | df['balance_BTC'] = np.nan 30 | 31 | balance_USD = STARTING_BALANCE_USD 32 | balance_BTC = STARTING_BALANCE_BTC 33 | with open(LOG_FILE_ABS, 'rt') as f: 34 | logs_data_lines = [line for line in f.readlines() if line.strip()] # strips empty lines 35 | for line in logs_data_lines: 36 | if line[-1] == '\n': 37 | line = line[:-1] # [:-1] removes /n 38 | timestamp = line[:19] 39 | # using "at" vs "loc" should be faster, but for some reason here it isn't 40 | if '[PREDICTION]' in line: 41 | if 'UP: ' in line: # should be more optimized than with "and" 42 | up_percentage = int(line.partition(f'UP: ')[2][:-1]) # 2 bcs ('.', 'UP: ', '52%) and -1 to remove % 43 | if up_percentage > 50: 44 | df.at[shift_hour_trunc(timestamp, h=-1), 'predictions_shifted'] = 'UP' 45 | elif up_percentage < 50: 46 | df.at[shift_hour_trunc(timestamp, h=-1), 'predictions_shifted'] = 'DOWN' 47 | elif '[ORDER]' in line: 48 | if 'BUY' in line: 49 | df.at[shift_hour_trunc(timestamp, h=-1), 'orders_shifted'] = 'BUY' 50 | elif 'SELL' in line: 51 | df.at[shift_hour_trunc(timestamp, h=-1), 'orders_shifted'] = 'SELL' 52 | elif '[BALANCE]' in line: # balance is not shifted 53 | balance_USD = float(line.split(' ')[4]) 54 | balance_BTC = float(line.split(' ')[6]) 55 | # h=0, h=1 because it's current balance (e.g. 14:00) and last index on chart BTC_sclose will be (13:00) 56 | # df.index doesnt need trunc, but it needs added h=1 57 | # we want to omit last balance record, because we don't have BTC_close price for it yet 58 | if shift_hour_trunc(timestamp, h=0) != shift_hour_trunc(str(df.index[-1]), h=1): 59 | df.at[shift_hour_trunc(timestamp, h=0), 'balance_USD'] = balance_USD 60 | df.at[shift_hour_trunc(timestamp, h=0), 'balance_BTC'] = balance_BTC 61 | 62 | # [None, 1, 2, None, None, 3, None, None, 2, None, 1, 2, None, 1] -> [1, 1, 2, 2, 2, 3, 3, 3, 2, 2, 1, 2, 2, 1] 63 | # here there are values like 0.002341 or 94,82 instead of 1 2 3, but it shows the idea 64 | # firstly forward fill 65 | df['balance_USD'].interpolate(method='ffill', inplace=True) 66 | # now assume that before starting timestamp balance was the same that last entry 67 | df['balance_USD'].interpolate(method='bfill', inplace=True) 68 | 69 | df['balance_BTC'].interpolate(method='ffill', inplace=True) # doing the same for BTC 70 | df['balance_BTC'].interpolate(method='bfill', inplace=True) 71 | 72 | def check_prediction(x): 73 | # target, predictions_shifted -> accurate_prediction 74 | # UP, UP -> True 75 | # UP, DOWN -> False 76 | # UP, NONE -> None 77 | # np.nan, None -> None 78 | # None, None -> None 79 | if type(x['target']) == str and type(x['predictions_shifted']) == str: 80 | if x['target'] == x['predictions_shifted']: 81 | return True 82 | else: 83 | return False 84 | else: 85 | return None 86 | 87 | df['accurate_prediction'] = df.apply(check_prediction, axis=1) 88 | df.to_csv(f'{LOG_FILE_ABS[:-4]}.csv', header=True) # truncating extension .log and saving as .csv 89 | # logfile.log is still nedeed, it will be displayed in Log textarea 90 | 91 | 92 | if __name__ == "__main__": 93 | process_logfile() 94 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | 2 | import time 3 | from datetime import datetime, timedelta 4 | 5 | # dealing with timezones and summer time 6 | IS_DST = time.daylight and (time.localtime().tm_isdst > 0) # daylight saving time 7 | UTC_OFFSET = int((time.altzone if IS_DST else time.timezone) / 3600) # PC=-2 RPi=-1, Warsaw time in summer 8 | 9 | def unix_to_datetime(x: int, h: int = 0) -> str: # use with h=UTC_OFFSET 10 | return (datetime.fromtimestamp(int(x) / 1000) + timedelta(hours=h)).strftime('%Y-%m-%d %H:%M:%S') 11 | 12 | def datetime_to_unix(x: str, h: int = 0) -> int: # use with h=-UTC_OFFSET 13 | return int(datetime.timestamp(datetime.strptime(x, '%Y-%m-%d %H:%M:%S') + timedelta(hours=h))) * 1000 14 | 15 | 16 | def shift_hour_exact(timestamp, h): # '2021-07-31 13:13:23', h=-1 -> '2021-07-31 12:13:23' 17 | return unix_to_datetime(datetime_to_unix(timestamp), h=h) 18 | 19 | def shift_hour_trunc(timestamp, h): # '2021-07-31 13:13:23', h=-1 -> '2021-07-31 12:00:00' 20 | return unix_to_datetime(datetime_to_unix(timestamp), h=h)[:-6] + ':00:00' 21 | 22 | def shift_hour_trunc_short(timestamp, h): # '2021-07-31 13:13:23', h=-1 -> '2021-07-31 12' 23 | return unix_to_datetime(datetime_to_unix(timestamp), h=h)[:-6] 24 | 25 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamsec/crypto-bot/4d247efe76a71ca7a8e7a638f48f791a06f428b5/static/favicon.ico -------------------------------------------------------------------------------- /static/main.css: -------------------------------------------------------------------------------- 1 | 2 | canvas{ 3 | margin: 0 auto; 4 | } 5 | 6 | :root{ 7 | --bg_dark: #1c1b17; 8 | --bg_light: #38362e; 9 | --gray_light: #999999; 10 | --gray_dark: #333333; 11 | 12 | --green_light: #00ff00; 13 | --green_dark: #33cc33; 14 | --red_light: #ff0000; 15 | --red_dark: #990000; 16 | } 17 | 18 | body{ 19 | height:100vh; 20 | background: var(--bg_dark); 21 | color: var(--gray_light); 22 | display: flex; 23 | } 24 | 25 | .main-wrapper{ 26 | width: 100%; 27 | margin-top: auto; 28 | margin-bottom: auto; 29 | } 30 | 31 | .container{ 32 | width: 98%; 33 | max-width: 1780px; 34 | border-bottom: 1px solid var(--gray_dark); 35 | } 36 | 37 | textarea{ 38 | font-family:"Times New Roman", Times, serif; 39 | background-color: var(--bg_dark); 40 | white-space: pre; 41 | overflow-wrap: normal; 42 | border: none; 43 | border: solid 1px var(--gray_dark); 44 | color: var(--gray_light); 45 | font-size: 15px; 46 | -webkit-box-sizing: border-box; 47 | -moz-box-sizing: border-box; 48 | box-sizing: border-box; 49 | resize: none; 50 | } 51 | 52 | .textarea-container { 53 | position: relative; 54 | height: 90%; 55 | min-height:270px; 56 | } 57 | 58 | .textarea-container textarea { 59 | width: 100%; 60 | height: 100%; 61 | box-sizing: border-box; 62 | } 63 | 64 | .textarea-container button { 65 | background-color: var(--bg_dark); 66 | border-radius: 5px; 67 | color: var(--gray_light); 68 | border: solid 2px var(--green_light); 69 | position: absolute; 70 | top: 3px; 71 | right: 22px; 72 | } 73 | 74 | .textarea-container button:hover { 75 | background-color: var(--bg_light); 76 | } 77 | 78 | .button-clicked{ 79 | border: solid 1px var(--gray_light) !important; 80 | top: 4px !important; 81 | right: 23px !important; 82 | } 83 | 84 | /* removes browser focus outline */ 85 | :focus { 86 | outline:none !important; 87 | } 88 | 89 | /* dots for chart legend */ 90 | .green-small-dot, .green-big-dot, .red-small-dot, .red-big-dot{ 91 | margin-right: 3px; 92 | border-radius: 50%; 93 | display: inline-block; 94 | } 95 | 96 | .green-small-dot, .red-small-dot{ 97 | margin-bottom: 2px; 98 | height: 5px; 99 | width: 5px; 100 | } 101 | 102 | .green-big-dot, .red-big-dot{ 103 | margin-top: 8px; 104 | height: 8px; 105 | width: 8px; 106 | } 107 | 108 | .red-small-dot, .red-big-dot{ 109 | background-color: var(--red_light); 110 | } 111 | 112 | .green-small-dot, .green-big-dot{ 113 | background-color: var(--green_light); 114 | } 115 | 116 | .tile { 117 | text-align: center; 118 | display: block; 119 | border: 1px solid var(--gray_dark); 120 | border-radius: 5px; 121 | min-height: 65px; 122 | max-height: 65px; 123 | padding: 0px 10px; 124 | margin-left: 5px; 125 | margin-right: 5px; 126 | margin-bottom: 8px; 127 | } 128 | 129 | /* firefox only but without that it is fine too */ 130 | .tile:nth-child(4n) { 131 | break-after: always; /* New syntax */ 132 | } 133 | 134 | .tile-header{ 135 | border-bottom: 1px solid var(--gray_dark); 136 | } 137 | 138 | .tile-content{ 139 | line-height: 18px; 140 | margin-top: 0px; 141 | } 142 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | 2 | // access colors defined in css 3 | var vars_from_css = getComputedStyle(document.body) 4 | // .substring(1) removes first space in " #ff0000" 5 | var gray_light = vars_from_css.getPropertyValue('--gray_light').substring(1); 6 | var gray_dark = vars_from_css.getPropertyValue('--gray_dark').substring(1); 7 | var green_light = vars_from_css.getPropertyValue('--green_light').substring(1); 8 | var green_dark = vars_from_css.getPropertyValue('--green_dark').substring(1); 9 | var red_light = vars_from_css.getPropertyValue('--red_light').substring(1); 10 | var red_dark = vars_from_css.getPropertyValue('--red_dark').substring(1); 11 | 12 | // points colors on the chart - UP/DOWN 13 | var points_colors = []; 14 | for (var x of predictions) { 15 | if (x == 'UP') { 16 | points_colors[points_colors.length] = green_light; 17 | } else if (x == 'DOWN') { 18 | points_colors[points_colors.length] = red_light; 19 | } else if (x == null) { 20 | points_colors[points_colors.length] = gray_light; 21 | } 22 | } 23 | 24 | // points borders colors on the chart 25 | var points_borders = []; 26 | for (var x of orders) { 27 | if (x == 'BUY') { 28 | points_borders[points_borders.length] = green_light; 29 | } else if (x == 'SELL') { 30 | points_borders[points_borders.length] = red_light; 31 | } else if (x == null) { 32 | points_borders[points_borders.length] = "transparent"; 33 | } 34 | } 35 | 36 | new Chart("BTCUSD_chart", { 37 | type: "line", 38 | data: { 39 | labels: data_index, 40 | datasets: [{ 41 | label: "Binance BTC/USDT_close_1h", 42 | fill: false, 43 | lineTension: 0, 44 | pointStyle: "circle", 45 | pointBackgroundColor: points_colors, 46 | pointStrokeColor: points_borders, 47 | pointBorderColor: points_borders, 48 | pointBorderWidth: 5, 49 | pointRadius: 2, 50 | borderWidth: 1, 51 | backgroundColor: gray_light, 52 | borderColor: gray_light, 53 | data: data_values 54 | }], 55 | }, 56 | options: { 57 | responsive: true, 58 | legend: { 59 | display: false, 60 | }, 61 | scales: { 62 | xAxes: [{ 63 | gridLines: { 64 | color: gray_dark 65 | }, 66 | ticks: { 67 | fontSize: 12, 68 | autoSkip: true, 69 | maxRotation: 90, 70 | minRotation: 70 71 | } 72 | }], 73 | yAxes: [{ 74 | gridLines: { 75 | color: gray_dark 76 | }, 77 | ticks: { 78 | fontSize: 12 79 | } 80 | }], 81 | }, 82 | /* 83 | animation: { 84 | duration: 0, 85 | } 86 | */ 87 | } 88 | }); 89 | 90 | 91 | // texts colors 92 | function set_tile_text_color(elementId, text_green, text_red) 93 | { 94 | let element = document.getElementById(elementId) 95 | let content = element.textContent; 96 | if (content == text_green) { 97 | document.getElementById(elementId).style.color = green_light; 98 | } 99 | else if (content == text_red) 100 | { 101 | document.getElementById(elementId).style.color = red_light; 102 | } 103 | } 104 | set_tile_text_color("status", "Active", "Disabled"); 105 | set_tile_text_color("orders", "Allowed", "Disallowed"); 106 | set_tile_text_color("backups", "Allowed", "Disallowed"); 107 | 108 | function set_color_acc_pct(elementId) 109 | { 110 | let element = document.getElementById(elementId) 111 | let content = element.textContent.slice(0, -1); // removing % from 52.1% 112 | if (content > 50.0) { 113 | document.getElementById(elementId).style.color = green_light; 114 | } 115 | else if (content < 50.0) 116 | { 117 | document.getElementById(elementId).style.color = red_light; 118 | } 119 | } 120 | set_color_acc_pct("accuracy_pct"); 121 | set_color_acc_pct("accuracy_72h_pct"); 122 | 123 | function set_color_acc_text(elementId) 124 | { 125 | let element = document.getElementById(elementId) 126 | let first_char = element.textContent.charAt(0); 127 | if (first_char == "+") { 128 | document.getElementById(elementId).style.color = green_light; 129 | } 130 | else if (first_char == "-") 131 | { 132 | document.getElementById(elementId).style.color = red_light; 133 | } 134 | } 135 | set_color_acc_text("accuracy_text"); 136 | set_color_acc_text("accuracy_72h_text"); 137 | set_color_acc_text("runtime_price_change_text"); 138 | 139 | 140 | // making logfile textarea scrolled to bottom by default 141 | var textarea = document.getElementById('log_textarea'); 142 | textarea.scrollTop = textarea.scrollHeight; 143 | 144 | 145 | // add breaks in textarea with some regex 146 | var lines_broken = true; 147 | var log_data = textarea.textContent; // imported above 148 | log_data_new = log_data.replace(/FILLED] /g,"FILLED]\n "); // /text/g flag makes replace work for all instances 149 | log_data_new = log_data_new.replace(/ Try again/g,"\n Try again"); 150 | function toggle_line_break() { 151 | if(lines_broken == false) { 152 | textarea.value = log_data; 153 | lines_broken = true; 154 | textarea.scrollTop = textarea.scrollHeight; 155 | document.getElementById("break_line_button").classList.add("button-clicked"); 156 | } 157 | else if (lines_broken == true) { 158 | textarea.value = log_data_new; 159 | lines_broken = false; 160 | textarea.scrollTop = textarea.scrollHeight; 161 | document.getElementById("break_line_button").classList.remove("button-clicked"); 162 | } 163 | } 164 | toggle_line_break() 165 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | crypto-bot 17 | 18 | 19 | 20 | 21 |
22 | 31 | 32 |
33 |
34 |
35 | 36 |
37 |
Binance BTCUSDT_close_1h - 72h chart
38 | 39 | 40 |
41 |
Prediction UP
42 |
Prediction DOWN
43 |
Bought BTC
44 |
Sold BTC
45 |
46 |
47 | 48 | 49 |
50 |
Log
51 |
52 | 53 | 54 |
55 |
56 | 57 |
58 |
59 | 60 | 61 |
62 |
63 | 64 |
66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
Current balance:{{ CURRENT_BALANCE_USD }} USD , {{ CURRENT_BALANCE_BTC }} BTC , TOTAL: {{ current_total_value_USD }} USD
Starting balance:
{{ STARTING_BALANCE_USD }} USD , {{ STARTING_BALANCE_BTC }} BTC , TOTAL: {{ starting_total_value_USD }} USD
Runtime accuracy:{{ accuracy_from_start }}% , TOTAL: {{ profit_from_start_text }}
72h accuracy:{{ accuracy_72h }}% , TOTAL: {{ _72h_profit_text }}
Runtime price change:{{ runtime_price_change_text }}
88 |
89 | 90 |
91 |
92 |
Started
93 |
{{ exact_starting_timestamp[:10] }}
{{ exact_starting_timestamp[11:] }}
94 |
95 |
96 |
Enviroment
97 |
binance.com
{{ ENVIROMENT }}
98 |
99 |
100 |
Last backup
101 |
{{ last_commit_time[:10] }}
{{ last_commit_time[11:] }}
102 |
103 |
104 |
Prediction model
105 |
{{ MODEL_PATH.split("/")[2] }}
106 |
107 |
108 |
Status
109 |
{{ STATUS_ACTIVE_text }}
110 |
111 | 112 |
113 |
Orders
114 |
{{ ORDERS_ALLOWED_text }}
115 |
116 |
117 |
GitHub backups
118 |
{{ BACKUPS_ALLOWED_text }}
119 |
120 |
121 | 122 |
123 |
124 | 125 |
All timestamps are given in UTC+0
126 |
127 |
128 | 129 | 130 | 131 | 132 | 133 | 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | 2 | # this file is for testing functions manually and executing tools from tools/ that are not used by the bot 3 | # it is not used by the bot.py or app.py 4 | 5 | def test_simulation(): 6 | from tools.simulate import simulation 7 | simulation() 8 | # test_simulation() 9 | 10 | 11 | def test_prediction(): 12 | # watch out because executing it will print output to logfile.log and might have unexpected behaviour for app.py 13 | from src.predict import predict_next 14 | predict_next() 15 | # test_prediction() 16 | 17 | 18 | def my_test_orders(): 19 | from binance.client import Client 20 | import configparser 21 | 22 | cfg_secrets = configparser.ConfigParser() 23 | cfg_secrets.read('secrets.ini') 24 | TESTNET_API_KEY = cfg_secrets['DEFAULT']['TESTNET_API_KEY'] 25 | TESTNET_API_SECRET = cfg_secrets['DEFAULT']['TESTNET_API_SECRET'] 26 | 27 | client = Client(TESTNET_API_KEY, TESTNET_API_SECRET, testnet=True) 28 | 29 | BTC = client.get_asset_balance(asset='BTC')['free'] 30 | USDT = client.get_asset_balance(asset='USDT')['free'] 31 | ETH = client.get_asset_balance(asset='ETH')['free'] 32 | print(f'BTC: {BTC}, USDT: {USDT}, ETH: {ETH}') 33 | 34 | # https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md#lot_size 35 | # quantity= or quoteOrderQty= 36 | # order = client.create_order(side='SELL', quantity=round(49.963952), symbol='BTCUSDT', type='MARKET') 37 | # order = client.create_order(side='BUY', quoteOrderQty=round(50, 6), symbol='ETHUSDT', type='MARKET') 38 | # print(order) 39 | 40 | BTC = client.get_asset_balance(asset='BTC')['free'] 41 | USDT = client.get_asset_balance(asset='USDT')['free'] 42 | ETH = client.get_asset_balance(asset='ETH')['free'] 43 | print(f'BTC: {BTC}, USDT: {USDT}, ETH: {ETH}') 44 | # exit() 45 | 46 | # request: 47 | # BTC = client.get_asset_balance(asset='BTC')['free'] 48 | # resulted in: 49 | # binance.exceptions.BinanceAPIException: APIError(code=-1021): Timestamp for this request was 1000ms ahead of the server's time. 50 | # windows fix: 51 | # from control panel > date and time > internet time 52 | # change the server to >>>> time.nist.gov 53 | # my_test_orders() 54 | 55 | -------------------------------------------------------------------------------- /tools/data_add_new_pair.py: -------------------------------------------------------------------------------- 1 | # this file is not used by the bot or app 2 | 3 | import pandas as pd 4 | import numpy as np 5 | import time 6 | from datetime import datetime 7 | from requests import get 8 | from json import loads 9 | 10 | from src.utils import unix_to_datetime, datetime_to_unix, UTC_OFFSET 11 | from src.data_update import update_ratio 12 | from src.config import DATA_PATH_ABS 13 | 14 | def download_data(starting_time, ratio, filename): 15 | try: 16 | open(filename, "r") 17 | raise Exception('File already exists, you can only update it.') 18 | except FileNotFoundError: 19 | pass 20 | last_record_unix = datetime_to_unix(str(starting_time), h=-UTC_OFFSET) # -2 because of timezones 21 | 22 | response = get('https://api.binance.com/api/v1/klines', 23 | params={'symbol': ratio, 24 | 'interval': '1h', 25 | 'startTime': last_record_unix, 26 | 'limit': 1000}) 27 | raw_data = loads(response.content) 28 | raw_data_df = pd.DataFrame(raw_data) 29 | 30 | new_df = pd.DataFrame(columns=['date', f'{ratio}_close', f'{ratio}_volume']) 31 | # we receive data in UTC+2 timezone, so need to set h=-2, so we pass list of [-2] as second argument 32 | new_df['date'] = list(map(unix_to_datetime, raw_data_df[0], [UTC_OFFSET] * len(raw_data_df[0]))) 33 | new_df[f'{ratio}_close'] = list(map(float, raw_data_df[4])) # df[4] # Close 34 | new_df[f'{ratio}_volume'] = list(map(float, raw_data_df[7])) # df[7] # Quote asset volume (second assets unit, so in BTC/USDT its volume_USDT) # noqa 35 | new_df.set_index("date", inplace=True) 36 | # if we send request at 15:27 (UTC-0 13:27) it will return data with last entry 13:00 which will change over time 37 | new_df = new_df[:-1] # dropping that entry 38 | 39 | new_df.index = pd.to_datetime(new_df.index) 40 | expected_range = pd.date_range(start=new_df.index.min(), end=new_df.index.max(), freq='H') 41 | missing_ts = list(expected_range.difference(new_df.index)) 42 | if missing_ts: 43 | # inp = input(f'Missing timestamps: {missing_ts}.\n' 44 | # f'{len(expected_range) - len(new_df.index)} missing timestamps. y - interpolate, n - exit\n') 45 | new_df = new_df.resample('60min').mean() 46 | new_df.replace(to_replace=0, value=np.nan, inplace=True) # converting 0s to NaNs 47 | new_df.interpolate(method='linear', inplace=True) # interpolating missing nan values 48 | print(f'Missing timestamps: {missing_ts}.\n' 49 | f'{len(missing_ts)} missing timestamps. auto interpolate') 50 | 51 | return new_df 52 | 53 | 54 | def data_add_new_pair(): 55 | ratio = ['XRPUSDT'] # ["BCHUSDT"] 56 | starting_time = datetime.strptime('2017-01-01 00:00:00', '%Y-%m-%d %H:%M:%S') 57 | # it only downloads first 500 records, to get all data use update_data() on new pair .csv file 58 | filename = f"{DATA_PATH_ABS}Binance_{ratio}_1h.csv" 59 | df_first = download_data(starting_time, ratio, filename) 60 | df_first.to_csv(filename) 61 | while True: # added data_update from src.data_update.py 62 | filename = f"{DATA_PATH_ABS}Binance_{ratio}_1h.csv" 63 | time.sleep(0.3) 64 | new_df = update_ratio(ratio, filename, request_records=1000, fill_missing=True) 65 | new_df.to_csv(filename, mode='a', header=False) 66 | 67 | 68 | if __name__ == "__main__": 69 | data_add_new_pair() 70 | -------------------------------------------------------------------------------- /tools/simulate.py: -------------------------------------------------------------------------------- 1 | # this file is not used by the bot or app 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from sklearn import preprocessing 6 | from collections import deque 7 | from src.config import RATIOS, RATIO_TO_PREDICT, SEQ_LEN, MODEL_PATH_ABS, DATA_PATH_ABS, logger 8 | import tflite_runtime.interpreter as tflite 9 | # windows: C:\Users\Admin\Desktop\python37\Scripts>pip3 install --index-url https://google-coral.gith 10 | # rpi: tflite_runtime-2.3.1-cp37-cp37m-linux_armv6l.whl 11 | 12 | pd.set_option('display.max_columns', None) 13 | pd.set_option('display.expand_frame_repr', False) 14 | 15 | # simulation removes the latest record from data, 16 | # because in this case its not possible to verify real price and "target" 17 | 18 | NUM_OF_PREDS = 1000 # tu inaczej niz w single prediction 19 | FUTURE_PERIOD_PREDICT = 1 20 | 21 | def construct_df_for_multiple_pred(ratios): 22 | main_df = pd.DataFrame() 23 | # 'unix,date,symbol,open,high,low,close,Volume LTC,Volume USDT,tradecount' 24 | for ratio in ratios: 25 | # drive_path = 'drive/MyDrive/Colab Notebooks/crypto_prediction/model3/data/' 26 | dataset = f"{DATA_PATH_ABS}Binance_{ratio}_1h.csv" 27 | df = pd.read_csv(dataset, nrows=None, skiprows=None, parse_dates=['date'], usecols=[0, 1, 2]) 28 | df.set_index("date", inplace=True) 29 | 30 | # later we use pct_change so we lose 1 row and need 1 more (-49 for single pred) 31 | # "+ 1" is because the newest row is removed cause target would be NAN, so 32 | # "+ 1" has to be here for training, and cant be here for prediction 33 | # but "+ 1" has to be here for simulation of multiple predictions 34 | short_df = df[-(SEQ_LEN + 1) - NUM_OF_PREDS:] 35 | 36 | if len(main_df) == 0: 37 | main_df = short_df 38 | else: 39 | main_df = main_df.join(short_df) 40 | 41 | # in this case it reduces main_df length to the length of pair with the smallest data 42 | main_df.dropna(how='any', inplace=True) 43 | 44 | def classify(current, future): 45 | if float(future) > float(current): 46 | return 1 47 | else: 48 | return 0 49 | # we determine targets by making ['future'] column with shifted rows, 50 | # and then if its price is higher than current day setting ['target'] to 1, and 0 otherwise 51 | main_df['future'] = main_df[f"{RATIO_TO_PREDICT}_close"].shift(-FUTURE_PERIOD_PREDICT) 52 | main_df.dropna(how='any', inplace=True) # todo check if it shouldnt be replaced everywhere 53 | main_df['target'] = list(map(classify, main_df[f"{RATIO_TO_PREDICT}_close"], main_df["future"])) 54 | main_df.drop('future', 1, inplace=True) # future column is no longer needed, used only to determine target column 55 | # rows with future = NaN also are dropped here 56 | 57 | return main_df 58 | 59 | 60 | # percentage change and scaling 61 | def preprocess_data_for_pred(df): 62 | df = df.copy() # fixes random SettingWithCopyWarning appearing with pct_change and preprocessing.scale 63 | for col in df.columns: 64 | # todo here we lose 1 row 65 | df[col] = df[col].pct_change() # converted to percentage change 66 | # example: 67 | # hour 1:00 close = 93.13, hour 2:00 close = 92.41, so 68 | # 93.13 + 93.13 * x = 92.41, x = -0.007731 69 | # so percentage change of 1.0 means doubling the price 70 | # first row (the oldest values) is being filled with NaN percentage, but we don't want to drop whole row yet 71 | # so we wait until loop ends (we will have whole row filled with nans) 72 | df.dropna(inplace=True) 73 | # print('b', df.shape, df.isnull().values.any()) 74 | for col in df.columns: 75 | if col != "target": 76 | # scaling should be used after splitting to training and test set - leaks data from test set to training 77 | df[col] = preprocessing.scale(df[col].values) # normalizes to [-1, +1] 78 | # df[col] = preprocessing.StandardScaler().fit_transform(df[col].values.reshape(-1, 1)) # it does the same 79 | # scales the data so it has mean = 0, standard deviation = 1, variance = 1 80 | # it can be checked before and after with e.g. print(df['BTCUSDT_close'].mean()) .std() .var() 81 | 82 | prev_days = deque(maxlen=SEQ_LEN) # list of SEQ_LEN items, we append to it, as it exceed, it pops old values 83 | sequential_data = [] 84 | for i in df.values: 85 | prev_days.append([n for n in i]) # set of price and volume features added (-1 so without target) 86 | if len(prev_days) == SEQ_LEN: 87 | sequential_data.append(np.array([prev_days])) 88 | # print(len(sequential_data)) 89 | # ok so the sequences are like this ([a, b, 1] is single prev_days) (* and ^ just to point where it goes): 90 | # [([[a, b] [c, d], [e, f]], 1*) ([[c, d] [e, f] [g, h]], 1) ... ([[g, h], [i, j], [k, l]], 0^)] 91 | 92 | return sequential_data 93 | 94 | 95 | balance_USD = float(100) 96 | balance_BTC = float(0) 97 | order_counter = 0 98 | fee = 0.001 99 | def simulation(): 100 | main_df = construct_df_for_multiple_pred(RATIOS) 101 | real_y = main_df['target'][SEQ_LEN:] 102 | main_df.drop('target', 1, inplace=True) 103 | print(main_df.shape) 104 | 105 | predict_x = preprocess_data_for_pred(main_df) 106 | print(predict_x[0].shape) 107 | 108 | def classifier(prob): 109 | return 1 if prob > 0.5 else 0 110 | 111 | predictions = [] 112 | interpreter = tflite.Interpreter(model_path=MODEL_PATH_ABS) 113 | interpreter.allocate_tensors() 114 | input_details = interpreter.get_input_details() 115 | output_details = interpreter.get_output_details() 116 | for x, y in zip(predict_x, real_y.values): 117 | data_for_tflite = np.array(x, dtype=np.float32) 118 | interpreter.set_tensor(input_details[0]['index'], data_for_tflite) 119 | interpreter.invoke() 120 | predict_y = interpreter.get_tensor(output_details[0]['index']) 121 | predictions.append(classifier(predict_y[0][1])) 122 | 123 | predictions = np.array(predictions) 124 | real_y = np.array(real_y.values) 125 | print(f'pred 0s: {(predictions == 0).sum()}, 1s: {(predictions == 1).sum()}') 126 | print(f'real 0s: {(real_y == 0).sum()}, 1s: {(real_y == 1).sum()}') 127 | print(f'accuracy: {np.mean(predictions == real_y)}') 128 | 129 | # zakres [Number 130 | real_BTC_prices = main_df['BTCUSDT_close'][SEQ_LEN:].values.tolist() 131 | predictions = predictions.tolist() 132 | global balance_USD 133 | global balance_BTC 134 | global order_counter 135 | 136 | def buy_BTC(money, USD_BTC_price): 137 | global balance_USD 138 | global order_counter 139 | balance_USD = 0 140 | order_counter += 1 141 | print(f'ORDER: {order_counter} | HOUR {i}: Bought {money / USD_BTC_price}BTC for {money}USD at the price of {USD_BTC_price}USD/BTC') 142 | return money / USD_BTC_price - fee * (money / USD_BTC_price) 143 | 144 | def sell_BTC(money, USD_BTC_price): 145 | global balance_BTC 146 | global order_counter 147 | balance_BTC = 0 148 | order_counter += 1 149 | print(f'ORDER: {order_counter} | HOUR {i}: Sold {money}BTC for {money * USD_BTC_price}USD at the price of {USD_BTC_price}USD/BTC') 150 | return money * USD_BTC_price - fee * (money * USD_BTC_price) 151 | 152 | last_buy_price = 0 153 | print(f'Start balance_BTC: {balance_BTC}') 154 | print(f'Start balance_USD: {balance_USD}') 155 | for i in range(1, len(real_BTC_prices)): # todo removed -2 here, check again if everything is correct 156 | if (balance_USD > 0) and (predictions[i] == 1): 157 | balance_BTC = buy_BTC(balance_USD, real_BTC_prices[i]) 158 | continue 159 | # if (balance_BTC > 0) and (real_BTC_prices[i] > real_BTC_prices[i - 1]) and predictions[i] == 0: 160 | if (balance_BTC > 0) and (predictions[i] == 0): 161 | balance_USD = sell_BTC(balance_BTC, real_BTC_prices[i]) 162 | # elif (balance_BTC > 0) and (real_BTC_prices[i] > real_BTC_prices[i - 1]) and predictions[i] == 1: 163 | elif (balance_BTC > 0) and predictions[i] == 1: 164 | continue 165 | 166 | if balance_BTC > 0: 167 | balance_USD = sell_BTC(balance_BTC, real_BTC_prices[-1]) 168 | print(f'Final balance_BTC: {balance_BTC}') 169 | print(f'Final balance_USD: {balance_USD}') 170 | 171 | 172 | if __name__ == "__main__": 173 | simulation() -------------------------------------------------------------------------------- /training-google-colab/convert_model.py: -------------------------------------------------------------------------------- 1 | # this file is not used by the bot or app 2 | # it shows how to convert tensorflow model to TFlite to be suitable for Raspberry Pi 3 | # it can be done on Google Colab and downloaded 4 | 5 | import tensorflow as tf 6 | from config import MODEL_PATH 7 | 8 | # Convert the model 9 | converter = tf.lite.TFLiteConverter.from_saved_model(MODEL_PATH) # path to the SavedModel directory 10 | tflite_model = converter.convert() 11 | 12 | # Save it 13 | with open('model2.tflite', 'wb') as f: 14 | print("Started writing") 15 | f.write(tflite_model) 16 | print("Finished") 17 | -------------------------------------------------------------------------------- /training-google-colab/model3.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "nbformat": 4, 3 | "nbformat_minor": 0, 4 | "metadata": { 5 | "colab": { 6 | "name": "model3.ipynb", 7 | "provenance": [], 8 | "collapsed_sections": [] 9 | }, 10 | "kernelspec": { 11 | "name": "python3", 12 | "display_name": "Python 3" 13 | }, 14 | "language_info": { 15 | "name": "python" 16 | }, 17 | "accelerator": "GPU" 18 | }, 19 | "cells": [ 20 | { 21 | "cell_type": "code", 22 | "metadata": { 23 | "id": "sfwZbBSN6dnu" 24 | }, 25 | "source": [ 26 | "from sklearn import preprocessing\n", 27 | "from collections import deque\n", 28 | "from datetime import datetime\n", 29 | "import random\n", 30 | "import time\n", 31 | "import numpy as np\n", 32 | "import pandas as pd\n", 33 | "import tensorflow as tf\n", 34 | "from tensorflow import keras\n", 35 | "from tensorflow.keras.models import Sequential\n", 36 | "from tensorflow.keras.layers import Dense, Dropout, LSTM, BatchNormalization\n", 37 | "from tensorflow.python.keras.layers import CuDNNLSTM\n", 38 | "from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping\n", 39 | "\n", 40 | "drive_path = f'drive/MyDrive/Colab Notebooks/crypto_prediction/model3/'" 41 | ], 42 | "execution_count": 1, 43 | "outputs": [] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "metadata": { 48 | "id": "SYa7ACGv6xuU" 49 | }, 50 | "source": [ 51 | "pd.set_option('display.max_columns', None)\n", 52 | "pd.set_option('display.expand_frame_repr', False)\n", 53 | "\n", 54 | "# ratios = [\"BTCUSDT\", \"ETHUSDT\", \"NANOUSDT\"]\n", 55 | "ratios = [\"BTCUSDT\", \"LTCUSDT\", \"ETHUSDT\", \"BNBUSDT\",\n", 56 | " \"ADAUSDT\", \"BTTUSDT\", \"DASHUSDT\", \"XMRUSDT\",\n", 57 | " \"NANOUSDT\", \"DOGEUSDT\", \"XLMUSDT\", \"BCHUSDT\"]\n", 58 | "RATIO_TO_PREDICT = \"BTCUSDT\"\n", 59 | "\n", 60 | "SEQ_LEN = 48\n", 61 | "FUTURE_PERIOD_PREDICT = 1\n", 62 | "EPOCHS = 40\n", 63 | "BATCH_SIZE = 64\n", 64 | "current_time = datetime.utcnow().strftime('%Y-%m-%d %H-%M-%S')\n", 65 | "NAME = f\"LSTM-{current_time}\"\n", 66 | "\n", 67 | "def construct_main_df(ratios):\n", 68 | " main_df = pd.DataFrame()\n", 69 | " # 'unix,date,symbol,open,high,low,close,Volume LTC,Volume USDT,tradecount'\n", 70 | " for ratio in ratios:\n", 71 | " dataset = f\"{drive_path}data/Binance_{ratio}_1h.csv\"\n", 72 | " df = pd.read_csv(dataset, nrows=None, skiprows=None, parse_dates=['date'], usecols=[0, 1, 2]) # todo\n", 73 | " df.set_index(\"date\", inplace=True)\n", 74 | "\n", 75 | " if len(main_df) == 0:\n", 76 | " main_df = df\n", 77 | " else:\n", 78 | " main_df = main_df.join(df)\n", 79 | "\n", 80 | " # in this case it reduces main_df length to the length of pair with the smallest data\n", 81 | " main_df.dropna(how='any', inplace=True)\n", 82 | "\n", 83 | " # we determine targets by making ['future'] column with shifted rows,\n", 84 | " # and then if its price is higher than current day setting ['target'] to 1, and 0 otherwise\n", 85 | " main_df['future'] = main_df[f\"{RATIO_TO_PREDICT}_close\"].shift(-FUTURE_PERIOD_PREDICT)\n", 86 | " main_df['target'] = list(map(classify, main_df[f\"{RATIO_TO_PREDICT}_close\"], main_df[\"future\"]))\n", 87 | " main_df.drop('future', 1, inplace=True) # future column is no longer needed, used only to determine target column\n", 88 | " # rows with future = NaN also are dropped here\n", 89 | " return main_df\n", 90 | "\n", 91 | "def classify(current, future):\n", 92 | " return 1 if float(future) > float(current) else 0\n", 93 | "\n", 94 | "def split_main_df(main_df, train_split, validation_split, test_split):\n", 95 | " # [train train train train_index val val val val_index test test test_index]\n", 96 | " train_index = int(train_split * len(main_df)) # last indexes\n", 97 | " validation_index = int(train_index + validation_split * len(main_df))\n", 98 | " test_index = test_split # we don't really need to use it, its always the last index\n", 99 | "\n", 100 | " train_df = main_df.iloc[:train_index]\n", 101 | " validation_df = main_df.iloc[train_index:validation_index]\n", 102 | " test_df = main_df.iloc[validation_index:]\n", 103 | " return train_df, validation_df, test_df\n", 104 | "\n", 105 | "# percentage change and scaling\n", 106 | "def preprocess_data(df):\n", 107 | " df = df.copy() # fixes random SettingWithCopyWarning appearing with pct_change and preprocessing.scale\n", 108 | " for col in df.columns:\n", 109 | " if col != \"target\":\n", 110 | " df[col] = df[col].pct_change() # converted to percentage change\n", 111 | " # example:\n", 112 | " # hour 1:00 close = 93.13, hour 2:00 close = 92.41, so\n", 113 | " # 93.13 + 93.13 * x = 92.41, x = -0.007731\n", 114 | " # so percentage change of 1.0 means doubling the price\n", 115 | " # first row (the oldest values) is being filled with NaN percentage, but we don't want to drop whole row yet\n", 116 | " # so we wait until loop ends (we will have whole row filled with nans)\n", 117 | " df.dropna(inplace=True)\n", 118 | " # print('b', df.shape, df.isnull().values.any())\n", 119 | " for col in df.columns:\n", 120 | " if col != \"target\":\n", 121 | " # scaling should be used after splitting to training and test set - leaks data from test set to training\n", 122 | " df[col] = preprocessing.scale(df[col].values) # normalizes to [-1, +1]\n", 123 | " # df[col] = preprocessing.StandardScaler().fit_transform(df[col].values.reshape(-1, 1)) # it does the same\n", 124 | " # scales the data so it has mean = 0, standard deviation = 1, variance = 1\n", 125 | " # it can be checked before and after with e.g. print(df['BTCUSDT_close'].mean()) .std() .var()\n", 126 | "\n", 127 | " sequential_data = []\n", 128 | " prev_days = deque(maxlen=SEQ_LEN) # list of SEQ_LEN items, we append to it, as it exceed, it pops old values\n", 129 | "\n", 130 | " for i in df.values:\n", 131 | " prev_days.append([n for n in i[:-1]]) # set of price and volume features added (-1 so without target)\n", 132 | " if len(prev_days) == SEQ_LEN:\n", 133 | " sequential_data.append([np.array(prev_days), i[-1]])\n", 134 | " # print(len(sequential_data))\n", 135 | " # ok so the sequences are like this ([a, b, 1] is single prev_days) (* and ^ just to point where it goes):\n", 136 | " # [([[a, b] [c, d], [e, f]], 1*) ([[c, d] [e, f] [g, h]], 1) ... ([[g, h], [i, j], [k, l]], 0^)]\n", 137 | " random.shuffle(sequential_data)\n", 138 | "\n", 139 | "\n", 140 | " buys = [] # list that will store our buy sequences and targets\n", 141 | " sells = [] # list that will store our sell sequences and targets\n", 142 | "\n", 143 | " for seq, target in sequential_data: # iterate over the sequential data\n", 144 | " if target == 0: # if it's a \"not buy\"\n", 145 | " sells.append([seq, target]) # append to sells list\n", 146 | " elif target == 1: # otherwise if the target is a 1...\n", 147 | " buys.append([seq, target]) # it's a buy!\n", 148 | "\n", 149 | " random.shuffle(buys) # shuffle the buys\n", 150 | " random.shuffle(sells) # shuffle the sells\n", 151 | "\n", 152 | " lower = min(len(buys), len(sells)) # what's the shorter length?\n", 153 | "\n", 154 | " buys = buys[:lower] # make sure both lists are only up to the shortest length.\n", 155 | " sells = sells[:lower] # make sure both lists are only up to the shortest length.\n", 156 | "\n", 157 | " sequential_data = buys + sells # concatenate lists\n", 158 | " random.shuffle(\n", 159 | " sequential_data) # another shuffle, so the model doesn't get confused with all 1 class then the other.\n", 160 | "\n", 161 | " x = []\n", 162 | " y = []\n", 163 | "\n", 164 | " for seq, target in sequential_data:\n", 165 | " x.append(seq)\n", 166 | " y.append(target)\n", 167 | " return np.array(x), np.array(y)" 168 | ], 169 | "execution_count": 2, 170 | "outputs": [] 171 | }, 172 | { 173 | "cell_type": "code", 174 | "metadata": { 175 | "id": "FY9cXsAF64Uq", 176 | "colab": { 177 | "base_uri": "https://localhost:8080/" 178 | }, 179 | "outputId": "d7263d26-d264-4fec-92a6-d99f22a2bc5b" 180 | }, 181 | "source": [ 182 | "time0 = time.perf_counter()\n", 183 | "main_df = construct_main_df(ratios)\n", 184 | "print(main_df.shape)\n", 185 | "time1 = time.perf_counter()\n", 186 | "print(time1 - time0)\n", 187 | "\n", 188 | "train_df, validation_df, test_df = split_main_df(main_df, train_split=0.6, validation_split=0.2, test_split=0.2)\n", 189 | "print(train_df.shape, validation_df.shape, test_df.shape)\n", 190 | "time2 = time.perf_counter()\n", 191 | "print(time2 - time1)\n", 192 | "\n", 193 | "train_x, train_y = preprocess_data(train_df)\n", 194 | "validation_x, validation_y = preprocess_data(validation_df)\n", 195 | "test_x, test_y = preprocess_data(test_df)\n", 196 | "time3 = time.perf_counter()\n", 197 | "print(train_x.shape, train_y.shape, validation_x.shape, validation_y.shape, test_x.shape, test_y.shape,)\n", 198 | "print(time3 - time2)" 199 | ], 200 | "execution_count": 3, 201 | "outputs": [ 202 | { 203 | "output_type": "stream", 204 | "text": [ 205 | "(15737, 25)\n", 206 | "0.4711706650000451\n", 207 | "(9442, 25) (3147, 25) (3148, 25)\n", 208 | "0.0005700589999833028\n", 209 | "(9134, 48, 24) (9134,) (2940, 48, 24) (2940,) (3066, 48, 24) (3066,)\n", 210 | "2.1966656359999774\n" 211 | ], 212 | "name": "stdout" 213 | } 214 | ] 215 | }, 216 | { 217 | "cell_type": "code", 218 | "metadata": { 219 | "id": "JJKJcltc7kDx", 220 | "colab": { 221 | "base_uri": "https://localhost:8080/" 222 | }, 223 | "outputId": "382dadb1-3de1-44fc-c440-aba94c06f01f" 224 | }, 225 | "source": [ 226 | "def build_model():\n", 227 | " model = Sequential()\n", 228 | " model.add(LSTM(128, input_shape=(train_x.shape[1:]), return_sequences=True))\n", 229 | " # i cannot save CuDNNLSTM due to some error, not using it\n", 230 | " # model.add(CuDNNLSTM(128, input_shape=(train_x.shape[1:]), return_sequences=True))\n", 231 | " model.add(Dropout(0.2))\n", 232 | " model.add(BatchNormalization())\n", 233 | "\n", 234 | " model.add(LSTM(128, input_shape=(train_x.shape[1:]), return_sequences=True))\n", 235 | " model.add(Dropout(0.1))\n", 236 | " model.add(BatchNormalization())\n", 237 | "\n", 238 | " model.add(LSTM(128, input_shape=(train_x.shape[1:])))\n", 239 | " model.add(Dropout(0.2))\n", 240 | " model.add(BatchNormalization())\n", 241 | "\n", 242 | " model.add(Dense(32, activation=\"relu\"))\n", 243 | " model.add(Dropout(0.2))\n", 244 | "\n", 245 | " model.add(Dense(2, activation=\"softmax\"))\n", 246 | "\n", 247 | " opt = tf.keras.optimizers.Adam(learning_rate=0.001, decay=1e-6)\n", 248 | " # opt = tf.keras.optimizers.Adam()\n", 249 | "\n", 250 | " model.compile(loss = \"sparse_categorical_crossentropy\", \n", 251 | " optimizer=opt,\n", 252 | " metrics=['accuracy'])\n", 253 | "\n", 254 | " print(model.summary())\n", 255 | " return model\n", 256 | "model = build_model()" 257 | ], 258 | "execution_count": 4, 259 | "outputs": [ 260 | { 261 | "output_type": "stream", 262 | "text": [ 263 | "Model: \"sequential\"\n", 264 | "_________________________________________________________________\n", 265 | "Layer (type) Output Shape Param # \n", 266 | "=================================================================\n", 267 | "lstm (LSTM) (None, 48, 128) 78336 \n", 268 | "_________________________________________________________________\n", 269 | "dropout (Dropout) (None, 48, 128) 0 \n", 270 | "_________________________________________________________________\n", 271 | "batch_normalization (BatchNo (None, 48, 128) 512 \n", 272 | "_________________________________________________________________\n", 273 | "lstm_1 (LSTM) (None, 48, 128) 131584 \n", 274 | "_________________________________________________________________\n", 275 | "dropout_1 (Dropout) (None, 48, 128) 0 \n", 276 | "_________________________________________________________________\n", 277 | "batch_normalization_1 (Batch (None, 48, 128) 512 \n", 278 | "_________________________________________________________________\n", 279 | "lstm_2 (LSTM) (None, 128) 131584 \n", 280 | "_________________________________________________________________\n", 281 | "dropout_2 (Dropout) (None, 128) 0 \n", 282 | "_________________________________________________________________\n", 283 | "batch_normalization_2 (Batch (None, 128) 512 \n", 284 | "_________________________________________________________________\n", 285 | "dense (Dense) (None, 32) 4128 \n", 286 | "_________________________________________________________________\n", 287 | "dropout_3 (Dropout) (None, 32) 0 \n", 288 | "_________________________________________________________________\n", 289 | "dense_1 (Dense) (None, 2) 66 \n", 290 | "=================================================================\n", 291 | "Total params: 347,234\n", 292 | "Trainable params: 346,466\n", 293 | "Non-trainable params: 768\n", 294 | "_________________________________________________________________\n", 295 | "None\n" 296 | ], 297 | "name": "stdout" 298 | } 299 | ] 300 | }, 301 | { 302 | "cell_type": "code", 303 | "metadata": { 304 | "colab": { 305 | "base_uri": "https://localhost:8080/" 306 | }, 307 | "id": "HwV1fg6uttcy", 308 | "outputId": "007144f2-b485-4dc2-d998-666f4781a6a9" 309 | }, 310 | "source": [ 311 | "checkpoint = ModelCheckpoint(filepath=f\"models/{NAME}\", monitor='val_accuracy', verbose=1, save_best_only=True, mode='max') # saves only the best ones\n", 312 | "earlystopping = EarlyStopping(monitor='val_loss', patience=4)\n", 313 | "cb = [checkpoint, earlystopping]\n", 314 | "\n", 315 | "history = model.fit(\n", 316 | " train_x, train_y,\n", 317 | " batch_size=BATCH_SIZE,\n", 318 | " epochs=EPOCHS,\n", 319 | " validation_data=(validation_x, validation_y),\n", 320 | " callbacks=cb)\n" 321 | ], 322 | "execution_count": 5, 323 | "outputs": [ 324 | { 325 | "output_type": "stream", 326 | "text": [ 327 | "Epoch 1/40\n", 328 | "143/143 [==============================] - 13s 23ms/step - loss: 0.7907 - accuracy: 0.5310 - val_loss: 0.6946 - val_accuracy: 0.4980\n", 329 | "\n", 330 | "Epoch 00001: val_accuracy improved from -inf to 0.49796, saving model to models/LSTM-2021-08-23 20-05-44\n" 331 | ], 332 | "name": "stdout" 333 | }, 334 | { 335 | "output_type": "stream", 336 | "text": [ 337 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 338 | ], 339 | "name": "stderr" 340 | }, 341 | { 342 | "output_type": "stream", 343 | "text": [ 344 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 345 | ], 346 | "name": "stdout" 347 | }, 348 | { 349 | "output_type": "stream", 350 | "text": [ 351 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 352 | ], 353 | "name": "stderr" 354 | }, 355 | { 356 | "output_type": "stream", 357 | "text": [ 358 | "Epoch 2/40\n", 359 | "143/143 [==============================] - 2s 17ms/step - loss: 0.6907 - accuracy: 0.5473 - val_loss: 0.7004 - val_accuracy: 0.5177\n", 360 | "\n", 361 | "Epoch 00002: val_accuracy improved from 0.49796 to 0.51769, saving model to models/LSTM-2021-08-23 20-05-44\n" 362 | ], 363 | "name": "stdout" 364 | }, 365 | { 366 | "output_type": "stream", 367 | "text": [ 368 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 369 | ], 370 | "name": "stderr" 371 | }, 372 | { 373 | "output_type": "stream", 374 | "text": [ 375 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 376 | ], 377 | "name": "stdout" 378 | }, 379 | { 380 | "output_type": "stream", 381 | "text": [ 382 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 383 | ], 384 | "name": "stderr" 385 | }, 386 | { 387 | "output_type": "stream", 388 | "text": [ 389 | "Epoch 3/40\n", 390 | "143/143 [==============================] - 2s 17ms/step - loss: 0.6819 - accuracy: 0.5651 - val_loss: 0.6283 - val_accuracy: 0.6435\n", 391 | "\n", 392 | "Epoch 00003: val_accuracy improved from 0.51769 to 0.64354, saving model to models/LSTM-2021-08-23 20-05-44\n" 393 | ], 394 | "name": "stdout" 395 | }, 396 | { 397 | "output_type": "stream", 398 | "text": [ 399 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 400 | ], 401 | "name": "stderr" 402 | }, 403 | { 404 | "output_type": "stream", 405 | "text": [ 406 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 407 | ], 408 | "name": "stdout" 409 | }, 410 | { 411 | "output_type": "stream", 412 | "text": [ 413 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 414 | ], 415 | "name": "stderr" 416 | }, 417 | { 418 | "output_type": "stream", 419 | "text": [ 420 | "Epoch 4/40\n", 421 | "143/143 [==============================] - 2s 17ms/step - loss: 0.6284 - accuracy: 0.6156 - val_loss: 0.6218 - val_accuracy: 0.7037\n", 422 | "\n", 423 | "Epoch 00004: val_accuracy improved from 0.64354 to 0.70374, saving model to models/LSTM-2021-08-23 20-05-44\n" 424 | ], 425 | "name": "stdout" 426 | }, 427 | { 428 | "output_type": "stream", 429 | "text": [ 430 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 431 | ], 432 | "name": "stderr" 433 | }, 434 | { 435 | "output_type": "stream", 436 | "text": [ 437 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 438 | ], 439 | "name": "stdout" 440 | }, 441 | { 442 | "output_type": "stream", 443 | "text": [ 444 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 445 | ], 446 | "name": "stderr" 447 | }, 448 | { 449 | "output_type": "stream", 450 | "text": [ 451 | "Epoch 5/40\n", 452 | "143/143 [==============================] - 2s 17ms/step - loss: 0.6344 - accuracy: 0.6056 - val_loss: 0.5704 - val_accuracy: 0.7007\n", 453 | "\n", 454 | "Epoch 00005: val_accuracy did not improve from 0.70374\n", 455 | "Epoch 6/40\n", 456 | "143/143 [==============================] - 2s 17ms/step - loss: 0.5978 - accuracy: 0.6362 - val_loss: 0.5423 - val_accuracy: 0.7170\n", 457 | "\n", 458 | "Epoch 00006: val_accuracy improved from 0.70374 to 0.71701, saving model to models/LSTM-2021-08-23 20-05-44\n" 459 | ], 460 | "name": "stdout" 461 | }, 462 | { 463 | "output_type": "stream", 464 | "text": [ 465 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 466 | ], 467 | "name": "stderr" 468 | }, 469 | { 470 | "output_type": "stream", 471 | "text": [ 472 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 473 | ], 474 | "name": "stdout" 475 | }, 476 | { 477 | "output_type": "stream", 478 | "text": [ 479 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 480 | ], 481 | "name": "stderr" 482 | }, 483 | { 484 | "output_type": "stream", 485 | "text": [ 486 | "Epoch 7/40\n", 487 | "143/143 [==============================] - 2s 17ms/step - loss: 0.5774 - accuracy: 0.6511 - val_loss: 0.5440 - val_accuracy: 0.7323\n", 488 | "\n", 489 | "Epoch 00007: val_accuracy improved from 0.71701 to 0.73231, saving model to models/LSTM-2021-08-23 20-05-44\n" 490 | ], 491 | "name": "stdout" 492 | }, 493 | { 494 | "output_type": "stream", 495 | "text": [ 496 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 497 | ], 498 | "name": "stderr" 499 | }, 500 | { 501 | "output_type": "stream", 502 | "text": [ 503 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 504 | ], 505 | "name": "stdout" 506 | }, 507 | { 508 | "output_type": "stream", 509 | "text": [ 510 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 511 | ], 512 | "name": "stderr" 513 | }, 514 | { 515 | "output_type": "stream", 516 | "text": [ 517 | "Epoch 8/40\n", 518 | "143/143 [==============================] - 2s 17ms/step - loss: 0.5530 - accuracy: 0.6632 - val_loss: 0.5340 - val_accuracy: 0.7252\n", 519 | "\n", 520 | "Epoch 00008: val_accuracy did not improve from 0.73231\n", 521 | "Epoch 9/40\n", 522 | "143/143 [==============================] - 2s 17ms/step - loss: 0.5499 - accuracy: 0.6642 - val_loss: 0.5367 - val_accuracy: 0.7330\n", 523 | "\n", 524 | "Epoch 00009: val_accuracy improved from 0.73231 to 0.73299, saving model to models/LSTM-2021-08-23 20-05-44\n" 525 | ], 526 | "name": "stdout" 527 | }, 528 | { 529 | "output_type": "stream", 530 | "text": [ 531 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 532 | ], 533 | "name": "stderr" 534 | }, 535 | { 536 | "output_type": "stream", 537 | "text": [ 538 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 539 | ], 540 | "name": "stdout" 541 | }, 542 | { 543 | "output_type": "stream", 544 | "text": [ 545 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 546 | ], 547 | "name": "stderr" 548 | }, 549 | { 550 | "output_type": "stream", 551 | "text": [ 552 | "Epoch 10/40\n", 553 | "143/143 [==============================] - 3s 18ms/step - loss: 0.5401 - accuracy: 0.6751 - val_loss: 0.5112 - val_accuracy: 0.7333\n", 554 | "\n", 555 | "Epoch 00010: val_accuracy improved from 0.73299 to 0.73333, saving model to models/LSTM-2021-08-23 20-05-44\n" 556 | ], 557 | "name": "stdout" 558 | }, 559 | { 560 | "output_type": "stream", 561 | "text": [ 562 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 563 | ], 564 | "name": "stderr" 565 | }, 566 | { 567 | "output_type": "stream", 568 | "text": [ 569 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 570 | ], 571 | "name": "stdout" 572 | }, 573 | { 574 | "output_type": "stream", 575 | "text": [ 576 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 577 | ], 578 | "name": "stderr" 579 | }, 580 | { 581 | "output_type": "stream", 582 | "text": [ 583 | "Epoch 11/40\n", 584 | "143/143 [==============================] - 3s 19ms/step - loss: 0.5340 - accuracy: 0.6775 - val_loss: 0.6029 - val_accuracy: 0.7456\n", 585 | "\n", 586 | "Epoch 00011: val_accuracy improved from 0.73333 to 0.74558, saving model to models/LSTM-2021-08-23 20-05-44\n" 587 | ], 588 | "name": "stdout" 589 | }, 590 | { 591 | "output_type": "stream", 592 | "text": [ 593 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 594 | ], 595 | "name": "stderr" 596 | }, 597 | { 598 | "output_type": "stream", 599 | "text": [ 600 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 601 | ], 602 | "name": "stdout" 603 | }, 604 | { 605 | "output_type": "stream", 606 | "text": [ 607 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 608 | ], 609 | "name": "stderr" 610 | }, 611 | { 612 | "output_type": "stream", 613 | "text": [ 614 | "Epoch 12/40\n", 615 | "143/143 [==============================] - 3s 18ms/step - loss: 0.5349 - accuracy: 0.6846 - val_loss: 0.5104 - val_accuracy: 0.7388\n", 616 | "\n", 617 | "Epoch 00012: val_accuracy did not improve from 0.74558\n", 618 | "Epoch 13/40\n", 619 | "143/143 [==============================] - 3s 18ms/step - loss: 0.5291 - accuracy: 0.6833 - val_loss: 0.5159 - val_accuracy: 0.7378\n", 620 | "\n", 621 | "Epoch 00013: val_accuracy did not improve from 0.74558\n", 622 | "Epoch 14/40\n", 623 | "143/143 [==============================] - 3s 18ms/step - loss: 0.5258 - accuracy: 0.6866 - val_loss: 0.5121 - val_accuracy: 0.7422\n", 624 | "\n", 625 | "Epoch 00014: val_accuracy did not improve from 0.74558\n", 626 | "Epoch 15/40\n", 627 | "143/143 [==============================] - 2s 17ms/step - loss: 0.5268 - accuracy: 0.6813 - val_loss: 0.5140 - val_accuracy: 0.7357\n", 628 | "\n", 629 | "Epoch 00015: val_accuracy did not improve from 0.74558\n", 630 | "Epoch 16/40\n", 631 | "143/143 [==============================] - 2s 17ms/step - loss: 0.5207 - accuracy: 0.6915 - val_loss: 0.5407 - val_accuracy: 0.7466\n", 632 | "\n", 633 | "Epoch 00016: val_accuracy improved from 0.74558 to 0.74660, saving model to models/LSTM-2021-08-23 20-05-44\n" 634 | ], 635 | "name": "stdout" 636 | }, 637 | { 638 | "output_type": "stream", 639 | "text": [ 640 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 641 | ], 642 | "name": "stderr" 643 | }, 644 | { 645 | "output_type": "stream", 646 | "text": [ 647 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 648 | ], 649 | "name": "stdout" 650 | }, 651 | { 652 | "output_type": "stream", 653 | "text": [ 654 | "INFO:tensorflow:Assets written to: models/LSTM-2021-08-23 20-05-44/assets\n" 655 | ], 656 | "name": "stderr" 657 | } 658 | ] 659 | }, 660 | { 661 | "cell_type": "code", 662 | "metadata": { 663 | "id": "Eye28PNHttoz", 664 | "colab": { 665 | "base_uri": "https://localhost:8080/", 666 | "height": 556 667 | }, 668 | "outputId": "b0babe6b-b99f-4306-9c33-c12d2ed431a2" 669 | }, 670 | "source": [ 671 | "def plot_history(history):\n", 672 | " hist_df = pd.DataFrame(history.history)\n", 673 | " print(f'Trained model:')\n", 674 | " print(hist_df.tail(1), '\\n')\n", 675 | " hist_df_short = hist_df[['accuracy', 'val_accuracy', 'val_loss']]\n", 676 | " hist_df_short.plot(figsize=(8,8), grid=True)\n", 677 | "\n", 678 | "plot_history(history)" 679 | ], 680 | "execution_count": 7, 681 | "outputs": [ 682 | { 683 | "output_type": "stream", 684 | "text": [ 685 | "Trained model:\n", 686 | " loss accuracy val_loss val_accuracy\n", 687 | "15 0.520694 0.691482 0.540717 0.746599 \n", 688 | "\n" 689 | ], 690 | "name": "stdout" 691 | }, 692 | { 693 | "output_type": "display_data", 694 | "data": { 695 | "image/png": "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\n", 696 | "text/plain": [ 697 | "
" 698 | ] 699 | }, 700 | "metadata": { 701 | "needs_background": "light" 702 | } 703 | } 704 | ] 705 | }, 706 | { 707 | "cell_type": "code", 708 | "metadata": { 709 | "id": "suv_B7cOvYq8", 710 | "colab": { 711 | "base_uri": "https://localhost:8080/" 712 | }, 713 | "outputId": "63ed1bbf-4905-4cfb-9654-ac44a6a76a54" 714 | }, 715 | "source": [ 716 | "model_path = f'{drive_path}models/{NAME}'\n", 717 | "model.save(model_path)\n", 718 | "\n", 719 | "model = keras.models.load_model(model_path)\n", 720 | "score = model.evaluate(test_x, test_y, verbose=10) # evaluate against unseen test data\n", 721 | "print(\"TEST LOSS:\", score[0])\n", 722 | "print(\"TEST ACCURACY:\", score[1])" 723 | ], 724 | "execution_count": 8, 725 | "outputs": [ 726 | { 727 | "output_type": "stream", 728 | "text": [ 729 | "WARNING:absl:Found untraced functions such as lstm_cell_layer_call_and_return_conditional_losses, lstm_cell_layer_call_fn, lstm_cell_1_layer_call_and_return_conditional_losses, lstm_cell_1_layer_call_fn, lstm_cell_2_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 730 | ], 731 | "name": "stderr" 732 | }, 733 | { 734 | "output_type": "stream", 735 | "text": [ 736 | "INFO:tensorflow:Assets written to: drive/MyDrive/Colab Notebooks/crypto_prediction/model3/models/LSTM-2021-08-23 20-05-44/assets\n" 737 | ], 738 | "name": "stdout" 739 | }, 740 | { 741 | "output_type": "stream", 742 | "text": [ 743 | "INFO:tensorflow:Assets written to: drive/MyDrive/Colab Notebooks/crypto_prediction/model3/models/LSTM-2021-08-23 20-05-44/assets\n" 744 | ], 745 | "name": "stderr" 746 | }, 747 | { 748 | "output_type": "stream", 749 | "text": [ 750 | "TEST LOSS: 0.6933889985084534\n", 751 | "TEST ACCURACY: 0.5228310227394104\n" 752 | ], 753 | "name": "stdout" 754 | } 755 | ] 756 | }, 757 | { 758 | "cell_type": "code", 759 | "metadata": { 760 | "id": "xd9UqtJ1hmSD" 761 | }, 762 | "source": [ 763 | "\n" 764 | ], 765 | "execution_count": null, 766 | "outputs": [] 767 | } 768 | ] 769 | } -------------------------------------------------------------------------------- /training-google-colab/model3_kfold.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "nbformat": 4, 3 | "nbformat_minor": 0, 4 | "metadata": { 5 | "colab": { 6 | "name": "model3_kfold.ipynb", 7 | "provenance": [], 8 | "collapsed_sections": [] 9 | }, 10 | "kernelspec": { 11 | "name": "python3", 12 | "display_name": "Python 3" 13 | }, 14 | "language_info": { 15 | "name": "python" 16 | }, 17 | "accelerator": "GPU" 18 | }, 19 | "cells": [ 20 | { 21 | "cell_type": "code", 22 | "metadata": { 23 | "id": "sfwZbBSN6dnu" 24 | }, 25 | "source": [ 26 | "from sklearn import preprocessing\n", 27 | "from sklearn.model_selection import KFold\n", 28 | "from collections import deque\n", 29 | "from datetime import datetime\n", 30 | "import random\n", 31 | "import time\n", 32 | "import numpy as np\n", 33 | "import pandas as pd\n", 34 | "import tensorflow as tf\n", 35 | "from tensorflow import keras\n", 36 | "from tensorflow.keras.models import Sequential\n", 37 | "from tensorflow.keras.layers import Dense, Dropout, LSTM, BatchNormalization\n", 38 | "from tensorflow.python.keras.layers import CuDNNLSTM\n", 39 | "from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping\n", 40 | "\n", 41 | "drive_path = f'drive/MyDrive/Colab Notebooks/crypto_prediction/model3/'" 42 | ], 43 | "execution_count": null, 44 | "outputs": [] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "metadata": { 49 | "id": "SYa7ACGv6xuU" 50 | }, 51 | "source": [ 52 | "pd.set_option('display.max_columns', None)\n", 53 | "pd.set_option('display.expand_frame_repr', False)\n", 54 | "\n", 55 | "# ratios = [\"BTCUSDT\", \"ETHUSDT\", \"NANOUSDT\"]\n", 56 | "ratios = [\"BTCUSDT\", \"LTCUSDT\", \"ETHUSDT\", \"BNBUSDT\",\n", 57 | " \"ADAUSDT\", \"BTTUSDT\", \"DASHUSDT\", \"XMRUSDT\",\n", 58 | " \"NANOUSDT\", \"DOGEUSDT\", \"XLMUSDT\", \"BCHUSDT\"]\n", 59 | "RATIO_TO_PREDICT = \"BTCUSDT\"\n", 60 | "\n", 61 | "NUM_FOLDS = 5 # for 10 we got val (input[test] of 10-fold) acc ~0.70 and test acc ~0.52. Overfiting to \"val\" set. Use 5, so its like 20% used for val\n", 62 | "SEQ_LEN = 48\n", 63 | "FUTURE_PERIOD_PREDICT = 1\n", 64 | "EPOCHS = 40 # 12 # 40\n", 65 | "BATCH_SIZE = 64\n", 66 | "current_time = datetime.utcnow().strftime('%Y-%m-%d %H-%M-%S')\n", 67 | "NAME = f\"LSTM-{current_time}\"\n", 68 | "\n", 69 | "acc_per_fold = []\n", 70 | "loss_per_fold = []\n", 71 | "kfold = KFold(n_splits=NUM_FOLDS, shuffle=True)\n", 72 | "\n", 73 | "def construct_main_df(ratios):\n", 74 | " main_df = pd.DataFrame()\n", 75 | " # 'unix,date,symbol,open,high,low,close,Volume LTC,Volume USDT,tradecount'\n", 76 | " for ratio in ratios:\n", 77 | " dataset = f\"{drive_path}data/Binance_{ratio}_1h.csv\"\n", 78 | " df = pd.read_csv(dataset, nrows=None, skiprows=None, parse_dates=['date'], usecols=[0, 1, 2]) # todo\n", 79 | " df.set_index(\"date\", inplace=True)\n", 80 | "\n", 81 | " if len(main_df) == 0:\n", 82 | " main_df = df\n", 83 | " else:\n", 84 | " main_df = main_df.join(df)\n", 85 | "\n", 86 | " # in this case it reduces main_df length to the length of pair with the smallest data\n", 87 | " main_df.dropna(how='any', inplace=True)\n", 88 | "\n", 89 | " # we determine targets by making ['future'] column with shifted rows,\n", 90 | " # and then if its price is higher than current day setting ['target'] to 1, and 0 otherwise\n", 91 | " main_df['future'] = main_df[f\"{RATIO_TO_PREDICT}_close\"].shift(-FUTURE_PERIOD_PREDICT)\n", 92 | " main_df['target'] = list(map(classify, main_df[f\"{RATIO_TO_PREDICT}_close\"], main_df[\"future\"]))\n", 93 | " main_df.drop('future', 1, inplace=True) # future column is no longer needed, used only to determine target column\n", 94 | " # rows with future = NaN also are dropped here\n", 95 | " return main_df\n", 96 | "\n", 97 | "def classify(current, future):\n", 98 | " return 1 if float(future) > float(current) else 0\n", 99 | "\n", 100 | "\n", 101 | "# in k-fold we dont need validation set\n", 102 | "def split_main_df(main_df, train_split, test_split):\n", 103 | " # [train train train train_index val val val val_index test test test_index]\n", 104 | " train_index = int(train_split * len(main_df)) # last indexes\n", 105 | " test_index = test_split # we don't really need to use it, its always the last index\n", 106 | "\n", 107 | " train_df = main_df.iloc[:train_index]\n", 108 | " test_df = main_df.iloc[train_index:]\n", 109 | " return train_df, test_df\n", 110 | "\n", 111 | "# percentage change and scaling\n", 112 | "def preprocess_data(df):\n", 113 | " df = df.copy() # fixes random SettingWithCopyWarning appearing with pct_change and preprocessing.scale\n", 114 | " for col in df.columns:\n", 115 | " if col != \"target\":\n", 116 | " df[col] = df[col].pct_change() # converted to percentage change\n", 117 | " # example:\n", 118 | " # hour 1:00 close = 93.13, hour 2:00 close = 92.41, so\n", 119 | " # 93.13 + 93.13 * x = 92.41, x = -0.007731\n", 120 | " # so percentage change of 1.0 means doubling the price\n", 121 | " # first row (the oldest values) is being filled with NaN percentage, but we don't want to drop whole row yet\n", 122 | " # so we wait until loop ends (we will have whole row filled with nans)\n", 123 | " df.dropna(inplace=True)\n", 124 | " # print('b', df.shape, df.isnull().values.any())\n", 125 | " for col in df.columns:\n", 126 | " if col != \"target\":\n", 127 | " # scaling should be used after splitting to training and test set - leaks data from test set to training\n", 128 | " df[col] = preprocessing.scale(df[col].values) # normalizes to [-1, +1]\n", 129 | " # df[col] = preprocessing.StandardScaler().fit_transform(df[col].values.reshape(-1, 1)) # it does the same\n", 130 | " # scales the data so it has mean = 0, standard deviation = 1, variance = 1\n", 131 | " # it can be checked before and after with e.g. print(df['BTCUSDT_close'].mean()) .std() .var()\n", 132 | "\n", 133 | " sequential_data = []\n", 134 | " prev_days = deque(maxlen=SEQ_LEN) # list of SEQ_LEN items, we append to it, as it exceed, it pops old values\n", 135 | "\n", 136 | " for i in df.values:\n", 137 | " prev_days.append([n for n in i[:-1]]) # set of price and volume features added (-1 so without target)\n", 138 | " if len(prev_days) == SEQ_LEN:\n", 139 | " sequential_data.append([np.array(prev_days), i[-1]])\n", 140 | " # print(len(sequential_data))\n", 141 | " # ok so the sequences are like this ([a, b, 1] is single prev_days) (* and ^ just to point where it goes):\n", 142 | " # [([[a, b] [c, d], [e, f]], 1*) ([[c, d] [e, f] [g, h]], 1) ... ([[g, h], [i, j], [k, l]], 0^)]\n", 143 | " random.shuffle(sequential_data)\n", 144 | "\n", 145 | "\n", 146 | " buys = [] # list that will store our buy sequences and targets\n", 147 | " sells = [] # list that will store our sell sequences and targets\n", 148 | "\n", 149 | " for seq, target in sequential_data: # iterate over the sequential data\n", 150 | " if target == 0: # if it's a \"not buy\"\n", 151 | " sells.append([seq, target]) # append to sells list\n", 152 | " elif target == 1: # otherwise if the target is a 1...\n", 153 | " buys.append([seq, target]) # it's a buy!\n", 154 | "\n", 155 | " random.shuffle(buys) # shuffle the buys\n", 156 | " random.shuffle(sells) # shuffle the sells\n", 157 | "\n", 158 | " lower = min(len(buys), len(sells)) # what's the shorter length?\n", 159 | "\n", 160 | " buys = buys[:lower] # make sure both lists are only up to the shortest length.\n", 161 | " sells = sells[:lower] # make sure both lists are only up to the shortest length.\n", 162 | "\n", 163 | " sequential_data = buys + sells # concatenate lists\n", 164 | " random.shuffle(\n", 165 | " sequential_data) # another shuffle, so the model doesn't get confused with all 1 class then the other.\n", 166 | "\n", 167 | " x = []\n", 168 | " y = []\n", 169 | "\n", 170 | " for seq, target in sequential_data:\n", 171 | " x.append(seq)\n", 172 | " y.append(target)\n", 173 | " return np.array(x), np.array(y)" 174 | ], 175 | "execution_count": null, 176 | "outputs": [] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "metadata": { 181 | "id": "FY9cXsAF64Uq", 182 | "colab": { 183 | "base_uri": "https://localhost:8080/" 184 | }, 185 | "outputId": "b566df64-2e8b-4938-c8f4-4437ec503987" 186 | }, 187 | "source": [ 188 | "time0 = time.perf_counter()\n", 189 | "main_df = construct_main_df(ratios)\n", 190 | "print(main_df.shape)\n", 191 | "time1 = time.perf_counter()\n", 192 | "# print(time1 - time0)\n", 193 | "\n", 194 | "train_df, test_df = split_main_df(main_df, train_split=0.8, test_split=0.2)\n", 195 | "print(train_df.shape, test_df.shape)\n", 196 | "time2 = time.perf_counter()\n", 197 | "# print(time2 - time1)\n", 198 | "\n", 199 | "train_x, train_y = preprocess_data(train_df)\n", 200 | "test_x, test_y = preprocess_data(test_df)\n", 201 | "time3 = time.perf_counter()\n", 202 | "print(train_x.shape, train_y.shape, test_x.shape, test_y.shape,)\n", 203 | "# print(time3 - time2)" 204 | ], 205 | "execution_count": null, 206 | "outputs": [ 207 | { 208 | "output_type": "stream", 209 | "text": [ 210 | "(15737, 25)\n", 211 | "(12589, 25) (3148, 25)\n", 212 | "(12440, 48, 24) (12440,) (3066, 48, 24) (3066,)\n" 213 | ], 214 | "name": "stdout" 215 | } 216 | ] 217 | }, 218 | { 219 | "cell_type": "code", 220 | "metadata": { 221 | "id": "JJKJcltc7kDx" 222 | }, 223 | "source": [ 224 | "def build_model():\n", 225 | " model = Sequential()\n", 226 | " model.add(LSTM(128, input_shape=(train_x.shape[1:]), return_sequences=True))\n", 227 | " # i cannot save CuDNNLSTM due to some error, not using it\n", 228 | " # model.add(CuDNNLSTM(128, input_shape=(train_x.shape[1:]), return_sequences=True))\n", 229 | " model.add(Dropout(0.2))\n", 230 | " model.add(BatchNormalization())\n", 231 | "\n", 232 | " model.add(LSTM(128, input_shape=(train_x.shape[1:]), return_sequences=True))\n", 233 | " model.add(Dropout(0.1))\n", 234 | " model.add(BatchNormalization())\n", 235 | "\n", 236 | " model.add(LSTM(128, input_shape=(train_x.shape[1:])))\n", 237 | " model.add(Dropout(0.2))\n", 238 | " model.add(BatchNormalization())\n", 239 | "\n", 240 | " model.add(Dense(32, activation=\"relu\"))\n", 241 | " model.add(Dropout(0.2))\n", 242 | "\n", 243 | " model.add(Dense(2, activation=\"softmax\"))\n", 244 | "\n", 245 | " opt = tf.keras.optimizers.Adam(learning_rate=0.001, decay=1e-6)\n", 246 | " # opt = tf.keras.optimizers.Adam()\n", 247 | "\n", 248 | " model.compile(loss = \"sparse_categorical_crossentropy\", \n", 249 | " optimizer=opt,\n", 250 | " metrics=['accuracy'])\n", 251 | " # print(model.summary())\n", 252 | " return model\n" 253 | ], 254 | "execution_count": null, 255 | "outputs": [] 256 | }, 257 | { 258 | "cell_type": "code", 259 | "metadata": { 260 | "colab": { 261 | "base_uri": "https://localhost:8080/" 262 | }, 263 | "id": "HwV1fg6uttcy", 264 | "outputId": "ed7bbc35-1e42-4a17-c82b-df0cd53bb575" 265 | }, 266 | "source": [ 267 | "print(train_x.shape)\n", 268 | "\n", 269 | "\n", 270 | "FINAL_PRODUCTION_TRAINING = True # set manually!\n", 271 | "\n", 272 | "if FINAL_PRODUCTION_TRAINING is False:\n", 273 | " # this way we can use test_x and test_y as final evaluation after k-fold\n", 274 | " inputs = train_x\n", 275 | " targets = train_y\n", 276 | "elif FINAL_PRODUCTION_TRAINING is True:\n", 277 | " # this way we cant make that final evaluation, and rely on average score from k-fold\n", 278 | " inputs = np.concatenate((train_x, test_x), axis=0)\n", 279 | " targets = np.concatenate((train_y, test_y), axis=0)\n", 280 | "\n", 281 | "# production model training should be performed with train_x with 100% of main_df. we can expect its results close to average score from k-fold evals\n", 282 | "fold_no = 1\n", 283 | "for train, test in kfold.split(inputs, targets):\n", 284 | " model = build_model()\n", 285 | "\n", 286 | " checkpoint = ModelCheckpoint(filepath=f\"models/{NAME}\", monitor='val_accuracy', verbose=1, save_best_only=True, mode='max') # saves only the best ones\n", 287 | " earlystopping = EarlyStopping(monitor='accuracy', patience=4) # todo val_loss not available for EarlyStopping callback\n", 288 | " cb = [earlystopping]\n", 289 | " print('------------------------------------------------------------------------')\n", 290 | " print(f'Training for fold {fold_no} ...')\n", 291 | " history = model.fit(\n", 292 | " inputs[train], targets[train],\n", 293 | " batch_size=BATCH_SIZE,\n", 294 | " epochs=EPOCHS,\n", 295 | " verbose=0,\n", 296 | " # validation_data=(validation_x, validation_y),\n", 297 | " callbacks=cb)\n", 298 | " \n", 299 | " scores = model.evaluate(inputs[test], targets[test], verbose=0) # input[test] is like validation set, its part of training set\n", 300 | " print(f'Score for fold {fold_no}: {model.metrics_names[0]} of {scores[0]}; {model.metrics_names[1]} of {scores[1]*100}%')\n", 301 | " acc_per_fold.append(scores[1] * 100)\n", 302 | " loss_per_fold.append(scores[0])\n", 303 | "\n", 304 | " # Increase fold number\n", 305 | " fold_no = fold_no + 1\n", 306 | "\n", 307 | "\n" 308 | ], 309 | "execution_count": null, 310 | "outputs": [ 311 | { 312 | "output_type": "stream", 313 | "text": [ 314 | "(12440, 48, 24)\n", 315 | "------------------------------------------------------------------------\n", 316 | "Training for fold 1 ...\n", 317 | "Score for fold 1: loss of 1.3445900678634644; accuracy of 63.7653112411499%\n", 318 | "------------------------------------------------------------------------\n", 319 | "Training for fold 2 ...\n", 320 | "Score for fold 2: loss of 1.2719135284423828; accuracy of 64.75330591201782%\n", 321 | "------------------------------------------------------------------------\n", 322 | "Training for fold 3 ...\n", 323 | "Score for fold 3: loss of 1.3596887588500977; accuracy of 65.07578492164612%\n", 324 | "------------------------------------------------------------------------\n", 325 | "Training for fold 4 ...\n", 326 | "Score for fold 4: loss of 1.2974392175674438; accuracy of 62.59270906448364%\n", 327 | "------------------------------------------------------------------------\n", 328 | "Training for fold 5 ...\n", 329 | "Score for fold 5: loss of 1.2682578563690186; accuracy of 65.68848490715027%\n" 330 | ], 331 | "name": "stdout" 332 | } 333 | ] 334 | }, 335 | { 336 | "cell_type": "code", 337 | "metadata": { 338 | "colab": { 339 | "base_uri": "https://localhost:8080/" 340 | }, 341 | "id": "naOrFdKs7TRw", 342 | "outputId": "51cb67dd-c6fe-4262-c4db-c9cf310233c5" 343 | }, 344 | "source": [ 345 | "# == Provide average scores ==\n", 346 | "print('------------------------------------------------------------------------')\n", 347 | "print('Score per fold')\n", 348 | "for i in range(0, len(acc_per_fold)):\n", 349 | " print('------------------------------------------------------------------------')\n", 350 | " print(f'> Fold {i+1} - Loss: {loss_per_fold[i]} - Accuracy: {acc_per_fold[i]}%')\n", 351 | "print('------------------------------------------------------------------------')\n", 352 | "print('Average scores for all folds:')\n", 353 | "print(f'> Accuracy: {np.mean(acc_per_fold)} (+- {np.std(acc_per_fold)})')\n", 354 | "print(f'> Loss: {np.mean(loss_per_fold)}')\n", 355 | "print('------------------------------------------------------------------------')\n" 356 | ], 357 | "execution_count": null, 358 | "outputs": [ 359 | { 360 | "output_type": "stream", 361 | "text": [ 362 | "------------------------------------------------------------------------\n", 363 | "Score per fold\n", 364 | "------------------------------------------------------------------------\n", 365 | "> Fold 1 - Loss: 1.3445900678634644 - Accuracy: 63.7653112411499%\n", 366 | "------------------------------------------------------------------------\n", 367 | "> Fold 2 - Loss: 1.2719135284423828 - Accuracy: 64.75330591201782%\n", 368 | "------------------------------------------------------------------------\n", 369 | "> Fold 3 - Loss: 1.3596887588500977 - Accuracy: 65.07578492164612%\n", 370 | "------------------------------------------------------------------------\n", 371 | "> Fold 4 - Loss: 1.2974392175674438 - Accuracy: 62.59270906448364%\n", 372 | "------------------------------------------------------------------------\n", 373 | "> Fold 5 - Loss: 1.2682578563690186 - Accuracy: 65.68848490715027%\n", 374 | "------------------------------------------------------------------------\n", 375 | "Average scores for all folds:\n", 376 | "> Accuracy: 64.37511920928955 (+- 1.0869902263795579)\n", 377 | "> Loss: 1.3083778858184814\n", 378 | "------------------------------------------------------------------------\n" 379 | ], 380 | "name": "stdout" 381 | } 382 | ] 383 | }, 384 | { 385 | "cell_type": "code", 386 | "metadata": { 387 | "id": "Eye28PNHttoz", 388 | "colab": { 389 | "base_uri": "https://localhost:8080/", 390 | "height": 556 391 | }, 392 | "outputId": "a38c3822-9b2d-4a31-b534-f63468fa653d" 393 | }, 394 | "source": [ 395 | "def plot_history(history):\n", 396 | " hist_df = pd.DataFrame(history.history)\n", 397 | " print(f'Trained model:')\n", 398 | " print(hist_df.tail(1), '\\n')\n", 399 | " hist_df_short = hist_df[['accuracy', 'loss']]\n", 400 | " hist_df_short.plot(figsize=(8,8), grid=True)\n", 401 | "\n", 402 | "plot_history(history)" 403 | ], 404 | "execution_count": null, 405 | "outputs": [ 406 | { 407 | "output_type": "stream", 408 | "text": [ 409 | "Trained model:\n", 410 | " loss accuracy\n", 411 | "39 0.124177 0.950181 \n", 412 | "\n" 413 | ], 414 | "name": "stdout" 415 | }, 416 | { 417 | "output_type": "display_data", 418 | "data": { 419 | "image/png": "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\n", 420 | "text/plain": [ 421 | "
" 422 | ] 423 | }, 424 | "metadata": { 425 | "needs_background": "light" 426 | } 427 | } 428 | ] 429 | }, 430 | { 431 | "cell_type": "code", 432 | "metadata": { 433 | "id": "suv_B7cOvYq8", 434 | "colab": { 435 | "base_uri": "https://localhost:8080/" 436 | }, 437 | "outputId": "8f339af5-0531-4546-a2eb-f6a69b5c8697" 438 | }, 439 | "source": [ 440 | "model_path = f'{drive_path}models/{NAME}'\n", 441 | "model.save(model_path)\n", 442 | "\n", 443 | "model = keras.models.load_model(model_path)\n", 444 | "score = model.evaluate(test_x, test_y, verbose=10) # evaluate against unseen test data\n", 445 | "print(\"TEST LOSS:\", score[0])\n", 446 | "print(\"TEST ACCURACY:\", score[1])" 447 | ], 448 | "execution_count": null, 449 | "outputs": [ 450 | { 451 | "output_type": "stream", 452 | "text": [ 453 | "WARNING:absl:Found untraced functions such as lstm_cell_15_layer_call_and_return_conditional_losses, lstm_cell_15_layer_call_fn, lstm_cell_16_layer_call_and_return_conditional_losses, lstm_cell_16_layer_call_fn, lstm_cell_17_layer_call_and_return_conditional_losses while saving (showing 5 of 15). These functions will not be directly callable after loading.\n" 454 | ], 455 | "name": "stderr" 456 | }, 457 | { 458 | "output_type": "stream", 459 | "text": [ 460 | "INFO:tensorflow:Assets written to: drive/MyDrive/Colab Notebooks/crypto_prediction/model3/models/LSTM-2021-08-23 20-19-28/assets\n" 461 | ], 462 | "name": "stdout" 463 | }, 464 | { 465 | "output_type": "stream", 466 | "text": [ 467 | "INFO:tensorflow:Assets written to: drive/MyDrive/Colab Notebooks/crypto_prediction/model3/models/LSTM-2021-08-23 20-19-28/assets\n" 468 | ], 469 | "name": "stderr" 470 | }, 471 | { 472 | "output_type": "stream", 473 | "text": [ 474 | "TEST LOSS: 0.3757164478302002\n", 475 | "TEST ACCURACY: 0.8891063332557678\n" 476 | ], 477 | "name": "stdout" 478 | } 479 | ] 480 | }, 481 | { 482 | "cell_type": "code", 483 | "metadata": { 484 | "id": "xd9UqtJ1hmSD" 485 | }, 486 | "source": [ 487 | "converter = tf.lite.TFLiteConverter.from_saved_model(model_path) # path to the SavedModel directory\n", 488 | "tflite_model = converter.convert()" 489 | ], 490 | "execution_count": null, 491 | "outputs": [] 492 | }, 493 | { 494 | "cell_type": "code", 495 | "metadata": { 496 | "colab": { 497 | "base_uri": "https://localhost:8080/" 498 | }, 499 | "id": "8q4GjSyVy5em", 500 | "outputId": "d93c7889-ea51-4b1e-d3ce-a83e02a37528" 501 | }, 502 | "source": [ 503 | "with open(f'{model_path}.tflite', 'wb') as f:\n", 504 | " f.write(tflite_model)\n", 505 | " print(\"Finished\")\n", 506 | " f.close()" 507 | ], 508 | "execution_count": null, 509 | "outputs": [ 510 | { 511 | "output_type": "stream", 512 | "text": [ 513 | "Finished\n" 514 | ], 515 | "name": "stdout" 516 | } 517 | ] 518 | }, 519 | { 520 | "cell_type": "code", 521 | "metadata": { 522 | "id": "epvKcKD1zX0f" 523 | }, 524 | "source": [ 525 | "" 526 | ], 527 | "execution_count": null, 528 | "outputs": [] 529 | } 530 | ] 531 | } -------------------------------------------------------------------------------- /whl/tflite_runtime-2.3.1-cp37-cp37m-linux_armv6l.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamsec/crypto-bot/4d247efe76a71ca7a8e7a638f48f791a06f428b5/whl/tflite_runtime-2.3.1-cp37-cp37m-linux_armv6l.whl -------------------------------------------------------------------------------- /whl/tflite_runtime-2.5.0-cp37-cp37m-linux_armv7l.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamsec/crypto-bot/4d247efe76a71ca7a8e7a638f48f791a06f428b5/whl/tflite_runtime-2.5.0-cp37-cp37m-linux_armv7l.whl -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | 2 | from app import app 3 | 4 | if __name__ == "__main__": 5 | app.run() 6 | --------------------------------------------------------------------------------