-
Notifications
You must be signed in to change notification settings - Fork 38
/
09-run-multiple-parallel-traders.Rmd
435 lines (361 loc) · 15.3 KB
/
09-run-multiple-parallel-traders.Rmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# Run multiple traders in parallel
## Objectives
- describe and design the required functionality
- implement rebuy in the `Naive.Trader`
- implement rebuy in the `Naive.Leader`
- improve logs by assigning ids to traders
## Describe and design the required functionality
At this moment, inside the `Naive.Leader` we have a silly code that starts all of the traders at the same moment:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
...
traders =
for _i <- 1..settings.chunks,
do: start_new_trader(trader_state)
...
```
All the changes we made in this episode will enable us to fix this.
\newpage
Let's say that we placed a buy order that got filled and the price has fallen before reaching the sell level. We can see here that we missed a nice opportunity to buy more as price drops and make money as it climbs back:
```{r, fig.align="center", out.height="20%", echo=FALSE}
knitr::include_graphics("images/chapter_09_01_single_trader.png")
```
We will implement an additional trade event callback inside the `Naive.Trader` that will keep checking the price after the buy order has been filled. Whenever a price drops below the `buy_order`'s `price` by `rebuy_interval` we will notify the `Naive.Leader` to start the new `Naive.Trader` process:
```{r, fig.align="center", out.height="20%", echo=FALSE}
knitr::include_graphics("images/chapter_09_02_multi_traders.png")
```
The `Naive.Leader` keeps track of how many `Naive.Trader`s are running and needs to honor the number of `chunks` set up in the settings (one chunk == one trader).
To stop the `Naive.Trader`s from continuously notifying about a drop in the price we will also introduce a boolean flag that will track has the `Naive.Leader` been already notified.
## Implement rebuy inside `Naive.Trader`
We will start by adding the `rebuy_interval` and the `rebuy_notified` to the trader's state:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/trader.ex
...
defmodule State do
@enforce_keys [
:symbol,
:budget,
:buy_down_interval,
:profit_interval,
:rebuy_interval, # <= add this field
:rebuy_notified, # <= add this field
:tick_size,
:step_size
]
defstruct [
:symbol,
:budget,
:buy_order,
:sell_order,
:buy_down_interval,
:profit_interval,
:rebuy_interval, # <= add this field
:rebuy_notified, # <= add this field
:tick_size,
:step_size
]
end
```
Rebuy logic should be placed almost as the last callback just before the one that ignores all events. We will need to retrieve the current `price` and `buy_price` and confirm that we didn't notify the leader yet(`rebuy_notified` flag):
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/trader.ex
...
# sell filled callback here
...
def handle_info(
%TradeEvent{
price: current_price
},
%State{
symbol: symbol,
buy_order: %Binance.OrderResponse{
price: buy_price
},
rebuy_interval: rebuy_interval,
rebuy_notified: false
} = state
) do
end
# catch all callback here
```
We need to calculate is the current price below the rebuy interval. If yes we will notify the leader and update the boolean flag. We will abstract calculation to separate function(for readability) that we will write below:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/trader.ex
# body of the above callback
if trigger_rebuy?(buy_price, current_price, rebuy_interval) do
Logger.info("Rebuy triggered for #{symbol} trader")
new_state = %{state | rebuy_notified: true}
Naive.Leader.notify(:rebuy_triggered, new_state)
{:noreply, new_state}
else
{:noreply, state}
end
```
As mentioned before, we will set the `rebuy_notified` boolean flag to true and notify the leader using the `notify` function with the dedicated atom.
At the bottom of the module we need to add the `trigger_rebuy?` helper function:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/trader.ex
# bottom of the module
defp trigger_rebuy?(buy_price, current_price, rebuy_interval) do
rebuy_price =
D.sub(
buy_price,
D.mult(buy_price, rebuy_interval)
)
D.lt?(current_price, rebuy_price)
end
```
## Implement rebuy in the `Naive.Leader`
Moving on to the `Naive.Leader` module, we can get update starting of the traders automatically by the leader to starting just one inside `handle_continue`:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
def handle_continue(:start_traders, %{symbol: symbol} = state) do
...
traders = [start_new_trader(trader_state)] # <= updated part
...
end
```
We will need to add a new clause of the `notify` function that will handle the rebuy scenario:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
# add below current `notify` function
def notify(:rebuy_triggered, trader_state) do
GenServer.call(
:"#{__MODULE__}-#{trader_state.symbol}",
{:rebuy_triggered, trader_state}
)
end
```
We need to add a new `handle_call` function that will start new traders only when there are still chunks available(enforce the maximum number of parallel traders) - let's start with a header:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
# place this one after :update_trader_state handle_call
def handle_call(
{:rebuy_triggered, new_trader_state},
{trader_pid, _},
%{traders: traders, symbol: symbol, settings: settings} = state
) do
end
```
There are few important details to make note of:
* we need the trader's PID to be able to find it details inside the list of traders
* we need settings to confirm the number of chunks to be able to limit the maximum number of parallel traders
Moving on to the body of our callback. As with other ones, we will check can we find a trader inside the list of traders, and based on that we will either start another one(if we didn't reach the limit of chunks) or ignore it:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
# body of our callback
case Enum.find_index(traders, &(&1.pid == trader_pid)) do
nil ->
Logger.warning("Rebuy triggered by trader that leader is not aware of")
{:reply, :ok, state}
index ->
old_trader_data = Enum.at(traders, index)
new_trader_data = %{old_trader_data | :state => new_trader_state}
updated_traders = List.replace_at(traders, index, new_trader_data)
updated_traders =
if settings.chunks == length(traders) do
Logger.info("All traders already started for #{symbol}")
updated_traders
else
Logger.info("Starting new trader for #{symbol}")
[start_new_trader(fresh_trader_state(settings)) | updated_traders]
end
{:reply, :ok, %{state | :traders => updated_traders}}
end
```
In the above code, we need to remember to update the state of the trader that triggered the rebuy inside the `traders` list as well as add the state of a new trader to that list.
As with other setting we will hardcode the `rebuy_interval`(inside the `fetch_symbol_settings` function) and assign them to
trader's state(inside the `fresh_trader_state` function):
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
defp fresh_trader_state(settings) do
%{
struct(Trader.State, settings)
| budget: D.div(settings.budget, settings.chunks),
rebuy_notified: false # <= add this line
}
end
defp fetch_symbol_settings(symbol) do
...
Map.merge(
%{
...
chunks: 5, # <= update to 5 parallel traders max
budget: 100, # <= update this line
...
profit_interval: "-0.0012",
rebuy_interval: "0.001" # <= add this line
},
symbol_filters
)
end
```
We also update the `chunks` and the `budget` to allow our strategy to start up to 5 parallel traders with a budget of 20 USDT each(100/5) as Binance has minimal order requirement at about $15(when using the `BinanceMock` this doesn't really matter).
## Improve logs by assigning ids to traders
The final change will be to add an `id` to the trader's state so we can use it inside log messages to give us meaningful data about what's going on(otherwise we won't be able to tell which message was logged by which trader).
First, let's add the `id` into the `Naive.Leader`'s fresh_trader_state as it will be defined per trader:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/leader.ex
defp fresh_trader_state(settings) do
%{
struct(Trader.State, settings)
| id: :os.system_time(:millisecond), # <= add this line
budget: D.div(settings.budget, settings.chunks),
rebuy_notified: false
}
end
```
Now we can move on to the `Naive.Trader` and add it to the `%State{}` struct as well as we will modify every callback to include that id inside log messages:
```{r, engine = 'elixir', eval = FALSE}
# /apps/naive/lib/naive/trader.ex
defmodule State do
@enforce_keys [
:id,
...
]
defstruct [
:id,
...
]
end
...
def init(%State{id: id, symbol: symbol} = state) do
...
Logger.info("Initializing new trader(#{id}) for #{symbol}")
...
end
def handle_info(
%TradeEvent{price: price},
%State{
id: id,
...
} = state
) do
...
Logger.info(
"The trader(#{id}) is placing a BUY order " <>
"for #{symbol} @ #{price}, quantity: #{quantity}"
)
...
end
def handle_info(
%TradeEvent{
buyer_order_id: order_id
},
%State{
id: id,
...
} = state
) do
...
Logger.info(
"The trader(#{id}) is placing a SELL order for " <>
"#{symbol} @ #{sell_price}, quantity: #{quantity}."
)
...
Logger.info("Trader's(#{id} #{symbol} BUY order got partially filled")
...
end
def handle_info(
%TradeEvent{
seller_order_id: order_id
},
%State{
id: id,
...
} = state
) do
...
Logger.info("Trader(#{id}) finished trade cycle for #{symbol}")
...
Logger.info("Trader's(#{id} #{symbol} SELL order got partially filled")
...
end
def handle_info(
%TradeEvent{
price: current_price
},
%State{
id: id,
...
} = state
) do
...
Logger.info("Rebuy triggered for #{symbol} by the trader(#{id})")
...
end
```
That finishes the implementation part - we should now be able to test the implementation and see a dynamically growing number of traders for our strategy based on price movement.
## Test the implementation
Let's start an iEx session and open the `:observer`(inside go to "Applications" tab and click on `naive` from the list of the left) so we will be able to see how the number of traders is growing as well as PIDs are changing which means that they are finishing the full trades:
```{r, engine = 'bash', eval = FALSE}
$ iex -S mix
...
iex(1)> :observer.start()
...
iex(2)> Naive.start_trading("HNTUSDT")
10:22:05.018 [info] The trader(1616754009963) is placing a BUY order for HNTUSDT @ 8.175,
quantity: 2.446
10:22:11.665 [info] Rebuy triggered for HNTUSDT by the trader(1616754009963)
10:22:11.665 [info] Starting new trader for HNTUSDT
10:22:11.665 [info] Initializing new trader(1616754131665) for HNTUSDT
10:22:11.665 [info] The trader(1616754009963) is placing a SELL order for HNTUSDT @ 8.181,
quantity: 2.446.
10:22:11.665 [info] The trader(1616754131665) is placing a BUY order for HNTUSDT @ 8.157,
quantity: 2.451
10:22:58.339 [info] Trader(1616754009963) finished trade cycle for HNTUSDT
10:22:58.339 [info] HNTUSDT trader finished trade - restarting
10:22:58.339 [info] Initializing new trader(1616754178339) for HNTUSDT
10:22:58.339 [info] The trader(1616754178339) is placing a BUY order for HNTUSDT @ 8.212,
quantity: 2.435
10:23:13.232 [info] Rebuy triggered for HNTUSDT by the trader(1616754178339)
10:23:13.232 [info] Starting new trader for HNTUSDT
10:23:13.232 [info] Initializing new trader(1616754193232) for HNTUSDT
10:23:13.232 [info] The trader(1616754178339) is placing a SELL order for HNTUSDT @ 8.218,
quantity: 2.435.
10:23:31.120 [info] The trader(1616754193232) is placing a BUY order for HNTUSDT @ 8.194,
quantity: 2.44
10:23:31.121 [info] Trader(1616754178339) finished trade cycle for HNTUSDT
10:23:31.122 [info] HNTUSDT trader finished trade - restarting
10:23:31.122 [info] Initializing new trader(1616754211122) for HNTUSDT
10:24:31.891 [info] The trader(1616754211122) is placing a BUY order for HNTUSDT @ 8.198,
quantity: 2.439
10:25:24.155 [info] The trader(1616754211122) is placing a SELL order for HNTUSDT @ 8.204,
quantity: 2.439.
10:25:24.155 [info] The trader(1616754193232) is placing a SELL order for HNTUSDT @ 8.2,
quantity: 2.44.
10:25:24.155 [info] Rebuy triggered for HNTUSDT by the trader(1616754211122)
10:25:24.155 [info] Starting new trader for HNTUSDT
10:25:24.156 [info] Initializing new trader(1616754324155) for HNTUSDT
10:25:24.156 [info] Rebuy triggered for HNTUSDT by the trader(1616754193232)
10:25:24.156 [info] The trader(1616754324155) is placing a BUY order for HNTUSDT @ 8.183,
quantity: 2.444
10:25:24.156 [info] Starting new trader for HNTUSDT
10:25:24.156 [info] Initializing new trader(1616754324156) for HNTUSDT
10:25:24.156 [info] The trader(1616754324156) is placing a BUY order for HNTUSDT @ 8.176,
quantity: 2.446
10:25:24.156 [info] The trader(1616754324155) is placing a SELL order for HNTUSDT @ 8.189,
quantity: 2.444.
10:25:37.527 [info] Trader(1616754324155) finished trade cycle for HNTUSDT
10:25:37.528 [info] HNTUSDT trader finished trade - restarting
10:25:37.528 [info] Initializing new trader(1616754337528) for HNTUSDT
10:25:37.528 [info] The trader(1616754337528) is placing a BUY order for HNTUSDT @ 8.192,
quantity: 2.441
10:25:37.530 [info] Trader(1616754211122) finished trade cycle for HNTUSDT
10:25:37.530 [info] Trader(1616754193232) finished trade cycle for HNTUSDT
10:25:37.530 [info] HNTUSDT trader finished trade - restarting
10:25:37.530 [info] Initializing new trader(1616754337530) for HNTUSDT
10:25:37.530 [info] HNTUSDT trader finished trade - restarting
10:25:37.530 [info] Initializing new trader(1616754337530) for HNTUSDT
10:25:40.015 [info] Rebuy triggered for HNTUSDT by the trader(1616754337528)
10:25:40.015 [info] The trader(1616754337530) is placing a BUY order for HNTUSDT @ 8.179,
quantity: 2.445
10:25:40.015 [info] All traders already started for HNTUSDT
```
And our observer shows the following:
```{r, fig.align="center", out.width="100%", echo=FALSE}
knitr::include_graphics("images/chapter_09_supervision_tree.png")
```
We can clearly see that our strategy dynamically scaled from 1 to 5 parallel traders and they were going through different trading steps without any problems - I think that's really cool to see and it wasn't difficult to achieve in Elixir.
[Note] Please remember to run the `mix format` to keep things nice and tidy.
The source code for this chapter can be found on [GitHub](https://github.com/Cinderella-Man/hands-on-elixir-and-otp-cryptocurrency-trading-bot-source-code/tree/chapter_09)