Creating a Cryptocoin Price Ticker with Django 2.0 — Part Two

James Hiew
7 min readMay 28, 2018

--

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 a new 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 Views 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 the TickerConsumer instance to a group with name = the ticker code e.g. BTCUSD or ETHEUR
  • when we disconnect() , we remove the TickerConsumer 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.

--

--

No responses yet