Never Pay Fees Again: Mimic Market Buys with Limit Chase Orders in Python

Trading fees can be enourmous if you're trading any serious volume with market buy/sell orders. A strategy can be rendered unprofitable as a result of high trading fees, especially if you're entering and exiting a market frequently or using leverage (fees being proportional to trade volume). Most exchanges charge fees on market orders (taker) but have no fees, or sometimes even rebates on filled limit orders (maker). Here we code a short script to aggressively place limit orders at the top of the orderbook, chasing the price until we get filled and ensuring we are always the maker and never pay taker fees.

Some suites and terminals (paid/free) will have features that will do this for you. But it's even better to code it ourselves, in order to protect your exchange API keys, and tweak the design to our liking. We can also use the limit chase as a foundation for cheap Dollar Cost Averaging or TWAPing into an asset (Time Weighted Average Price).

One thing to note is that this approach is not always better than just doing a market buy, the main reason being that the price may instantly jump against you (pros/cons discussed at bottom of page).

Let's use the ccxt Python module (install:$ pip install ccxt) to connect to a cryptocurrency exchange (im using FTX) and provide us with all the basic functions we need. Obviously you can switch this with the API of your choosing. Here we're going to focus on the buy-side limit chase, and you can replicate for sells afterwards.

The script will need to get the current bid and ask price, and then place our limit order at the top of the orderbook:

import ccxt, time, random

ex = ccxt.ftx({
    'apiKey': '',
    'secret': '',
    'headers': {
        'ftx-subaccount' : ''
        }})

ticker = "BTC-PERP"
amount = 0.01

ticker_data = ex.fetch_ticker(ticker)
new_bid, new_ask = ticker_data['bid'], ticker_data['ask']
order = ex.create_limit_buy_order(ticker, amount, new_bid, {"postOnly": True})

            

Note here we are using a `postOnly` flag, ensuring the order will cancel if it were to cross the book (taker) upon the order being recieved by the exchange. For example, if we were to send a buy limit at $60k USD but the price moved down to $59,995 by the time the order reaches the exchange, rather than the order "crossing the book" and acting as a market buy (taker), the order is cancelled.

Now let's refresh our order and see if we've been filled:

def refresh_order(order):
    updated_orders = ex.fetch_orders()
    for updated_order in updated_orders:
        if updated_order["id"] == order["id"]:
            return updated_order
    print("Failed to find order {}".format(order["id"]))
    return None

order = refresh_order(order)
print(order)
amount_bought = float(order["info"]["filledSize"])

            

Chuck all this into a loop, continually cancelling and replacing buys at the top of the orderbook. We track the amount_traded, and loop until we have bought enough:

ticker = "BTC-PERP"

# Get minimum trade size for this ticker
market = [mk for mk in ex.fetch_markets() if mk["symbol"] == ticker][0]
min_size = market["limits"]["amount"]["min"]

amount = 0.01
amount_traded = 0

while amount - amount_traded > min_size:
    ticker_data = ex.fetch_ticker(ticker)
    new_bid, new_ask = ticker_data['bid'], ticker_data['ask']

    # place order
    order = ex.create_limit_buy_order(ticker, amount, new_bid, {"postOnly": True})
    time.sleep(1)

    # cancel order
    try:
        ex.cancel_order(order["id"])
    except Exception as e:
        print(e)

    # refresh order details and track how much we got filled
    order = refresh_order(order)
    amount_traded += float(order["info"]["filledSize"])

            

See that we loop until we have less than `min_size` left to buy, this will avoid annoying errors saying you can't buy tiny amounts of BTC when you didn't completely buy everything you wanted; the remaining size is below the threshold amount.

This is good and works, but we don't want to be closing our order every time we loop. We should only close our order if the price has changed, ensuring we're maximising the time we have an order in the book.

Now to track whether the price has changed, we're going to begin with use some starting values for prices bid=0 and ask=10^10 (v big number), as well as an empty order=None. This will set prepare us for any conditional if statements we put in our loop; eg. `if order is not None:`.

Upon the first run, the new_bid price will be different to bid. We then place our first buy order, updating the values for bid, ask, and order. Then on subsequent runs we do not cancel and replace the order unless the price has changed.

            

Complete code for a limit chase buy:

import ccxt, time, random ftx_keys = { 'apiKey': '', 'secret': '', 'headers': { 'ftx-subaccount' : '' } } ex = ccxt.ftx(ftx_keys) def refresh_order(order): updated_orders = ex.fetch_orders() for updated_order in updated_orders: if updated_order["id"] == order["id"]: return updated_order print("Failed to find order {}".format(order["id"])) return None ticker = "BTC-PERP" market = [mk for mk in ex.fetch_markets() if mk["symbol"] == ticker][0] min_size = market["limits"]["amount"]["min"] amount = 0.01 amount_traded = 0 # Initialise empty order and prices, preparing for loop order = None bid, ask = 0, 1e10 while amount - amount_traded > min_size: move = False ticker_data = ex.fetch_ticker(ticker) new_bid, new_ask = ticker_data['bid'], ticker_data['ask'] if bid != new_bid: bid = new_bid # If an order exists then cancel it if order is not None: # cancel order try: ex.cancel_order(order["id"]) except Exception as e: print(e) # refresh order details and track how much we got filled order = refresh_order(order) amount_traded += float(order["info"]["filledSize"]) # Exit now if we're done! if amount - amount_traded < min_size: break # place order order = ex.create_limit_buy_order(ticker, amount, new_bid, {"postOnly": True}) print("Buy {} {} at {}".format(amount, ticker, new_bid)) time.sleep(random.random()) # Even if the price has not moved, check how much we have filled. if order is not None: order = refresh_order(order) amount_traded += float(order["info"]["filledSize"]) time.sleep(0.1) print("Finished buying {} of {}".format(amount, ticker))

Some points of interest:

  • This chasing behaviour is predictable.
  • You may often get run over where price jumps against you. Bitcoin and other instruments tend not to change in price dollar by dollar (whatever tick size is), price jumps more than the smallest incriment. When you chase and chase the price up as you're trying to get filled, the moment you do you get filled you may find the price has instantly jumped against you. This can immediately put your position in the red. Traders/market makers will wait and wait until they have information that tells them your limit order is now worth taking, potentially meaning you're on the losing side as your order is filled. But the extent to which you are immediately offside may not be significant enough to make market-orders worthwhile. It is up to you whether the avoided taker fees are worth this risk. Personally I'd think it is, but haven't yet run any tests. It's probably a good option for fast buys unless you're: trading size, trading short time scale or if you have access to low taker fees. Getting run over can differ exchange to exchange; some say it happens more on FTX because all the nerds are trading on there, that and it happens less on retail exchanges where the random-noise plebs are.
  • Probably a really bad idea to do this with any large size (for reasons above)
  • You can obfuscate the trading behaviour a lot, and should be more complex than time.sleep(random.random()). You can vary position size, create some set of N buy limits down to a certain price, or whatever else you can think of.
  • This isn't exactly what I'm running, I slimmed it down. It has not been extensively tested.
  • This is essentially everything you need to build a TWAP function, e.g. just limit chase 1/1440 th of your desired position size every single minute over a day to give you a Time Weighted Average Price entry.


This implementation isn't flawless and certainly not fast or competitive on tiny timescales, but this approach works and could save you thousands (tens of thousands if you're a trader) in fees. If you learnt something or if I save you money, please support me:
BTC: bc1q64qjl9fs9fqutx5krmwsh0j78pfmzj4rxevapw
XMR: 8BctFibTfaefTA71ZAJt27b6k58Egc4D2LQbGn18AtKT1sTo5jDu8nXeyA6bRV2NK3LRdRidrffDrZsYJnDCdRhbCnG97FS