Building a simple candlestick chart using Docker, FastAPI, and Vue 3 - Part 1

Building a simple candlestick chart using Docker, FastAPI, and Vue 3 - Part 1

Introduction & FastAPI Backend

Introduction

I had an preliminary job interview recently with a fledgling finance startup (I've not actually heard back yes or no so I don't want to mention the company) and I was given a task to complete, as one typically is for a full stack development job. It was a fairly straightforward project, where the backend needed to be written in Python and the frontend was supposed to be in React or Svelte, but as I had told the CEO that I was a Vue developer he said I could do it in Vue. The point of the project was to not only follow instructions, which will be laid out over the course of this article and subsequent ones, but to also make use of open source libraries. That was a clear emphasis of this exercise; the candidate is not to reinvent the wheel but the CEO wants to see how flexible the candidate is as that's how real-world development is done.

Discussion about the frontend of the project will be in a subsequent article, but for now we're just going to worry about setting up the API endpoint. This project could very easily be done using one serverless function (Netlify, AWS Lambda, etc) but the job posting indicates that FastAPI is used so that's where we begin. For what it's worth, I'm a Django developer, primarily using Django Rest Framework, so this comes from the perspective of someone who has built things using Express but is much more familiar with the opinionated nature of something like Django.

FastAPI Backend

Project Setup

Setting up a FastAPI project is simpler than doing it for Django in that all the user needs to do is essentially create a folder then create a main.py file. Of course, any Python developer knows you should also set up a virtual environment so that's what we'll do.

mkdir /path/to/server
cd /path/to/server
python -m venv env
pip install fastapi "uvicorn[standard]"
touch main.py

If you're using Windows you should know how to go to the location in your file explorer and create a new file but if you want to use the command line and something like type NUL > main.py doesn't work then look here for hints: stackoverflow.com/questions/1702762/how-can..

Basic FastAPI main.py setup

Following along with the fantastic docs for FastAPI we can create a simple test endpoint for the main.py file as the following

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

Then, in order to run the server simply go back to the terminal and type

uvicorn main:app --reload

This will serve the app that was created in main.py and will watch for code updates.

Yahoo finance package

There were two packages I looked into for fetching financial data from Yahoo finance:

  • Alpha Vantage
    • Pro: It has a search endpoint where you can fetch on a partial ticker and get a list of possible tickers.
    • Con: The free API key is rate limited and that can be a problem when making many requests in a short time frame which I anticipated doing while building this project. Also, the assignment specifically mentioned Yahoo's finance data and I'm not sure if Alpha Vantage pulls from Yahoo.
  • yfinance
    • Pro: Pulls from Yahoo's API and there are no rate limits.
    • Con: No error is returned if the ticker included doesn't exist. For example, searching on "MSF" instead of "MSFT" will simply return an empty response instead of erroring out.

As the assignment called for utilizing Yahoo's data and yfinance seems to be one of the more widely used Python libraries with 5.7k GitHub stars that's the one I used. So, with the virtual environment activated, simply type

pip install yfinance

Then we update the main.py file as a quote fetcher instead of a simple get request as described in the previous section.

Updating main.py

The format of the endpoint can be up to the user, but for the purpose of this exercise only 2 of the 4 parameters will be required: ticker and interval. The start date and end date will be optional, will be passed in as part of a querystring, and will be handled later. So, the endpoint will be of the format http://localhost:8000/quote/{ticker}/{interval}

Input validation

To start off the inputs need to be checked. Yes, we will build the frontend that will consume this endpoint so we'll control the parameters but it's good practice to have validation on the backend as well. So the first update to main.py will replace the file set up in the previous section with

from datetime import datetime as dt
from typing import Optional
from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/quote/{ticker}/{interval}")
def get_prices(ticker: str, interval: str, start: Optional[str] = None,
               end: Optional[str] = None):
    # Make sure that the interval is in the allowable group
    if interval not in ['1d', '1wk', '1mo']:
        raise HTTPException(
            status_code=400,
            detail="Only daily, weekly, monthly intervals are allowed.")

    # If end is passed in and start is not then just set
    # the start to be 1 year back
    if end is not None and start is None:
        end_date = end.split('-')
        year = int(end_date[0]) - 1
        start = '-'.join([str(year), end_date[1], end_date[2]])
    elif end is not None and start is not None:
        if dt.strptime(end, '%Y-%m-%d') <= \
                dt.strptime(start, '%Y-%m-%d'):
            raise HTTPException(
                status_code=400,
                detail='End date must be later than start date'
            )

The first section simply ensures that the interval passed in is either daily, weekly, or monthly, and if not a 400 error is returned.

Regarding the dates, if neither a start nor end date are passed in then we simply won't include these when we fetch data from yfinance. If they are passed in, though, we check two things:

  • If the end is not passed in but the start is we will just set the start date one year earlier than the end date.
  • If both the start and end date are passed in we make sure that the end date is greater than the start date. If not, then return a 400 error.

Fetching stock data

In order to do this we need call on yfinance. The docs are fairly good so the reader is directed there for more details, but we'll be using the "Ticker" and "history" methods from that library. The data returned from the "history" method is in the format of a Pandas dataframe and, as mentioned before, will not error out if the ticker is not found but instead will simply return an empty dataframe so we'll want to check for this in our error handling.

The user will want to add the following lines to the beginning of main.py

import pandas as pd
import yfinance as yf

then add the following lines to the end of the file, but still inside the get_prices method

# Grab the stock, get history, convert to json
stock = yf.Ticker(ticker)
history = stock.history(interval=interval, start=start, end=end)
if type(history) == pd.DataFrame and history.empty:
    raise HTTPException(status_code=404,
                        detail='No data was found for those parameters.  '
                               'Please try again.')

Yahoo finance doesn't actually throw an error if the company is not found, which means that we need to get creative with how we handle this sort of thing. Thus, we can check to see if the returned historical data is in fact a pandas dataframe and if that dataframe is empty then we can just throw a generic 404 error. This is not robust and it doesn't check for every situation, but for the purposes of this quick 2-hour project it works.

Creating data output

The last thing that we need to do for the backend is convert the data to the format we want for frontend consumption. I'll be using the OHLC data as well as the volume, and the the returned data will need to include the date. The final output will be an array of objects with each object looking something like

{
  date: '2021-10-05',
  data: [100, 105, 98, 102],  // the order of the array is Open, High, Low, Close
  volume: 1200000
}

So in order to do this we add the following code snippet to the end of the get_prices method

# The default date_format is epoch unless using table format
data = history.to_json(orient='index', date_format='iso')
res = []
for k, v in json.loads(data).items():
    res.append(OutputData(date=k, data=[v.get('Open'), v.get(
        'High'), v.get('Low'), v.get('Close')], volume=v.get('Volume')))
return res

In order to do this we need to create the OutputData pydantic model.

from pydantic import BaseModel

class OutputData(BaseModel):
    date: dt
    data: List[float]
    volume: int

This takes the received pandas dataframe and converts it into json in the index format. Then we just need to cycle through each entry, convert the data to the output model, and append the output to the created list, res.

CORS

The last thing we need to handle is CORS functionality since this is going to be serving a frontend app that is served at another localhost port. FastAPI has very good basic documentation regarding setting up CORS. Succinctly, you need to import CORSMiddleware, set up the origins that you want to allow, and add a couple other configuration properties.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = [
    'http://localhost:3000',
    'http://127.0.0.1:3000'
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_methods=['GET'],
    allow_headers=['*']
)

This will simply apply the pre-built CORSMiddleware module, allow the origins you've already defined, and here we've explicitly set the only allowable method to be a GET request, but if you want to allow all request types you can set allow_methods=['*'], the wildcard parameter ['*'] being what was used for allow_headers. In production you'll want these to be more explicit but for this it's fine.

Full code

The entire code, in one main.py file can be found below. This includes a main method so that you can run the file in a debugger.

import json
from datetime import datetime as dt
import uvicorn
from typing import Optional, Union, Literal, List
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import pandas as pd
import yfinance as yf

app = FastAPI()

origins = ['http://localhost:3000', 'http://localhost:8080',
           'http://127.0.0.1:3000']

app.add_middleware(CORSMiddleware, allow_origins=origins,
                   allow_methods=['GET'], allow_headers=['*'])


class OutputData(BaseModel):
    date: dt
    data: List[float]
    volume: int


@app.get("/quote/{ticker}/{interval}")
def get_prices(ticker: str, interval: Literal['1d', '1wk', '1mo'],
               start: Optional[str] = None,
               end: Optional[str] = None, response_model=List[OutputData]):
    # Make sure that the interval is in the allowable group
    if interval not in ['1d', '1wk', '1mo']:
        raise HTTPException(
            status_code=400,
            detail="Only daily, weekly, monthly intervals are allowed.")

    # If end is passed in and start is not then just set
    # the start to be 1 year back
    if end is not None and start is None:
        end_date = end.split('-')
        year = int(end_date[0]) - 1
        start = '-'.join([str(year), end_date[1], end_date[2]])
    elif end is not None and start is not None:
        if dt.strptime(end, '%Y-%m-%d') <= \
                dt.strptime(start, '%Y-%m-%d'):
            raise HTTPException(
                status_code=400,
                detail='End date must be later than start date'
            )

    # Grab the stock, get history, convert to json
    stock = yf.Ticker(ticker)
    history = stock.history(interval=interval, start=start, end=end)
    if type(history) == pd.DataFrame and history.empty:
        raise HTTPException(status_code=404,
                            detail='No data was found for those parameters.  '
                                   'Please try again.')

    # The default date_format is epoch unless using table format
    data = history.to_json(orient='index', date_format='iso')
    res = []
    for k, v in json.loads(data).items():
        res.append(OutputData(date=k, data=[v.get('Open'), v.get(
            'High'), v.get('Low'), v.get('Close')], volume=v.get('Volume')))
    return res


if __name__ == '__main__':
    uvicorn.run('main:app', port=8001, reload=True)

We can now run either uvicorn main:app --reload or python main.py to have the server start up (by running main.py it will run in port 8001 instead of the default 8000).

Part 2 of the series discusses getting the Vue frontend set up with Tailwind.