Creating a Cryptocoin Price Ticker with Django 2.0 — Part Two
Following on from the first tutorial, we’ve got a Django project set up with cryptocoin prices periodically being pulled to our local Redis cache. Now we’ll create a very simple dashboard to let users subscribe to receive price updates for different cryptocoin/currency ticker codes, using WebSockets.
WebSockets are a way for a user’s browser to open a connection to our backend and then receive updates in the form of events, without having to poll our backend as it may have to with HTTP. WebSockets are a great match for our use case — once a user has subscribed to a particular ticker code (opened a WebSocket to a particular endpoint), every time the ticker price changes in our backend, we will push out an updated price to all connected WebSockets for that ticker code. We will be using the Channels plugin for handling WebSocket logic on our backend — install it using pipenv install channels
and add the following to your settings.py
.
# channels
INSTALLED_APPS += [
'channels',
]
ASGI_APPLICATION = "ccticker.routing.application"
We also need to create a ccticker/routing.py
file with the following:
from channels.routing import ProtocolTypeRouter
application = ProtocolTypeRouter({
# Empty for now (http->django views is added by default)
})
The way Channels works is it replaces the standard Django WSGI server which only handles HTTP requests with an ASGI server which can handle other protocols (including WebSockets) as well. For this reason, we need to provide a Router
object which routes different protocols to different handlers — in this case, a plain ProtocolTypeRouter
will suffice for now, which we then specify in the ASGI_APPLICATION
setting, and it already sends HTTP requests to Django views by default. We will update it to handle WebSockets later on during the tutorial.
Now we will create our ticker dashboard as a standard Django view. First, add the following to ccapp/views.py
from django.views.generic import TemplateView
class TickerView(TemplateView):
template_name = 'ccapp/tickers.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['ticker_codes'] = [
'BTCUSD',
'BTCEUR',
'ETHUSD',
'ETHEUR',
]
return context
We’re going to have a template at ccapp/templates/ccapp/tickers.html
and in it’s template context, pass it a list of ticker codes — the same ones we are using in our Celery task. These will be used to construct the dashboard. Connect this up to ccapp/urls.py
like so:
from django.urls import path
from .views import TickerView
urlpatterns = [
path('', TickerView.as_view()),
]
… and then connect up ccapp
URLs to our main ccticker/urls.py
like so:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('tickers/', include('ccapp.urls')),
]
Now, create ccapp/templates/ccapp/tickers.html
and fill it out with the following code:
<html>
<head>
<title>Tickers</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<style>
#container {
margin: 0 auto;
padding: 1em;
width: 300px;
}
.ticker {
width: 200px;
margin: 1em auto;
}
</style>
</head>
<body>
<div id="container">
{% for ticker_code in ticker_codes %}
<div class="ticker input-group">
<div class="input-group-prepend">
<button type="button" value="{{ ticker_code }}" class="btn btn-info" data-subscribed="false">{{ ticker_code }}</button>
</div>
<input id="{{ ticker_code }}" class="form-control" placeholder="..." type="text" readonly="readonly"/>
</div>
{% endfor %}
</div>
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"
integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js"
integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q"
crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"
integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl"
crossorigin="anonymous"></script>
<script>
const sockets = {}
{% for ticker_code in ticker_codes %}{
const tc = '{{ ticker_code }}'
const button = $(`button[value="${tc}"`)
button.on('click', () => {
if (button.attr('data-subscribed') === 'false') {
button.attr('data-subscribed', 'true')
button.toggleClass('btn-info btn-success')
sockets[tc] = new WebSocket(`ws://${window.location.host}/ws/${tc.substring(0, 3)}/${tc.substring(3, 6)}`)
sockets[tc].onmessage = event => {
console.log(event)
const data = JSON.parse(event.data)
button.css({
'background-color': 'green',
'transition': 'background-color 0.2s linear',
})
if ('price' in data) {
$(`#${tc}`).val(data['price'])
}
setTimeout(() => {
button.css({
'background-color': '',
'transition': '',
})
}, 200)
}
} else {
button.attr('data-subscribed', 'false')
button.toggleClass('btn-success btn-info')
if (sockets[tc]) {
sockets[tc].close()
}
}
})
}{% endfor %}
</script>
</body>
</html>
In summary, the code of this dashboard is as follows:
- for each
ticker_code
from the template context, we’re creating a<button>
element to toggle a ticker subscription and an<input readonly>
element to contain the price - when the
<button>
is pressed, we open anew WebSocket
connection to e.g.ws://localhost:8000/ws/BTC/USD
- we await messages on the WebSocket connection with a
price
key — when we receive a message with this key, we update the<input readonly>
element accordingly and flash the<button>
‘s background color to a different shade of green - if the
<button>
is pressed again, we close the WebSocket connection
If you now ./manage.py runserver
and go to http://localhost:8000/tickers
, you should see a page with the four ticker buttons and empty input elements which will contain the prices. Pressing a button toggles a WebSocket connection for that ticker — currently, the dashboard will not work as we have not yet set up the WebSockets on our backend. If you toggle one of the buttons, you will see errors in your web console like so:
We’re now going to write the WebSocket handlers. In Channels, these are known as Consumer
s, analogous to standard Django View
s for HTTP requests. Create a new file ccapp/consumers.py
and add the following code:
# chat/consumers.py
from channels.generic.websocket import WebsocketConsumer
import json
from django.core.cache import cache
class TickerConsumer(WebsocketConsumer):
def connect(self):
cryptocoin = self.scope['url_route']['kwargs']['cryptocoin']
currency = self.scope['url_route']['kwargs']['currency']
self.ticker_code = cryptocoin + currency
super().connect()
self.send(text_data=json.dumps({
'message': f'connected'
}))
self.price_update({
'price': cache.get(self.ticker_code)
})
def price_update(self, event):
price = event['price']
# Send message to WebSocket
self.send(text_data=json.dumps({
'price': price,
}))
When a WebSocket connect()
s, we parse out the URL kwargs cryptocoin
and currency
, then send back a couple of events — one a message indicating that the connection was successful, then another with the current cryptocoin price as gotten from our Redis cache (the price_update()
method). We send events as JSON strings using the self.send(text_data=...)
method.
Now we need to connect up this Consumer
so that it is reachable at an endpoint. Add the following to your ccapp/urls.py
from .consumers import TickerConsumerwebsocket_urlpatterns = [
path('ws/<str:cryptocoin>/<str:currency>', TickerConsumer),
]
… and update ccticker/routing.py
to the following
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import ccapp.urls
application = ProtocolTypeRouter({
# Empty for now (http->django views is added by default)
'websocket': AuthMiddlewareStack(
URLRouter(
ccapp.urls.websocket_urlpatterns
)
),
})
If you go to the dashboard now, you’ll notice you can now receive a price for a ticker when you press the associated button.
Great! The final step is to update our update_cc_prices
Celery task so that it pushes price updates down the WebSockets whenever the price updates on CryptoCompare.
To do this, we’re going to introduce a new concept called groups. Our backend can have multiple Consumer
instances running — for example, we may have three users, each subscribed to different tickers on their dashboards. User #1 and User #2 may be subscribed to the BTCUSD ticker, and so on our backend we will have two TickerConsumer
instances open at ws://localhost/ws/btc/usd
, one for each user. When the BTCUSD price updates on our backend, we want to send price_update
events down these WebSocket connections with the new price, similar to how we did with the initial price. The way we do this is by grouping TickerConsumer
instances by ticker name, and then using a handle to the group in our update_cc_prices
task to send the event accordingly.
First, once again Redis is being used as a backing cache — in this case, for storing details of Consumer
groups.pipenv install channels_redis
and add the following to settings.py
:
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
Replace ccapp/consumers.py
with the following code:
# chat/consumers.py
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
import json
from django.core.cache import cache
class TickerConsumer(WebsocketConsumer):
def connect(self):
cryptocoin = self.scope['url_route']['kwargs']['cryptocoin']
currency = self.scope['url_route']['kwargs']['currency']
self.ticker_code = cryptocoin + currency
async_to_sync(self.channel_layer.group_add)(
self.ticker_code,
self.channel_name
)
super().connect()
self.send(text_data=json.dumps({
'message': f'connected'
}))
self.price_update({
'price': cache.get(self.ticker_code)
})
def disconnect(self, close_code):
# Leave room group
async_to_sync(self.channel_layer.group_discard)(
self.ticker_code,
self.channel_name
)
super().disconnect(close_code)
def price_update(self, event):
price = event['price']
# Send message to WebSocket
self.send(text_data=json.dumps({
'price': price,
}))
The new code is as follows:
- when we first
connect()
, we add theTickerConsumer
instance to a group with name = the ticker code e.g. BTCUSD or ETHEUR - when we
disconnect()
, we remove theTickerConsumer
instance from the group
Now our TickerConsumer
instances are being grouped, let’s update the update_cc_prices
task in ccapp/tasks.py
to the following:
...
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync@shared_task
def update_cc_prices():
cryptocoins = ['ETH', 'BTC']
currencies = ['EUR', 'USD']
response = price.get_current_price(cryptocoins, currencies)
channel_layer = get_channel_layer()
for cryptocoin in cryptocoins:
for currency in currencies:
latest_price = response[cryptocoin][currency]
ticker_code = cryptocoin + currency
if cache.get(ticker_code) != latest_price:
cache.set(ticker_code, response[cryptocoin][currency])
async_to_sync(channel_layer.group_send)(
ticker_code,
{
'type': 'price_update',
'price': latest_price,
}
)
The new code checks the latest price against the current price in the Redis cache, and if it is different, sends out a price_update
event to the appropriate ticker_code
group.
Restart your Celery worker as we have updated the code, and your dashboard should now be updating with new prices! That’s it! Further improvements to the code could be to keep the cryptocoin and currency codes all in one place rather than repeating them all over the place as was done here — or even keeping them in Django models for easy editing in the admin.