Building a Crypto Trading Bot in Python for Beginners (Part 2: automated testing)

crjameson
11 min readOct 27, 2023

--

Hi again. In the last tutorial we build our first simple Python trading bot and in this tutorial, we make sure, that it works.

I will introduce you to one of the most important concepts when developing real money trading bots: automated testing.

May sound a bit boring, but actually its essential to be successful in this field. I won’t go into every detail, but link you some good resources. Make sure to check them out.

Hint: Don’t skip testing! especially for real money trading bots. I would recommend writing tests for every more complex script which is supposed to run in any kind of production environment.

Short introduction to automated testing

Let’s say we want to make sure, that the bot code we just wrote in the previous tutorial works. We could either test everything manually by importing the keys to Metamask, execute the swap to buy the tokens, check Metamask again, switch account, check again, etc. … and do all this, every time we change something in the code … you get it, that’s cumbersome and unreliable.

That’s why we are going for fully automated tests where we can run our code with a single command.

I will use pytest during this tutorial ( https://docs.pytest.org/en/7.4.x/ ) because i like the syntax, it is widely adopted, well documented and easy to use, once you understood the basic concepts. But Python also comes with it’s own testing framework ( https://docs.python.org/3/library/unittest.html ) which is also a great choice.

Usually i do something called Test Driven Development. That means, i write the tests first, and later write my bot code, until all tests pass. What i like about this approach is, that you think through what you expect your code to do in various cases before you’re writing it.

Also if you make any changes to your code later, its easy to make sure, you didn’t break anything.

And another advantage is, that you learn to structure your Python functions in small testable “units” which handle exactly one thing.

So even if it looks like you’re writing twice as much code or even more, proper automated testing will save you a lot of time in the long run and improve your code quality by a lot.

When you write your tests, there are two types of tests interesting for us.

  1. Unit Tests: which means you take a single isolated piece of code like a function and check that it behaves like expected.
  2. Integration Tests: during the integration tests you test multiple functions, classes and modules and see if everything works together.

Depending on my requirements, i also do other tests like load tests, where i run thousands of bots / requests at the same time, to make sure my server can handle that. And also very important for more complex trading strategies are so called backtests, where you run your bot on historical data. We will do that as soon as we add the automated entries to our bot. But for today we will just focus on the two mentioned above.

It’s not a lot of code and may look simple, but when you understood this few lines, you are ready to do all the testing you need for your bots.

The pytest code for automatically testing the bot

To test our bot we create a file named test_autosellbot.py and put it in the same directory as our bot script file.

Pytest will automatically find all files starting with test_ and execute all functions in named test_.

The imports

Let’s start with the imports again:

from autosell_bot import AutoSellBot
from autosell_bot import (
UNISWAP_V2_SWAP_ROUTER_ADDRESS,
WETH_TOKEN_ADDRESS,
MIN_ERC20_ABI,
UNISWAPV2_ROUTER_ABI,
)
import pytest
from web3 import Account, Web3
import time

Make sure to install the pytest and the pytest-mock package via pip.

Some constants

Next step, is to define a few constants we need for our tests:

TEST_ACCOUNT_1 = "0x5d9d3c897ad4f2b8b51906185607f79672d7fec086a6fb6afc2de423c017330c"
TEST_ACCOUNT_2 = "0x9562571d198ba47c95aea31c2714573fbadb8d6b6da42b3b3a352cefd0b37537"
ELON_TOKEN_ADDRESS = "0x761D38e5ddf6ccf6Cf7c55759d5210750B5D60F3"

This are the private keys for our local Ganache testing accounts and the address of the DogElonMars (symbol: ELON) token, which we will use for testing. This is no buy recommendation, but i just love the name and it has a decent liquidity for our tests available on Uniswap v2.

Fixtures

When you want to share code between multiple tests, you can use so called “fixtures” in pytest.

@pytest.fixture(scope="session")
def web3():
rpc_endpoint = "http://127.0.0.1:8545" # our local ganache instance
web3 = Web3(Web3.HTTPProvider(rpc_endpoint))
return web3

This is an example fixture providing a web3 connection to all our tests during the current session. To use this, just pass web3 as parameter to your test_ function.

Scope=”sessions” means, it will only be instantiated once per test session. If you need a fixture for each function, you can use scope=”function”.

Hint: If you are testing multiple files and bigger projects later, Irecommend putting all fixtures in a conftest.py file placed in its own tests/ subdirectory with all the different test files. Such details are all documented in the official pytest docs.

Helper functions

Now we need a helper function for buying the ELON token, so our bot can sell them later. This is just a copy of the buy function from the previous tutorials.

Pytest modules are like any other python module and all functions not starting with test_ will just be ignored by pytest. So you can add as many helper functions as you need.

def buy_token(web3, account, amount, token_address=ELON_TOKEN_ADDRESS):
router_contract = web3.eth.contract(
address=UNISWAP_V2_SWAP_ROUTER_ADDRESS, abi=UNISWAPV2_ROUTER_ABI
)
token_contract = web3.eth.contract(address=token_address, abi=MIN_ERC20_ABI)

buy_path = [WETH_TOKEN_ADDRESS, token_address]

buy_tx_params = {
"nonce": web3.eth.get_transaction_count(account.address),
"from": account.address,
"chainId": 1337,
"gas": 500_000,
"maxPriorityFeePerGas": web3.eth.max_priority_fee,
"maxFeePerGas": 100 * 10**10,
"value": amount,
}
buy_tx = router_contract.functions.swapExactETHForTokens(
0, # min amount out
buy_path,
account.address,
int(time.time()) + 180, # deadline now + 180 sec
).build_transaction(buy_tx_params)

signed_buy_tx = web3.eth.account.sign_transaction(buy_tx, account.key)

tx_hash = web3.eth.send_raw_transaction(signed_buy_tx.rawTransaction)
web3.eth.wait_for_transaction_receipt(tx_hash)

# now make sure we got some tokens
token_balance = token_contract.functions.balanceOf(account.address).call()
print(f"token balance: {token_balance / 10**18}")
print(f"eth balance: {web3.eth.get_balance(account.address) / 10**18}")
return token_balance

Finally we can define our first test now.

Testing the approve function

I will start with a unit test, testing, that the bots approve function indeed approves the Uniswap router to spend the token.

So my test case is:

When i create a bot instance i expect my code to approve the Uniswap v2 router to spend an unlimited amount of my ELON token.

# pip install pytest-mock to use mocker fixture
def test_approve(mocker):
get_balance_call = mocker.patch(
"autosell_bot.AutoSellBot.get_balance", return_value=1000000
)
get_position_value_call = mocker.patch(
"autosell_bot.AutoSellBot.get_position_value", return_value=1000000
)

account1_bot = AutoSellBot(
"autosell_bot", ELON_TOKEN_ADDRESS, private_key=TEST_ACCOUNT_1
)
# now we make sure the bot has approved the router to sell the token
approval = account1_bot.token_contract.functions.allowance(
account1_bot.account.address, UNISWAP_V2_SWAP_ROUTER_ADDRESS
).call()
assert approval == 2**256 - 1

This few lines of code, make sure our approval function in the bot constructor works. This test has already one advanced testing concept.

As you see “mocker” as argument to the test function. The mocker object is a fixture allowing you to “mock” other functions. That means instead of calling the real function like get_balance, our bot code will just return the given return value of 100000.

This is very handy for unit testing. Our only goal with this test is to make sure that the approve function works. We are not interested in testing get_balance or get_position_value because they will be tested in their own unit test function.

In this test case we need to mock them, because our bot constructor won’t start with a zero balance and we are not interested in the actual position_value.

Finally after mocking this functions we can create our bot which calls approve in its __init__ method.

To make sure it was called, we get the current approval value for the bot wallet by calling the token contract and we assert that this value is 2**256–1 which means unlimited.

That’s basically how all tests are structured:

You have a function named test_ with some fixtures or arguments containing one or more assert statements checking for the desired behavior. Each assert statement is just an expression which evaluates to true or false.

To run this test, just execute the following command in the same directory where your test file is:

pytest -s --disable-warnings --durations=0 test_autosellbot.py

I usually disable warnings, use -s to capture the print statements and stdout output!, and — durations=0 measures the execution time of the tests.

If you want to test just a single function of a test file you can do it like this:

pytest -s --disable-warnings --durations=0 test_autosellbot.py::test_approve

I usually call each test function separately until i am done implementing all of them and run a final test on the whole file / project.

Hint: You can either setup your testing framework to automatically start / stop Ganache after each test or test session (you can use pytest_sessionstart / pytest_sessionfinish for this) or the easiest solution for now is to just run Ganache and restart it manually after a test session. Just keep in mind that if you want to test state changing functions like approve/swap having the same Ganache instance running all the time might lead to wrong results.

Hint: Python project management tools like poetry can help you running tests and automating different tasks like managing the virtual environment, dependencies and other things. Make sure to check it out before writing production code.

Testing the stop loss execution

Now we want to make sure, that the bot really sells all our tokens, when the position value dropped below our stop loss target.

So my test case is:

When i execute the bot strategy and the position value is the same, nothing should happen.

When i execute the bot strategy and the position value has decreased but is still above my stop_loss_value, nothing should happen.

When i execute the bot strategy and the position value is below my stop loss threshold, I expect the bot code to call the sell_token function.

Hint: It might seem a little redundant here to write it down that way, but that is actually how i usually do it (at least in my mind). I go through every possible situation, write it down and then define a test case for it. If i later catch any new error during the execution, i just add a new test case for it and then fix my code until that test passes as well.

def test_execute_sl(mocker):
# our initial balance is set to 1 ether (1*10**18 wei)
get_balance_call = mocker.patch(
"autosell_bot.AutoSellBot.get_balance", return_value=1 * 10**18
)
sell_token_call = mocker.patch(
"autosell_bot.AutoSellBot.sell_token", return_value=True
)
# we mock the function call to get_position_value to make it return different position values
get_position_value_call = mocker.patch(
"autosell_bot.AutoSellBot.get_position_value"
)
get_position_value_call.side_effect = [
int(1 * 10**18), # constructor call - it returns the same value
int(0.99 * 10**18), # first call - it returns a little less - still more than the limit
int(0.8 * 10**18), # second call - we made 20% loss -> sell
]

account1_bot = AutoSellBot(
"autosell_bot", ELON_TOKEN_ADDRESS, private_key=TEST_ACCOUNT_1
)

# now we run the bot for the first time
account1_bot.execute()
# assert that nothing happened
sell_token_call.assert_not_called()
sell_token_call.reset_mock()

# now we run the bot a second time, this time the value decreased and it should call sell
account1_bot.execute()
sell_token_call.assert_called_once()

Again we mock a few functions here. For get_balance and sell_token we just define a static return value.

This is a nice example to demonstrate how useful this technique is. I just want to make sure with this test, that my sell function is called. For this test I am not interested if the sell functions works or not. I will check that later in a separate test.

New is the usage of get_position_value_call.side_effect. The side_effect allows us to pass a list of return values for each function call.

We pass 3 int values as argument, and when get_position_value is called for the first time during the bot initialization it will return 1*10**18.

Now i run the strategy for the first time by calling execute(). This function again calls the get_position_value_mock which this time returns 0.99 * 10**18. So the position value decreased but is still above our threshold.

So i expect that my sell function is not called. To test this you can use the assert_not_called() function of the mock. Just make sure to read the documentation about all this amazing features here ( https://pytest-mock.readthedocs.io/en/latest/ ).

Then i reset my mock object and run my bot again. This time my get get_position_value mock function returns 0.8 * 10 **18, which is below the stop loss value. So i assert that my sell function was called once.

And that is it. We have a test function now to make sure our stop loss works and executes the sell function.

Testing the take profit execution

This is basically the same as above, so i will just give you the code without any further explanations.

def test_execute_tp(mocker):
# our initial balance is set to 1 ether (1*10**18 wei)
get_balance_call = mocker.patch(
"autosell_bot.AutoSellBot.get_balance", return_value=1 * 10**18
)
sell_token_call = mocker.patch(
"autosell_bot.AutoSellBot.sell_token", return_value=True
)
# we mock the function call to get_position_value to make it return different position values
get_position_value_call = mocker.patch(
"autosell_bot.AutoSellBot.get_position_value"
)
get_position_value_call.side_effect = [
int(1 * 10**18), # constructor call - it returns the same value
int(1.1 * 10**18), # first call - it returns a little more - still less than the limit
int(1.6 * 10**18), # second call - we made 60% gain -> sell
]

account1_bot = AutoSellBot(
"autosell_bot", ELON_TOKEN_ADDRESS, private_key=TEST_ACCOUNT_1
)

# now we run the bot for the first time
account1_bot.execute()
# assert that nothing happened
sell_token_call.assert_not_called()
sell_token_call.reset_mock()

# now we run the bot a second time, this time the value decreased and it should call sell
account1_bot.execute()
sell_token_call.assert_called_once()

As it would be always the same principle now for testing all the other functions, i will skip them here and instead we take a look at an integration test where we dump some token.

The integration test

The idea for this test is to have all functions working together in a “realistic” environment. We are still using a forked Ganache instance of Ethereum Mainnet like described in the setup tutorial.

The testplan is to use one account to buy ELON token for 900 ETH and pump the token price. Then we buy the ELON token with our bot account for 1 ETH.

Now we want to dump from the first wallet the whole ELON position worth 900 ETH and we expect our bot to automatically sell his ELON token as well.

To test the take profit the same way, you might just want to change the transaction order.

And this is how it looks like in code:

def test_autosellbot(web3):
account1 = Account.from_key(TEST_ACCOUNT_1)
account2 = Account.from_key(TEST_ACCOUNT_2)

# buy with token wallet 2 for 900 eth
account2_token_balance = buy_token(web3, account2, 900 * 10**18)
assert account2_token_balance > 0

# buy with token wallet 1 for 1 eth
account1_token_balance = buy_token(web3, account1, 1 * 10**18)
assert account1_token_balance > 0

# create our bots - they only start with a balance > 0
account1_bot = AutoSellBot(
"autosell_bot", ELON_TOKEN_ADDRESS, private_key=TEST_ACCOUNT_1
)
account2_bot = AutoSellBot(
"dump_bot", ELON_TOKEN_ADDRESS, private_key=TEST_ACCOUNT_2
)

# run the account 1 bot - to test that nothing happens
account1_bot.execute()
account1_token_balance_run1 = account1_bot.get_balance()
assert account1_token_balance_run1 == account1_token_balance

# dump with token wallet 2 and sell all token
account2_bot.sell_token()
account2_token_balance_sold = account2_bot.get_balance()
assert account2_token_balance_sold == 0

# make sure account 1 bot sold as well
account1_bot.execute()
account1_token_balance_run2 = account1_bot.get_balance()
assert account1_token_balance_run2 == 0

Hint: I am using the web3 fixture without really using it, it was just for demonstration purpose … and you know it is better to be prepared ;)

The code should be self-explanatory by now. Make sure to run this tests by yourself and play around with it a bit. Add some more tests, step through the integration test with a debugger to check the transactions and read the pytest documentation.

The full source code for this test is as always available in the Github repository:

https://github.com/crjameson/python-defi-tutorials/blob/main/bots/autosellbot/test_autosellbot.py

Final thoughts

Always write unit tests! And make sure to check out my more advanced tutorials on my Python and DeFi Substack.

--

--

crjameson

DeFi Developer, EVM & Cosmos Chains, Python, Writing about my journey, developing @orbitrum_net (blockchain analytics & trade automation on DEXes)