DEV Community

Play Button Pause Button
Rick Delpo
Rick Delpo

Posted on

Interactive stock market S&P 500 line chart using Bokeh, Python, JS, Pyscript and a movable angle finder for Trend Line Analysis

Is the stock market overvalued in 2024? Thanks to a Python library called Bokeh we can chart the S&P 500 over time and superimpose an angle tool right over the graph for interactivity. As a bonus we can render all this in HTML using Pyscript. The angle finder is draggable and will show the degrees of our trend line (play video above for a demo). By moving our angle finder over recent data we are apparently trending way over the mean indicating that the market is indeed overvalued.

below, I present 3 code examples
1) a 30 year representation of the S&P 500 as a png screenshot (see caveats below for this version, can’t always find squared off data at Yahoo Finance)
2) a 15 year version of the same with a local json data source (more reliable than a png because we have control over squaring off the data)
3) a Pyscript version of example 2 importing JSON data from an AWS S3 bucket

problem statement

Measuring angles in stock market technical analysis has always been problematic because chart grid lines are usually elongated horizontally or vertically thus the angle of a trend line is distorted by several degrees. If u must use angles please ensure that the length of x axis ticks are equal to y axis ticks. This will square off our data. We can still have x represent time and y represent price but the units of time need to be the same as the units of price, so time is not a date here, it is just a unit. This seems paradoxical but if the graph is not equalized then we get the distortion. We can measure angles of ascent but in and of itself this measurement may not be very useful. Where we can make it useful is to compare this angle to a longer term mean trend line angle to see if we are above or below trend. When the angle of ascent exceeds the angle of the mean we are above trend and the stock price drops thus reverting to the mean.

Evolution of Technical Analysis - Trend line analysis

The use of angles in stock market analysis is long supported by Gann Theory developed by WD Gann in the early 1900s. But it is a tedious measurement because the graph must be equalized for an accurate result as described above. More importantly than the angle measurement itself is whether a stock is trending above the mean or below it. Note that in 2022 the s&p reverted to the mean. Reversion to the mean was theorized by Sir Francis Galton in the late 1800s. Charles Dow, founder of Dow Jones 30 Industrial Index, coined the term technical analysis in the late 1800s originally calling his approach the "Dow Theory". Many others jumped on this bandwagon figuring that charting stocks was more important than their fundamentals, hence line charts became their go to tool thus paving the way to the many charting tools available today.

taking our angle tool for a test run - play video at beginning of this article for a demo

Note we can draw a mean for 4 years of data between 2020 and 2024 (about 45 degrees). In late 21 we were trending way above the mean at 66 degrees but in 22 we reverted to the mean. With other charting tools we are unable to interact with our graph but here Bokeh allows it. First establish a mean trend of 3 lows over the long run then measure more recent data (angle of ascent) to see if trending higher than the mean

some basic definitions for our analysis

1 one unit of time must equal one unit of price to make a 1:1 ratio also known as the aspect ratio. On a chart 1:1 translates into a 45 degree angle, rise over run = 1:1
2 note, mean trend line should touch 3 low points on graph over a longer time frame
3 trendline and mean comparison of the underlying data = trend line analysis
4 don't think of my x axis as dates, it is strictly units of time
5 mean = average value of a set of data points
6 we must use a square in bokeh to equalize the x and y data, if we elongate our x axis then the angle flattens, so our png needs to be cropped to become a 650 x 650 square

Here is the code - Best used on a desktop..also note that Pyscript is very slow and even slower on mobile

First a quick note on why I am using Bokeh vs Matplotlib for this exercise. Bokeh is much easier for a beginner trying to combine Python with Javascript. Recently I have been migrating all my dev efforts over to Plain Javascript. But I needed some Python too for a line chart and I needed it to be interactive. Matplotlib is more complicated to learn and provides mainly static output whereas Bokeh is interactive. Also I wanted Pyscript for my presentation layer as it serves nicely as a simple IDE (Notepad only) for making changes to my code and plays nicely with Bokeh.

Example 1

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>stock chart with angle finder</title>
    <link rel="stylesheet" href="https://pyscript.net/releases/2024.6.1/core.css"> <!--most recent pyscript lib-->
    <script type="module" src="https://pyscript.net/releases/2024.6.1/core.js"></script> <!--most recent pyscript lib-->
    <script type="text/javascript" src="https://cdn.bokeh.org/bokeh/release/bokeh-3.4.1.min.js"></script> <!--recent bokeh lib-->
    <script type="text/javascript" src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.4.1.min.js"></script> <!--need for div to work-->
       <py-config>packages = ["bokeh"]</py-config>
<style>
.div {
  border: 2px solid;
  padding: 20px; 
  width: 740px; /* was 450*/
  height: 650px;
/*
  resize: both;  /* allow for resizing*/
  overflow: auto;
*/
}
</style>
</head>
<body>                 
   <b>wait 8 seconds to display Bokeh Line Chart due to Pyscript being slow, but well worth the wait</b>
    <div id="chart" class="div"></div>

        <script type="py" async>
        from bokeh.plotting import column, figure
        from bokeh.models import PointDrawTool, ColumnDataSource, Div, CustomJS
        from math import atan2, degrees
        from bokeh.layouts import column
        from bokeh.embed import json_item
        from pyscript import window, fetch, ffi 
        Bokeh = window.Bokeh #pass Bokeh into the main threads window object
        #import math
        from bokeh.models import Label
        async def get_data():
            p = figure(x_range=(0,7),y_range=(0,7), width = 740, height = 650) 
            p.image_url(url=['https://rickd.s3.us-east-2.amazonaws.com/s&p9.png'], x=0, y=0, anchor="bottom_left") #add s&p image to p
            #p.image_url(url=['https://rickd.s3.us-east-2.amazonaws.com/s&p15yrs4.png'], x=0, y=0, anchor="bottom_left")
            p.toolbar.autohide = True #only works in v3.0 #need to elim entirely later

                  # add label annotation   
            label = Label(x=.75, y=4, text="tap once then drag line endpoints, degrees show here", text_font_size="16pt")
            p.add_layout(label)

                  # add angle finder
            source = ColumnDataSource( data = {'x': [.25,2], 'y': [4,6]} )
            r_c = p.scatter(x = 'x', y = 'y', size = 15, fill_color = 'red', line_color = 'black', source=source ) #using scatter instead
            r_l = p.line(x = 'x', y = 'y', line_color = 'blue', line_width = 3, legend_label="S&P 500 Index with Angle Finder", source = source )
            tool = PointDrawTool(renderers=[r_c], add = False)
            p.add_tools(tool)
            p.toolbar.active_tap = tool

            p.legend.location = "top"

                  # add callback
            callback = CustomJS(
                  # pass above python vars as args here...we are declaring vars in our custom js
                args = {'src': source, 'p':p, 'label':label},
                code = '''
                    const dx = src.data.x[1] - src.data.x[0];
                    const dy = src.data.y[1] - src.data.y[0];
                    const angle = (180/Math.PI)*(Math.atan2(dy, dx)); //need to do the original way
                    label.text =  ` angle = `+`${angle.toFixed(2)}`+` degrees`;  
                    // this is a comment inside the callback not #....because callback is js and above is python where # denotes comment        
                '''
            )
                 # when angle is moved call the callback function
            source.js_on_change('data', callback)

            await Bokeh.embed.embed_item(ffi.to_js(json_item(p, "chart"))) #ffi converts a Python object into its JavaScript counterpart
        # lastly, run the get_data method above
        await get_data()
    </script>

</body>

<!--
instructions for getting png
go to yahoo finance and find a squared off chart, then take a screenshot and use this png file as an image in bokeh. With the same plot create our movable trend line over a plot with equalized x and y.
   resize 650 x 650 to fit on chart
  at yahoo must find squared chart if doing png but if doing actual data we can square off ourselves..this is very important
    the challenge is getting screen shot of squared chart
  at yahoo finance advanced chart 1 year and interval of 1 day is as square as i can get it..press blue arrow on right for full screen
  a 2 years, 1 week gets us close enough with both blue arrows making graph smaller

go to advanced chart then press indicaters and get rid of volume under..this way we only see the chart without the daily volume
press arrow on right side so chart fills screen
settings get rid of cross hair
chart at max with one month intervals should be squared
press window key and print screen (PrtSc) at the same time
go to pictures then screen shots then rename file and save where needed
then double click file press edit icon and make bigger (zoom to 100% then adjust top and side resize handles, crop to 650x650 ..and do final save)
-->
</html>
Enter fullscreen mode Exit fullscreen mode

Example 2

from bokeh.plotting import column, figure, show
from bokeh.models import PointDrawTool, ColumnDataSource, CustomJS, HoverTool, CustomJSHover
import json
from bokeh.models import Label
import requests #this will not work in pyscript ******* it does work when using IDLE

#there are 2 options below for our data source...plain array and json array in s3

  #next 3 lines are for implementing s3 remote data source
  # for this to work from s3 save below json array to a .json file in AWS s3 and uncomment import requests above, uncomment next 2 lines, then comment out just the data array below
#response = requests.get('https://rickd.s3.us-east-2.amazonaws.com/data.json')  #this is my live working link 
#data = response.json()

#about the data, note time (x) is denoted in equal intervals matching y range so both axis are squared and so we can display the angle trend line
     #if alternatively we were to show time intervals as 1-10 for example then angle trend line would not be visable
     #values are annual closing prices on may 1 of each year from yahoo historical data pushed to google sheet, transformed to json and saved in s3 as data.json

data =[
    {
        "price": "919",
        "time": "919"
    },
    {
        "price": "1089",
        "time": "1217"
    },
    {
        "price": "1345",
        "time": "1515"
    },
    {
        "price": "1310",
        "time": "1813"
    },
    {
        "price": "1631",
        "time": "2111"
    },
    {
        "price": "1924",
        "time": "2409"
    },
    {
        "price": "2107",
        "time": "2707"
    },
    {
        "price": "2097",
        "time": "3005"
    },
    {
        "price": "2412",
        "time": "3303"
    },
    {
        "price": "2705",
        "time": "3601"
    },
    {
        "price": "2752",
        "time": "3899"
    },
    {
        "price": "3044",
        "time": "4197"
    },
    {
        "price": "4204",
        "time": "4495"
    },
    {
        "price": "4132",
        "time": "4793"
    },
    {
        "price": "4180",
        "time": "5091"
    },
    {
        "price": "5278",
        "time": "5389"
    },
    {
        "price": "5696",
        "time": "5696"
    }
]

 # if json in txt format like above, need to convert price and time to number format, either int or float, vals cannot have commas, reformatted while in google sheet
for format in data:
    for key in format:
        format["price"] = float(format["price"]) # will not convert numbers with commas in them
        format["time"] = float(format["time"])

x=[i['time'] for i in data] #grab s3 data into array using for in loop
y=[i['price'] for i in data] #grab s3 data into array using loop
# comment out above 6 lines and all of json array when using plain arrays below

    # comment out below 2 lines when json is in use from above data
    # these 2 lines x and y are the plain arrays that can be accessed when not using json 
#x = [919, 1217, 1515, 1813, 2111, 2409, 2707, 3005,3303,3601,3899,4197,4495,4793,5091,5389,5695] #time intervals increments of 281 to achieve squaring
           #these x increments are merely units of time, can also be 1 thru 17 but then angle trend line will not show up
#y = [919,1089,1345,1310,1630,1923,2107,2096,2411,2705,2752,3044,4204,4132,4179,5277,5695] # actual prices
p = figure(width=700, height=600, title='Bokeh line graph with S&P data and Angle Finder (hover over line for vals)')
p.xaxis.axis_label = 'time by year'
p.yaxis.axis_label = 'S&P price over 16 years'

    #add custom labels to x ticks because x ticks are only units of time so we override these with actual dates
p.xaxis.ticker = x
    #then overide existing x labels with custom labels 
p.xaxis.major_label_overrides = {919:'2009', 1217:'2010', 1515:'2011', 1813:'2012', 2111:'2013',2409:'2014',2707:'2015',3005:'2016',3303:'2017',3601:'2018',3899:'2019',4197:'2020',4495:'2021',4793:'2022',5091:'2023',5389:'2024',5696:'2025'}
new_labels = p.xaxis.major_label_overrides #assign above to new var to use in callback

p.line(x, y, line_width=2)
p2=p.line(x, y, line_width=2) #assign p.line to var p2 to use below in renderers

    #add some interactivity...a draggable trend line with angle finder
source = ColumnDataSource( data = {'x': [2000,4000], 'y': [2000,4000]} ) #needs to be within same coordinates as our stock data in order to be visable 
r_c = p.scatter(x = 'x', y = 'y', size = 15, fill_color = 'red', line_color = 'black', source=source )
r_l = p.line(x = 'x', y = 'y', line_color = 'blue', line_width = 3, source = source )
tool = PointDrawTool(renderers=[r_c], add = False)
p.add_tools(tool)
p.toolbar.active_tap = tool

  #add custom callback for hover , adding some more interactivity
x_custom = CustomJSHover(
    args=dict(special2=new_labels),  #pass in new_labels var to callback
    code="""
    return special2.get(value)
    """)

    #add hover tool plus tooltips to plot
p.add_tools(HoverTool(
    tooltips=[
        ( 'price = ','@y{0.}' ),
        ( 'year = ','@x{custom}' )
    ],
    formatters={'@x': x_custom}, # call customJSHover callback
    renderers=[p2]  #make only p2 show tooltips
))

    # add a label to carry an annotation..the result in degrees
label = Label(x=2000, y=5000, text="tap and drag red endpoints and angle will show here", text_font_size="12pt")
p.add_layout(label)

    #add callback for trend line
callback = CustomJS(
       #pass above python vars as args here...we are declaring vars in our custom js
    args = {'src': source, 'label':label },
    code = '''
        const dx = src.data.x[1] - src.data.x[0];
        const dy = src.data.y[1] - src.data.y[0];
        const angle = (180/Math.PI)*(Math.atan2(dy, dx)); //need to do the original way
        label.text =  ` angle = `+`${angle.toFixed(2)}`+` degrees`; //show this result in label object
        '''
)
source.js_on_change('data', callback) # when trend line endpoint is moved run callback, more interactivity included

# I am deliberatly hiding grid lines...data is squared but gridlines are not
         #note minor grids or factors do not resolve the visual
p.xgrid.grid_line_color = None
p.ygrid.grid_line_color = None

show(p)

Enter fullscreen mode Exit fullscreen mode

Example 3

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>get json from AWS S3</title>
    <link rel="stylesheet" href="https://pyscript.net/releases/2024.6.1/core.css"> <!--most recent pyscript lib-->
    <script type="module" src="https://pyscript.net/releases/2024.6.1/core.js"></script> <!--most recent pyscript lib-->
    <script type="text/javascript" src="https://cdn.bokeh.org/bokeh/release/bokeh-3.4.1.min.js"></script> <!--recent bokeh lib-->
    <py-config>packages = ["bokeh"]</py-config>
</head>
<body>
    <h3>wait 8 seconds to display Bokeh Line Chart because Pyscript is very slow, but worth waiting for</h3>
    <div id="chart"></div> <!--inside the script tags below is my Python code-->

    <script type="py" async>
        from bokeh.plotting import figure
        from bokeh.models import PointDrawTool, ColumnDataSource, CustomJS, HoverTool, CustomJSHover
        import json
        from bokeh.models import Label
            #below 3 lines only needed when using Pyscript
        from bokeh.embed import json_item
        from pyscript import window, fetch, ffi 
        Bokeh = window.Bokeh #pass Bokeh into the main threads window object

        async def get_data():
               #getting json data from aws s3 ..in Pyscript we are using fetch but in plain python we import requests
            response = await fetch('https://rickd.s3.us-east-2.amazonaws.com/data.json')
            data = await response.json()
            for format in data: #need to convert txt in json to number format, either int or float
                for key in format:
                    format["price"] = float(format["price"]) # will not convert numbers with commas in them
                    format["time"] = float(format["time"])        
            x=[i['time'] for i in data] #grab s3 data into array using for in loop
            y=[i['price'] for i in data] #grab s3 data into array using loop

            p = figure(width=700, height=600, title='Bokeh line graph with S&P data and Angle Finder (hover over line for vals)')
            p.xaxis.axis_label = 'time by year'
            p.yaxis.axis_label = 'S&P price over 16 years'
            p.xgrid.grid_line_color = None #hides grid lines using IDLE but not in pyscript
            p.ygrid.grid_line_color = None    #trying to deliberately hide grid lines

                #add custom labels to ticks
            p.xaxis.ticker = x
                #then overide existing x labels with custom labels 
            p.xaxis.major_label_overrides = {919:'2009', 1217:'2010', 1515:'2011', 1813:'2012', 2111:'2013',2409:'2014',2707:'2015',3005:'2016',3303:'2017',3601:'2018',3899:'2019',4197:'2020',4495:'2021',4793:'2022',5091:'2023',5389:'2024',5696:'2024.5'}
            new_labels = p.xaxis.major_label_overrides #assign above to new var to use in callback

            p.line(x, y, line_width=2)
            p2=p.line(x, y, line_width=2) #assign p.line to var p2 to use below in renderers

                #add draggable trendline with angle finder
            source = ColumnDataSource( data = {'x': [2000,4000], 'y': [2000,4000]} ) #needs to be within same coordinates as our stock data in order to be visable 
            r_c = p.scatter(x = 'x', y = 'y', size = 15, fill_color = 'red', line_color = 'black', source=source )
            r_l = p.line(x = 'x', y = 'y', line_color = 'blue', line_width = 3, source = source )
            tool = PointDrawTool(renderers=[r_c], add = False)
            p.add_tools(tool)
            p.toolbar.active_tap = tool

                #add custom callback for hover 
            x_custom = CustomJSHover(
                args=dict(special2=new_labels),  #pass in new_labels var to callback
                code="""
                return special2.get(value)
                """)

                #add hover tool plus tooltips to plot
            p.add_tools(HoverTool(
                tooltips=[
                    ( 'price = ','@y{0.}' ),
                    ( 'year = ','@x{custom}' )
                ],
                formatters={'@x': x_custom}, # call customJSHover callback
                renderers=[p2]  #make only p2 show tooltips
            ))

                # add a label to carry an annotation..the result in degrees
            label = Label(x=2000, y=5000, text="tap and drag red endpoints and angle will show here", text_font_size="12pt")
            p.add_layout(label)

                #add callback for trendline
            callback = CustomJS(
                    #pass above python vars as args here...we are declaring vars in our custom js
                args = {'src': source, 'label':label },
                code = '''
                const dx = src.data.x[1] - src.data.x[0];
                const dy = src.data.y[1] - src.data.y[0];
                const angle = (180/Math.PI)*(Math.atan2(dy, dx)); //need to do the original way
                label.text =  ` angle = `+`${angle.toFixed(2)}`+` degrees`; //show this result in label object
                ''')
            source.js_on_change('data', callback) # when trendline endpoint is moved run callback
            await Bokeh.embed.embed_item(ffi.to_js(json_item(p, "chart"))) #ffi converts a Python object into its JavaScript counterpart
        await get_data()
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

caveats

I have provided 3 code examples, all squared off but with slightly differing results because more or less data is fit into the same square. Our png model shows 30 years whereas our non png shows only 15 years of data. The trend line angles derived from our 15 year model result in a flattening of the line and less degrees. When we scrunch 30 years worth of data into the same square the slope steepens. This is why we cannot fully depend on angles in our analysis. But we can depend on trending over or below the mean.

Caveat #2
Our second example offers plain array data, json data locally and json data from s3
note x and y are squared off but does not show that way in grid lines because units of price are rounded off to the nearest 1000...this will require a question to the Bokeh help desk to rectify. For now I have hidden the grid lines.

Note in our example 3 that I can not hide the grid lines when using Pyscript..another help desk issue

Caveat #3
It is difficult to get squared data from yahoo finance for a screenshot png, plus the image needs cropping to 650px x 650px. So get as squared off as we can. We have more control over squaring if we do not use a png as in example 2 and 3

Happy Coding!

Top comments (0)