diff --git a/.gitignore b/.gitignore index 9a1d8a1..973c350 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ __pycache__ .ruff_cache # Virtual environment -.env \ No newline at end of file +.env +.venv \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 665f963..e2dc784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "pandas", "numpy", "matplotlib", + "yfinance", ] [project.optional-dependencies] diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index d475bc7..1293361 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -1,9 +1,8 @@ import matplotlib.pyplot as plt -import pandas as pd from argus.services.timeseries_service import prepare_trend_analysis -def create_trendchart(curr: str, dates: pd.DataFrame): +def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): """ Create a trend chart for exchange-rate analysis. @@ -31,8 +30,10 @@ def create_trendchart(curr: str, dates: pd.DataFrame): Minimum and maximum exchange-rate values are marked with scatter points and annotations. """ - df = pd.DataFrame() - df, min_max_rates = prepare_trend_analysis(curr, dates) + result = prepare_trend_analysis(curr_symbol, start, end, interval) + if result is None: + return None + df, min_max_rates = result min_date = min_max_rates["min_date"][0] min_rate = min_max_rates["min_rate"][0] max_date = min_max_rates["max_date"][0] diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py new file mode 100644 index 0000000..de86041 --- /dev/null +++ b/src/argus/clients/yfinance_client.py @@ -0,0 +1,43 @@ +import yfinance as yf +import logging + + +def get_timeseries(curr_symbol, start, end, interval): + """ + Fetch historical exchange-rate time series data from Yahoo Finance. + + Args: + curr_symbol (str): Currency symbol used by Yahoo Finance, for example + "EURUSD=X". + start (str): Start date of the requested time range in YYYY-MM-DD format. + end (str): End date of the requested time range in YYYY-MM-DD format. + interval (str): Data interval supported by Yahoo Finance, for example + "1d", "1h", or "15m". + + Returns: + pandas.DataFrame | None: A DataFrame containing the columns ``date`` and + ``rate`` if data was successfully fetched. Returns ``None`` if the + request fails, returns no data, or an exception occurs. + """ + try: + yf_logger = logging.getLogger("yfinance") + yf_logger.disabled = True + data = yf.download( + tickers=curr_symbol, + start=start, + end=end, + interval=interval, + multi_level_index=False, + progress=False, + ) + yf_logger.disabled = False + if data is None: + return None + if data.empty: + return None + data = data.reset_index() + data = data[["Date", "Close"]] + data = data.rename(columns={"Date": "date", "Close": "rate"}) + return data + except Exception: + return None diff --git a/src/argus/gui/app.py b/src/argus/gui/app.py index 829a905..9a19523 100644 --- a/src/argus/gui/app.py +++ b/src/argus/gui/app.py @@ -1,5 +1,4 @@ import tkinter as tk -import pandas as pd from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from argus.analytics.charts.trend_chart import create_trendchart from argus.services.calculator_service import calc, check_op @@ -77,27 +76,10 @@ def show_trend() -> None: global trend_canvas global trend_chart_widget - mock_dates = { - "date": [ - "2026-06-01", - "2026-06-02", - "2026-06-03", - "2026-06-04", - "2026-06-05", - "2026-06-06", - "2026-06-07", - "2026-06-08", - "2026-06-09", - "2026-06-10", - "2026-06-11", - "2026-06-12", - "2026-06-13", - "2026-06-14", - "2026-06-15", - ] - } - mock_dates = pd.DataFrame(mock_dates) - mock_curr = "USD" + curr_symbol = "EURUSD=X" + start = "2024-01-01" + end = "2025-01-01" + interval = "1d" calc_frame.pack_forget() conv_frame.pack_forget() @@ -108,14 +90,16 @@ def show_trend() -> None: content.pack(side="top", fill=tk.BOTH, expand=True) if trend_canvas is None: - fig = create_trendchart(mock_curr, mock_dates) + fig = create_trendchart(curr_symbol, start, end, interval) + if fig is None: + return None fig.set_size_inches(7, 4) trend_canvas = FigureCanvasTkAgg(fig, master=content) trend_chart_widget = trend_canvas.get_tk_widget() if trend_chart_widget is None: - return + return None trend_canvas.draw() trend_chart_widget.pack(fill=tk.BOTH, expand=True) diff --git a/src/argus/services/timeseries_service.py b/src/argus/services/timeseries_service.py index 3646200..b6251bb 100644 --- a/src/argus/services/timeseries_service.py +++ b/src/argus/services/timeseries_service.py @@ -1,5 +1,5 @@ import pandas as pd -from argus.clients.mock_client import get_mock_timeseries +from argus.clients.yfinance_client import get_timeseries from argus.analytics.metrics.trend_metrics import ( add_rolling_average, add_daily_percentage_change, @@ -8,24 +8,33 @@ def prepare_trend_analysis( - mock_curr: str, df: pd.DataFrame -) -> tuple[pd.DataFrame, dict]: + curr_symbol: str, start: str, end: str, intervall: str +) -> tuple[pd.DataFrame, dict] | None: """ - Prepares the data for trend analysis by adding conversion rates, daily percentage change, and rolling average. + Prepare time-series data for trend analysis. - Arg1: mock_curr: str - the currency code for which the trend analysis is to - be performed - Arg2: df: pd.DataFrame - the DataFrame containing the dates for which the - conversion rates are to be added + Fetches historical exchange-rate data for the given currency symbol and + enriches it with daily percentage changes and a rolling average. It also + calculates the minimum and maximum exchange rates for the resulting time + series. - Return: tuple[pd.DataFrame, dict] - a tuple containing the updated DataFrame with conversion rates, - daily percentage change, and rolling average, and a dictionary with the minimum and maximum rates + Args: + curr_symbol (str): Currency symbol used by Yahoo Finance, for example + "EURUSD=X". + start (str): Start date of the requested time range in YYYY-MM-DD format. + end (str): End date of the requested time range in YYYY-MM-DD format. + intervall (str): Data interval supported by Yahoo Finance, for example + "1d", "1h", or "15m". + + Returns: + tuple[pd.DataFrame, dict] | None: A tuple containing the prepared + DataFrame and a dictionary with minimum and maximum rates. Returns + ``None`` if no time-series data could be fetched. """ - df["rate"] = 0.0 - # For each date one API call to get the rate - for i in range(len(df)): - date = str(df.loc[i, "date"]) - df.loc[i, "rate"] = get_mock_timeseries(mock_curr, date) + + df = get_timeseries(curr_symbol, start, end, intervall) + if df is None: + return None df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) diff --git a/src/legacy/analytics/__init__.py b/src/legacy/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/legacy/analytics/charts/__init__.py b/src/legacy/analytics/charts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/legacy/analytics/charts/trend_chart.py b/src/legacy/analytics/charts/trend_chart.py new file mode 100644 index 0000000..e18aeb1 --- /dev/null +++ b/src/legacy/analytics/charts/trend_chart.py @@ -0,0 +1,72 @@ +import matplotlib.pyplot as plt +import pandas as pd +from legacy.services.timeseries_service import prepare_trend_analysis + + +def create_trendchart(curr: str, dates: pd.DataFrame): + """ + Create a trend chart for exchange-rate analysis. + + Builds a Matplotlib figure showing the exchange rate, its rolling + average, and the daily percentage change for a selected currency. + The minimum and maximum exchange-rate values are highlighted in the + chart. + + Args: + curr (str): Currency code or currency pair identifier used for + the trend analysis. + dates (pd.DataFrame): DataFrame containing the date information + used to prepare the time-series analysis. + + Returns: + matplotlib.figure.Figure: Matplotlib figure containing the trend + chart. + + Notes: + The chart uses two y-axes: + + - The left y-axis displays the exchange rate and rolling average. + - The right y-axis displays the daily percentage change. + + Minimum and maximum exchange-rate values are marked with scatter + points and annotations. + """ + df = pd.DataFrame() + df, min_max_rates = prepare_trend_analysis(curr, dates) + min_date = min_max_rates["min_date"][0] + min_rate = min_max_rates["min_rate"][0] + max_date = min_max_rates["max_date"][0] + max_rate = min_max_rates["max_rate"][0] + + # Rate and Rolling Average needs seperat x-Achse von Daily Percentage Chnage erhalten + fig, ax1 = plt.subplots(figsize=(5, 3.5), dpi=100) + + # Subplot 1 + ax1.plot(df["date"], df["rate"], color="black", label="Exchange Rate") + ax1.plot(df["date"], df["roll_avg"], color="blue", label="Rolling Average") + + # Scatter and Annote Min/Max Rate + ax1.scatter(min_date, min_rate, color="red") + ax1.scatter(max_date, max_rate, color="green") + ax1.annotate("Min", (min_date, min_rate)) + ax1.annotate("Max", (max_date, max_rate)) + + # Rotate date values for better visibillity + ax1.tick_params(axis="x", rotation=45) + + # Subplot 2 + ax2 = ax1.twinx() + bar_colors = ["green" if x >= 0 else "red" for x in df["daily_pct_change"]] + ax2.bar( + df["date"], + df["daily_pct_change"], + color=bar_colors, + alpha=0.4, + label="Daily Change", + ) + ax2.legend(loc="upper left") + ax2.set_ylabel("Percentage Scale") + + # Adjust the layout + fig.tight_layout() + return fig diff --git a/src/legacy/analytics/metrics/__init__.py b/src/legacy/analytics/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/legacy/analytics/metrics/trend_metrics.py b/src/legacy/analytics/metrics/trend_metrics.py new file mode 100644 index 0000000..5622acb --- /dev/null +++ b/src/legacy/analytics/metrics/trend_metrics.py @@ -0,0 +1,75 @@ +import pandas as pd + + +def add_daily_percentage_change(df: pd.DataFrame) -> pd.DataFrame: + """ + Add the daily percentage change of the exchange rate. + + Calculates the percentage change between each rate value and the + previous rate value. The result is added as a new column named + ``daily_pct_change``. + + Args: + df (pd.DataFrame): DataFrame containing at least a ``rate`` column. + + Returns: + pd.DataFrame: A copy of the input DataFrame with an added + ``daily_pct_change`` column. + + Notes: + The first row will contain ``NaN`` because there is no previous + rate value to compare against. + """ + result = df.copy() + result["daily_pct_change"] = result["rate"].pct_change() * 100 + return result + + +def add_rolling_average(df: pd.DataFrame) -> pd.DataFrame: + """ + Add a rolling average of the exchange rate. + + Calculates a rolling mean over the ``rate`` column using a fixed + window size of 3 rows. The result is added as a new column named + ``roll_avg``. + + Args: + df (pd.DataFrame): DataFrame containing at least a ``rate`` column. + + Returns: + pd.DataFrame: A copy of the input DataFrame with an added + ``roll_avg`` column. + """ + result = df.copy() + result["roll_avg"] = result["rate"].rolling(window=3, min_periods=1).mean() + return result + + +def get_min_max_rates(df: pd.DataFrame) -> dict: + """ + Get the minimum and maximum exchange-rate values. + + Finds the rows with the lowest and highest values in the ``rate`` + column and returns their dates and rates in a dictionary. + + Args: + df (pd.DataFrame): DataFrame containing at least ``date`` and + ``rate`` columns. + + Returns: + dict: Dictionary containing the minimum and maximum rate data with + the following keys: + + - ``min_date``: Date of the lowest exchange rate. + - ``min_rate``: Lowest exchange-rate value. + - ``max_date``: Date of the highest exchange rate. + - ``max_rate``: Highest exchange-rate value. + """ + min_max = {"min_date": [], "min_rate": [], "max_date": [], "max_rate": []} + min_id = df["rate"].idxmin() + max_id = df["rate"].idxmax() + min_max["min_date"].append(df.loc[min_id, "date"]) + min_max["min_rate"].append(df.loc[min_id, "rate"]) + min_max["max_date"].append(df.loc[max_id, "date"]) + min_max["max_rate"].append(df.loc[max_id, "rate"]) + return min_max diff --git a/src/legacy/clients/__init__.py b/src/legacy/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/argus/clients/mock_client.py b/src/legacy/clients/mock_client.py similarity index 100% rename from src/argus/clients/mock_client.py rename to src/legacy/clients/mock_client.py diff --git a/src/legacy/gui/app.py b/src/legacy/gui/app.py new file mode 100644 index 0000000..ff9d790 --- /dev/null +++ b/src/legacy/gui/app.py @@ -0,0 +1,126 @@ +import tkinter as tk +import pandas as pd +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from legacy.analytics.charts.trend_chart import create_trendchart + + +def on_close() -> None: + """ + Handles the closing of the application window. It ensures that any open trend chart is destroyed and + the application is properly closed. + """ + if trend_chart_widget is not None: + trend_chart_widget.destroy() + + root.quit() + root.destroy() + + +def hide_trend_chart() -> None: + """ + Hides the trend chart from the GUI if it is currently displayed. + """ + if trend_chart_widget is not None: + trend_chart_widget.pack_forget() + + +def show_menu() -> None: + """ + Displays the main menu in the application. It updates the GUI to show the menu interface. + """ + app_frame.pack_forget() + hide_trend_chart() + + menu_frame.pack(side="right", fill=tk.BOTH, expand=True) + + +def show_trend() -> None: + """ + Displays the trend chart in the application. It prepares the data for trend analysis, + creates the trend chart, and updates the GUI to show the chart. + """ + global trend_canvas + global trend_chart_widget + + mock_dates = { + "date": [ + "2026-06-01", + "2026-06-02", + "2026-06-03", + "2026-06-04", + "2026-06-05", + "2026-06-06", + "2026-06-07", + "2026-06-08", + "2026-06-09", + "2026-06-10", + "2026-06-11", + "2026-06-12", + "2026-06-13", + "2026-06-14", + "2026-06-15", + ] + } + mock_dates = pd.DataFrame(mock_dates) + mock_curr = "USD" + + menu_frame.pack_forget() + + app_frame.pack(fill=tk.BOTH, expand=True) + sidebar.pack(side="top", fill="x") + content.pack(side="top", fill=tk.BOTH, expand=True) + + if trend_canvas is None: + fig = create_trendchart(mock_curr, mock_dates) + fig.set_size_inches(7, 4) + + trend_canvas = FigureCanvasTkAgg(fig, master=content) + trend_chart_widget = trend_canvas.get_tk_widget() + + if trend_chart_widget is None: + return + trend_canvas.draw() + trend_chart_widget.pack(fill=tk.BOTH, expand=True) + + +def app() -> None: + """ + The main function that initializes and starts the GUI application. It sets up the main window, frames, labels, entries, and buttons, and defines the layout of the application. + """ + root.mainloop() + + +# Window +root = tk.Tk() +root.title("FX-Converter Lab") +root.geometry("800x600") # Width x Length +root.protocol("WM_DELETE_WINDOW", on_close) + +# Frames +menu_frame = tk.Frame(root) +app_frame = tk.Frame(root) +sidebar = tk.Frame(app_frame) +content = tk.Frame(app_frame) + +# Trend chart is loaded lazily +trend_canvas = None +trend_chart_widget = None + +# Labels +menu_label = tk.Label(menu_frame, text="Menu", font=("Arial", 20)) +menu_label.pack(pady=20) + + +# Buttons +from_menu_trend_chart = tk.Button(menu_frame, text="Trend Chart", command=show_trend) +from_sidebar_trend_chart = tk.Button(sidebar, text="Trend Chart", command=show_trend) +return_menu = tk.Button(sidebar, text="Back to menu", command=show_menu) + +from_menu_trend_chart.pack(fill="x", padx=50, pady=15) +from_sidebar_trend_chart.pack(side="left") +return_menu.pack(side="left") + +show_menu() + +if __name__ == "__main__": + app() diff --git a/src/legacy/services/timeseries_service.py b/src/legacy/services/timeseries_service.py new file mode 100644 index 0000000..d79d599 --- /dev/null +++ b/src/legacy/services/timeseries_service.py @@ -0,0 +1,32 @@ +import pandas as pd +from legacy.clients.mock_client import get_mock_timeseries +from legacy.analytics.metrics.trend_metrics import ( + add_rolling_average, + add_daily_percentage_change, + get_min_max_rates, +) + + +def prepare_trend_analysis( + mock_curr: str, df: pd.DataFrame +) -> tuple[pd.DataFrame, dict]: + """ + Prepares the data for trend analysis by adding conversion rates, daily percentage change, and rolling average. + + Arg1: mock_curr: str - the currency code for which the trend analysis is to + be performed + Arg2: df: pd.DataFrame - the DataFrame containing the dates for which the + conversion rates are to be added + + Return: tuple[pd.DataFrame, dict] - a tuple containing the updated DataFrame with conversion rates, + daily percentage change, and rolling average, and a dictionary with the minimum and maximum rates + """ + df["rate"] = 0.0 + # For each date one API call to get the rate + for i in range(len(df)): + date = str(df.loc[i, "date"]) + df.loc[i, "rate"] = get_mock_timeseries(mock_curr, date) + df = add_daily_percentage_change(df) + df = add_rolling_average(df) + min_max_rates = get_min_max_rates(df) + return df, min_max_rates diff --git a/tests/test_timeseries_service.py b/tests/test_timeseries_service.py index dbc5675..7dd3c9f 100644 --- a/tests/test_timeseries_service.py +++ b/tests/test_timeseries_service.py @@ -4,26 +4,31 @@ from argus.services.timeseries_service import prepare_trend_analysis -def test_is_pct_change_added(): - test_curr = "USD" - test_dates = { - "date": ["2026-06-01", "2026-06-02", "2026-06-03"], - } - test_dates = pd.DataFrame(test_dates) +def test_get_a_full_timeseries(): + test_curr = "EURUSD=X" + test_start = "2024-01-01" + test_end = "2024-01-04" + test_interval = "1d" expect_result = { - "date": ["2026-06-01", "2026-06-02", "2026-06-03"], - "rate": [1.08, 1.1, 1.14], - "daily_pct_change": [np.nan, 1.85185185185186, 3.6363636363636154], - "roll_avg": [1.08, 1.09, 1.1066666666666667], + "date": ["2024-01-01", "2024-01-02", "2024-01-03"], + "rate": [1.1055831909179688, 1.1038745641708374, 1.0941756963729858], + "daily_pct_change": [np.nan, -0.1545452898675692, -0.8786204622023064], + "roll_avg": [1.1055831909179688, 1.104728877544403, 1.101211150487264], } expect_dict = { - "min_date": ["2026-06-01"], - "min_rate": [1.08], - "max_date": ["2026-06-03"], - "max_rate": [1.14], + "min_date": ["2024-01-03 00:00:00"], + "min_rate": [1.0941756963729858], + "max_date": ["2024-01-01 00:00:00"], + "max_rate": [1.1055831909179688], } - result_df, result_dict = prepare_trend_analysis(test_curr, test_dates) + result = prepare_trend_analysis(test_curr, test_start, test_end, test_interval) + if result is None: + return False + result_df, result_dict = result + result_df["date"] = result_df["date"].astype("str") + result_dict["min_date"] = [str(result_dict["min_date"][0])] + result_dict["max_date"] = [str(result_dict["max_date"][0])] expect_df = pd.DataFrame(expect_result) pdt.assert_frame_equal(result_df, expect_df) diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py new file mode 100644 index 0000000..faf15fc --- /dev/null +++ b/tests/test_yfinance_client.py @@ -0,0 +1,80 @@ +from argus.clients.yfinance_client import get_timeseries +import pandas as pd +import pandas.testing as pdt + + +def test_get_dataframe(monkeypatch): + test_resp = pd.DataFrame( + { + "Close": [1.105583, 1.103875, 1.094176], + }, + index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), + ) + test_resp.index.name = "Date" + test_curr = "EURUSD=X" + test_start = "2024-01-01" + test_end = "2024-01-04" + test_interval = "1d" + + def fake_yfinance_download(*args, **kwargs): + return test_resp + + monkeypatch.setattr("yfinance.download", fake_yfinance_download) + + result = get_timeseries(test_curr, test_start, test_end, test_interval) + expected = pd.DataFrame( + { + "date": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), + "rate": [1.105583, 1.103875, 1.094176], + } + ) + assert result is not None + pdt.assert_frame_equal(result, expected) + + +def test_get_none(monkeypatch): + test_curr = "EURUSD=X" + test_start = "2024-01-01" + test_end = "2024-01-04" + test_interval = "1d" + + def fake_yfinance_download(*args, **kwargs): + return None + + monkeypatch.setattr("yfinance.download", fake_yfinance_download) + + result = get_timeseries(test_curr, test_start, test_end, test_interval) + assert result is None + + +def test_get_empty_frame(monkeypatch): + test_curr = "EURUSD=X" + test_start = "2024-01-01" + test_end = "2024-01-01" + test_interval = "1d" + + def fake_yfinance_download(*args, **kwargs): + return pd.DataFrame() + + monkeypatch.setattr("yfinance.download", fake_yfinance_download) + + result = get_timeseries(test_curr, test_start, test_end, test_interval) + assert result is None + + +def test_error_raise(monkeypatch): + test_curr = "EURUSD=X" + # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible + test_start = "2024-01-04" + test_end = "2024-01-02" + test_interval = "1d" + + def fake_yfinance_download( + tickers=test_curr, start=test_start, end=test_end, interval=test_interval + ): + return Exception("fake yfinance error") + + monkeypatch.setattr("yfinance.download", fake_yfinance_download) + + result = get_timeseries(test_curr, test_start, test_end, test_interval) + assert result is None