Chapter 2 Create a naive trading strategy - a single trader process without supervision
In this chapter, we’ll build our first automated trading strategy. We’ll start with a naive version: it won’t be smart, but it will demonstrate valuable Elixir concepts and show how processes can manage and use their internal state.
Important: This example connects to Binance and places real trades with cryptocurrency. For those who prefer not to use real funds, Chapter 4 is dedicated to building a mocked Binance implementation, allowing you to run the code without an actual exchange account.
2.1 Objectives
- Create another supervised application inside our umbrella to house our trading strategy.
- Define callbacks to react to trade events based on the trader’s state.
- Connect the streamer and trader applications so events can flow between them.
2.2 Initialisation
To develop our naive strategy, we need to create a new supervised application inside our umbrella project:
Now, let’s create the trader abstraction inside our new application.
First, create a file named trader.ex in apps/naive/lib/naive/ directory.
Let’s start with a skeleton of a GenServer:
# /apps/naive/lib/naive/trader.ex
defmodule Naive.Trader do
use GenServer
require Logger
def start_link(args) do
GenServer.start_link(__MODULE__, args, name: :trader)
end
def init(args) do
{:ok, args}
end
endOur module uses the GenServer behavior.
To satisfy the GenServer contract, we must implement the init/1 callback.
While init/1 is the only required callback to satisfy the GenServer behavior,
start_link/1 is a strong convention used by Supervisors to start processes.
We’re also registering the process with a name (:trader) so we can refer to it easily later.
Finally, we require Logger because we’ll be logging information throughout the module.
Next, let’s model the state of our server:
# /apps/naive/lib/naive/trader.ex
defmodule State do
@enforce_keys [:symbol, :profit_target, :tick_size]
defstruct [
:symbol,
:buy_order,
:sell_order,
:profit_target,
:tick_size
]
endOur trader needs to know:
- which symbol to trade (a symbol represents a trading pair—for example, “XRPUSDT” means trading XRP against USDT)
- placed buy order (if any)
- placed sell order (if any)
- profit target (the target profit percentage for a single trade cycle (one buy and one sell))
- tick_size (a bit of trading jargon, which we’ll explain next)
Since our trader can’t function without :symbol, :profit_target, or :tick_size,
we use the @enforce_keys attribute. This prevents us from creating a %State{} struct without these essential
values, ensuring our trader is always initialized correctly.
Terminology: Tick size
Each symbol has a different tick size, which represents the smallest acceptable price increment. For example, in the physical world, the tick size for USD is a single cent. You can’t price something at $1.234; it must be either $1.23 or $1.24. That one-cent increment is the tick size - more info at https://www.investopedia.com/terms/t/tick.asp. We need to fetch this value from Binance and use it to calculate valid prices.
Now that we know our GenServer needs these details as arguments, we can update the pattern matching in our
start_link/1 and init/1 functions to ensure the arguments are maps:
# /apps/naive/lib/naive/trader.ex
def start_link(%{} = args) do
...
end
def init(%{symbol: symbol, profit_target: profit_target}) do
...
endNow, let’s update init/1 to fetch the tick_size for the given symbol and initialize the trader’s state:
# /apps/naive/lib/naive/trader.ex
def init(%{symbol: symbol, profit_target: profit_target}) do
symbol = String.upcase(symbol)
Logger.info("Initializing new trader for #{symbol}")
tick_size = fetch_tick_size(symbol)
{:ok,
%State{
symbol: symbol,
profit_target: profit_target,
tick_size: tick_size
}}
endWe convert the symbol to uppercase because Binance’s REST API only accepts uppercase symbols.
It’s time to connect to Binance’s REST API. The easiest way to do that will be to use the binance module.
Following the installation instructions in the library’s documentation on GitHub, we’ll start by adding binance to the deps in /apps/naive/mix.exs:
# /apps/naive/mix.ex
defp deps do
[
{:binance, "~> 1.0"},
{:decimal, "~> 2.0"},
{:streamer, in_umbrella: true}
]
endAlong with the :binance library, we’re also adding :decimal and our own :streamer application.
The decimal library is essential for calculating prices,
as using standard floats would lead to precision errors common in financial calculations.
Lastly, we include our own :streamer application (from Chapter 1) because the naive application will
need to handle the %Streamer.Binance.TradeEvent{} struct it produces.
We need to run mix deps.get to install our new deps.
Now, let’s return to the Naive.Trader module and implement the logic for fetching the tick_size from Binance:
# /apps/naive/lib/naive/trader.ex
defp fetch_tick_size(symbol) do
Binance.get_exchange_info()
|> elem(1)
|> Map.get(:symbols)
|> Enum.find(&(&1["symbol"] == symbol))
|> Map.get("filters")
|> Enum.find(&(&1["filterType"] == "PRICE_FILTER"))
|> Map.get("tickSize")
endWe use get_exchange_info/0 to fetch the list of symbols, which we then filter to find the specific symbol
we need to trade. The tick size is found within a filter of type PRICE_FILTER.
Here’s the link to the documentation listing all keys in the result: https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md#exchange-information.
In a nutshell, here’s what the important parts of the result look like:
2.3 How the Trading Strategy Will Work
Our trader maintains state that indicates where it is in the trade cycle. Think of it as a simple state machine with three states:

Our trader receives a sequence of trade events and makes decisions based on its current state and the data in each event.
Our trader will operate in one of three states:
- Ready to buy - No open orders, waiting for a first trade event to arrive
- Buy order placed - Waiting for our buy order to execute
- Sell order placed - Waiting for our sell order to execute and complete the trade cycle
2.3.1 First state - Ready to buy
The trader starts with no open orders. We can check for this state by pattern matching on a
nil value in the buy_order field. We’ll use the current price from the incoming trade event
to place our buy order.
2.3.2 Second state - Buy order placed
After placing a buy order, the trader compares its price to the price from incoming trade events. When a trade event arrives with a price lower than our buy order’s price (and we have no sell order), this indicates our buy order was likely filled. The trader can now place the sell order.
A quick note on simplification: For this naive strategy, we’ll assume an order is filled as soon as the market price crosses our order’s price. A production-ready system would need to handle complexities like partial fills, but this simplification is perfect for our current learning objectives.
2.3.3 Third state - Sell order placed
This state works just like the previous one, but in reverse. The trader compares the price from incoming trade events to its sell order’s price. When a trade event arrives with a price greater than our sell order’s price, we assume the order is filled (using the same simplification as before). This completes the trade cycle, and the trader process can terminate.
2.3.4 Implementation of the first scenario
Enough theory! :) Back in the editor, we’ll start with the first scenario.
Since we’ll be pattern matching on %Streamer.Binance.TradeEvent{} structs frequently,
let’s alias it first to keep our code clean.
To confirm that we are dealing with a “new” trader, we will pattern match on buy_order: nil from its state:
# /apps/naive/lib/naive/trader.ex
def handle_cast(
%TradeEvent{price: price},
%State{symbol: symbol, buy_order: nil} = state
) do
quantity = "100" # <= Hardcoded until chapter 7
Logger.info("Placing BUY order for #{symbol} @ #{price}, quantity: #{quantity}")
{:ok, %Binance.OrderResponse{} = order} =
Binance.order_limit_buy(symbol, quantity, price, "GTC")
{:noreply, %{state | buy_order: order}}
endFor now, we’ll hardcode the quantity. This keeps the chapter focused, and don’t worry—we’ll refactor this in a later chapter.
By pattern matching on buy_order: nil, we ensure this code only runs for a “new” trader that
hasn’t placed an order yet. After placing the order, it’s crucial that we return the updated state.
If we returned the original state, buy_order would remain nil, and our trader would go on a
shopping spree, placing a new buy order on every single trade event it receives!
2.3.5 Implementation of the second scenario
Now, let’s implement the logic for when the buy order is filled.
We’ll add a handle_cast/2 function that matches trade events where the price has dropped
below our buy price and where no sell order has been placed yet:
# /apps/naive/lib/naive/trader.ex
def handle_cast(
%TradeEvent{
price: trade_price
},
%State{
symbol: symbol,
buy_order: %Binance.OrderResponse{
price: buy_price,
order_id: order_id,
orig_qty: quantity,
transact_time: timestamp
},
sell_order: nil,
profit_target: profit_target,
tick_size: tick_size
} = state
) when trade_price < buy_price do
{:ok, %Binance.Order{} = current_buy_order} =
Binance.get_order(
symbol,
timestamp,
order_id
)
buy_order_response = convert_order_to_order_response(current_buy_order)
sell_price = calculate_sell_price(buy_price, profit_target, tick_size)
Logger.info(
"Buy order filled, placing SELL order for " <>
"#{symbol} @ #{sell_price}, quantity: #{quantity}"
)
{:ok, %Binance.OrderResponse{} = order} =
Binance.order_limit_sell(symbol, quantity, sell_price, "GTC")
{:noreply, %{state | buy_order: buy_order_response, sell_order: order}}
endWe’ll implement the sell price calculation in a helper function that takes the buy price, profit target, and tick size as arguments.
Our pattern match confirms the buy order was likely filled (the trade price dropped below our order’s price and no sell order exists). We can now place a sell order using the quantity from our original buy order and a newly calculated sell price. Once again, it’s crucial to return the updated state. Otherwise, the trader would repeatedly place new sell orders for every subsequent trade event.
To keep the trader’s state up to date, we’ll fetch the newest state of the buy order, which will be
returned to us as a %Binance.Order struct. We need to convert this %Binance.Order{} back into a
%Binance.OrderResponse{} because the rest of our code relies on pattern matching against the
%Binance.OrderResponse{} struct.
These two structs are nearly identical; the main difference is the naming of the timestamp field
(time vs. transact_time). We can easily convert from one to the other with this helper function at the bottom of the Naive.Trader module:
# /apps/naive/lib/naive/trader.ex
defp convert_order_to_order_response(%Binance.Order{} = order) do
%{
struct(
Binance.OrderResponse,
order |> Map.to_list()
)
| transact_time: order.time
}
endAdditionally, to calculate the sell price we will need to use precise math and that will require a custom module. We will use the Decimal module, so first, let’s alias it at the top of the file:
Now to calculate the correct sell price, we can use the following formula which gets us pretty close to expected value:
# /apps/naive/lib/naive/trader.ex
defp calculate_sell_price(buy_price, profit_target, tick_size) do
fee = "1.001"
original_price = D.mult(D.from_float(buy_price), fee)
net_target_price =
D.mult(
original_price,
D.add("1.0", profit_target)
)
gross_target_price = D.mult(net_target_price, fee)
D.to_float(
D.mult(
D.div_int(gross_target_price, tick_size),
tick_size
)
)
endFirst, we hardcode the trading fee. The value 1.001 represents a 0.1% fee (1 + 0.001 = 1.001).
We start by calculating the original_price, which is the buy price plus the trading fee we paid.
Next, we increase the original paid price by the profit target. This gives us the net_target_price.
Since we’ll also be charged a fee on the sell order, we must account for it again. We multiply our net_target_price by the fee to get the final gross_target_price.
Finally, we use the tick_size. Binance rejects orders with prices that aren’t a multiple of the symbol’s tick size,
so we must round our calculated price down to the nearest valid increment.
2.3.6 Implementation of the third scenario
Getting back to handling incoming events, we can now add a clause for a trader that wants to confirm that his sell order was filled:
# /apps/naive/lib/naive/trader.ex
def handle_cast(
%TradeEvent{
price: trade_price
},
%State{
symbol: symbol,
sell_order: %Binance.OrderResponse{
price: sell_price,
order_id: order_id,
transact_time: timestamp
}
} = state
) when trade_price > sell_price do
{:ok, %Binance.Order{} = current_sell_order} =
Binance.get_order(
symbol,
timestamp,
order_id
)
sell_order_response = convert_order_to_order_response(current_sell_order)
Logger.info("Trade finished, trader will now exit")
{:stop, :normal, %{state | sell_order: sell_order_response}}
endWhen a trade event’s price is above our sell order’s price, we consider the order filled.
We fetch the final state of the sell order one last time (this will be useful in a later chapter)
and then return a {:stop, ...} tuple. This special tuple tells the GenServer to shut down gracefully,
completing its lifecycle.
2.3.7 Implementation fallback scenario
Finally, we need a catch-all handle_cast/2 function.
This function will handle any %TradeEvent{} that doesn’t match the specific conditions in the clauses above
(for example, when the market price is moving between our buy and sell prices).
By simply returning the state unchanged, we effectively ignore these events:
2.3.8 Updating the Naive interface
Now we will update an interface of our naive application by modifying the Naive module to allow to send an event to the trader:
# /apps/naive/lib/naive.ex
defmodule Naive do
@moduledoc """
Documentation for `Naive`.
"""
alias Streamer.Binance.TradeEvent
def send_event(%TradeEvent{} = event) do
GenServer.cast(:trader, event)
end
endWe will use the fact that we have registered our trader process with a name to be able to cast a message to it.
2.3.9 Updating streamer app
To keep things simple in this chapter, we’ll connect our apps by modifying the streamer app directly by adding the following function call to the end of the process_event/1 function inside the Streamer.Binance module:
# /apps/streamer/lib/streamer/binance.ex
defp process_event(%{"e" => "trade"} = event) do
...
Naive.send_event(trade_event)
endThis creates tight coupling between the streamer and naive applications.
We’ll refactor this in the next chapter, as these applications ideally shouldn’t have direct knowledge of each other.
2.3.10 Access details to Binance
Inside the config of our umbrella project we create a new file config/secrets.exs. We will use this for our Binance account access details.
# /config/secrets.exs
import Config
config :binance,
api_key: "YOUR-API-KEY-HERE",
secret_key: "YOUR-SECRET-KEY-HERE"We don’t want to check this file in, so we add it to our .gitignore:
Finally, we update our main config file to include it using import_config:
# /config/config.exs
...
# Import secrets file with Binance keys if it exists
if File.exists?("config/secrets.exs") do
import_config("secrets.exs")
endImportant note: To be able to run the below test and perform real trades, a Binance account is required with a balance of at least 20 USDT. In the 4th chapter, we will focus on creating a BinanceMock that will allow us to run our bot without the requirement for a real Binance account. You don’t need to test run it now if you don’t need/want to have an account.
2.3.11 Test run
Now it’s time to put our implementation to the test. As a reminder, you will need at least 20 USDT in your Binance wallet to perform this live test. For this test, we’ll use a negative profit_target to guarantee the trade cycle completes quickly. This will result in a small, controlled loss of around 0.5%:
$ iex -S mix
...
iex(1)> Naive.Trader.start_link(%{symbol: "XRPUSDT", profit_target: "-0.01"})
13:45:30.648 [info] Initializing new trader for XRPUSDT
{:ok, #PID<0.355.0>}
iex(2)> Streamer.start_streaming("xrpusdt")
{:ok, #PID<0.372.0>}
iex(3)>
13:45:32.561 [info] Placing BUY order for XRPUSDT @ 0.25979000, quantity: 100
13:45:32.831 [info] Buy order filled, placing SELL order for XRPUSDT @ 0.2577, quantity: 100
13:45:33.094 [info] Trade finished, trader will now exitAfter starting the IEx session, start the trader process with a map containing the symbol and a negative profit target. This forces the sell order to be placed at a lower price, ensuring a faster fill for our test (instead of waiting for the market price to actually rise).
Next, start streaming for the same symbol.
As soon as you do, the streamer will begin sending trade events to the trader process,
triggering the trading logic immediately.
The log output shows our trader placed a buy order at 0.25979 USDT per XRP. It was filled in under 300ms, at which point the trader placed a sell order at approximately 0.2577 USDT, which was also filled quickly. With the sell order filled, the trader has finished its trade cycle, and the process terminates as designed.
That’s it. Congratulations! You just built and executed your first algorithmic trade.
You should be proud! More importantly, by building this bot, you’ve put one of Elixir’s most powerful
patterns into practice: using a GenServer to manage state and react to incoming messages.
Putting this pattern into practice with a real-world example is a major step in your Elixir journey!
[Note] Please remember to run the mix format to keep things nice and tidy.
The source code for this chapter can be found in the book’s source code repository (branch: chapter_02).