'tkinter canvas not displaying line with matplotlib chart

I have a candlestick chart that I am displaying in tkinter using mplfinance. I have mplfinance return the figure so that I can use matplotlib to find the x and y coordinates that the user may need to draw lines on the chart.

I have been successful with drawing lines on the chart using the underlying canvas. My idea is to save the lines in a database so that when the user returns to the chart, the lines are still displayed. In addition, the user should be able to edit or delete the lines as well after returning to the chart.

I have been able to save the lines in the database and retrieve them as well. My problem is that I cannot get them to reappear on the canvas when I start the program. The program is retrieving the lines from the database, and it appears that it is going through the motions of drawing the lines. The lines are not appearing though.

Using a few print statements, the program is telling me that the lines have been drawn. What do I need to do in order to get the lines to appear on the canvas? My minimal example is below.

I have not included the code for storing the lines in the database. In my example, the line I am asking the program to draw is not showing up. That is the only problem I am having. What am I missing?

You can find the csv file that I use here, or you can use any csv file that has open, high, low, close, volume information for a particular equity. Any help would be greatly appreciated.

from tkinter import *
import pandas as pd
import numpy as np
import datetime as dt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.widgets import MultiCursor
import mplfinance as mpf
from functools import partial
import math

class Example:
    def __init__(self, figax, color='#0000FF', width=1):
        """
        This class is used to draw on the canvas of a matplotlib chart.

        :param: figax The figure axes object created by matplotlib
        :param: color The color that should be used currently. The default
        color is blue (#0000FF).
        :param: width The width of the line stroke. The default is 1.
        """
        self.fig, self.ax = figax
        self.cur_ax = None
        #bbox_height is total height of figure.
        self.bbox_height = self.fig.canvas.figure.bbox.height
        #  bbox_width is total width of figure.
        self.bbox_width = self.fig.canvas.figure.bbox.width
        ax_len = len(self.ax)
        #  Create a list to hold the dimensions of the axes.
        self.ax_dims = []
        #  Create a variable to hold the number of axes in the figure.
        self.ax_ct = 0

        self.ax_bounds = None
        #  Get the width and height of each axis in pixels.
        for i in range(0, ax_len, 2):
            self.ax_ct += 1
            dims = self.ax[i].get_window_extent().transformed(self.fig.dpi_scale_trans.inverted())
            awidth, aheight = dims.width, dims.height
            #  awidth is in pixels.
            awidth *= self.fig.dpi
            #  aheight is in pixels.
            aheight *= self.fig.dpi
            d = {'Width': awidth, 'Height': aheight}
            self.ax_dims.append(d)

        self.ax_bounds = None
        self.calc_axes_bounds()
        #  Set the ID of the object currently being drawn.
        self.cur_id = None
        self.color = color
        self.width = width
        self.draw_line()

    def setColor(self, color):
        self.color = color

    def setWidth(self, width):
        self.width = width

    def calc_axes_bounds(self):
        self.ax_bounds = []
        #  The first axis (ax[0]) will have a top y coordinate of 0.
        heightTot = 0
        #  Calculate the bounding x, y coordinates for each axis.

        for i in range(self.ax_ct):
            #  The x axis is shared by all plots;  therefore, all axes
            #  will start and end at the same x mark.
            x0 = 0
            x1 = math.ceil(self.ax_dims[i]['Width'])
            #  Dealing with the top axis.
            y0 = heightTot
            y1 = self.ax_dims[i]['Height'] + y0
            heightTot += y1
            d = {'x0': x0, 'y0': y0, 'x1': x1, 'y1': y1}
            self.ax_bounds.append(d)

    def inaxes(self, x, y):
        for i in range(len(self.ax_bounds)):
            if (self.ax_bounds[i]['x0'] <= x <= self.ax_bounds[i]['x1']) and (self.ax_bounds[i]['y0'] <= y <= self.ax_bounds[i]['y1']):
               self.cur_ax = i
               ylim = self.ax[self.cur_ax].get_ylim()

    def draw_line(self):
        self.cur_ax = 0
        self.cur_id = Line(self, 156, 39, 861, 273, self.color, self.width)
        print("Done!")


class Line:
    def __init__(self, parent, x0, y0, x1, y1, color, width):
        self.parent = parent
        self.ax = self.parent.ax
        self.id = None
        self.x0 = x0
        self.y0 = y0
        self.x1 = x1
        self.y1 = y1
        self.fig = self.parent.fig
        self.color = color
        self.width = width
        #bbox_height is total height of figure.
        self.bbox_height = self.fig.canvas.figure.bbox.height
        #  bbox_width is total width of figure.
        self.bbox_width = self.fig.canvas.figure.bbox.width
        #  The current axis that is being worked with
        self.cur_ax = self.parent.cur_ax
        #print("Current axis is:", self.cur_ax)
        #self.ax_bounds = self.parent.ax_bounds
        self.id = None
        self.draw()

    def draw(self):
        print("x0 is:", self.x0)
        print("y0 is:", self.y0)
        print("x1 is:", self.x1)
        print("y1 is:", self.y1)
        self.id = self.fig.canvas._tkcanvas.create_line(self.x0, self.y0, self.x1, self.y1, fill=self.color, width=self.width, activewidth=2, smooth=True)
        print("ID is:", self.id)

    def __str__(self):
        return str(self.id)


if __name__ == '__main__':
    dashboard = Tk()
    dashboard.geometry("1200x700")
    dashboard['bg'] = 'grey'
    dashboard.title("Example Drawing Tools")
    dashboard.state("zoomed") #  Makes the window fully enlarged
    # Opening data source
    df = pd.read_csv("ATOS.csv", index_col=0, parse_dates=True)
    dates = df.index.to_pydatetime().tolist()
    # Create `marketcolors` to use with the `charles` style:
    mc = mpf.make_marketcolors(up='#008000',down='#FF0000', vcdopcod=True, inherit=True)
    # Create a new style based on `charles`.
    sm_style = mpf.make_mpf_style(base_mpf_style='charles',
                                 marketcolors=mc,
                                 facecolor='#FFFFFF',
                                 edgecolor='#999999',
                                 figcolor='#FFFFFF'
                                )

    figax =  mpf.plot(df,
                    warn_too_much_data=6000,
                    panel_ratios=(3,1),
                    type="candle",
                    volume=True,
                    figsize=(12, 7),
                    main_panel=0,
                    volume_panel=1,
                    num_panels=2,
                    tight_layout=True,
                    scale_padding={'left': 0.02, 'top': 0, 'right': 1.2, 'bottom': 0.5},
                    ylabel="",
                    style=sm_style,
                    returnfig=True
                )
    fig, ax = figax
    vol_ax = ax[2]
    vol_ax.set_xlabel("")
    vol_ax.set_ylabel("")

    canvasbar = FigureCanvasTkAgg(fig, master=dashboard)
    cursor = MultiCursor(canvasbar, ax, horizOn=True, vertOn=True, linewidth=0.75, color='#000000')
    canvasbar.draw()

    examp = Example(figax)
    canvasbar.get_tk_widget().grid(row=0, column=0, columnspan=5, padx=0, pady=(0,20))
    btn1 = Button(dashboard, text="Exit", command=quit)
    btn1.grid(row=0, column=6, padx=5, pady=10, sticky='n')
    dashboard.mainloop()

Edit:

This is the function that allows the user to draw a line on the screen.

    def draw_trend_line(self, event):
        #print("cur_draw_id is:", str(self.cur_draw_id))
        #print("Begin draw_trend_line")
        self.event = event
        #print("Event (x,y) is:", self.event.x, self.event.y)
        if self.cur_draw_id is not None:
            self.remove()

            xMin = math.ceil(self.ax_bounds[self.cur_ax]['x0'])
            xMax = math.ceil(self.ax_bounds[self.cur_ax]['x1'])
            yMin = math.ceil(self.ax_bounds[self.cur_ax]['y0'])
            yMax = math.ceil(self.ax_bounds[self.cur_ax]['y1'])
            #print("yMax is:", yMax)
            if self.event.x >= xMax:
                x0 = xMax

            elif self.event.x <= xMin:
                x0 = xMin

            else:
                x0 = self.event.x

            if self.event.y >= yMax:
                y0 = yMax

            elif self.event.y <= yMin:
                y0 = yMin

            else:
                y0 = self.event.y

            #  Starting Position
            if self.x_start is None:
                self.x_start = x0

            else:
                x0 = self.x_start 

            if self.y_start is None:
                self.y_start = y0

            else:
                y0 = self.y_start

            #  Ending Position
            if self.event.x >= xMax:
                x1 = xMax

            elif self.event.x <= xMin:
                x1 = xMin

            else:
                x1 = self.event.x

            if self.event.y >= yMax:
                y1 = yMax

            elif self.event.y <= yMin:
                y1 = yMin

            else:
                y1 = self.event.y

            self.cur_draw_id = Line(self, x0, y0, x1, y1, self.color, self.width)
        #print("End draw_trend_line")

I want to be able to replicate the lines the user draws when they open the program the next time. I realize that I have to save the line in a database, which I have no problems with. I can retrieve the coordinates for the line from the database. The program just doesn't display it.

The print statements show that the program is supposedly drawing the line. I have even tried forcing the canvas to redraw using self.fig.canvas.draw().

In the draw_trend_line function, I have a variable called self.cur_ax. In my full program, I am using panels, so there could be multiple axes. Please feel free to ask any questions about anything that you want me to elaborate on.



Solution 1:[1]

This isn't really an "answer" per se, but I have a number of ideas that may help and it's just easier to write them here instead of as a series of comments.

I wish I could help more, but I am not very familiar with tkinter. I was hoping by seeing the code for both the working and non-working case then I might spot something.

Here are my thoughts: I don't understand why you are creating the line directly on the canvas (self.id = self.fig.canvas._tkcanvas.create_line(...)) whereas matplotlib (and mplfinance) draw on the Axes (not on the canvas/Figure).

Overall the issue you are having seems to me a tkinter related issue, or perhaps a tkinter/matplotlib problem: If you could reproduce with a very simple matplotlib example (instead of mplfinance) then it may be easier to isolate.

That said, I would point out that mplfinance has the ability to plot "arbitrary" lines (and trend lines). Perhaps it would be easier for you to use the alines kwarg of mpf.plot() and simply re-paint the plot each time the user requests a trend line.


Finally, here is some code that I tweaked in response to another mplfinance user's question. The code does basically what you want. The data for this example comes from the examples/data directory in the mplfinance repository. The code makes a MACD plot, and then uses matplotlib's Figure.ginput() to get the location of any mouse clicks. The user can click on the main portion of the plot, and every two mouse clicks will result in drawing a line on the plot between the two mouse clicks. Each line is drawn by adding the two points to the list of lines within the alines kwarg specification in the call to mpf.plot():

EDIT: I've tweaked the code below somewhat from the original version here by using dill to simulate your database that holds the lines that the user has drawn. Hopefully this is at least somewhat helpful, or gives you some ideas as to how you may do something similar with tkinter.

import pandas as pd
import mplfinance as mpf
import dill
import os
from matplotlib.widgets import MultiCursor

# read the data:
idf = pd.read_csv('../data/SPY_20110701_20120630_Bollinger.csv',
                  index_col=0,parse_dates=True)
df  = idf.loc['2011-07-01':'2011-12-30',:]

# macd related calculations:
exp12 = df['Close'].ewm(span=12, adjust=False).mean()
exp26 = df['Close'].ewm(span=26, adjust=False).mean()
macd = exp12 - exp26
signal    = macd.ewm(span=9, adjust=False).mean()
histogram = macd - signal

# initial plot:
apds = [mpf.make_addplot(exp12,color='lime'),
        mpf.make_addplot(exp26,color='c'),
        mpf.make_addplot(histogram,type='bar',width=0.7,panel=1,
                         color='dimgray',alpha=1,secondary_y=False),
        mpf.make_addplot(macd,panel=1,color='fuchsia',secondary_y=True),
        mpf.make_addplot(signal,panel=1,color='b',secondary_y=True),
       ]

# For some reason, which i have yet to determine, MultiCursor somehow
# causes ymin to be set to zero for the main candlestick Axes, but we
# can correct that problem by passing in specific values:
ymin = min(df['Low'])  * 0.98
ymax = max(df['High']) * 1.02

# initial plot with cursor:
if os.path.exists('lines.dill'):
    alines = dill.load(open('lines.dill','rb'))
else:
    alines = []

fig, axlist = mpf.plot(df,type='candle',addplot=apds,figscale=1.25,
                       figratio=(8,6),title='\nMACD', ylim=(ymin,ymax),
                       alines=dict(alines=alines,colors='r'),
                       style='blueskies',volume=True,volume_panel=2,
                       panel_ratios=(6,3,2),returnfig=True)
multi = MultiCursor(fig.canvas, axlist[0:2], horizOn=True, 
                    vertOn=True, color='pink', lw=1.2)

fig.canvas.draw_idle()

# ---------------------------------------------------
# set up an event loop where we wait for two
# mouse clicks, and then draw a line in between them,
# and then wait again for another two mouse clicks.

# This is a crude way to do it, but its quick and easy.
# Disadvantage is: user has 8 seconds to provide two clicks
# or the first click will be erased.  But the 8 seconds
# repeats as long as the user does not close the Figure,
# so user can draw as many trend lines as they want.
# The advantage of doing it this way is we don't have
# to write all the mouse click handling stuff that's
# already written in `Figure.ginput()`.


not_closed = True
def on_close(event):
    global not_closed
    global alines
    dill.dump(alines, open('lines.dill','wb'))
    print('closing, please wait ...')
    not_closed = False

fig.canvas.mpl_connect('close_event', on_close)

while not_closed:

    vertices = fig.ginput(n=2,timeout=8)
    if len(vertices) < 2:
        continue
    p1 = vertices[0]
    p2 = vertices[1]

    d1 = df.index[ round(p1[0]) ]
    d2 = df.index[ round(p2[0]) ]

    alines.append( [ (d1,p1[1]), (d2,p2[1]) ] )

    apds = [mpf.make_addplot(exp12,color='lime',ax=axlist[0]),
            mpf.make_addplot(exp26,color='c',ax=axlist[0]),
            mpf.make_addplot(histogram,type='bar',width=0.7,panel=1,
                             ax=axlist[2],color='dimgray',alpha=1),
            mpf.make_addplot(macd,panel=1,color='fuchsia',ax=axlist[3]),
            mpf.make_addplot(signal,panel=1,color='b',ax=axlist[3])
           ]

    mpf.plot(df,ax=axlist[0],type='candle',addplot=apds,ylim=(ymin,ymax),
             alines=dict(alines=alines,colors='r'),
             style='blueskies',volume=axlist[4],volume_panel=2,
             panel_ratios=(6,3,2))

    fig.canvas.draw_idle()

Solution 2:[2]

Just in case someone else runs into a similar problem that I had, I want to post the solution that I came up with after looking at some of the other posts on stackoverflow. It actually is very simple. I added one line right above dashboard.mainloop(). That line is dashboard.after(20, drawInitialized.draw_line). I also removed the call to self.draw_line() in the Example class. It displays the line when the application is started every time. Obviously, the coordinates for this line could be stored in a database, or as Mr. Daniel Goldfarb suggested, the 'dill' module could be used instead.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Greg G