diff --git a/src/mplfinance/_styles.py b/src/mplfinance/_styles.py index 84eb7fd5..771c5e4f 100644 --- a/src/mplfinance/_styles.py +++ b/src/mplfinance/_styles.py @@ -125,6 +125,12 @@ def _valid_make_mpf_style_kwargs(): 'Description' : 'name for this style; useful when calling `mpf.write_style_file(style,filename)`', 'Validator' : lambda value: isinstance(value,str) }, + 'volume_color_updown_dependency' + : { 'Default' : None, + 'Description' : 'How volume bar up/down colors are decided: ' + '"Open vs Close" (default), "Previous Close", or "Previous Volume".', + 'Validator' : _valid_volume_color_updown_dependency }, + } _validate_vkwargs_dict(vkwargs) @@ -135,6 +141,18 @@ def _valid_make_mpf_style_kwargs(): def available_styles(): return list(_styles.keys()) +def _valid_volume_color_updown_dependency(value): + ''' + Validate a `volume_color_updown_dependency` value. Allowed values + (matched case- and space-insensitively) are: "Open vs Close" (default), + "Previous Close", and "Previous Volume". + ''' + return ( value is None or + ( isinstance(value,str) and + value.lower().replace(' ','') in + ('openvsclose','previousclose','previousvolume') ) ) + + def make_mpf_style( **kwargs ): config = _process_kwargs(kwargs, _valid_make_mpf_style_kwargs()) if config['rc'] is not None and config['legacy_rc'] is not None: @@ -175,6 +193,13 @@ def make_mpf_style( **kwargs ): if style['marketcolors'] is None: style['marketcolors'] = _styles['default']['marketcolors'] + # `volume_color_updown_dependency` may be passed directly to make_mpf_style(); + # store it in marketcolors (copying first, so a shared/default dict is never mutated). + vcud = style.pop('volume_color_updown_dependency', None) + if vcud is not None: + style['marketcolors'] = dict(style['marketcolors']) + style['marketcolors']['volume_color_updown_dependency'] = vcud + return style def _valid_mpf_color_spec(value): @@ -264,6 +289,13 @@ def _valid_make_marketcolors_kwargs(): 'Description' : 'True/False volume color depends on price change from previous day', 'Validator' : lambda value: isinstance(value,bool) }, + 'volume_color_updown_dependency' + : { 'Default' : None, + 'Description' : 'What the up/down color of the volume bars depends on: ' + '"Open vs Close" (default), "Previous Close", or "Previous Volume". ' + 'Matching is case- and space-insensitive.', + 'Validator' : _valid_volume_color_updown_dependency }, + 'inherit' : { 'Default' : False, 'Description' : 'inherit color from base_mpf_style for: edge,volume,ohlc,wick', @@ -355,6 +387,9 @@ def _check_and_set_mktcolor(candle,**kwarg): if config['vcdopcod'] is not None: marketcolors.update({'vcdopcod':config['vcdopcod']}) + if config['volume_color_updown_dependency'] is not None: + marketcolors.update({'volume_color_updown_dependency':config['volume_color_updown_dependency']}) + return marketcolors def write_style_file(style,filename): diff --git a/src/mplfinance/plotting.py b/src/mplfinance/plotting.py index 3f5350e8..795e9b8d 100644 --- a/src/mplfinance/plotting.py +++ b/src/mplfinance/plotting.py @@ -152,6 +152,12 @@ def _valid_plot_kwargs(): 'mco_faceonly' : { 'Default' : False, # If True: Override only the face of the candle 'Description' : 'True/False marketcolor_overrides only apply to face of candle.', 'Validator' : lambda value: isinstance(value,bool) }, + + 'volume_color_updown_dependency' + : { 'Default' : None, + 'Description' : 'How volume bar up/down colors are decided: ' + '"Open vs Close" (default), "Previous Close", or "Previous Volume".', + 'Validator' : _styles._valid_volume_color_updown_dependency }, 'no_xgaps' : { 'Default' : True, # None means follow default logic below: 'Description' : 'deprecated', @@ -676,7 +682,30 @@ def plot( data, **kwargs ): mc = style['marketcolors'] vup,vdown = mc['volume'].values() #-- print('vup,vdown=',vup,vdown) - vcolors = _updown_colors(vup, vdown, opens, closes, use_prev_close=style['marketcolors']['vcdopcod']) + + # How the up/down color of each volume bar is decided: + # 'openvsclose' : today's open vs close (default, same as the candles) + # 'previousclose' : today's close vs the previous close (legacy `vcdopcod=True`) + # 'previousvolume' : today's volume vs the previous volume + # A `volume_color_updown_dependency` kwarg passed directly to `plot()` + # takes precedence over the same setting in the style's marketcolors. + _vcdep = config['volume_color_updown_dependency'] + if _vcdep is None: + _vcdep = mc.get('volume_color_updown_dependency', None) + if _vcdep is not None: + _vcmode = _vcdep.lower().replace(' ', '') + elif mc.get('vcdopcod', False): + _vcmode = 'previousclose' # backward compatibility with legacy `vcdopcod` + else: + _vcmode = 'openvsclose' + + def _volume_updown_colors(upcolor, downcolor): + if _vcmode == 'previousvolume': + return _updown_colors(upcolor, downcolor, volumes, volumes, use_prev_close=True) + return _updown_colors(upcolor, downcolor, opens, closes, + use_prev_close=(_vcmode == 'previousclose')) + + vcolors = _volume_updown_colors(vup, vdown) #-- print('len(vcolors),len(opens),len(closes)=',len(vcolors),len(opens),len(closes)) #-- print('vcolors=',vcolors) @@ -687,7 +716,7 @@ def plot( data, **kwargs ): if mc['volume'] == mc['vcedge']: edgecolors = _adjust_color_brightness(vcolors,0.90) elif veup != vedown: - edgecolors = _updown_colors(veup, vedown, opens, closes, use_prev_close=style['marketcolors']['vcdopcod']) + edgecolors = _volume_updown_colors(veup, vedown) else: edgecolors = veup diff --git a/tests/test_volume_color_updown_dependency.py b/tests/test_volume_color_updown_dependency.py new file mode 100644 index 00000000..7efab7cf --- /dev/null +++ b/tests/test_volume_color_updown_dependency.py @@ -0,0 +1,115 @@ +""" +Regression tests for the `volume_color_updown_dependency` setting, which controls +how the up/down color of each volume bar is decided: + + "Open vs Close" (default) : today's open vs today's close (same as candles) + "Previous Close" : today's close vs the previous close + "Previous Volume" : today's volume vs the previous volume + +Value matching is case- and space-insensitive. The setting can be provided via +`make_marketcolors()`, `make_mpf_style()`, or directly as a `plot()` kwarg, and it +is backward compatible with the legacy `vcdopcod` setting. +""" + +import numpy as np +import pandas as pd +import mplfinance as mpf +import matplotlib.pyplot as plt +import pytest + +print('mpf.__version__ =', mpf.__version__) # for the record + + +def _sample_df(): + idx = pd.date_range('2024-01-01', periods=8, freq='D') + close = np.array([10, 11, 10.5, 12, 11.8, 13, 12.5, 14.0]) + # Volume deliberately rises/falls independently of price, so that the + # 'Previous Volume' coloring is distinguishable from the price-based modes. + vol = np.array([100, 80, 120, 90, 150, 60, 200, 50.0]) + df = pd.DataFrame({'Open': close - 0.2, 'High': close + 0.5, + 'Low': close - 0.5, 'Close': close, 'Volume': vol}, index=idx) + df.index.name = 'Date' + return df + + +def _bar_colors(fig, axlist): + volume_ax = axlist[2] # panel holding the volume bars + colors = [tuple(np.round(patch.get_facecolor(), 3)) for patch in volume_ax.patches] + plt.close(fig) + return colors + + +def _base_marketcolors(**mc_kwargs): + return mpf.make_marketcolors(up='g', down='r', volume={'up': 'g', 'down': 'r'}, **mc_kwargs) + + +def _colors_via_marketcolors(df, **mc_kwargs): + style = mpf.make_mpf_style(marketcolors=_base_marketcolors(**mc_kwargs)) + return _bar_colors(*mpf.plot(df, type='candle', volume=True, style=style, returnfig=True)) + + +def _colors_via_plot_kwarg(df, mode): + style = mpf.make_mpf_style(marketcolors=_base_marketcolors()) + return _bar_colors(*mpf.plot(df, type='candle', volume=True, style=style, + volume_color_updown_dependency=mode, returnfig=True)) + + +def _colors_via_make_mpf_style(df, mode): + style = mpf.make_mpf_style(marketcolors=_base_marketcolors(), + volume_color_updown_dependency=mode) + return _bar_colors(*mpf.plot(df, type='candle', volume=True, style=style, returnfig=True)) + + +def test_volume_color_modes_differ(): + df = _sample_df() + open_vs_close = _colors_via_marketcolors(df, volume_color_updown_dependency='Open vs Close') + previous_vol = _colors_via_marketcolors(df, volume_color_updown_dependency='Previous Volume') + assert previous_vol != open_vs_close + assert len(previous_vol) == len(df) + + +def test_case_and_space_insensitive(): + df = _sample_df() + canonical = _colors_via_marketcolors(df, volume_color_updown_dependency='Previous Volume') + for variant in ('previous volume', 'PREVIOUSVOLUME', ' Previous Volume '): + assert _colors_via_marketcolors(df, volume_color_updown_dependency=variant) == canonical + + +def test_backward_compatible_with_vcdopcod(): + df = _sample_df() + legacy = _colors_via_marketcolors(df, vcdopcod=True) + explicit = _colors_via_marketcolors(df, volume_color_updown_dependency='Previous Close') + assert legacy == explicit + + +def test_settable_via_plot_kwarg(): + df = _sample_df() + via_plot = _colors_via_plot_kwarg(df, 'Previous Volume') + via_marketcolors = _colors_via_marketcolors(df, volume_color_updown_dependency='Previous Volume') + assert via_plot == via_marketcolors + + +def test_settable_via_make_mpf_style(): + df = _sample_df() + via_style = _colors_via_make_mpf_style(df, 'Previous Volume') + via_marketcolors = _colors_via_marketcolors(df, volume_color_updown_dependency='Previous Volume') + assert via_style == via_marketcolors + + +def test_plot_kwarg_overrides_style_setting(): + df = _sample_df() + # Style says 'Open vs Close', but the plot() kwarg should take precedence. + style = mpf.make_mpf_style(marketcolors=_base_marketcolors(), + volume_color_updown_dependency='Open vs Close') + overridden = _bar_colors(*mpf.plot(df, type='candle', volume=True, style=style, + volume_color_updown_dependency='Previous Volume', + returnfig=True)) + pure_previous_vol = _colors_via_marketcolors(df, volume_color_updown_dependency='Previous Volume') + assert overridden == pure_previous_vol + + +def test_invalid_value_raises(): + for maker in (lambda v: mpf.make_marketcolors(volume_color_updown_dependency=v), + lambda v: mpf.make_mpf_style(volume_color_updown_dependency=v)): + with pytest.raises(Exception): + maker('not a real mode')