├── CryptoMonitoring ├── conftest.py ├── src │ └── bitcoinmonitor │ │ ├── __init__.py │ │ ├── utils │ │ ├── __init__.py │ │ ├── sde_config.py │ │ └── db.py │ │ └── exchange_data_etl.py ├── env ├── assets │ └── images │ │ ├── bc_arch.png │ │ ├── bc_dash.png │ │ └── bc_sec_gp.png ├── containers │ ├── pipelinerunner │ │ ├── requirements.txt │ │ └── Dockerfile │ └── warehouse │ │ └── 1-setup-exchange.sql ├── scheduler │ └── pull_bitcoin_exchange_info ├── test │ ├── unit │ │ └── test_exchange_data_etl_unit.py │ ├── fixtures │ │ └── sample_raw_exchange_data.csv │ └── integration │ │ └── test_exchange_data_etl_integration.py ├── Makefile ├── deploy_helpers │ ├── send_code_to_prod.sh │ └── install_docker.sh └── docker-compose.yml ├── LICENSE └── README.md /CryptoMonitoring/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CryptoMonitoring/src/bitcoinmonitor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CryptoMonitoring/src/bitcoinmonitor/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CryptoMonitoring/env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=sdeuser 2 | POSTGRES_PASSWORD=sdepassword1234 3 | POSTGRES_DB=finance 4 | POSTGRES_HOST=warehouse 5 | POSTGRES_PORT=5432 -------------------------------------------------------------------------------- /CryptoMonitoring/assets/images/bc_arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rameshei87/Build-Crypto-Monitoring-Tool/HEAD/CryptoMonitoring/assets/images/bc_arch.png -------------------------------------------------------------------------------- /CryptoMonitoring/assets/images/bc_dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rameshei87/Build-Crypto-Monitoring-Tool/HEAD/CryptoMonitoring/assets/images/bc_dash.png -------------------------------------------------------------------------------- /CryptoMonitoring/assets/images/bc_sec_gp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rameshei87/Build-Crypto-Monitoring-Tool/HEAD/CryptoMonitoring/assets/images/bc_sec_gp.png -------------------------------------------------------------------------------- /CryptoMonitoring/containers/pipelinerunner/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2 2 | requests 3 | boto3 4 | pytest 5 | pytest-mock 6 | black 7 | flake8 8 | mypy 9 | types-requests 10 | isort -------------------------------------------------------------------------------- /CryptoMonitoring/scheduler/pull_bitcoin_exchange_info: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | HOME=/ 3 | */5 * * * * WAREHOUSE_USER=sdeuser WAREHOUSE_PASSWORD=sdepassword1234 WAREHOUSE_DB=finance WAREHOUSE_HOST=warehouse WAREHOUSE_PORT=5432 PYTHONPATH=/code/src /usr/local/bin/python /code/src/bitcoinmonitor/exchange_data_etl.py 4 | 5 | -------------------------------------------------------------------------------- /CryptoMonitoring/test/unit/test_exchange_data_etl_unit.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from bitcoinmonitor.exchange_data_etl import get_utc_from_unix_time 4 | 5 | 6 | def test_get_utc_from_unix_time(): 7 | ut: int = 1625249025588 8 | expected_dt = datetime.datetime(2021, 7, 2, 18, 3, 45, 588000) 9 | assert expected_dt == get_utc_from_unix_time(ut) 10 | -------------------------------------------------------------------------------- /CryptoMonitoring/src/bitcoinmonitor/utils/sde_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from bitcoinmonitor.utils.db import DBConnection 4 | 5 | 6 | def get_warehouse_creds() -> DBConnection: 7 | return DBConnection( 8 | user=os.getenv('WAREHOUSE_USER', ''), 9 | password=os.getenv('WAREHOUSE_PASSWORD', ''), 10 | db=os.getenv('WAREHOUSE_DB', ''), 11 | host=os.getenv('WAREHOUSE_HOST', ''), 12 | port=int(os.getenv('WAREHOUSE_PORT', 5432)), 13 | ) 14 | -------------------------------------------------------------------------------- /CryptoMonitoring/containers/warehouse/1-setup-exchange.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS bitcoin.exchange; 2 | DROP SCHEMA IF EXISTS bitcoin; 3 | CREATE SCHEMA bitcoin; 4 | CREATE TABLE bitcoin.exchange ( 5 | id VARCHAR(50), 6 | name VARCHAR(50), 7 | rank INT, 8 | percentTotalVolume NUMERIC(8, 5), 9 | volumeUsd NUMERIC, 10 | tradingPairs INT, 11 | socket BOOLEAN, 12 | exchangeUrl VARCHAR(50), 13 | updated_unix_millis BIGINT, 14 | updated_utc TIMESTAMP 15 | ); -------------------------------------------------------------------------------- /CryptoMonitoring/test/fixtures/sample_raw_exchange_data.csv: -------------------------------------------------------------------------------- 1 | "exchangeId","name","rank","percentTotalVolume","volumeUsd","tradingPairs","socket","exchangeUrl","updated" 2 | "binance","Binance",1,"25.44443","12712561147.7913049212358699",650,1,"https://www.binance.com/","1625787943298" 3 | "zg","ZG.com",2,"13.03445","6512276458.5226475820074930",133,0,"https://api.zg.com/","1625787941554" 4 | "huobi","Huobi",3,"5.93652","2966009471.8337660651992927",589,1,"https://www.hbg.com/","1625787943276" 5 | "okex","Okex",4,"4.99990","2498051785.3601278924449889",287,0,"https://www.okex.com/","1625787941641" 6 | -------------------------------------------------------------------------------- /CryptoMonitoring/Makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker compose --env-file env up --build -d 3 | 4 | down: 5 | docker compose down 6 | 7 | shell: 8 | docker exec -ti pipelinerunner bash 9 | 10 | format: 11 | docker exec pipelinerunner python -m black -S --line-length 79 . 12 | 13 | isort: 14 | docker exec pipelinerunner isort . 15 | 16 | pytest: 17 | docker exec pipelinerunner pytest /code/test 18 | 19 | type: 20 | docker exec pipelinerunner mypy --ignore-missing-imports /code 21 | 22 | lint: 23 | docker exec pipelinerunner flake8 /code 24 | 25 | ci: isort format type lint pytest 26 | 27 | stop-etl: 28 | docker exec pipelinerunner service cron stop -------------------------------------------------------------------------------- /CryptoMonitoring/deploy_helpers/send_code_to_prod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # inputs IP, pem file location 4 | if [ $# -ne 2 ]; then 5 | echo 'Please enter your pem location and EC2 public DNS as ./send_code_to_prod.sh pem-full-file-location EC2-Public-IPv4-DNS' 6 | exit 0 7 | fi 8 | 9 | # zip repo into gz file 10 | cd .. 11 | rm -f bitcoinmonitor.gzip 12 | zip -r bitcoinmonitor.gzip bitcoinmonitor/* 13 | 14 | # Send zipped repo to EC2 15 | chmod 400 $1 16 | scp -i $1 bitcoinmonitor.gzip ubuntu@$2:~/. 17 | cd bitcoinmonitor 18 | 19 | # Send docker installation script to EC2 20 | scp -i $1 ./deploy_helpers/install_docker.sh ubuntu@$2:~/. 21 | 22 | # sh into EC2 23 | ssh -i $1 ubuntu@$2 -------------------------------------------------------------------------------- /CryptoMonitoring/src/bitcoinmonitor/utils/db.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from dataclasses import dataclass 3 | 4 | import psycopg2 5 | 6 | 7 | @dataclass 8 | class DBConnection: 9 | db: str 10 | user: str 11 | password: str 12 | host: str 13 | port: int = 5432 14 | 15 | 16 | class WarehouseConnection: 17 | def __init__(self, db_conn: DBConnection): 18 | self.conn_url = ( 19 | f'postgresql://{db_conn.user}:{db_conn.password}@' 20 | f'{db_conn.host}:{db_conn.port}/{db_conn.db}' 21 | ) 22 | 23 | @contextmanager 24 | def managed_cursor(self, cursor_factory=None): 25 | self.conn = psycopg2.connect(self.conn_url) 26 | self.conn.autocommit = True 27 | self.curr = self.conn.cursor(cursor_factory=cursor_factory) 28 | try: 29 | yield self.curr 30 | finally: 31 | self.curr.close() 32 | self.conn.close() 33 | -------------------------------------------------------------------------------- /CryptoMonitoring/containers/pipelinerunner/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.5 2 | 3 | # set up location of code 4 | WORKDIR /code 5 | ENV PYTHONPATH=/code/src 6 | 7 | # install cron 8 | RUN apt-get update && apt-get install cron -y 9 | 10 | # install python requirements 11 | ADD ./containers/pipelinerunner/requirements.txt requirements.txt 12 | RUN pip install -r requirements.txt 13 | 14 | # copy repo 15 | COPY ./ /code/ 16 | 17 | # ref: https://stackoverflow.com/questions/37458287/how-to-run-a-cron-job-inside-a-docker-container 18 | # Copy pull_bitcoin_exchange_info file to the cron.d directory 19 | COPY /scheduler/pull_bitcoin_exchange_info /etc/cron.d/pull_bitcoin_exchange_info 20 | 21 | # Give execution rights on the cron job 22 | RUN chmod 0644 /etc/cron.d/pull_bitcoin_exchange_info 23 | 24 | # Apply cron job 25 | RUN crontab /etc/cron.d/pull_bitcoin_exchange_info 26 | 27 | # Create the log file to be able to run tail 28 | RUN touch /var/log/cron.log 29 | 30 | # Run cron 31 | CMD cron && tail -f /var/log/cron.log -------------------------------------------------------------------------------- /CryptoMonitoring/deploy_helpers/install_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # install docker 4 | sudo apt-get remove docker docker-engine docker.io containerd runc 5 | sudo apt-get update 6 | sudo apt-get -y install \ 7 | apt-transport-https \ 8 | ca-certificates \ 9 | curl \ 10 | gnupg \ 11 | lsb-release \ 12 | zip \ 13 | unzip 14 | 15 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg 16 | echo \ 17 | "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \ 18 | $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null 19 | sudo apt-get update 20 | sudo apt-get -y install docker-ce docker-ce-cli containerd.io 21 | 22 | # install docker compose 23 | sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 24 | sudo chmod +x /usr/local/bin/docker-compose 25 | 26 | # add user to docker group 27 | sudo usermod -aG docker $USER 28 | newgrp docker 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ramesh chinnaraj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CryptoMonitoring/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | warehouse: 5 | image: postgres:13 6 | container_name: warehouse 7 | environment: 8 | POSTGRES_USER: ${POSTGRES_USER} 9 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 10 | POSTGRES_DB: ${POSTGRES_DB} 11 | volumes: 12 | - ./containers/warehouse:/docker-entrypoint-initdb.d 13 | healthcheck: 14 | test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"] 15 | interval: 5s 16 | retries: 5 17 | restart: always 18 | ports: 19 | - "5432:5432" 20 | pipelinerunner: 21 | image: pipelinerunner 22 | container_name: pipelinerunner 23 | build: 24 | context: ./ 25 | dockerfile: ./containers/pipelinerunner/Dockerfile 26 | volumes: 27 | - ./:/code 28 | environment: 29 | WAREHOUSE_USER: ${POSTGRES_USER} 30 | WAREHOUSE_PASSWORD: ${POSTGRES_PASSWORD} 31 | WAREHOUSE_DB: ${POSTGRES_DB} 32 | WAREHOUSE_HOST: ${POSTGRES_HOST} 33 | WARREHOUSE_PORT: ${POSTGRES_PORT} 34 | dashboard: 35 | image: metabase/metabase 36 | container_name: dashboard 37 | ports: 38 | - "3000:3000" 39 | -------------------------------------------------------------------------------- /CryptoMonitoring/src/bitcoinmonitor/exchange_data_etl.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import sys 4 | from typing import Any, Dict, List, Optional 5 | 6 | import psycopg2.extras as p 7 | import requests 8 | 9 | from bitcoinmonitor.utils.db import WarehouseConnection 10 | from bitcoinmonitor.utils.sde_config import get_warehouse_creds 11 | 12 | 13 | def get_utc_from_unix_time( 14 | unix_ts: Optional[Any], second: int = 1000 15 | ) -> Optional[datetime.datetime]: 16 | return ( 17 | datetime.datetime.utcfromtimestamp(int(unix_ts) / second) 18 | if unix_ts 19 | else None 20 | ) 21 | 22 | 23 | def get_exchange_data() -> List[Dict[str, Any]]: 24 | url = 'https://api.coincap.io/v2/exchanges' 25 | try: 26 | r = requests.get(url) 27 | except requests.ConnectionError as ce: 28 | logging.error(f"There was an error with the request, {ce}") 29 | sys.exit(1) 30 | return r.json().get('data', []) 31 | 32 | 33 | def _get_exchange_insert_query() -> str: 34 | return ''' 35 | INSERT INTO bitcoin.exchange ( 36 | id, 37 | name, 38 | rank, 39 | percenttotalvolume, 40 | volumeusd, 41 | tradingpairs, 42 | socket, 43 | exchangeurl, 44 | updated_unix_millis, 45 | updated_utc 46 | ) 47 | VALUES ( 48 | %(exchangeId)s, 49 | %(name)s, 50 | %(rank)s, 51 | %(percentTotalVolume)s, 52 | %(volumeUsd)s, 53 | %(tradingPairs)s, 54 | %(socket)s, 55 | %(exchangeUrl)s, 56 | %(updated)s, 57 | %(update_dt)s 58 | ); 59 | ''' 60 | 61 | 62 | def run() -> None: 63 | data = get_exchange_data() 64 | for d in data: 65 | d['update_dt'] = get_utc_from_unix_time(d.get('updated')) 66 | with WarehouseConnection(get_warehouse_creds()).managed_cursor() as curr: 67 | p.execute_batch(curr, _get_exchange_insert_query(), data) 68 | 69 | 70 | if __name__ == '__main__': 71 | run() 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build-Crypto-Monitoring-Tool 2 | 3 | This is an ETL pipeline to pull bitcoin exchange data from CoinCap API and load it into our data warehouse. 4 | 5 | Architecture 6 | 7 | ![image](https://user-images.githubusercontent.com/110036451/184507210-ffff0b00-e8d7-44b6-8067-a6fcf024c500.png) 8 | 9 | Arch 10 | 11 | We use python to pull, transform and load data. Our warehouse is postgres. We also spin up a Metabase instance for our presentation layer. 12 | 13 | All of the components are running as docker containers. 14 | 15 | Setup 16 | 17 | Pre-requisites 18 | Docker and Docker Compose v1.27.0 or later. 19 | AWS account. 20 | AWS CLI installed and configured. 21 | git. 22 | 23 | Local 24 | 25 | We have a Makefile with common commands. These are executed in the running container. 26 | 27 | cd Build Crpyto Monitoring Tool 28 | 29 | make up # starts all the containers 30 | make ci # runs formatting, lint check, type check and python test 31 | 32 | If the CI step passes you can go to http://localhost:3000 to checkout your Metabase instance. 33 | 34 | You can connect to the warehouse with the following credentials 35 | 36 | Host: warehouse 37 | Database name: finance 38 | The remaining configs are available in the env file. 39 | 40 | Refer to this doc for creating a Metabase dashboard. 41 | 42 | Production 43 | 44 | In production we will run the instances as containers. We have helper scripts in deploy_helpers for this. 45 | 46 | You will need to have an ubuntu x_86 EC2 instance with a custom TCP inbound rule with port 3000 open to the IP 0.0.0.0/0. 47 | 48 | These can be set when you create an AWS EC2 instance in the configure security group section. A t2.micro (free-tier eligible) instance would be sufficient. 49 | 50 | 51 | ![image](https://user-images.githubusercontent.com/110036451/184507301-8156571d-3631-4e9d-8bec-fd36d117bfa8.png) 52 | 53 | 54 | Sec group 55 | 56 | You can setup a prod instance as shown below. 57 | 58 | cd bitcoinmonitor 59 | 60 | chmod 755 ./deploy_helpers/send_code_to_prod.sh 61 | 62 | chmod 400 pem-full-file-location 63 | 64 | ./deploy_helpers/send_code_to_prod.sh pem-full-file-location EC2-Public-IPv4-DNS 65 | 66 | 67 | # the above command will take you to your ubuntu instance. 68 | # If you are having trouble connecting use method 2 from https://aws.amazon.com/premiumsupport/knowledge-center/ec2-linux-fix-permission-denied-errors/ 69 | 70 | # install docker on your Ubuntu EC2 instance 71 | 72 | chmod 755 install_docker.sh 73 | ./install_docker.sh 74 | 75 | # verify that docker and docker compose installed 76 | docker --version 77 | docker-compose --version 78 | 79 | # start the containers 80 | 81 | unzip crpytomonitoringtool.gzip && cd cryptomonitoringtool/ 82 | docker-compose --env-file env up --build -d 83 | Tear down 84 | You can spin down your local instance with. 85 | 86 | make down 87 | Do not forget to turn off your EC2 instance. 88 | -------------------------------------------------------------------------------- /CryptoMonitoring/test/integration/test_exchange_data_etl_integration.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | from decimal import Decimal 4 | 5 | import psycopg2 6 | 7 | from bitcoinmonitor.exchange_data_etl import run 8 | from bitcoinmonitor.utils.db import WarehouseConnection 9 | from bitcoinmonitor.utils.sde_config import get_warehouse_creds 10 | 11 | 12 | class TestBitcoinMonitor: 13 | def teardown_method(self, test_exchange_data_etl_run): 14 | with WarehouseConnection( 15 | get_warehouse_creds() 16 | ).managed_cursor() as curr: 17 | curr.execute("TRUNCATE TABLE bitcoin.exchange;") 18 | 19 | def get_exchange_data(self): 20 | with WarehouseConnection(get_warehouse_creds()).managed_cursor( 21 | cursor_factory=psycopg2.extras.DictCursor 22 | ) as curr: 23 | curr.execute( 24 | '''SELECT id, 25 | name, 26 | rank, 27 | percenttotalvolume, 28 | volumeusd, 29 | tradingpairs, 30 | socket, 31 | exchangeurl, 32 | updated_unix_millis, 33 | updated_utc 34 | FROM bitcoin.exchange;''' 35 | ) 36 | table_data = [dict(r) for r in curr.fetchall()] 37 | return table_data 38 | 39 | def test_exchange_data_etl_run(self, mocker): 40 | mocker.patch( 41 | 'bitcoinmonitor.exchange_data_etl.get_exchange_data', 42 | return_value=[ 43 | r 44 | for r in csv.DictReader( 45 | open('test/fixtures/sample_raw_exchange_data.csv') 46 | ) 47 | ], 48 | ) 49 | run() 50 | expected_result = [ 51 | { 52 | 'id': 'binance', 53 | 'name': 'Binance', 54 | 'rank': 1, 55 | 'percenttotalvolume': Decimal('25.44443'), 56 | 'volumeusd': Decimal('12712561147.7913049212358699'), 57 | 'tradingpairs': 650, 58 | 'socket': True, 59 | 'exchangeurl': 'https://www.binance.com/', 60 | 'updated_unix_millis': 1625787943298, 61 | 'updated_utc': datetime.datetime( 62 | 2021, 7, 8, 23, 45, 43, 298000 63 | ), 64 | }, 65 | { 66 | 'id': 'zg', 67 | 'name': 'ZG.com', 68 | 'rank': 2, 69 | 'percenttotalvolume': Decimal('13.03445'), 70 | 'volumeusd': Decimal('6512276458.5226475820074930'), 71 | 'tradingpairs': 133, 72 | 'socket': False, 73 | 'exchangeurl': 'https://api.zg.com/', 74 | 'updated_unix_millis': 1625787941554, 75 | 'updated_utc': datetime.datetime( 76 | 2021, 7, 8, 23, 45, 41, 554000 77 | ), 78 | }, 79 | { 80 | 'id': 'huobi', 81 | 'name': 'Huobi', 82 | 'rank': 3, 83 | 'percenttotalvolume': Decimal('5.93652'), 84 | 'volumeusd': Decimal('2966009471.8337660651992927'), 85 | 'tradingpairs': 589, 86 | 'socket': True, 87 | 'exchangeurl': 'https://www.hbg.com/', 88 | 'updated_unix_millis': 1625787943276, 89 | 'updated_utc': datetime.datetime( 90 | 2021, 7, 8, 23, 45, 43, 276000 91 | ), 92 | }, 93 | { 94 | 'id': 'okex', 95 | 'name': 'Okex', 96 | 'rank': 4, 97 | 'percenttotalvolume': Decimal('4.99990'), 98 | 'volumeusd': Decimal('2498051785.3601278924449889'), 99 | 'tradingpairs': 287, 100 | 'socket': False, 101 | 'exchangeurl': 'https://www.okex.com/', 102 | 'updated_unix_millis': 1625787941641, 103 | 'updated_utc': datetime.datetime( 104 | 2021, 7, 8, 23, 45, 41, 641000 105 | ), 106 | }, 107 | ] 108 | result = self.get_exchange_data() 109 | assert expected_result == result 110 | --------------------------------------------------------------------------------