diff --git a/openbb_terminal/forex/forex_controller.py b/openbb_terminal/forex/forex_controller.py index e30a1da3960..3840a01e256 100644 --- a/openbb_terminal/forex/forex_controller.py +++ b/openbb_terminal/forex/forex_controller.py @@ -10,7 +10,7 @@ from typing import List import pandas as pd from openbb_terminal.custom_prompt_toolkit import NestedCompleter - +from openbb_terminal.common.quantitative_analysis import qa_view from openbb_terminal import feature_flags as obbff from openbb_terminal.decorators import log_start_end from openbb_terminal.forex import forex_helper, fxempire_view, av_view @@ -25,9 +25,9 @@ from openbb_terminal.parent_classes import BaseController from openbb_terminal.rich_config import ( console, MenuText, - translate, get_ordered_list_sources, ) +from openbb_terminal.stocks import stocks_helper from openbb_terminal.decorators import check_api_key from openbb_terminal.forex.forex_helper import parse_forex_symbol @@ -71,6 +71,8 @@ class ForexController(BaseController): self.data = pd.DataFrame() if session and obbff.USE_PROMPT_TOOLKIT: + one_to_hundred: dict = {str(c): {} for c in range(1, 100)} + choices: dict = {c: {} for c in self.controller_choices} choices["load"] = {c: {} for c in FX_TICKERS} choices["load"]["--ticker"] = {c: {} for c in FX_TICKERS} @@ -86,7 +88,19 @@ class ForexController(BaseController): choices["quote"]["--source"] = { c: {} for c in get_ordered_list_sources(f"{self.PATH}quote") } - choices["candle"]["--ma"] = None + choices["candle"] = { + "--sort": {c: {} for c in forex_helper.CANDLE_SORT}, + "--plotly": {}, + "-p": "--plotly", + "--descending": {}, + "-d": "--descending", + "--raw": {}, + "--trend": {}, + "-t": "--trend", + "--ma": None, + "--limit": one_to_hundred, + "-l": "--limit", + } choices["support"] = self.SUPPORT_CHOICES choices["about"] = self.ABOUT_CHOICES @@ -225,20 +239,92 @@ class ForexController(BaseController): prog="candle", description="Show candle for loaded fx data", ) + parser.add_argument( + "-p", + "--plotly", + dest="plotly", + action="store_false", + default=True, + help="Flag to show interactive plotly chart", + ) + parser.add_argument( + "--sort", + choices=forex_helper.CANDLE_SORT, + default="", + type=str.lower, + dest="sort", + help="Choose a column to sort by. Only works when raw data is displayed.", + ) + parser.add_argument( + "-d", + "--descending", + action="store_true", + dest="descending", + default=False, + help="Sort selected column descending. Only works when raw data is displayed.", + ) + parser.add_argument( + "--raw", + action="store_true", + dest="raw", + default=False, + help="Shows raw data instead of chart.", + ) + parser.add_argument( + "-t", + "--trend", + action="store_true", + default=False, + help="Flag to add high and low trends to candle", + dest="trendlines", + ) parser.add_argument( "--ma", dest="mov_avg", type=str, - help=translate( + help=( "Add moving average in number of days to plot and separate by a comma. " "Value for ma (moving average) keyword needs to be greater than 1." ), default=None, ) - ns_parser = self.parse_known_args_and_warn(parser, other_args) + parser.add_argument( + "--log", + help="Plot with y axis on log scale", + action="store_true", + default=False, + dest="logy", + ) + + ns_parser = self.parse_known_args_and_warn( + parser, + other_args, + EXPORT_ONLY_RAW_DATA_ALLOWED, + limit=20, + ) + if ns_parser: - mov_avgs = [] - if not self.data.empty: + if not self.to_symbol: + console.print("No ticker loaded. First use 'load '") + return + + data = stocks_helper.process_candle(self.data) + if ns_parser.raw: + if ns_parser.trendlines: + if (data.index[1] - data.index[0]).total_seconds() >= 86400: + data = stocks_helper.find_trendline(data, "OC_High", "high") + data = stocks_helper.find_trendline(data, "OC_Low", "low") + + qa_view.display_raw( + data=data, + sortby=ns_parser.sort, + ascend=not ns_parser.descending, + limit=ns_parser.limit, + ) + + else: + mov_avgs = [] + if ns_parser.mov_avg: mov_list = (num for num in ns_parser.mov_avg.split(",")) @@ -256,16 +342,22 @@ class ForexController(BaseController): ) forex_helper.display_candle( - self.data, self.to_symbol, self.from_symbol, mov_avgs - ) - else: - logger.error( - "No forex historical data loaded. Load first using ." - ) - console.print( - "[red]No forex historical data loaded. Load first using .[/red]\n" + to_symbol=self.to_symbol, + from_symbol=self.from_symbol, + data=data, + use_matplotlib=ns_parser.plotly, + add_trend=ns_parser.trendlines, + ma=mov_avgs, + yscale="log" if ns_parser.logy else "linear", ) + export_data( + ns_parser.export, + os.path.dirname(os.path.abspath(__file__)), + f"{self.fx_pair}", + self.data, + ) + @log_start_end(log=logger) def call_quote(self, other_args: List[str]): """Process quote command.""" diff --git a/openbb_terminal/forex/forex_helper.py b/openbb_terminal/forex/forex_helper.py index a08217cd25f..e88ce349f79 100644 --- a/openbb_terminal/forex/forex_helper.py +++ b/openbb_terminal/forex/forex_helper.py @@ -9,15 +9,31 @@ import re import pandas as pd import matplotlib.pyplot as plt from matplotlib.lines import Line2D +from matplotlib.ticker import LogLocator, ScalarFormatter import mplfinance as mpf - +import numpy as np +import plotly.graph_objects as go +from plotly.subplots import make_subplots from openbb_terminal.forex import av_model, polygon_model from openbb_terminal.rich_config import console from openbb_terminal.decorators import log_start_end -from openbb_terminal.helper_funcs import plot_autoscale, is_valid_axes_count -from openbb_terminal.config_terminal import theme +import openbb_terminal.config_terminal as cfg +from openbb_terminal.helper_funcs import ( + is_valid_axes_count, + plot_autoscale, + lambda_long_number_format_y_axis, +) from openbb_terminal.stocks import stocks_helper +CANDLE_SORT = [ + "adjclose", + "open", + "close", + "high", + "low", + "volume", + "logret", +] FOREX_SOURCES: Dict = { "YahooFinance": "YahooFinance", @@ -207,6 +223,9 @@ def display_candle( from_symbol: str = "", ma: Optional[Iterable[int]] = None, external_axes: Optional[List[plt.Axes]] = None, + use_matplotlib: bool = True, + add_trend: bool = False, + yscale: str = "linear", ): """Show candle plot for fx data. @@ -223,51 +242,245 @@ def display_candle( external_axes: Optional[List[plt.Axes]] External axes (1 axis is expected in the list), by default None """ - candle_chart_kwargs = { - "type": "candle", - "style": theme.mpf_style, - "volume": False, - "xrotation": theme.xticks_rotation, - "scale_padding": {"left": 0.3, "right": 1, "top": 0.8, "bottom": 0.8}, - "update_width_config": { - "candle_linewidth": 0.6, - "candle_width": 0.8, - "volume_linewidth": 0.8, - "volume_width": 0.8, - }, - "warn_too_much_data": 20000, - } - if ma: - candle_chart_kwargs["mav"] = ma - # This plot has 1 axis - if not external_axes: - candle_chart_kwargs["returnfig"] = True - candle_chart_kwargs["figratio"] = (10, 7) - candle_chart_kwargs["figscale"] = 1.10 - candle_chart_kwargs["figsize"] = plot_autoscale() - fig, ax = mpf.plot(data, **candle_chart_kwargs) - if from_symbol and to_symbol: + # We check if there's Volume data to avoid errors and empty subplots + has_volume = bool(data["Volume"].sum() > 0) + + if add_trend: + if (data.index[1] - data.index[0]).total_seconds() >= 86400: + data = stocks_helper.find_trendline(data, "OC_High", "high") + data = stocks_helper.find_trendline(data, "OC_Low", "low") + + if use_matplotlib: + ap0 = [] + if add_trend: + if "OC_High_trend" in data.columns: + ap0.append( + mpf.make_addplot( + data["OC_High_trend"], + color=cfg.theme.up_color, + secondary_y=False, + ), + ) + + if "OC_Low_trend" in data.columns: + ap0.append( + mpf.make_addplot( + data["OC_Low_trend"], + color=cfg.theme.down_color, + secondary_y=False, + ), + ) + + candle_chart_kwargs = { + "type": "candle", + "style": cfg.theme.mpf_style, + "volume": has_volume, + "addplot": ap0, + "xrotation": cfg.theme.xticks_rotation, + "scale_padding": {"left": 0.3, "right": 1, "top": 0.8, "bottom": 0.8}, + "update_width_config": { + "candle_linewidth": 0.6, + "candle_width": 0.8, + "volume_linewidth": 0.8, + "volume_width": 0.8, + }, + "warn_too_much_data": 10000, + "yscale": yscale, + } + + if ma: + candle_chart_kwargs["mav"] = ma + + if external_axes is None: + candle_chart_kwargs["returnfig"] = True + candle_chart_kwargs["figratio"] = (10, 7) + candle_chart_kwargs["figscale"] = 1.10 + candle_chart_kwargs["figsize"] = plot_autoscale() + candle_chart_kwargs["warn_too_much_data"] = 100_000 + + fig, ax = mpf.plot(data, **candle_chart_kwargs) + + if has_volume: + lambda_long_number_format_y_axis(data, "Volume", ax) + fig.suptitle( f"{from_symbol}/{to_symbol}", x=0.055, y=0.965, horizontalalignment="left", ) + + if ma: + # Manually construct the chart legend + colors = [cfg.theme.get_colors()[i] for i, _ in enumerate(ma)] + lines = [Line2D([0], [0], color=c) for c in colors] + labels = ["MA " + str(label) for label in ma] + ax[0].legend(lines, labels) + + if yscale == "log": + ax[0].yaxis.set_major_formatter(ScalarFormatter()) + ax[0].yaxis.set_major_locator( + LogLocator(base=100, subs=[1.0, 2.0, 5.0, 10.0]) + ) + ax[0].ticklabel_format(style="plain", axis="y") + + cfg.theme.visualize_output(force_tight_layout=False) + elif is_valid_axes_count(external_axes, 2): + ax1, ax2 = external_axes + candle_chart_kwargs["ax"] = ax1 + if has_volume: + candle_chart_kwargs["volume"] = ax2 + mpf.plot(data, **candle_chart_kwargs) + + if not use_matplotlib: + fig = make_subplots( + rows=2 if has_volume else 1, + cols=1, + shared_xaxes=True, + vertical_spacing=0.06, + subplot_titles=( + f"{from_symbol}/{to_symbol}", + "Volume" if has_volume else None, + ), + row_width=[0.2, 0.7] if has_volume else [1], + ) + fig.add_trace( + go.Candlestick( + x=data.index, + open=data.Open, + high=data.High, + low=data.Low, + close=data.Close, + name="OHLC", + ), + row=1, + col=1, + ) if ma: - # Manually construct the chart legend - colors = [theme.get_colors()[i] for i, _ in enumerate(ma)] + plotly_colors = [ + "black", + "teal", + "blue", + "purple", + "orange", + "gray", + "deepskyblue", + ] + for idx, ma_val in enumerate(ma): + temp = data["Adj Close"].copy() + temp[f"ma{ma_val}"] = data["Adj Close"].rolling(ma_val).mean() + temp = temp.dropna() + fig.add_trace( + go.Scatter( + x=temp.index, + y=temp[f"ma{ma_val}"], + name=f"MA{ma_val}", + mode="lines", + line=go.scatter.Line( + color=plotly_colors[np.mod(idx, len(plotly_colors))] + ), + ), + row=1, + col=1, + ) - lines = [Line2D([0], [0], color=c) for c in colors] - labels = ["MA " + str(label) for label in ma] - ax[0].legend(lines, labels) - theme.visualize_output(force_tight_layout=False) + if add_trend: + if "OC_High_trend" in data.columns: + fig.add_trace( + go.Scatter( + x=data.index, + y=data["OC_High_trend"], + name="High Trend", + mode="lines", + line=go.scatter.Line(color="green"), + ), + row=1, + col=1, + ) + if "OC_Low_trend" in data.columns: + fig.add_trace( + go.Scatter( + x=data.index, + y=data["OC_Low_trend"], + name="Low Trend", + mode="lines", + line=go.scatter.Line(color="red"), + ), + row=1, + col=1, + ) - elif is_valid_axes_count(external_axes, 1): - (ax1,) = external_axes - candle_chart_kwargs["ax"] = ax1 - mpf.plot(data, **candle_chart_kwargs) - else: - return + if has_volume: + colors = [ + "red" if row.Open < row["Adj Close"] else "green" + for _, row in data.iterrows() + ] + fig.add_trace( + go.Bar( + x=data.index, + y=data.Volume, + name="Volume", + marker_color=colors, + ), + row=2, + col=1, + ) + fig.update_layout( + yaxis_title="Stock Price ($)", + xaxis=dict( + rangeselector=dict( + buttons=list( + [ + dict( + count=1, + label="1m", + step="month", + stepmode="backward", + ), + dict( + count=3, + label="3m", + step="month", + stepmode="backward", + ), + dict(count=1, label="YTD", step="year", stepmode="todate"), + dict( + count=1, + label="1y", + step="year", + stepmode="backward", + ), + dict(step="all"), + ] + ) + ), + rangeslider=dict(visible=False), + type="date", + ), + ) + + fig.update_layout( + updatemenus=[ + dict( + buttons=[ + dict( + label="linear", + method="relayout", + args=[{"yaxis.type": "linear"}], + ), + dict( + label="log", + method="relayout", + args=[{"yaxis.type": "log"}], + ), + ] + ) + ] + ) + + fig.show(config=dict({"scrollZoom": True})) + + return @log_start_end(log=logger)