Different behavior between x_range and y_range

Hello I’m trying to make a candlestick plot for different stock price using yahoo financial api.
I use a select widget to download data and I plot it using a cds.
This is the code (I work in a notebook, I use panel but I observe the same behavior with output_notebook of bokeh) :

import json
import datetime
import numpy as np
import panel as pn
import pandas as pd
import yfinance as yf
from math import  pi
from bokeh.plotting import figure
from bokeh.models import CustomJS, ColumnDataSource, Range1d
from pandas_datareader import data as pdr
yf.pdr_override()
pn.extension()
societies = {'Accor SA': 'AC.PA', "L'Air\xa0Liquide\xa0S.A.": 'AI.PA', 'Alstom SA': 'ALO.PA', 'AXA SA': 'CS.PA', 'BNP Paribas SA': 'BNP.PA', 'Bouygues SA': 'EN.PA', 'Capgemini SE': 'CAP.PA', 'Carrefour SA': 'CA.PA', 'Credit Agricole S.A.': 'ACA.PA', 'Danone S.A.': 'BN.PA', 'Electricite de France S.A.': 'EDF.PA', "L'Oreal S.A.": 'OR.PA', 'Legrand SA': 'LR.PA', 'Lagardere SCA': 'MMB.PA', 'LVMH Moet Hennessy - Louis Vuitton, Societe Europeenne': 'MC.PA', 'Compagnie Generale des Etablissements Michelin': 'ML.PA', 'Pernod Ricard SA': 'RI.PA', 'Peugeot S.A.': 'UG.PA', 'Renault SA': 'RNO.PA', 'Compagnie de Saint-Gobain S.A.': 'SGO.PA', 'Sanofi': 'SAN.PA', 'Schneider Electric S.E.': 'SU.PA', 'Societe Generale Societe anonyme': 'GLE.PA', 'STMicroelectronics N.V.': 'STM.PA', 'TOTAL S.A.': 'FP.PA', 'Thales S.A.': 'HO.PA', 'Vallourec S.A.': 'VK.PA', 'Veolia Environnement S.A.': 'VIE.PA', 'VINCI SA': 'DG.PA', 'Vivendi SA': 'VIV.PA', 'Orange S.A.': 'ORA.PA', 'ENGIE SA': 'ENGI.PA', 'Airbus SE': 'AIR.PA', 'Sodexo S.A.': 'SW.PA', 'Kering SA': 'KER.PA', 'Atos SE': 'ATO.PA', 'TechnipFMC plc': 'FTI.PA'}
def query_data(symbol, start=None, end=None, interval="5m", inc_color="#D5E1DD", dec_color="#F2583E"):
    date_types = [datetime.datetime, datetime.date, str, type(None)]
    assert type(start) in date_types, "Wrong date format for end, type must be one of {} and not {}".format(date_types, type(start))
    assert type(end) in date_types, "Wrong date format for end, type must be one of {} and not {}".format(date_types, type(end))
    end = (datetime.datetime.now() if end is None
           else datetime.datetime.fromisoformat(end) if type(end) is str
           else datetime.datetime.combine(end, datetime.time(23,59)) if type(end) is datetime.date
           else end) 
    start = (end - datetime.timedelta(days=30) if start is None
             else datetime.datetime.fromisoformat(start) if type(start) is str
             else datetime.datetime.combine(start, datetime.time(23,59)) if type(start) is datetime.date
             else start)
    assert start < end, 'Invalid start end, start should be smaller than end'
    data = pdr.get_data_yahoo(symbol, start=start, end=end, interval=interval, progress=False)
    df = data.reset_index(level=0, inplace=False).rename(columns={"Datetime": "date"}).rename(columns={c: c.lower() for c in data.columns})
    inc = df.close > df.open
    dec = df.open > df.close
    df.loc[inc, 'fill_color'] = inc_color
    df.loc[dec, 'fill_color'] = dec_color
    return df
def compute_x_range(cds):
    x_min = cds.data['date'].min().astype(datetime.datetime)*1e-6 # ns -> ms
    x_max = cds.data['date'].max().astype(datetime.datetime)*1e-6 # ns -> ms
    dx = x_max - x_min
    x_pad = 0.1
    start = x_min - dx*x_pad/2
    end = x_max + dx*x_pad/2
    return start, end

def compute_y_range(cds):
    y_min = cds.data['low'].min()
    y_max = cds.data['high'].max()
    dy = y_max - y_min
    y_pad = 0.1
    start = y_min - dy*y_pad/2
    end = y_max + dy*y_pad/2
    return start, end

options = list(societies.items())
value = options[0][1]
cds = ColumnDataSource(query_data(value))

w = 0.5*5*60*1000 # 80% of 5 minutes in ms

select = pn.widgets.Select(options=societies)

x_range = Range1d(*compute_x_range(cds))
y_range = Range1d(*compute_y_range(cds))

p = figure(x_axis_type="datetime", plot_width=1000, title = "MSFT Candlestick",
          x_range=x_range, y_range=y_range)


p.xaxis.major_label_orientation = pi/4
p.grid.grid_line_alpha=0.3

p.segment(x0='date', y0='high', x1='date', y1='low', color="black", source=cds)
p.vbar(x="date", width=w, top="open", bottom="close", fill_color="fill_color", line_color="black", source=cds)

pan = pn.panel(p)
def update_data(*events):
    cds.data = ColumnDataSource.from_df(query_data(events[0].new))
    p.x_range.start, p.x_range.end = compute_x_range(cds)
    p.x_range.reset_start, p.x_range.reset_end = compute_x_range(cds)
    p.y_range.start, p.y_range.end = compute_y_range(cds)
    p.y_range.reset_start, p.y_range.reset_end = compute_y_range(cds)
    pan.param.trigger('object')

select.param.watch(update_data, ['value'])

pn.Row(pan, select)

Everything work as expected except for the xaxis, I’d like the same behavior as the yaxis (when I select a new stock price, both axis resets to start and end specified in the update function)
As you can see on the following gif when I zoom on a part of the graph and I select an other value, y_range change whereas the x_range stay at the zoom level.

Ok I know why it does not work.
Zooming does not change start and end of the x_range on the python side and since the data are always on the same x range no change is triggered

I can add

p.x_range.trigger('start', 0, 1)

but I’m not sure it’s the recommended way

1 Like

Hi Xavier,

First of all, thank you for the excellent working example. It is so helpful in understanding the question.

It sounds like you’ve figured out the logic behind the behavior, and I think your solution is fine. I commented out the two x_range lines from your callback and replaced them with the x_range.trigger solution you proposed, and that appears to work great.

As always, I welcome feedback from others, but I think your fix is good.

I don’t really understand this claim:

Zooming does not change start and end of the x_range on the python side

Zooming (i.e. using a box zoom tool) does update the values on the Python side, as this minimal example demonstrates:

from bokeh.io import curdoc
from bokeh.plotting import figure

p = figure(x_range=(0,10), y_range=(0,10), tools="box_zoom")

def print_value(attr, old, new):
    print(attr, old, new)

p.x_range.on_change('start', print_value)
p.x_range.on_change('end', print_value)

curdoc().add_root(p)

If you are saying you are not seeing the x-range start/end values update as a result of a box zoom operation, then what is really needed is a minimal reproducing script (i.e. stripped of any and all code not directly related to demonstrating the issue) that can be run and investigated.

Otherwise I would only say that it’s highly unusual for anyone to use trigger directly. The common mechanism is to simply set attributes:

p.x_range.start = new_start
p.x_range.end = new_end

If served with a bokeh server but not in a jupyter notebook

So, this is really a Panel question, then, because Panel layers on different behaviors than straight Bokeh (evidently). It’s probably better to start anything Panel-related off in the holoviz discourse:

Here is why I posted this in Bokeh.
I use notebook to develop my apps and I began with something similar to:

import numpy as np
import pandas as pd
from bokeh.plotting import figure
from bokeh.io import show, output_notebook, push_notebook
from bokeh.models import CustomJS, ColumnDataSource
output_notebook()

def generate_data(n=100):
    x = np.linspace(0,1,n)
    return pd.DataFrame(np.hstack((x[:,None], np.random.rand(n,4)+np.random.randn(1))), columns=['date', 'low', 'high', 'open', 'close'])
def compute_x_range(cds):
    x_min = cds.data['date'].min()
    x_max = cds.data['date'].max()
    dx = x_max - x_min
    x_pad = 0.1
    start = x_min - dx*x_pad/2
    end = x_max + dx*x_pad/2
    return int(start), int(end)
def compute_y_range(cds):
    y_min = cds.data['low'].min()
    y_max = cds.data['high'].max()
    dy = y_max - y_min
    y_pad = 0.1
    start = y_min - dy*y_pad/2
    end = y_max + dy*y_pad/2
    return start, end

#init columndatasource
cds = ColumnDataSource(generate_data())
x_range = compute_x_range(cds)
y_range = compute_y_range(cds)
p = figure(sizing_mode='stretch_width', y_range=y_range, x_range=x_range)
p.segment(x0='date', y0='high', x1='date', y1='low', color="black", source=cds)
show(p, notebook_handle=True)

then in an other cell I used :

cds.data = ColumnDataSource.from_df(generate_data())
p.x_range.start, p.x_range.end = compute_x_range(cds)
p.x_range.reset_start, p.x_range.reset_end = compute_x_range(cds)
p.y_range.start, p.y_range.end = compute_y_range(cds)
p.y_range.reset_start, p.y_range.reset_end = compute_y_range(cds)
push_notebook()

When I did that it confused me, my y axis reseted but not my x axis.
Then I continue to develop my app but I haven’t understood why this difference of behavior between my axes.
It’s after I post in discourse I figure out my mistake.