diff --git a/docs/plotting.md b/docs/plotting.md index 9fae38504..ed20da452 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -211,6 +211,12 @@ Sample configuration with inline comments explaining the process: "RSI": { 'rsi': {'color': 'red'} } + }, + 'volume': { + 'showBuySell': 'true', + 'showVolumeProfile': 'true', + # 'VolumeProfileHistoryBars': 96, # default: all + # 'VolumeProfilePriceRangeSplices': 100 # default: 50 } } diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 6d44d56b1..0e2d755e9 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -18,6 +18,13 @@ from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.strategy import IStrategy +# Possible later improvement: +# just show the volume profile for the zoomed-in area: +# currently the volume profile is always based on all data in the plot +# check if it is possible to bind the y-axis of the volume profile to the x-axis +# of the candels and group by price-read and sum the (buy/sell)-volume together + + logger = logging.getLogger(__name__) @@ -255,7 +262,14 @@ def create_plotconfig(indicators1: List[str], indicators2: List[str], :return: plot_config - eventually with indicators 1 and 2 """ - if plot_config: + if plot_config and 'main_plot' not in plot_config and 'subplots' not in plot_config: + # if just the volumeProfile config is set but main_plot and subplots are empty + indicators1 = ['sma', 'ema3', 'ema5'] + plot_config['main_plot'] = {ind: {} for ind in indicators1} + indicators2 = ['macd', 'macdsignal'] + plot_config['subplots'] = {'Other': {ind: {} for ind in indicators2}} + + elif plot_config: if indicators1: plot_config['main_plot'] = {ind: {} for ind in indicators1} if indicators2: @@ -340,6 +354,115 @@ def add_areas(fig, row: int, data: pd.DataFrame, indicators) -> make_subplots: return fig +def generateBuySellVolumes(dataframe) -> pd.DataFrame: + candles = dataframe.copy() + + candles['volume_buy'] = 0 + candles['volume_sell'] = 0 + + for i in range(len(candles)): + + candles['volume_buy'].iat[i] = (candles['volume'].iat[i] * + (candles['close'].iat[i]-candles['low'].iat[i]) / + (candles['high'].iat[i]-candles['low'].iat[i])) \ + if (candles['high'].iat[i]-candles['low'].iat[i]) > 0 else 0 + + candles['volume_sell'].iat[i] = (candles['volume'].iat[i] * + (candles['high'].iat[i]-candles['close'].iat[i]) / + (candles['high'].iat[i]-candles['low'].iat[i])) \ + if (candles['high'].iat[i]-candles['low'].iat[i]) > 0 else 0 + + return candles + + +def createVolumeProfileData(data: pd.DataFrame, bar_split: int = 50, + history_bars: int = None) -> pd.DataFrame: + """ + Generate the Volume Profile Date for the given dataframe + :param df: DataFrame with candle data + :param bar_split: in how many bars should the PriceRange be spit (default=50) + :param history_bars: how many history bars should be considered (default=all) + :return: DataFrame with Price/Trade Date + """ + df = data.copy() + + df["volume_buy"] = generateBuySellVolumes(df)["volume_buy"] + df["volume_sell"] = generateBuySellVolumes(df)["volume_sell"] + + df_vol = pd.DataFrame(columns=['price_lower', 'price_upper', + 'price_avg', 'volume', 'volume_buy', 'volume_sell']) + + div = df['close'].max() - df['close'].min() + step = div/bar_split + for i in range(0, bar_split): + # finding the priceRange + price_lower = df['close'].min() + step*i + price_upper = df['close'].min() + step*(i+1) + price_avg = (price_lower + price_upper)/2 + + # the volume for the given priceRange + if history_bars is not None: + dt_tail = df.tail(history_bars) + else: + dt_tail = df + + volume_comb = dt_tail[(dt_tail['close'] >= price_lower) & ( + dt_tail['close'] < price_upper)]['volume'].sum() + + volume_buy = dt_tail[(dt_tail['close'] >= price_lower) & ( + dt_tail['close'] < price_upper)]['volume_buy'].sum() + + volume_sell = dt_tail[(dt_tail['close'] >= price_lower) & ( + dt_tail['close'] < price_upper)]['volume_sell'].sum() + + df_vol = df_vol.append({'price_lower': price_lower, + 'price_upper': price_upper, + 'price_avg': price_avg, + 'volume': volume_comb, + 'volume_buy': volume_buy, + 'volume_sell': volume_sell}, ignore_index=True) + return df_vol + + +def add_volumeProfile(data: pd.DataFrame, fig, plot_config: Dict[str, Dict]): + # load VolumeProfile configurations + volume_config = plot_config['volume'] if 'volume' in plot_config else {} + showVolumeProfile = volume_config['showVolumeProfile'] if 'showVolumeProfile' in \ + volume_config else 'false' + VolumeProfileHistoryBars = volume_config['VolumeProfileHistoryBars'] if \ + 'VolumeProfileHistoryBars' in volume_config else -1 + VolumeProfilePriceRangeSplices = volume_config[ + 'VolumeProfilePriceRangeSplices'] if 'VolumeProfilePriceRangeSplices' in \ + volume_config else 50 + + # Show VolumeProfile + if showVolumeProfile.lower() == 'true': + + volumeProfileData = createVolumeProfileData( + data, VolumeProfilePriceRangeSplices, VolumeProfileHistoryBars) + + volume_buy = go.Bar( + x=volumeProfileData['volume_buy'], + y=volumeProfileData['price_avg'], + name='VolumeProfile Buy', + marker_color='rgba(99, 146, 52, 0.6)', + marker_line_color='rgba(99, 146, 52, 0.6)', + orientation='h', + ) + volume_sell = go.Bar( + x=volumeProfileData['volume_sell'], + y=volumeProfileData['price_avg'], + name='VolumeProfile Sell', + marker_color='rgba(208, 41, 41, 0.6)', + marker_line_color='rgba(208, 41, 41, 0.6)', + orientation='h', + ) + + fig.add_trace(volume_sell, 1, 2) + fig.add_trace(volume_buy, 1, 2) + fig.update_layout(barmode='stack') + + def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFrame = None, *, indicators1: List[str] = [], indicators2: List[str] = [], @@ -359,19 +482,30 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra plot_config = create_plotconfig(indicators1, indicators2, plot_config) rows = 2 + len(plot_config['subplots']) row_widths = [1 for _ in plot_config['subplots']] + + # load VolumeProfile configurations + volume_config = plot_config['volume'] if 'volume' in plot_config else {} + showBuySell = volume_config['showBuySell'] if 'showBuySell' in volume_config else 'false' + showVolumeProfile = volume_config['showVolumeProfile'] if 'showVolumeProfile' in \ + volume_config else 'false' + # Define the graph fig = make_subplots( rows=rows, - cols=1, + cols=2, # ToDo: Check if 2 columns (instead of one) cause any issues somewhere else + # set the width of the Volume Profile + column_widths=[8, 1 if showVolumeProfile.lower() == 'true' else 0], shared_xaxes=True, + shared_yaxes=True, row_width=row_widths + [1, 4], vertical_spacing=0.0001, + horizontal_spacing=0.0001, ) fig['layout'].update(title=pair) fig['layout']['yaxis1'].update(title='Price') - fig['layout']['yaxis2'].update(title='Volume') + fig['layout']['yaxis3'].update(title='Volume') for i, name in enumerate(plot_config['subplots']): - fig['layout'][f'yaxis{3 + i}'].update(title=name) + fig['layout'][f'yaxis{5 + i*2 }'].update(title=name) fig['layout']['xaxis']['rangeslider'].update(visible=False) fig.update_layout(modebar_add=["v1hovermode", "toggleSpikeLines"]) @@ -436,15 +570,42 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra fig = add_indicators(fig=fig, row=1, indicators=plot_config['main_plot'], data=data) fig = add_areas(fig, 1, data, plot_config['main_plot']) fig = plot_trades(fig, trades) - # sub plot: Volume goes to row 2 - volume = go.Bar( - x=data['date'], - y=data['volume'], - name='Volume', - marker_color='DarkSlateGrey', - marker_line_color='DarkSlateGrey' - ) - fig.add_trace(volume, 2, 1) + + add_volumeProfile(data, fig, plot_config) + + # standard volume plot + if showBuySell.lower() == 'true': # show volume plot split by sell & buy trades + volume_red = go.Bar( + x=data['date'], + y=data['volume'] * (data['high']-data['close']) / (data['high']-data['low']), + name='Volume Sell', + marker_color='rgba(208, 41, 41, 0.6)', + marker_line_color='rgba(208, 41, 41, 0.6)' + ) + fig.add_trace(volume_red, 2, 1) + + volume_green = go.Bar( + x=data['date'], + + y=data['volume'] * (data['close']-data['low']) / (data['high']-data['low']), + name='Volume Buy', + marker_color='rgba(99, 146, 52, 0.6)', + marker_line_color='rgba(99, 146, 52, 0.6)' + ) + fig.add_trace(volume_green, 2, 1,) + fig.update_layout(barmode='stack') + + else: # show 'normal' gray volume plot + # sub plot: Volume goes to row 2 + volume = go.Bar( + x=data['date'], + y=data['volume'], + name='Volume', + marker_color='DarkSlateGrey', + marker_line_color='DarkSlateGrey' + ) + fig.add_trace(volume, 2, 1) + # add each sub plot to a separate row for i, label in enumerate(plot_config['subplots']): sub_config = plot_config['subplots'][label] diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 8a40f4a20..98d7e474e 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -13,8 +13,9 @@ from freqtrade.data import history from freqtrade.data.btanalysis import create_cum_profit, load_backtest_data from freqtrade.exceptions import OperationalException from freqtrade.plot.plotting import (add_areas, add_indicators, add_profit, create_plotconfig, - generate_candlestick_graph, generate_plot_filename, - generate_profit_graph, init_plotscript, load_and_plot_trades, + createVolumeProfileData, generate_candlestick_graph, + generate_plot_filename, generate_profit_graph, + generateBuySellVolumes, init_plotscript, load_and_plot_trades, plot_profit, plot_trades, store_plot_file) from freqtrade.resolvers import StrategyResolver from tests.conftest import get_args, log_has, log_has_re, patch_exchange @@ -60,6 +61,33 @@ def test_init_plotscript(default_conf, mocker, testdatadir): assert "ADA/BTC" in ret["ohlcv"] +def test_generate_volumeProfile(default_conf, testdatadir): + pair = "UNITTEST/BTC" + timerange = TimeRange(None, 'line', 0, -1000) + + data = history.load_pair_history(pair=pair, timeframe='1m', + datadir=testdatadir, timerange=timerange) + strategy = StrategyResolver.load_strategy(default_conf) + + data = strategy.analyze_ticker(data, {'pair': pair}) + + buysell_volumes = generateBuySellVolumes(data) + + assert buysell_volumes['volume_buy'].any() + assert buysell_volumes['volume_sell'].any() + + volumeProfile = createVolumeProfileData(buysell_volumes, 50, 100) + + assert volumeProfile['price_lower'].any() + assert volumeProfile['price_upper'].any() + assert volumeProfile['price_avg'].any() + assert volumeProfile['volume'].any() + assert volumeProfile['volume_buy'].any() + assert volumeProfile['volume_sell'].any() + + assert len(volumeProfile.index) == 50 + + def test_add_indicators(default_conf, testdatadir, caplog): pair = "UNITTEST/BTC" timerange = TimeRange(None, 'line', 0, -1000) @@ -273,6 +301,48 @@ def test_generate_candlestick_graph_no_trades(default_conf, mocker, testdatadir) assert trades_mock.call_count == 1 +def test_generate_candlestick_graph_withVolumeProfile(default_conf, mocker, testdatadir, caplog): + row_mock = mocker.patch('freqtrade.plot.plotting.add_indicators', + MagicMock(side_effect=fig_generating_mock)) + trades_mock = mocker.patch('freqtrade.plot.plotting.plot_trades', + MagicMock(side_effect=fig_generating_mock)) + + pair = "UNITTEST/BTC" + timerange = TimeRange(None, 'line', 0, -1000) + data = history.load_pair_history(pair=pair, timeframe='1m', + datadir=testdatadir, timerange=timerange) + data['buy'] = 0 + data['sell'] = 0 + + fig = generate_candlestick_graph(pair=pair, data=data, trades=None, + plot_config={'main_plot': {'sma': {}, 'ema200': {}}, + 'subplots': {'Other': {'macdsignal': {}}}, + 'volume': {'showBuySell': 'true', + 'showVolumeProfile': 'true', + 'VolumeProfileHistoryBars': 96, + 'VolumeProfilePriceRangeSplices': 60}}) + assert isinstance(fig, go.Figure) + assert fig.layout.title.text == pair + figure = fig.layout.figure + + assert len(figure.data) == 5 + # Candlesticks are plotted first + candles = find_trace_in_fig_data(figure.data, "Price") + assert isinstance(candles, go.Candlestick) + + assert isinstance(find_trace_in_fig_data(figure.data, "Volume Sell"), go.Bar) + assert isinstance(find_trace_in_fig_data(figure.data, "Volume Buy"), go.Bar) + + assert isinstance(find_trace_in_fig_data(figure.data, "VolumeProfile Sell"), go.Bar) + assert isinstance(find_trace_in_fig_data(figure.data, "VolumeProfile Buy"), go.Bar) + + assert row_mock.call_count == 2 + assert trades_mock.call_count == 1 + + assert log_has("No buy-signals found.", caplog) + assert log_has("No sell-signals found.", caplog) + + def test_generate_Plot_filename(): fn = generate_plot_filename("UNITTEST/BTC", "5m") assert fn == "freqtrade-plot-UNITTEST_BTC-5m.html" @@ -468,6 +538,7 @@ def test_plot_profit(default_conf, mocker, testdatadir): ([], [], {}, {'main_plot': {'sma': {}, 'ema3': {}, 'ema5': {}}, 'subplots': {'Other': {'macd': {}, 'macdsignal': {}}}}), + # use indicators (['sma', 'ema3'], ['macd'], {}, {'main_plot': {'sma': {}, 'ema3': {}}, 'subplots': {'Other': {'macd': {}}}}), @@ -495,6 +566,29 @@ def test_plot_profit(default_conf, mocker, testdatadir): {'main_plot': {'sma': {}}, 'subplots': {'RSI': {'rsi': {'color': 'red'}}}}, {'main_plot': {'sma': {}}, 'subplots': {'Other': {'macd': {}, 'macd_signal': {}}}} ), + # No indicators, use plot_conf with just volume profile + ([], [], + { + 'volume': {'showBuySell': 'true', 'showVolumeProfile': 'true', + 'VolumeProfileHistoryBars': 96, 'VolumeProfilePriceRangeSplices': 100}, + }, + {'main_plot': {'sma': {}, 'ema3': {}, 'ema5': {}}, + 'subplots': {'Other': {'macd': {}, 'macdsignal': {}}}, + 'volume': {'showBuySell': 'true', 'showVolumeProfile': 'true', + 'VolumeProfileHistoryBars': 96, 'VolumeProfilePriceRangeSplices': 100}}, + ), + # No indicators, use full plot_conf with volume profile + ([], [], + {'main_plot': {'sma': {}, 'ema200': {}}, + 'subplots': {'Other': {'macdsignal': {}}}, + 'volume': {'showBuySell': 'true', 'showVolumeProfile': 'true', + 'VolumeProfileHistoryBars': 9, 'VolumeProfilePriceRangeSplices': 111}}, + {'main_plot': {'sma': {}, 'ema200': {}}, + 'subplots': {'Other': {'macdsignal': {}}}, + 'volume': {'showBuySell': 'true', 'showVolumeProfile': 'true', + 'VolumeProfileHistoryBars': 9, 'VolumeProfilePriceRangeSplices': 111}}, + ) + ]) def test_create_plotconfig(ind1, ind2, plot_conf, exp):