13

I want to add a fill colour between the black and blue line on my Plotly chart. I am aware this can be accomplished already with Plotly but I am not sure how to fill the chart with two colours based on conditions.This is my Plotly chartThis is what I want to achieve

The chart with the blue background is my Plotly chart. I want to make it look like the chart with the white background. (Ignore the red and green bars on the white chart)

The conditions I want it to pass is:

Fill the area between the two lines GREEN, if the black line is above the blue line.

Fill the area between the two lines RED, if the black line is below the blue line.

How can this be done with Plotly? If this is not possible with Plotly can it be accomplished with other graphing tools that work with Python.

vestland
  • 55,229
  • 37
  • 187
  • 305
Patrick Miller
  • 335
  • 1
  • 3
  • 8

4 Answers4

18

For a number of reasons (that I'm willing to explain further if you're interested) the best approach seems to be to add two traces to a go.Figure() object for each time your averages cross eachother, and then define the fill using fill='tonexty' for the second trace using:

for df in dfs:
    fig.add_traces(go.Scatter(x=df.index, y = df.ma1,
                              line = dict(color='rgba(0,0,0,0)')))
    
    fig.add_traces(go.Scatter(x=df.index, y = df.ma2,
                              line = dict(color='rgba(0,0,0,0)'),
                              fill='tonexty', 
                              fillcolor = fillcol(df['label'].iloc[0])))

fillcol is a simple custom function described in the full snippet below. And I've used the approach described in How to split a dataframe each time a string value changes in a column? to produce the necessary splits in the dataframe each time your averages cross eachother.

Plot

enter image description here

Complete code:

import plotly.graph_objects as go
import numpy as np

import pandas as pd
from datetime import datetime
pd.options.plotting.backend = "plotly"

# sample data
df = pd.read_csv('https://raw.githubusercontent.com/plotly/datasets/master/finance-charts-apple.csv')
df.index = df.Date
df = df[['AAPL.Close', 'mavg']]
df['mavg2'] = df['AAPL.Close'].rolling(window=50).mean()
df.columns = ['y', 'ma1', 'ma2']
df=df.tail(250).dropna()
df1 = df.copy()

# split data into chunks where averages cross each other
df['label'] = np.where(df['ma1']>df['ma2'], 1, 0)
df['group'] = df['label'].ne(df['label'].shift()).cumsum()
df = df.groupby('group')
dfs = []
for name, data in df:
    dfs.append(data)

# custom function to set fill color
def fillcol(label):
    if label >= 1:
        return 'rgba(0,250,0,0.4)'
    else:
        return 'rgba(250,0,0,0.4)'

fig = go.Figure()

for df in dfs:
    fig.add_traces(go.Scatter(x=df.index, y = df.ma1,
                              line = dict(color='rgba(0,0,0,0)')))
    
    fig.add_traces(go.Scatter(x=df.index, y = df.ma2,
                              line = dict(color='rgba(0,0,0,0)'),
                              fill='tonexty', 
                              fillcolor = fillcol(df['label'].iloc[0])))

# include averages
fig.add_traces(go.Scatter(x=df1.index, y = df1.ma1,
                          line = dict(color = 'blue', width=1)))

fig.add_traces(go.Scatter(x=df1.index, y = df1.ma2,
                          line = dict(color = 'red', width=1)))

# include main time-series
fig.add_traces(go.Scatter(x=df1.index, y = df1.y,
                          line = dict(color = 'black', width=2)))

fig.update_layout(showlegend=False)
fig.show()
vestland
  • 55,229
  • 37
  • 187
  • 305
  • This doesn't work when the labels alternate for each value pair. In other words: in my dataset the groups contain most of the time only one value. Plotly then doesn't draw a filled graphed at all. – winklerrr Dec 14 '20 at 10:59
  • 1
    'tonexty' reads "to next y" took me a while to understand – Jonatas Eduardo Jul 19 '22 at 18:16
0

[This is a javascript solution of the problem]

I went for a completely different approach to create climate charts.

I have used a set of functions that check if two traces intersect each other. For each intersection, the function will take all points of the traces until the intersection point to create seperate polygons and colour them in. This function is recursively. If there is no intersection, there will only be one polygon, if there is one intersection, there are two polygons, if there are two intersections, there are three polygons, etc. These polygons are then added to the chart.

An introduction to shapes in plotly is given here: https://plotly.com/javascript/shapes/

I used an existing function for finding intersection points from here: https://stackoverflow.com/a/38977789/3832675

I wrote my own function for creating the polygon strings that create the path strings necessary to the polygons. Depending on which line is above the other (which can be realised using a simple comparison), the variable "colour" is either green or red.

function draw_and_colour_in_polygon(temperature_values, precipitation_values, x_values) {

        var path_string = '';
        for (var i = 0; i < x_values.length; i++) {
            path_string += ' L ' + x_values[i] + ', '+ temperature_values[i];
        }
        for (var i = precipitation_values.length-1; i >= 0; i--) {
            path_string += ' L ' + x_values[i] + ', ' + precipitation_values[i];
        }
        
        path_string += ' Z';
        path_string = path_string.replace(/^.{2}/g, 'M ');
        
        if (temperature_values[0] >= precipitation_values[0] && temperature_values[1] >= precipitation_values[1]) {
            var colour = 'rgba(255, 255, 0, 0.5)';
        }
        else {
            var colour = 'rgba(65, 105, 225, 0.5)';
        }
        
        return {
            path: path_string,
            polygon_colour: colour,
        }; 
};

All of this put together looks like this: Plotly - Climate chart

In this case, we have three seperate polygons added to the chart. They are either blue or yellow depending on whether temperature value is higher than the precipiation value or vice versa. Please bear in mind that the polygons are composed of y values of both traces. My two traces don't use the same y-axis and therefore a transformation function has to be applied to one of the traces to harmonise the height values before the polygon string can be composed.

I can't share all the code as I have also added bits where the y-axes are scaled at higher values which would add quite some complexity to the answer, but I hope that you get the idea.

stopopol
  • 486
  • 7
  • 29
0

I have a solution based on maplotlib's fill_between:

ax = df[['rate1', 'rate2']].plot()
ax.fill_between(
    df.index, df['rate1'], df[['rate1', 'rate2']].min(1),
    color='red', alpha=0.1
);
ax.fill_between(
    df.index, df['rate1'], df[['rate1', 'rate2']].max(1),
    color='green', alpha=0.1
);
IanS
  • 15,771
  • 9
  • 60
  • 84
0

As it has been three years since the last answer, let me hopefully have a simplified answer and some more additional information about Plotly.

One reason why this kind of operation is difficult in Plotly as there is a long outstanding bug that one connect use connectgaps and fill in the same scatter plot (2019).

The solution is to

  • Create fill='tonexty' scatter where we set the area size to zero

  • Create separate scatters for green and red half of the technical indicator

Here is an example plot using this method:

enter image description here

Here is the code used to draw it:

import plotly.graph_objects as go
from tradeexecutor.visual.technical_indicator import export_plot_as_dataframe

# Set up DataFrames where one has price and one has moving average
candles = avax_candles  # OHLCV data
sma_plot = state.visualisation.plots["Native token SMA"]
sma_df = export_plot_as_dataframe(sma_plot)  # Simple moving average values

# Create a DataFrame that contains values from all of our plots using the same master DateTimeIndex.
# Namely moving averages have less samples, because moving average cannot be calculated
# early on as the time window has not yet enough data.
# We will also interpolate indicator values, as our indicator has less granular
# DateTimeIndex as the price data.
indicator_df = pd.DataFrame(index=candles.index)
indicator_df["price"] = candles["close"]
indicator_df["indicator_value"] = sma_df["value"]
indicator_df["indicator_value"].interpolate(inplace=True)

# There is a multiyear bug in Plotly that you cannot use connectgaps and fill in the same plot.
# This is why we set the indicator value to the price value when we do not want to plot the area,
# as this will fill area with the size of 0
# https://github.com/plotly/plotly.js/issues/1132#issuecomment-531030346
indicator_df["green_above"] = indicator_df.apply(lambda row: row["indicator_value"] if row["indicator_value"] > row["price"] else row["price"], axis="columns")
indicator_df["red_below"] = indicator_df.apply(lambda row: row["indicator_value"] if row["indicator_value"] <= row["price"] else row["price"], axis="columns")

# Fill the area between close price and SMA indicator
# See https://plotly.com/python/filled-area-plots/#interior-filling-for-area-chart
# See also https://stackoverflow.com/a/64743166/315168
fig = go.Figure()

# Plot out the
fig.add_trace(
    go.Trace(
        x=indicator_df.index,
        y=indicator_df["price"],
        name=f"Native token price",
        line_color="black",
    )
)

fig.add_trace(
    go.Trace(
        x=indicator_df.index,
        y=indicator_df["green_above"],
        name="SMA above",
        line_color="green",
        connectgaps=False,
        fillcolor="green",
        fill='tonexty',
    )
)

# We need to use an invisible trace so we can reset "next y"
# for the red area indicator
fig.add_trace(
    go.Trace(
        x=indicator_df.index,
        y=indicator_df["price"],
        line_color="rgba(0,0,0,0)",
    )
)
#
fig.add_trace(
    go.Trace(
        x=indicator_df.index,
        y=indicator_df["red_below"],
        name="SMA below",
        line_color="red",
        connectgaps=False,
        fillcolor="red",
        fill='tonexty',
    )
)

Mikko Ohtamaa
  • 82,057
  • 50
  • 264
  • 435