diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dc01479..1a21d8a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,12 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + ## Problem Describe what is broken. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..dce721c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,31 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +## Goal + +Describe what should be achieved. + +## Why + +Explain why this task is useful for the project. + +## Scope + +- +- +- + +## Acceptance criteria + +- [ ] +- [ ] +- [ ] + +> [!NOTE] +> Priority: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c60d464..df6deb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/commit-message.yml b/.github/workflows/commit-message.yml index 6ce4f57..40536bb 100644 --- a/.github/workflows/commit-message.yml +++ b/.github/workflows/commit-message.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Check commit message - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Fetch base branch @@ -23,7 +23,7 @@ jobs: - name: Validate commit message shell: bash run: | - pattern='^((feat|fix|docs|style|refactor|test|chore|ci|build|perf)\(#[0-9]+\): .{1,50}|chore\(deps\): .{1,72})$' + pattern='^((feat|fix|docs|style|refactor|test|chore|ci|build|perf)\(#[0-9]+\): .{1,72}|chore\(deps\): .{1,72})$' invalid=0 while IFS= read -r commit_message; do @@ -44,9 +44,14 @@ jobs: echo "" echo "Expected format:" echo " feat(#1): add feature" - echo " chore(#5): add a file to .gitignore" + echo " chore(#5): add file to .gitignore" + echo " ci(#3): validate commit messages" + echo " chore(deps): update dependencies" echo "" echo "Allowed types:" echo " feat, fix, docs, style, refactor, test, chore, ci, build, perf" + echo "" + echo "Special dependency format:" + echo " chore(deps): message" exit 1 fi 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/README.md b/README.md index 8f3304c..607f0d0 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,13 @@ ARGUS is a Python-based market analytics project evolving from a small FX conver ARGUS is currently focused on building a clean local foundation: -- currency conversion using live exchange-rate data +- currency conversion using live ExchangeRate API data +- historical market-data retrieval using yfinance - calculator and conversion logic - input validation and error handling - Tkinter GUI prototype - legacy CLI/debug interface -- first pandas/matplotlib-based analytics prototype +- pandas/matplotlib-based analytics prototype - tests and documentation > [!IMPORTANT] @@ -66,19 +67,16 @@ Each roadmap phase is treated as a separate development sprint. The roadmap desc ## Current Features - Calculator -- Currency conversion using live exchange rates +- Currency conversion using live ExchangeRate API data +- Historical market-data retrieval using yfinance - Input validation and error handling - Tkinter GUI prototype - Legacy CLI/debug interface - Basic pandas-based trend metrics - Matplotlib-based trend visualization -- Mock time-series data for early analytics development +- Basic client, service and analytics pipeline - Basic test suite -> [!CAUTION] -> Historical market data support is still limited. -> The current live exchange-rate client is useful for simple conversion, but future analytics work will require additional data sources such as Frankfurter or yfinance. - --- ## Project Structure @@ -115,6 +113,7 @@ README.md - requests - python-dotenv +- yfinance - pandas - NumPy - matplotlib @@ -124,6 +123,7 @@ README.md ### Current data source - ExchangeRate API for live currency conversion +- yfinance for historical market-data retrieval and analytics --- @@ -136,7 +136,6 @@ Planned or likely future technologies include: ### Data sources - Frankfurter API for historical FX data -- yfinance for broader market data - possible additional market-data APIs later ### Data processing @@ -193,7 +192,7 @@ Before running ARGUS locally, make sure you have: - Python 3.11 or newer - Git - pip -- an ExchangeRate API key for live currency conversion +- an ExchangeRate API key for live currency conversion. Historical analytics currently use yfinance and do not require an additional API key. Recommended for development: @@ -342,9 +341,9 @@ The project now has a runnable local Python application, a Tkinter GUI prototype Current focus: -- start Sprint 2 — Market Analytics & Data Source Expansion -- improve historical exchange-rate data support +- continue Sprint 2 — Market Analytics & Data Source Expansion +- extend historical market-data support beyond the first yfinance client - add stronger market metrics - expand pandas-based analytics workflows - improve dashboard usefulness without adding unnecessary chart noise -- document metric definitions, assumptions and data-source behavior +- document metric definitions, assumptions and data-source behavior \ 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