From 627f12b90d1243c1cfcf34969b8ac1d803fb1f51 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Wed, 1 Jul 2026 21:36:11 +0200 Subject: [PATCH 01/14] feat(#70): rename timeseries_service --- .../services/{timeseries_service.py => market_data_service.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/argus/services/{timeseries_service.py => market_data_service.py} (100%) diff --git a/src/argus/services/timeseries_service.py b/src/argus/services/market_data_service.py similarity index 100% rename from src/argus/services/timeseries_service.py rename to src/argus/services/market_data_service.py From 2256d0a2777ea2fd26bff0a5e8023ad80520a757 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Sun, 5 Jul 2026 21:54:42 +0200 Subject: [PATCH 02/14] refactor(#70): get data is a own func --- src/argus/analytics/charts/trend_chart.py | 2 +- src/argus/services/market_data_service.py | 37 ++++++++++++++----- src/argus/storage/database.py | 32 ++++++++++++++++ ..._service.py => test_market_data_series.py} | 2 +- 4 files changed, 61 insertions(+), 12 deletions(-) rename tests/{test_timeseries_service.py => test_market_data_series.py} (94%) diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index 1293361..58c93a1 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -1,5 +1,5 @@ import matplotlib.pyplot as plt -from argus.services.timeseries_service import prepare_trend_analysis +from argus.services.market_data_service import prepare_trend_analysis def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index b6251bb..9521021 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -7,9 +7,7 @@ ) -def prepare_trend_analysis( - curr_symbol: str, start: str, end: str, intervall: str -) -> tuple[pd.DataFrame, dict] | None: +def prepare_trend_analysis(df: pd.DataFrame) -> tuple[pd.DataFrame,dict] | None: """ Prepare time-series data for trend analysis. @@ -18,6 +16,27 @@ def prepare_trend_analysis( calculates the minimum and maximum exchange rates for the resulting time series. + Args: + df (pd.Dataframe): A timeserie with market data + + 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 = add_daily_percentage_change(df) + df = add_rolling_average(df) + min_max_rates = get_min_max_rates(df) + if df is None: + return None + return df, min_max_rates + +def get_market_data(curr_symbol: str, start: str, end: str, intervall: str) -> pd.DataFrame | None: + """ + Get a time series either from local stroage or client with first-storage-workflow + Args: curr_symbol (str): Currency symbol used by Yahoo Finance, for example "EURUSD=X". @@ -27,15 +46,13 @@ def prepare_trend_analysis( "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 + pd.DataFrame | None: A + DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - 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) - return df, min_max_rates + return df + + diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index ea7128c..715c24b 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -187,6 +187,21 @@ def get_or_create_instrument(connection, instrument: Instrument) -> int: def insert_price_bar(db: str, price_bar: PriceBar) -> None: + """ + Insert a price bar into the database. + + Ensures that the related data source and instrument exist, then inserts + the price bar into the ``price_bars`` table. Duplicate price bars are + ignored through the table's unique constraint. + + Args: + db (str): Path to the DuckDB database file. + price_bar (PriceBar): Price bar model containing source, + instrument, timestamp, timeframe, and market values. + + Returns: + None + """ insert_query = """ INSERT INTO price_bars ( source_id, @@ -233,6 +248,23 @@ def read_price_bars( start_date: date, end_date: date, ) -> pd.DataFrame: + """ + Read price bars for a source, instrument, and date range. + + Queries stored price bars joined with their data source and instrument + metadata. Results are ordered by timestamp and returned as a pandas + DataFrame. + + Args: + db (str): Path to the DuckDB database file. + source (DataSource): Data source used to filter the stored price bars. + instrument (Instrument): Instrument used to filter the stored price bars. + start_date (date): Inclusive start date of the requested time range. + end_date (date): Inclusive end date of the requested time range. + + Returns: + pd.DataFrame: DataFrame containing matching price bars and metadata. + """ search_query = """ SELECT diff --git a/tests/test_timeseries_service.py b/tests/test_market_data_series.py similarity index 94% rename from tests/test_timeseries_service.py rename to tests/test_market_data_series.py index cd5c97a..3637a5e 100644 --- a/tests/test_timeseries_service.py +++ b/tests/test_market_data_series.py @@ -1,7 +1,7 @@ import pandas as pd import pandas.testing as pdt import numpy as np -from argus.services.timeseries_service import prepare_trend_analysis +from argus.services.market_data_service import prepare_trend_analysis def test_get_a_full_timeseries(): From adec3c7d69a7d5ec70b3a80262de19d39cd4e9e6 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Sun, 5 Jul 2026 22:02:05 +0200 Subject: [PATCH 03/14] refactor(#70): service generate a chart --- src/argus/analytics/charts/trend_chart.py | 8 ++------ src/argus/services/market_data_service.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index 58c93a1..176bef2 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -1,8 +1,8 @@ import matplotlib.pyplot as plt -from argus.services.market_data_service import prepare_trend_analysis +import pandas as pd -def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): +def create_trendchart(df:pd.DataFrame,min_max_rates:dict): """ Create a trend chart for exchange-rate analysis. @@ -30,10 +30,6 @@ def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): Minimum and maximum exchange-rate values are marked with scatter points and annotations. """ - 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/services/market_data_service.py b/src/argus/services/market_data_service.py index 9521021..f1758b3 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -5,9 +5,10 @@ add_daily_percentage_change, get_min_max_rates, ) +from argus.analytics.charts.trend_chart import create_trendchart -def prepare_trend_analysis(df: pd.DataFrame) -> tuple[pd.DataFrame,dict] | None: +def prepare_trend_analysis(df: pd.DataFrame): """ Prepare time-series data for trend analysis. @@ -25,15 +26,18 @@ def prepare_trend_analysis(df: pd.DataFrame) -> tuple[pd.DataFrame,dict] | None: ``None`` if no time-series data could be fetched. """ - df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) if df is None: return None - return df, min_max_rates + fig = create_trendchart(df,min_max_rates) + return fig -def get_market_data(curr_symbol: str, start: str, end: str, intervall: str) -> pd.DataFrame | None: + +def get_market_data( + curr_symbol: str, start: str, end: str, intervall: str +) -> pd.DataFrame | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -54,5 +58,3 @@ def get_market_data(curr_symbol: str, start: str, end: str, intervall: str) -> p if df is None: return None return df - - From ef3939d73f5bbbf15a3c688e35151bd6fb7ea35f Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Sun, 5 Jul 2026 22:39:00 +0200 Subject: [PATCH 04/14] refactor(#70): coordinate workflow --- src/argus/analytics/charts/trend_chart.py | 2 +- src/argus/clients/yfinance_client.py | 18 ++++++++++---- src/argus/gui/app.py | 6 +++-- src/argus/services/market_data_service.py | 30 ++++++++++++++++++++--- src/argus/storage/database.py | 5 ++-- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index 176bef2..db5dacb 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -2,7 +2,7 @@ import pandas as pd -def create_trendchart(df:pd.DataFrame,min_max_rates:dict): +def create_trendchart(df: pd.DataFrame, min_max_rates: dict): """ Create a trend chart for exchange-rate analysis. diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index de86041..6965189 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,8 +1,16 @@ import yfinance as yf import logging +from datetime import date +from argus.domain.internal_models import DataSource, Instrument, PriceBar -def get_timeseries(curr_symbol, start, end, interval): +def get_timeseries( + source: DataSource, + instrument: Instrument, + bar: PriceBar, + start_date: date, + end_date: date, +): """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -23,10 +31,10 @@ def get_timeseries(curr_symbol, start, end, interval): yf_logger = logging.getLogger("yfinance") yf_logger.disabled = True data = yf.download( - tickers=curr_symbol, - start=start, - end=end, - interval=interval, + tickers=instrument.base_currency, + start=start_date, + end=end_date, + interval=bar.timeframe, multi_level_index=False, progress=False, ) diff --git a/src/argus/gui/app.py b/src/argus/gui/app.py index 9a19523..06a6f4a 100644 --- a/src/argus/gui/app.py +++ b/src/argus/gui/app.py @@ -1,6 +1,7 @@ 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.market_data_service import prepare_trend_analysis from argus.services.calculator_service import calc, check_op from argus.services.conversion_service import convert, check_currency from argus.domain.validation import parse_amount @@ -90,7 +91,8 @@ def show_trend() -> None: content.pack(side="top", fill=tk.BOTH, expand=True) if trend_canvas is None: - fig = create_trendchart(curr_symbol, start, end, interval) + df = pd.DataFrame() + fig = prepare_trend_analysis(df) if fig is None: return None fig.set_size_inches(7, 4) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index f1758b3..f7785c7 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,5 +1,8 @@ import pandas as pd +from datetime import date +from argus.domain.internal_models import DataSource, PriceBar, Instrument from argus.clients.yfinance_client import get_timeseries +from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( add_rolling_average, add_daily_percentage_change, @@ -31,12 +34,17 @@ def prepare_trend_analysis(df: pd.DataFrame): min_max_rates = get_min_max_rates(df) if df is None: return None - fig = create_trendchart(df,min_max_rates) + fig = create_trendchart(df, min_max_rates) return fig def get_market_data( - curr_symbol: str, start: str, end: str, intervall: str + db: str, + source: DataSource, + instrument: Instrument, + bar: PriceBar, + start_date: date, + end_date: date, ) -> pd.DataFrame | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -54,7 +62,23 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - df = get_timeseries(curr_symbol, start, end, intervall) + df, isNotEmpty = read_price_bars( + db=db, + source=source, + instrument=instrument, + start_date=start_date, + end_date=end_date, + ) + if isNotEmpty: + return df + else: + df = get_timeseries( + source=source, + instrument=instrument, + bar=bar, + start_date=start_date, + end_date=end_date, + ) if df is None: return None return df diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index 715c24b..ca66c1f 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -247,7 +247,7 @@ def read_price_bars( instrument: Instrument, start_date: date, end_date: date, -) -> pd.DataFrame: +) -> tuple[pd.DataFrame, bool]: """ Read price bars for a source, instrument, and date range. @@ -298,6 +298,7 @@ def read_price_bars( end_date, ], ).df() + isNotEmpty = not result.empty finally: connection.close() - return result + return result, isNotEmpty From a13d00bd56550fcf02556c38f59b856f82c429b5 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Mon, 6 Jul 2026 08:50:54 +0200 Subject: [PATCH 05/14] refactor(#70): establish the model --- src/argus/clients/yfinance_client.py | 30 ++++----- src/argus/domain/internal_models.py | 10 +++ src/argus/services/market_data_service.py | 50 +++++++------- tests/test_market_data_series.py | 29 ++++++--- tests/test_storage_database.py | 8 +-- tests/test_yfinance_client.py | 79 +++++++++++++++-------- 6 files changed, 128 insertions(+), 78 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 6965189..100de1b 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,16 +1,10 @@ import yfinance as yf import logging from datetime import date -from argus.domain.internal_models import DataSource, Instrument, PriceBar +from argus.domain.internal_models import MarketDataSet +import pandas as pd - -def get_timeseries( - source: DataSource, - instrument: Instrument, - bar: PriceBar, - start_date: date, - end_date: date, -): +def get_timeseries(market_data:MarketDataSet) -> MarketDataSet | None: """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -30,11 +24,15 @@ def get_timeseries( try: yf_logger = logging.getLogger("yfinance") yf_logger.disabled = True + start = str(market_data.start) + end = str(market_data.end) + timeframe = market_data.timeframe + curr_pair = f"{market_data.instrument.base_currency}{market_data.instrument.quote_currency}=X" data = yf.download( - tickers=instrument.base_currency, - start=start_date, - end=end_date, - interval=bar.timeframe, + tickers=curr_pair, + start=start, + end=end, + interval=timeframe, multi_level_index=False, progress=False, ) @@ -46,6 +44,8 @@ def get_timeseries( data = data.reset_index() data = data[["Date", "Close"]] data = data.rename(columns={"Date": "date", "Close": "rate"}) - return data + market_data.bars=data + print(market_data.bars) + return market_data except Exception: - return None + return None \ No newline at end of file diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 3b7630e..83f2088 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import date +import pandas as pd @dataclass @@ -32,3 +33,12 @@ class PriceBar: low: float | None = None adjusted_close: float | None = None volume: float | None = None + +@dataclass +class MarketDataSet: + source: DataSource + instrument: Instrument + timeframe: str + start: date + end: date + bars: pd.DataFrame \ No newline at end of file diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index f7785c7..d49d1b9 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, PriceBar, Instrument +from argus.domain.internal_models import DataSource, PriceBar, Instrument,MarketDataSet from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -40,12 +40,8 @@ def prepare_trend_analysis(df: pd.DataFrame): def get_market_data( db: str, - source: DataSource, - instrument: Instrument, - bar: PriceBar, - start_date: date, - end_date: date, -) -> pd.DataFrame | None: + market_data: MarketDataSet, +) -> MarketDataSet | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -62,23 +58,33 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), +) df, isNotEmpty = read_price_bars( db=db, - source=source, - instrument=instrument, - start_date=start_date, - end_date=end_date, + source=market_data.source, + instrument=market_data.instrument, + start_date=market_data.start, + end_date=market_data.end, ) - if isNotEmpty: - return df + market_data.source + market_data.instrument + market_data.timeframe + market_data.start + market_data.end + market_data.bars=df + if not(isNotEmpty): + return market_data else: - df = get_timeseries( - source=source, - instrument=instrument, - bar=bar, - start_date=start_date, - end_date=end_date, - ) - if df is None: + other_market_data = get_timeseries(market_data=market_data) + if other_market_data is None: return None - return df + return other_market_data diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index 3637a5e..b232b8e 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -1,15 +1,24 @@ import pandas as pd import pandas.testing as pdt import numpy as np -from argus.services.market_data_service import prepare_trend_analysis +from datetime import date +from argus.services.market_data_service import get_market_data +from argus.storage.database import initialize_database +from argus.domain.internal_models import DataSource,Instrument,MarketDataSet -def test_get_a_full_timeseries(): - test_curr = "EURUSD=X" - test_start = "2024-01-01" - test_end = "2024-01-04" - test_interval = "1d" - +def test_get_a_full_timeseries(tmp_path): + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + bars=pd.DataFrame(), +) + db = tmp_path / "test.duckdb" expect_result = { "date": ["2024-01-01", "2024-01-02", "2024-01-03"], "rate": [1.1055831909179688, 1.1038745641708374, 1.0941756963729858], @@ -22,10 +31,11 @@ def test_get_a_full_timeseries(): "max_date": ["2024-01-01 00:00:00"], "max_rate": [1.1055831909179688], } - result = prepare_trend_analysis(test_curr, test_start, test_end, test_interval) + initialize_database(db) + result = get_market_data(db=db,market_data=market_data) assert result is not None - + """ result_df, result_dict = result result_df["date"] = result_df["date"].astype("str") result_dict["min_date"] = [str(result_dict["min_date"][0])] @@ -34,3 +44,4 @@ def test_get_a_full_timeseries(): pdt.assert_frame_equal(result_df, expect_df) assert result_dict == expect_dict + """ diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index d513008..63911b7 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -168,7 +168,7 @@ def test_read_price_bars_returns_matching_data(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result = read_price_bars( + result,isNotEmpty = read_price_bars( db=db, source=source, instrument=instrument, @@ -176,7 +176,7 @@ def test_read_price_bars_returns_matching_data(tmp_path): end_date=date(2026, 1, 31), ) - assert result.empty is False + assert isNotEmpty is True assert len(result) == 1 assert result.iloc[0]["source_name"] == "Yahoo" assert result.iloc[0]["instrument_symbol"] == "EUR/USD" @@ -211,7 +211,7 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result = read_price_bars( + result,isNotEmpty = read_price_bars( db=db, source=source, instrument=instrument, @@ -219,4 +219,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert result.empty is True + assert not(isNotEmpty) is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index 6201b19..70bb4e4 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,4 +1,6 @@ from argus.clients.yfinance_client import get_timeseries +from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from datetime import date import pandas as pd import pandas.testing as pdt @@ -11,70 +13,91 @@ def test_get_dataframe(monkeypatch): 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" - + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + bars=pd.DataFrame(), + ) 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) + result = get_timeseries(market_data) 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) + pdt.assert_frame_equal(result.bars, expected) def test_get_none(monkeypatch): - test_curr = "EURUSD=X" - test_start = "2024-01-01" - test_end = "2024-01-04" - test_interval = "1d" - + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), +) 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) + result = get_timeseries(market_data) 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" + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), +) 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) + result = get_timeseries(market_data) 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 - ): + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), +) + + def fake_yfinance_download(): raise Exception("fake yfinance error") monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(test_curr, test_start, test_end, test_interval) + result = get_timeseries(market_data=market_data) assert result is None From 3a3b50ff14b404e0c2ca2a18dd1031858bb4da58 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Mon, 6 Jul 2026 08:56:25 +0200 Subject: [PATCH 06/14] test(#70): edit the tests --- src/argus/clients/yfinance_client.py | 7 ++- src/argus/domain/internal_models.py | 3 +- src/argus/services/market_data_service.py | 30 ++++++---- tests/test_market_data_series.py | 22 ++++---- tests/test_storage_database.py | 6 +- tests/test_yfinance_client.py | 69 +++++++++++++---------- 6 files changed, 76 insertions(+), 61 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 100de1b..894d393 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -4,7 +4,8 @@ from argus.domain.internal_models import MarketDataSet import pandas as pd -def get_timeseries(market_data:MarketDataSet) -> MarketDataSet | None: + +def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -44,8 +45,8 @@ def get_timeseries(market_data:MarketDataSet) -> MarketDataSet | None: data = data.reset_index() data = data[["Date", "Close"]] data = data.rename(columns={"Date": "date", "Close": "rate"}) - market_data.bars=data + market_data.bars = data print(market_data.bars) return market_data except Exception: - return None \ No newline at end of file + return None diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 83f2088..1ce9fac 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -34,6 +34,7 @@ class PriceBar: adjusted_close: float | None = None volume: float | None = None + @dataclass class MarketDataSet: source: DataSource @@ -41,4 +42,4 @@ class MarketDataSet: timeframe: str start: date end: date - bars: pd.DataFrame \ No newline at end of file + bars: pd.DataFrame diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index d49d1b9..29dd777 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, PriceBar, Instrument,MarketDataSet +from argus.domain.internal_models import DataSource, PriceBar, Instrument, MarketDataSet from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -58,16 +58,22 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument( + symbol="EUR - USD", + name="EUR/USD", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), + ) df, isNotEmpty = read_price_bars( db=db, source=market_data.source, @@ -80,8 +86,8 @@ def get_market_data( market_data.timeframe market_data.start market_data.end - market_data.bars=df - if not(isNotEmpty): + market_data.bars = df + if not (isNotEmpty): return market_data else: other_market_data = get_timeseries(market_data=market_data) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index b232b8e..aea6434 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -4,20 +4,20 @@ from datetime import date from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database -from argus.domain.internal_models import DataSource,Instrument,MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataSet def test_get_a_full_timeseries(tmp_path): - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2024, 1, 1), - end=date(2024, 1, 4), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + bars=pd.DataFrame(), + ) db = tmp_path / "test.duckdb" expect_result = { "date": ["2024-01-01", "2024-01-02", "2024-01-03"], @@ -32,7 +32,7 @@ def test_get_a_full_timeseries(tmp_path): "max_rate": [1.1055831909179688], } initialize_database(db) - result = get_market_data(db=db,market_data=market_data) + result = get_market_data(db=db, market_data=market_data) assert result is not None """ diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index 63911b7..a2c610b 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -168,7 +168,7 @@ def test_read_price_bars_returns_matching_data(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result,isNotEmpty = read_price_bars( + result, isNotEmpty = read_price_bars( db=db, source=source, instrument=instrument, @@ -211,7 +211,7 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result,isNotEmpty = read_price_bars( + result, isNotEmpty = read_price_bars( db=db, source=source, instrument=instrument, @@ -219,4 +219,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert not(isNotEmpty) is True + assert not (isNotEmpty) is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index 70bb4e4..fea1681 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,6 +1,6 @@ from argus.clients.yfinance_client import get_timeseries from argus.domain.internal_models import DataSource, Instrument, MarketDataSet -from datetime import date +from datetime import date import pandas as pd import pandas.testing as pdt @@ -13,8 +13,14 @@ def test_get_dataframe(monkeypatch): index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), ) test_resp.index.name = "Date" - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument( + symbol="EUR - USD", + name="EUR/USD", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) market_data = MarketDataSet( source=source, instrument=instrument, @@ -23,6 +29,7 @@ def test_get_dataframe(monkeypatch): end=date(2024, 1, 4), bars=pd.DataFrame(), ) + def fake_yfinance_download(*args, **kwargs): return test_resp @@ -35,22 +42,22 @@ def fake_yfinance_download(*args, **kwargs): } ) - assert result is not None pdt.assert_frame_equal(result.bars, expected) def test_get_none(monkeypatch): - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), + ) + def fake_yfinance_download(*args, **kwargs): return None @@ -61,16 +68,16 @@ def fake_yfinance_download(*args, **kwargs): def test_get_empty_frame(monkeypatch): - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), + ) def fake_yfinance_download(*args, **kwargs): return pd.DataFrame() @@ -83,16 +90,16 @@ def fake_yfinance_download(*args, **kwargs): def test_error_raise(monkeypatch): # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), + ) def fake_yfinance_download(): raise Exception("fake yfinance error") From 2ed30d7f4ea192b6c85f546909f9323d0055a398 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Mon, 6 Jul 2026 09:03:00 +0200 Subject: [PATCH 07/14] refactor(#70): remove unused vars --- src/argus/clients/yfinance_client.py | 2 -- src/argus/gui/app.py | 5 ----- src/argus/services/market_data_service.py | 2 +- tests/test_market_data_series.py | 15 +-------------- tests/test_storage_database.py | 2 +- 5 files changed, 3 insertions(+), 23 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 894d393..5751082 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,8 +1,6 @@ import yfinance as yf import logging -from datetime import date from argus.domain.internal_models import MarketDataSet -import pandas as pd def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: diff --git a/src/argus/gui/app.py b/src/argus/gui/app.py index 06a6f4a..02f4c1f 100644 --- a/src/argus/gui/app.py +++ b/src/argus/gui/app.py @@ -77,11 +77,6 @@ def show_trend() -> None: global trend_canvas global trend_chart_widget - curr_symbol = "EURUSD=X" - start = "2024-01-01" - end = "2025-01-01" - interval = "1d" - calc_frame.pack_forget() conv_frame.pack_forget() menu_frame.pack_forget() diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 29dd777..fd9d103 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, PriceBar, Instrument, MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataSet from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index aea6434..11e6521 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -1,6 +1,4 @@ import pandas as pd -import pandas.testing as pdt -import numpy as np from datetime import date from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database @@ -19,18 +17,7 @@ def test_get_a_full_timeseries(tmp_path): bars=pd.DataFrame(), ) db = tmp_path / "test.duckdb" - expect_result = { - "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": ["2024-01-03 00:00:00"], - "min_rate": [1.0941756963729858], - "max_date": ["2024-01-01 00:00:00"], - "max_rate": [1.1055831909179688], - } + initialize_database(db) result = get_market_data(db=db, market_data=market_data) diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index a2c610b..3697b52 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -219,4 +219,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert not (isNotEmpty) is True + assert isNotEmpty is not True From f129474597ae3042f4baace4af9eba8bd2ec1e3d Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 11:57:04 +0200 Subject: [PATCH 08/14] refactor(#70): improve req/resp model --- src/argus/clients/yfinance_client.py | 55 +++++++++--------- src/argus/domain/internal_models.py | 37 ++++++++---- src/argus/services/market_data_service.py | 59 ++++++-------------- src/argus/storage/database.py | 68 +++++++++++------------ tests/test_storage_database.py | 7 +-- tests/test_yfinance_client.py | 3 +- 6 files changed, 111 insertions(+), 118 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 5751082..98f6af1 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,9 +1,13 @@ import yfinance as yf -import logging -from argus.domain.internal_models import MarketDataSet +import pandas as pd +from argus.domain.internal_models import ( + MarketDataRequest, + PRICE_BAR_COLUMNS, + YFINANCE_PRICE_BAR_MAPPING, +) -def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: +def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -16,18 +20,17 @@ def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: "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. + pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. + Returns empty pandas.DataFrame if the request fails and an exception occurs (with an error message). """ try: - yf_logger = logging.getLogger("yfinance") - yf_logger.disabled = True - start = str(market_data.start) - end = str(market_data.end) - timeframe = market_data.timeframe - curr_pair = f"{market_data.instrument.base_currency}{market_data.instrument.quote_currency}=X" - data = yf.download( + start = str(request.start) + end = str(request.end) + timeframe = request.timeframe + curr_pair = ( + f"{request.instrument.base_currency}{request.instrument.quote_currency}=X" + ) + raw_resp = yf.download( tickers=curr_pair, start=start, end=end, @@ -35,16 +38,18 @@ def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: 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"}) - market_data.bars = data - print(market_data.bars) - return market_data + if raw_resp is None: + raise ConnectionError("Couldn't fetch data") + if raw_resp.empty: + raise ValueError("No data") + resp = normalize_yfinance_bars(raw_resp) + return resp except Exception: - return None + return pd.DataFrame() + + +def normalize_yfinance_bars(raw_df: pd.DataFrame) -> pd.DataFrame: + df = raw_df.copy() + df = df.reset_index() + df = df.rename(columns=YFINANCE_PRICE_BAR_MAPPING) + return df[list(PRICE_BAR_COLUMNS)] diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 1ce9fac..2cfc656 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -21,25 +21,38 @@ class Instrument: quote_currency: str | None = None +PRICE_BAR_COLUMNS = ( + "timestamp", + "open", + "high", + "low", + "close", + "adjusted_close", + "volume", +) + +YFINANCE_PRICE_BAR_MAPPING = { + "Date": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Adj Close": "adjusted_close", + "Volume": "volume", +} + + @dataclass -class PriceBar: +class MarketDataRequest: source: DataSource instrument: Instrument - timestamp: date timeframe: str - close: float - open: float | None = None - high: float | None = None - low: float | None = None - adjusted_close: float | None = None - volume: float | None = None + start: date + end: date @dataclass -class MarketDataSet: +class MarketDataResponse: source: DataSource instrument: Instrument - timeframe: str - start: date - end: date bars: pd.DataFrame diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index fd9d103..bf6a851 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest,MarketDataResponse from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -32,16 +32,14 @@ def prepare_trend_analysis(df: pd.DataFrame): df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) - if df is None: - return None fig = create_trendchart(df, min_max_rates) return fig def get_market_data( db: str, - market_data: MarketDataSet, -) -> MarketDataSet | None: + request: MarketDataRequest, +) -> MarketDataResponse | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -58,39 +56,18 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument( - symbol="EUR - USD", - name="EUR/USD", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), - bars=pd.DataFrame(), - ) - df, isNotEmpty = read_price_bars( - db=db, - source=market_data.source, - instrument=market_data.instrument, - start_date=market_data.start, - end_date=market_data.end, - ) - market_data.source - market_data.instrument - market_data.timeframe - market_data.start - market_data.end - market_data.bars = df - if not (isNotEmpty): - return market_data - else: - other_market_data = get_timeseries(market_data=market_data) - if other_market_data is None: - return None - return other_market_data + bars = read_price_bars(db,request) + if not (bars.empty): + db_response = MarketDataResponse( + source=request.source, + instrument=request.instrument, + bars=bars) + return db_response + + bars = get_timeseries(request) + if not (bars.empty): + api_response = MarketDataResponse( + source=request.source, + instrument=request.instrument, + bars=bars) + return api_response diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index ca66c1f..a1e9f8f 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -1,7 +1,13 @@ import duckdb from datetime import date import pandas as pd -from argus.domain.internal_models import DataSource, PriceBar, Instrument +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, + PRICE_BAR_COLUMNS, +) def initialize_database(database_path: str) -> None: @@ -186,7 +192,7 @@ def get_or_create_instrument(connection, instrument: Instrument) -> int: return result[0] -def insert_price_bar(db: str, price_bar: PriceBar) -> None: +def insert_price_bar(db: str, marketdata: MarketDataResponse) -> None: """ Insert a price bar into the database. @@ -207,7 +213,6 @@ def insert_price_bar(db: str, price_bar: PriceBar) -> None: source_id, instrument_id, timestamp, - timeframe, close, open, high, @@ -215,39 +220,35 @@ def insert_price_bar(db: str, price_bar: PriceBar) -> None: adjusted_close, volume ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING; """ connection = duckdb.connect(db) try: - source_id = get_or_create_source(connection, price_bar.source) - instrument_id = get_or_create_instrument(connection, price_bar.instrument) - connection.execute( - query=insert_query, - parameters=[ - source_id, - instrument_id, - price_bar.timestamp, - price_bar.timeframe, - price_bar.close, - price_bar.open, - price_bar.high, - price_bar.low, - price_bar.adjusted_close, - price_bar.volume, - ], - ) + source_id = get_or_create_source(connection, marketdata.source) + instrument_id = get_or_create_instrument(connection, marketdata.instrument) + if marketdata.bars is None: + return None + for _, row in marketdata.bars.iterrows(): + connection.execute( + query=insert_query, + parameters=[ + source_id, + instrument_id, + MarketDataResponse.bars["time"], + MarketDataResponse.bars["close"], + MarketDataResponse.bars["open"], + MarketDataResponse.bars["high"], + MarketDataResponse.bars["low"], + MarketDataResponse.bars["adjusted_close"], + MarketDataResponse.bars["volume"], + ], + ) finally: connection.close() -def read_price_bars( - db: str, - source: DataSource, - instrument: Instrument, - start_date: date, - end_date: date, -) -> tuple[pd.DataFrame, bool]: +def read_price_bars(db: str, request: MarketDataRequest) -> pd.DataFrame: """ Read price bars for a source, instrument, and date range. @@ -292,13 +293,12 @@ def read_price_bars( result = connection.execute( query=search_query, parameters=[ - source.name, - instrument.symbol, - start_date, - end_date, + request.source.name, + request.instrument.symbol, + request.start, + request.end, ], ).df() - isNotEmpty = not result.empty finally: connection.close() - return result, isNotEmpty + return result diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index 3697b52..add7f7c 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -168,7 +168,7 @@ def test_read_price_bars_returns_matching_data(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result, isNotEmpty = read_price_bars( + result = read_price_bars( db=db, source=source, instrument=instrument, @@ -176,7 +176,6 @@ def test_read_price_bars_returns_matching_data(tmp_path): end_date=date(2026, 1, 31), ) - assert isNotEmpty is True assert len(result) == 1 assert result.iloc[0]["source_name"] == "Yahoo" assert result.iloc[0]["instrument_symbol"] == "EUR/USD" @@ -211,7 +210,7 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result, isNotEmpty = read_price_bars( + result = read_price_bars( db=db, source=source, instrument=instrument, @@ -219,4 +218,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert isNotEmpty is not True + assert result.empty is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index fea1681..b4a97c5 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -27,7 +27,6 @@ def test_get_dataframe(monkeypatch): timeframe="1d", start=date(2024, 1, 1), end=date(2024, 1, 4), - bars=pd.DataFrame(), ) def fake_yfinance_download(*args, **kwargs): @@ -43,6 +42,7 @@ def fake_yfinance_download(*args, **kwargs): ) assert result is not None + assert result.bars is not None pdt.assert_frame_equal(result.bars, expected) @@ -76,7 +76,6 @@ def test_get_empty_frame(monkeypatch): timeframe="1d", start=date(2026, 1, 1), end=date(2026, 1, 1), - bars=pd.DataFrame(), ) def fake_yfinance_download(*args, **kwargs): From d1820078155bfef5e8d6e76d85cb540eda2844e1 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 13:32:50 +0200 Subject: [PATCH 09/14] test(#70): improve db and model tests --- src/argus/clients/yfinance_client.py | 2 +- src/argus/domain/internal_models.py | 12 ++ src/argus/services/market_data_service.py | 21 +- src/argus/storage/database.py | 21 +- tests/test_internal_models.py | 129 ++++-------- tests/test_market_data_series.py | 7 +- tests/test_storage_database.py | 239 ++++++++-------------- tests/test_yfinance_client.py | 39 ++-- 8 files changed, 190 insertions(+), 280 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 98f6af1..db689b1 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -20,7 +20,7 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: "1d", "1h", or "15m". Returns: - pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. + pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. Returns empty pandas.DataFrame if the request fails and an exception occurs (with an error message). """ try: diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 2cfc656..f4dcf27 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -56,3 +56,15 @@ class MarketDataResponse: source: DataSource instrument: Instrument bars: pd.DataFrame + + def __post_init__(self) -> None: + if not isinstance(self.bars, pd.DataFrame): + raise TypeError("bars must be a pandas DataFrame") + + missing_cols = [ + col for col in PRICE_BAR_COLUMNS if col not in self.bars.columns + ] + if missing_cols: + raise ValueError( + f"Missing required columns in bars DataFrame: {missing_cols}" + ) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index bf6a851..61b4144 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,11 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest,MarketDataResponse +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -56,18 +61,16 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - bars = read_price_bars(db,request) + bars = read_price_bars(db, request) if not (bars.empty): db_response = MarketDataResponse( - source=request.source, - instrument=request.instrument, - bars=bars) + source=request.source, instrument=request.instrument, bars=bars + ) return db_response - + bars = get_timeseries(request) if not (bars.empty): api_response = MarketDataResponse( - source=request.source, - instrument=request.instrument, - bars=bars) + source=request.source, instrument=request.instrument, bars=bars + ) return api_response diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index a1e9f8f..c14d05f 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -1,5 +1,4 @@ import duckdb -from datetime import date import pandas as pd from argus.domain.internal_models import ( DataSource, @@ -53,7 +52,6 @@ def initialize_database(database_path: str) -> None: source_id INTEGER NOT NULL, instrument_id INTEGER NOT NULL, timestamp DATE NOT NULL, - timeframe TEXT NOT NULL, close DOUBLE NOT NULL, open DOUBLE, high DOUBLE, @@ -62,7 +60,7 @@ def initialize_database(database_path: str) -> None: volume DOUBLE, FOREIGN KEY (source_id) REFERENCES data_sources (id), FOREIGN KEY (instrument_id) REFERENCES instruments (id), - UNIQUE (source_id, instrument_id, timestamp, timeframe) + UNIQUE (source_id, instrument_id, timestamp,close) ); """, ] @@ -203,7 +201,7 @@ def insert_price_bar(db: str, marketdata: MarketDataResponse) -> None: Args: db (str): Path to the DuckDB database file. price_bar (PriceBar): Price bar model containing source, - instrument, timestamp, timeframe, and market values. + instrument, timestamp and market values. Returns: None @@ -235,13 +233,13 @@ def insert_price_bar(db: str, marketdata: MarketDataResponse) -> None: parameters=[ source_id, instrument_id, - MarketDataResponse.bars["time"], - MarketDataResponse.bars["close"], - MarketDataResponse.bars["open"], - MarketDataResponse.bars["high"], - MarketDataResponse.bars["low"], - MarketDataResponse.bars["adjusted_close"], - MarketDataResponse.bars["volume"], + row["timestamp"], + row["close"], + row["open"], + row["high"], + row["low"], + row["adjusted_close"], + row["volume"], ], ) finally: @@ -272,7 +270,6 @@ def read_price_bars(db: str, request: MarketDataRequest) -> pd.DataFrame: data_sources.name AS source_name, instruments.symbol AS instrument_symbol, price_bars.timestamp, - price_bars.timeframe, price_bars.open, price_bars.high, price_bars.low, diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py index 97df4c6..2ef6277 100644 --- a/tests/test_internal_models.py +++ b/tests/test_internal_models.py @@ -1,101 +1,60 @@ -from argus.domain.internal_models import DataSource, Instrument, PriceBar +import pytest +import pandas as pd from datetime import date +from argus.domain.internal_models import DataSource, Instrument, MarketDataResponse -def test_data_source_can_be_created() -> None: - source = DataSource( - name="yfinance", - provider_kind="fx_rates", - ) +@pytest.fixture +def valid_source(): + return DataSource(name="Yahoo", provider_kind="yfinance_api") - assert source.name == "yfinance" - assert source.provider_kind == "fx_rates" - assert source.requires_api_key is False +@pytest.fixture +def valid_instrument(): + return Instrument(symbol="AAPL", name="Apple Inc.", asset_class="stock") -def test_instrument_can_be_created() -> None: - instrument = Instrument( - symbol="EUR/USD", - name="Euro / US Dollar", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - assert instrument.symbol == "EUR/USD" - assert instrument.name == "Euro / US Dollar" - assert instrument.asset_class == "fx" - assert instrument.base_currency == "EUR" - assert instrument.quote_currency == "USD" - assert instrument.currency is None - assert instrument.exchange is None +def test_market_data_response_accepts_valid_dataframe( + valid_source, valid_instrument +) -> None: + valid_bar = { + "timestamp": [date(2026, 1, 1)], + "open": [150.0], + "high": [155.0], + "low": [149.0], + "close": [153.5], + "adjusted_close": [153.5], + "volume": [1000000.0], + } + df = pd.DataFrame(valid_bar) + resp = MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + assert resp.bars.equals(df) -def test_rate_bar_can_be_created() -> None: - source = DataSource( - name="yfinance", - provider_kind="fx_rates", - ) - instrument_rate = Instrument( - symbol="EUR/USD", - name="Euro / US Dollar", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) +def test_market_data_response_raises_error_on_missing_columns( + valid_source, valid_instrument +) -> None: + incomplete_bar = { + "timestamp": [date(2026, 1, 1)], + "close": [153.5], + } + df = pd.DataFrame(incomplete_bar) - price_bar = PriceBar( - source=source, - instrument=instrument_rate, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) + with pytest.raises(ValueError) as exc_info: + MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) - assert price_bar.source == source - assert price_bar.instrument == instrument_rate - assert price_bar.timestamp == date(2026, 1, 1) - assert price_bar.timeframe == "1d" - assert price_bar.close == 1.89 - assert price_bar.open is None - assert price_bar.high is None - assert price_bar.low is None - assert price_bar.adjusted_close is None - assert price_bar.volume is None + assert "Missing required columns" in str(exc_info.value) -def test_stock_ohlcv_bar_can_be_created() -> None: - source = DataSource( - name="yfinance", - provider_kind="market_prices", - ) +def test_market_data_response_raises_error_if_not_a_dataframe( + valid_source, valid_instrument +) -> None: + invalid_input = "I'm just a string :D" - instrument = Instrument( - symbol="AAPL", - name="Apple Inc.", - asset_class="stock", - currency="USD", - exchange="NASDAQ", - ) + with pytest.raises(TypeError) as exc_info: + MarketDataResponse( + source=valid_source, instrument=valid_instrument, bars=invalid_input + ) # type: ignore - price_bar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - open=187.15, - high=188.44, - low=183.89, - close=185.64, - adjusted_close=184.25, - volume=50_200_000, - ) - - assert price_bar.instrument.symbol == "AAPL" - assert price_bar.open == 187.15 - assert price_bar.high == 188.44 - assert price_bar.low == 183.89 - assert price_bar.close == 185.64 - assert price_bar.adjusted_close == 184.25 - assert price_bar.volume == 50_200_000 + assert "must be a pandas DataFrame" in str(exc_info.value) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index 11e6521..ee800e9 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -2,24 +2,23 @@ from datetime import date from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database -from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest def test_get_a_full_timeseries(tmp_path): source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", start=date(2024, 1, 1), end=date(2024, 1, 4), - bars=pd.DataFrame(), ) db = tmp_path / "test.duckdb" initialize_database(db) - result = get_market_data(db=db, market_data=market_data) + result = get_market_data(db, req) assert result is not None """ diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index add7f7c..9a25ac3 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -1,8 +1,13 @@ from datetime import date - +import pytest import duckdb - -from argus.domain.internal_models import DataSource, Instrument, PriceBar +import pandas as pd +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from argus.storage.database import ( initialize_database, insert_price_bar, @@ -10,26 +15,16 @@ ) -def test_initialize_database_creates_required_tables(tmp_path): - db = tmp_path / "test.duckdb" - - initialize_database(db) - connection = duckdb.connect(db) - tables = connection.execute("SHOW TABLES;").fetchall() - connection.close() - table_names = {row[0] for row in tables} - - assert "data_sources" in table_names - assert "instruments" in table_names - assert "price_bars" in table_names - - -def test_data_is_inserted(tmp_path): - source = DataSource( +@pytest.fixture +def sample_source(): + return DataSource( name="Yahoo", provider_kind="yfinance_api", requires_api_key=False ) - instrument = Instrument( + +@pytest.fixture +def sample_instrument(): + return Instrument( symbol="EUR/USD", name="EUR - USD Rate", asset_class="fx", @@ -37,25 +32,52 @@ def test_data_is_inserted(tmp_path): quote_currency="USD", ) - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, + +@pytest.fixture +def sample_response(sample_source, sample_instrument): + test_bar = { + "timestamp": date(2026, 1, 1), + "open": None, + "high": None, + "low": None, + "close": 1.89, + "adjusted_close": None, + "volume": None, + } + return MarketDataResponse( + source=sample_source, + instrument=sample_instrument, + bars=pd.DataFrame(test_bar, index=[0]), ) + +@pytest.fixture +def db_path(tmp_path): db = tmp_path / "test.duckdb" initialize_database(db) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) + return db + + +def test_initialize_database_creates_required_tables(db_path): + connection = duckdb.connect(str(db_path)) + tables = connection.execute("SHOW TABLES;").fetchall() + connection.close() + table_names = {row[0] for row in tables} + + assert "data_sources" in table_names + assert "instruments" in table_names + assert "price_bars" in table_names + + +def test_data_is_inserted(db_path, sample_response) -> None: + insert_price_bar(db_path, sample_response) + + connection = duckdb.connect(str(db_path)) instrument_count = connection.execute( "SELECT COUNT(*) FROM instruments;" ).fetchone() - source_count = connection.execute("SELECT COUNT(*) FROM data_sources;").fetchone() - price_bar_count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() assert instrument_count is not None @@ -66,156 +88,71 @@ def test_data_is_inserted(tmp_path): assert price_bar_count[0] == 1 -def test_fx_has_correct_format(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) +def test_fx_has_correct_format(db_path, sample_response) -> None: + insert_price_bar(db_path, sample_response) - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - - price_bar_fx = connection.execute("SELECT * FROM price_bars;").fetchone() - connection.close() + connection = duckdb.connect(str(db_path)) + try: + price_bar_fx = connection.execute("SELECT * FROM price_bars;").fetchone() + finally: + connection.close() assert price_bar_fx is not None assert price_bar_fx[0] == 1 assert price_bar_fx[1] == 1 assert price_bar_fx[2] == 1 assert price_bar_fx[3] == date(2026, 1, 1) - assert price_bar_fx[4] == "1d" - assert price_bar_fx[5] == 1.89 + assert price_bar_fx[4] == 1.89 + assert price_bar_fx[5] is None assert price_bar_fx[6] is None assert price_bar_fx[7] is None assert price_bar_fx[8] is None assert price_bar_fx[9] is None - assert price_bar_fx[10] is None - -def test_duplicates_are_ignored(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) +def test_duplicates_are_ignored(db_path, sample_response) -> None: + insert_price_bar(db_path, sample_response) + insert_price_bar(db_path, sample_response) # Erneuter Insert des Duplikats - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() + connection = duckdb.connect(str(db_path)) + try: + count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() + finally: + connection.close() assert count is not None assert count[0] == 1 -def test_read_price_bars_returns_matching_data(tmp_path): - source = DataSource( - name="Yahoo", - provider_kind="yfinance_api", - requires_api_key=False, - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), +def test_read_price_bars_returns_matching_data( + db_path, sample_source, sample_instrument, sample_response +) -> None: + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, timeframe="1d", - close=1.89, + start=date(2026, 1, 1), + end=date(2026, 1, 1), ) + insert_price_bar(db_path, sample_response) - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - - result = read_price_bars( - db=db, - source=source, - instrument=instrument, - start_date=date(2026, 1, 1), - end_date=date(2026, 1, 31), - ) + result = read_price_bars(db_path, req) assert len(result) == 1 - assert result.iloc[0]["source_name"] == "Yahoo" - assert result.iloc[0]["instrument_symbol"] == "EUR/USD" - assert result.iloc[0]["timeframe"] == "1d" assert result.iloc[0]["close"] == 1.89 -def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): - source = DataSource( - name="Yahoo", - provider_kind="yfinance_api", - requires_api_key=False, - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), +def test_read_price_bars_returns_empty_dataframe_for_missing_range( + db_path, sample_source, sample_instrument +) -> None: + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, timeframe="1d", - close=1.89, + start=date(2026, 1, 1), + end=date(2026, 1, 1), ) - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - - result = read_price_bars( - db=db, - source=source, - instrument=instrument, - start_date=date(2027, 1, 1), - end_date=date(2027, 1, 31), - ) + result = read_price_bars(db_path, req) assert result.empty is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index b4a97c5..4558c29 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,10 +1,15 @@ from argus.clients.yfinance_client import get_timeseries -from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from datetime import date import pandas as pd import pandas.testing as pdt - +""" def test_get_dataframe(monkeypatch): test_resp = pd.DataFrame( { @@ -21,7 +26,7 @@ def test_get_dataframe(monkeypatch): base_currency="EUR", quote_currency="USD", ) - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", @@ -33,7 +38,7 @@ def fake_yfinance_download(*args, **kwargs): return test_resp monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data) + resp = get_timeseries(req) expected = pd.DataFrame( { "date": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), @@ -41,21 +46,19 @@ def fake_yfinance_download(*args, **kwargs): } ) - assert result is not None - assert result.bars is not None - pdt.assert_frame_equal(result.bars, expected) + assert resp is not None + pdt.assert_frame_equal(resp, expected) def test_get_none(monkeypatch): source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", start=date(2026, 1, 1), end=date(2026, 1, 4), - bars=pd.DataFrame(), ) def fake_yfinance_download(*args, **kwargs): @@ -63,14 +66,14 @@ def fake_yfinance_download(*args, **kwargs): monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data) - assert result is None + resp = get_timeseries(req) + assert resp is None def test_get_empty_frame(monkeypatch): source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", @@ -83,21 +86,20 @@ def fake_yfinance_download(*args, **kwargs): monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data) - assert result is None + resp = get_timeseries(req) + assert resp is None def test_error_raise(monkeypatch): # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", start=date(2026, 1, 1), end=date(2026, 1, 1), - bars=pd.DataFrame(), ) def fake_yfinance_download(): @@ -105,5 +107,6 @@ def fake_yfinance_download(): monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data=market_data) - assert result is None + resp = get_timeseries(req) + assert resp is None +""" From 5854655478dbf2aec97c795e80cd6c0524d22b03 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 15:13:39 +0200 Subject: [PATCH 10/14] test(#70): improve client tests --- src/argus/clients/yfinance_client.py | 50 +++--- src/argus/main.py | 7 +- src/argus/services/market_data_service.py | 43 +---- src/argus/services/trend_analysis_service.py | 32 ++++ tests/test_internal_models.py | 6 +- tests/test_market_data_series.py | 160 ++++++++++++++++--- tests/test_yfinance_client.py | 126 ++++++++------- 7 files changed, 270 insertions(+), 154 deletions(-) create mode 100644 src/argus/services/trend_analysis_service.py diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index db689b1..bf1100a 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,5 +1,6 @@ import yfinance as yf import pandas as pd +import logging from argus.domain.internal_models import ( MarketDataRequest, PRICE_BAR_COLUMNS, @@ -8,28 +9,14 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: - """ - 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 | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. - Returns empty pandas.DataFrame if the request fails and an exception occurs (with an error message). - """ + start = str(request.start) + end = str(request.end) + timeframe = request.timeframe + curr_pair = ( + f"{request.instrument.base_currency}{request.instrument.quote_currency}=X" + ) + try: - start = str(request.start) - end = str(request.end) - timeframe = request.timeframe - curr_pair = ( - f"{request.instrument.base_currency}{request.instrument.quote_currency}=X" - ) raw_resp = yf.download( tickers=curr_pair, start=start, @@ -38,14 +25,21 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: multi_level_index=False, progress=False, ) - if raw_resp is None: - raise ConnectionError("Couldn't fetch data") - if raw_resp.empty: - raise ValueError("No data") - resp = normalize_yfinance_bars(raw_resp) - return resp except Exception: - return pd.DataFrame() + raise ConnectionError("Network error or connection timeout") + + if raw_resp is None: + raise ConnectionError("Yahoo Finance API returned an invalid response") + + if ( + raw_resp.empty + or "Close" not in raw_resp.columns + or raw_resp["Close"].dropna().empty + ): + raise ValueError(f"Quote not found or no data available for symbol") + + resp = normalize_yfinance_bars(raw_resp) + return resp def normalize_yfinance_bars(raw_df: pd.DataFrame) -> pd.DataFrame: diff --git a/src/argus/main.py b/src/argus/main.py index 7130dbf..105de61 100644 --- a/src/argus/main.py +++ b/src/argus/main.py @@ -1,11 +1,14 @@ from argus.gui.app import app +from argus.storage.database import initialize_database -def main() -> None: +def main(db) -> None: """ The main function that starts the application. """ + initialize_database(db) app() -main() +db = "" +main(db) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 61b4144..3833e71 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,44 +1,6 @@ -import pandas as pd -from datetime import date -from argus.domain.internal_models import ( - DataSource, - Instrument, - MarketDataRequest, - MarketDataResponse, -) +from argus.domain.internal_models import MarketDataRequest, MarketDataResponse from argus.clients.yfinance_client import get_timeseries -from argus.storage.database import read_price_bars -from argus.analytics.metrics.trend_metrics import ( - add_rolling_average, - add_daily_percentage_change, - get_min_max_rates, -) -from argus.analytics.charts.trend_chart import create_trendchart - - -def prepare_trend_analysis(df: pd.DataFrame): - """ - Prepare time-series data for trend analysis. - - 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. - - Args: - df (pd.Dataframe): A timeserie with market data - - 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 = add_daily_percentage_change(df) - df = add_rolling_average(df) - min_max_rates = get_min_max_rates(df) - fig = create_trendchart(df, min_max_rates) - return fig +from argus.storage.database import read_price_bars, insert_price_bar def get_market_data( @@ -73,4 +35,5 @@ def get_market_data( api_response = MarketDataResponse( source=request.source, instrument=request.instrument, bars=bars ) + insert_price_bar(db, api_response) return api_response diff --git a/src/argus/services/trend_analysis_service.py b/src/argus/services/trend_analysis_service.py new file mode 100644 index 0000000..64d9122 --- /dev/null +++ b/src/argus/services/trend_analysis_service.py @@ -0,0 +1,32 @@ +import pandas as pd +from argus.analytics.metrics.trend_metrics import ( + add_rolling_average, + add_daily_percentage_change, + get_min_max_rates, +) +from argus.analytics.charts.trend_chart import create_trendchart + + +def prepare_trend_analysis(df: pd.DataFrame): + """ + Prepare time-series data for trend analysis. + + 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. + + Args: + df (pd.Dataframe): A timeserie with market data + + 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 = add_daily_percentage_change(df) + df = add_rolling_average(df) + min_max_rates = get_min_max_rates(df) + fig = create_trendchart(df, min_max_rates) + return fig diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py index 2ef6277..009e941 100644 --- a/tests/test_internal_models.py +++ b/tests/test_internal_models.py @@ -54,7 +54,9 @@ def test_market_data_response_raises_error_if_not_a_dataframe( with pytest.raises(TypeError) as exc_info: MarketDataResponse( - source=valid_source, instrument=valid_instrument, bars=invalid_input - ) # type: ignore + source=valid_source, + instrument=valid_instrument, + bars=invalid_input, # type: ignore + ) assert "must be a pandas DataFrame" in str(exc_info.value) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index ee800e9..8826fa5 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -1,33 +1,149 @@ +import pytest import pandas as pd from datetime import date +from unittest.mock import Mock +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from argus.services.market_data_service import get_market_data -from argus.storage.database import initialize_database -from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest -def test_get_a_full_timeseries(tmp_path): - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +@pytest.fixture +def sample_source(): + return DataSource(name="Yahoo", provider_kind="yfinance_api") + + +@pytest.fixture +def sample_instrument(): + return Instrument(symbol="AAPL", name="Apple Inc.", asset_class="stock") + + +@pytest.fixture +def sample_response(sample_source, sample_instrument): + test_bar = { + "timestamp": date(2026, 1, 1), + "open": None, + "high": None, + "low": None, + "close": 1.89, + "adjusted_close": None, + "volume": None, + } + return MarketDataResponse( + source=sample_source, + instrument=sample_instrument, + bars=pd.DataFrame(test_bar, index=[0]), + ) + + +def test_get_market_data_storage_hit( + monkeypatch, sample_source, sample_instrument, sample_response +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: sample_response.bars, + ) + + mock_get_timeseries = Mock() + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", mock_get_timeseries + ) + + res = get_market_data("mock_db_path", req) + + assert res is not None + assert not res.bars.empty + mock_get_timeseries.assert_not_called() + + +def test_get_market_data_storage_miss( + monkeypatch, sample_source, sample_instrument, sample_response +): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2024, 1, 1), - end=date(2024, 1, 4), + start=date(2026, 1, 1), + end=date(2026, 1, 1), ) - db = tmp_path / "test.duckdb" - initialize_database(db) - result = get_market_data(db, req) + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: pd.DataFrame(), + ) + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", + lambda r: sample_response.bars, + ) + + mock_insert = Mock() + monkeypatch.setattr( + "argus.services.market_data_service.insert_price_bar", mock_insert + ) - assert result is not None - """ - 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) + res = get_market_data("mock_db_path", req) + + assert res is not None + mock_insert.assert_called_once() + + +def test_get_market_data_api_returns_empty_safely( + monkeypatch, sample_source, sample_instrument +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: pd.DataFrame(), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", lambda r: pd.DataFrame() + ) + + res = get_market_data("mock_db_path", req) + assert res is None + + +def test_get_market_data_raises_on_broken_code( + monkeypatch, sample_source, sample_instrument +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: pd.DataFrame(), + ) + + def broken_client_code(request): + raise RuntimeError("Schwerwiegender Systemfehler im Client-Code!") + + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", broken_client_code + ) - pdt.assert_frame_equal(result_df, expect_df) - assert result_dict == expect_dict - """ + with pytest.raises(RuntimeError): + get_market_data("mock_db_path", req) diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index 4558c29..419d999 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,34 +1,45 @@ from argus.clients.yfinance_client import get_timeseries -from argus.domain.internal_models import ( - DataSource, - Instrument, - MarketDataRequest, - MarketDataResponse, -) +from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest from datetime import date +import pytest import pandas as pd import pandas.testing as pdt -""" -def test_get_dataframe(monkeypatch): + +@pytest.fixture +def sample_source(): + return DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + +@pytest.fixture +def sample_instrument(): + return Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + +def test_get_dataframe(monkeypatch, sample_source, sample_instrument): test_resp = pd.DataFrame( { + "Open": [None, None, None], + "High": [None, None, None], + "Low": [None, None, None], "Close": [1.105583, 1.103875, 1.094176], + "Adj Close": [None, None, None], + "Volume": [None, None, None], }, index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), ) test_resp.index.name = "Date" - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument( - symbol="EUR - USD", - name="EUR/USD", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", start=date(2024, 1, 1), end=date(2024, 1, 4), @@ -41,8 +52,13 @@ def fake_yfinance_download(*args, **kwargs): resp = get_timeseries(req) expected = pd.DataFrame( { - "date": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), - "rate": [1.105583, 1.103875, 1.094176], + "timestamp": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), + "open": [None, None, None], + "high": [None, None, None], + "low": [None, None, None], + "close": [1.105583, 1.103875, 1.094176], + "adjusted_close": [None, None, None], + "volume": [None, None, None], } ) @@ -50,63 +66,53 @@ def fake_yfinance_download(*args, **kwargs): pdt.assert_frame_equal(resp, expected) -def test_get_none(monkeypatch): - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +def test_client_network_error(monkeypatch, sample_source, sample_instrument): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), + start=date(2024, 1, 1), + end=date(2024, 1, 4), ) - def fake_yfinance_download(*args, **kwargs): - return None + def mock_crash(*args, **kwargs): + raise Exception() - monkeypatch.setattr("yfinance.download", fake_yfinance_download) + monkeypatch.setattr("yfinance.download", mock_crash) - resp = get_timeseries(req) - assert resp is None + with pytest.raises(ConnectionError, match="Network error or connection timeout"): + get_timeseries(req) -def test_get_empty_frame(monkeypatch): - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +def test_client_invalid_response(monkeypatch, sample_source, sample_instrument): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), + start=date(2024, 1, 1), + end=date(2024, 1, 4), ) - def fake_yfinance_download(*args, **kwargs): - return pd.DataFrame() + monkeypatch.setattr("yfinance.download", lambda *args, **kwargs: None) - monkeypatch.setattr("yfinance.download", fake_yfinance_download) - - resp = get_timeseries(req) - assert resp is None + with pytest.raises( + ConnectionError, match="Yahoo Finance API returned an invalid response" + ): + get_timeseries(req) -def test_error_raise(monkeypatch): - # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +def test_client_quote_not_found(monkeypatch, sample_source, sample_instrument): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), + start=date(2024, 1, 1), + end=date(2024, 1, 4), ) - def fake_yfinance_download(): - raise Exception("fake yfinance error") + monkeypatch.setattr("yfinance.download", lambda *args, **kwargs: pd.DataFrame()) - monkeypatch.setattr("yfinance.download", fake_yfinance_download) - - resp = get_timeseries(req) - assert resp is None -""" + with pytest.raises( + ValueError, match="Quote not found or no data available for symbol" + ): + get_timeseries(req) From 0c02bfbe26551723f57bb0a019b9c14f64ff651f Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 16:39:39 +0200 Subject: [PATCH 11/14] refactor(#70): trend service report err --- src/argus/domain/internal_models.py | 17 ++-- src/argus/services/market_data_service.py | 16 +++- src/argus/services/trend_analysis_service.py | 27 +++--- tests/test_internal_models.py | 8 +- tests/test_market_data_series.py | 41 ++++----- tests/test_trend_analysis_service.py | 94 ++++++++++++++++++++ 6 files changed, 152 insertions(+), 51 deletions(-) create mode 100644 tests/test_trend_analysis_service.py diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index f4dcf27..0d64796 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -56,15 +56,16 @@ class MarketDataResponse: source: DataSource instrument: Instrument bars: pd.DataFrame + message: str = "" def __post_init__(self) -> None: if not isinstance(self.bars, pd.DataFrame): raise TypeError("bars must be a pandas DataFrame") - - missing_cols = [ - col for col in PRICE_BAR_COLUMNS if col not in self.bars.columns - ] - if missing_cols: - raise ValueError( - f"Missing required columns in bars DataFrame: {missing_cols}" - ) + if self.message == "": + missing_cols = [ + col for col in PRICE_BAR_COLUMNS if col not in self.bars.columns + ] + if missing_cols: + raise ValueError( + f"Missing required columns in bars DataFrame: {missing_cols}" + ) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 3833e71..9328141 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,12 +1,13 @@ from argus.domain.internal_models import MarketDataRequest, MarketDataResponse from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars, insert_price_bar +import pandas as pd def get_market_data( db: str, request: MarketDataRequest, -) -> MarketDataResponse | None: +) -> MarketDataResponse: """ Get a time series either from local stroage or client with first-storage-workflow @@ -26,14 +27,21 @@ def get_market_data( bars = read_price_bars(db, request) if not (bars.empty): db_response = MarketDataResponse( - source=request.source, instrument=request.instrument, bars=bars + source=request.source, instrument=request.instrument, bars=bars, message="" ) return db_response - bars = get_timeseries(request) - if not (bars.empty): + try: + bars = get_timeseries(request) api_response = MarketDataResponse( source=request.source, instrument=request.instrument, bars=bars ) insert_price_bar(db, api_response) return api_response + except (ConnectionError, ValueError) as e: + return MarketDataResponse( + source=request.source, + instrument=request.instrument, + bars=pd.DataFrame(), + message=str(e), + ) diff --git a/src/argus/services/trend_analysis_service.py b/src/argus/services/trend_analysis_service.py index 64d9122..a8428ae 100644 --- a/src/argus/services/trend_analysis_service.py +++ b/src/argus/services/trend_analysis_service.py @@ -1,30 +1,35 @@ -import pandas as pd from argus.analytics.metrics.trend_metrics import ( add_rolling_average, add_daily_percentage_change, get_min_max_rates, ) +from matplotlib.figure import Figure from argus.analytics.charts.trend_chart import create_trendchart +from argus.services.market_data_service import get_market_data +from argus.domain.internal_models import MarketDataRequest -def prepare_trend_analysis(df: pd.DataFrame): +def prepare_trend_analysis(db: str, request: MarketDataRequest) -> Figure | str: """ - Prepare time-series data for trend analysis. + Prepare time-series data and generate a trend analysis chart. - 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. + Enriches the historical exchange-rate DataFrame with daily percentage changes + and a rolling average, calculates the minimum and maximum rates, and uses + the result to build a trend visualization chart. Args: - df (pd.Dataframe): A timeserie with market data + df (pd.DataFrame): A DataFrame containing market data time-series. 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. + plotly.graph_objects.Figure: A figure object representing the + generated trend chart. """ + response = get_market_data(db, request) + if response.message: + return response.message + + df = response.bars.copy() df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py index 009e941..0fc7f0c 100644 --- a/tests/test_internal_models.py +++ b/tests/test_internal_models.py @@ -28,7 +28,9 @@ def test_market_data_response_accepts_valid_dataframe( } df = pd.DataFrame(valid_bar) - resp = MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + resp = MarketDataResponse( + source=valid_source, instrument=valid_instrument, bars=df, message="" + ) assert resp.bars.equals(df) @@ -42,7 +44,9 @@ def test_market_data_response_raises_error_on_missing_columns( df = pd.DataFrame(incomplete_bar) with pytest.raises(ValueError) as exc_info: - MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + MarketDataResponse( + source=valid_source, instrument=valid_instrument, bars=df, message="" + ) assert "Missing required columns" in str(exc_info.value) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index 8826fa5..b7832a2 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -98,7 +98,7 @@ def test_get_market_data_storage_miss( mock_insert.assert_called_once() -def test_get_market_data_api_returns_empty_safely( +def test_get_market_data_handles_client_exceptions( monkeypatch, sample_source, sample_instrument ): req = MarketDataRequest( @@ -114,36 +114,25 @@ def test_get_market_data_api_returns_empty_safely( lambda db, r: pd.DataFrame(), ) - monkeypatch.setattr( - "argus.services.market_data_service.get_timeseries", lambda r: pd.DataFrame() - ) - - res = get_market_data("mock_db_path", req) - assert res is None - - -def test_get_market_data_raises_on_broken_code( - monkeypatch, sample_source, sample_instrument -): - req = MarketDataRequest( - source=sample_source, - instrument=sample_instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), - ) + def mock_value_error(request): + raise ValueError("Quote not found or no data available for symbol") monkeypatch.setattr( - "argus.services.market_data_service.read_price_bars", - lambda db, r: pd.DataFrame(), + "argus.services.market_data_service.get_timeseries", mock_value_error ) - def broken_client_code(request): - raise RuntimeError("Schwerwiegender Systemfehler im Client-Code!") + res_val = get_market_data("mock_db_path", req) + assert res_val.bars.empty + assert res_val.message == "Quote not found or no data available for symbol" + + # Fall 2: ConnectionError testen (z.B. Internet weg) + def mock_connection_error(request): + raise ConnectionError("Network error or connection timeout") monkeypatch.setattr( - "argus.services.market_data_service.get_timeseries", broken_client_code + "argus.services.market_data_service.get_timeseries", mock_connection_error ) - with pytest.raises(RuntimeError): - get_market_data("mock_db_path", req) + res_conn = get_market_data("mock_db_path", req) + assert res_conn.bars.empty + assert res_conn.message == "Network error or connection timeout" diff --git a/tests/test_trend_analysis_service.py b/tests/test_trend_analysis_service.py new file mode 100644 index 0000000..ab6c972 --- /dev/null +++ b/tests/test_trend_analysis_service.py @@ -0,0 +1,94 @@ +import pytest +import pandas as pd +from datetime import date +from matplotlib.figure import Figure +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) +from argus.services.trend_analysis_service import prepare_trend_analysis +from argus.services.market_data_service import get_market_data +from argus.storage.database import initialize_database + +""" +@pytest.fixture +def sample_source(): + return DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + +@pytest.fixture +def sample_instrument(): + return Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + +@pytest.fixture +def sample_response(sample_source, sample_instrument): + test_bar = { + "timestamp": [date(2024, 1, 1), date(2024, 1, 2), date(2024, 1, 3)], + "open": [None, None, None], + "high": [None, None, None], + "low": [None, None, None], + "close": [1.10, 1.12, 1.11], + "adjusted_close": [None, None, None], + "volume": [None, None, None], + } + return MarketDataResponse( + source=sample_source, instrument=sample_instrument, bars=pd.DataFrame(test_bar) + ) + + +def test_prepare_trend_analysis_success( + sample_source, sample_instrument, sample_response, monkeypatch +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + ) + + monkeypatch.setattr( + "argus.services.trend_analysis_service.get_market_data", + lambda db, r: sample_response, + ) + + res = prepare_trend_analysis("mock_db_path", req) + assert isinstance(res, Figure) + + +def test_prepare_trend_analysis_failure(sample_source, sample_instrument, monkeypatch): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + ) + + error_response = MarketDataResponse( + source=sample_source, + instrument=sample_instrument, + bars=pd.DataFrame(), + message="Quote not found or no data available for symbol", + ) + + monkeypatch.setattr( + "argus.services.trend_analysis_service.get_market_data", + lambda db, r: error_response, + ) + + res = prepare_trend_analysis("mock_db_path", req) + assert isinstance(res, str) + assert res == "Quote not found or no data available for symbol" +""" From ca80eb4aef7e5f2bcb12e2da095da2489f769cdd Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:03:41 +0200 Subject: [PATCH 12/14] chore(#70): ruff formatting --- docs/databases-and-storage.md | 193 ++++++++++++ docs/research-databases-and-storage.md | 388 ------------------------- tests/test_trend_analysis_service.py | 3 +- 3 files changed, 195 insertions(+), 389 deletions(-) create mode 100644 docs/databases-and-storage.md delete mode 100644 docs/research-databases-and-storage.md diff --git a/docs/databases-and-storage.md b/docs/databases-and-storage.md new file mode 100644 index 0000000..a4356be --- /dev/null +++ b/docs/databases-and-storage.md @@ -0,0 +1,193 @@ +# ARGUS Storage Research + +## Intro + +It was previously a research what ARGUS should store and which database/storage approach fits the project. +Now it's a simple documentation about how the storage and domain logic looks like and should develop in the next sprints. + +ARGUS is moving from live API requests and in-memory analytics toward real data workflows. +The first storage decision should support local market analytics, SQL practice and future dashboard features without adding unnecessary infrastructure too early. + +--- + +## First Storage Approach + +DuckDB should be the first storage technology for ARGUS. + +Reason: + +* ARGUS currently needs local analytical storage, not a full server database +* DuckDB fits historical time-series analysis well +* it supports SQL-based analytics without requiring a database server +* it works well with Python and notebook-based exploration +* it keeps the first storage implementation manageable +* it can later be replaced or complemented by PostgreSQL if ARGUS becomes more product-like + +The first storage implementation should focus on: + +* historical market data +* cleaned OHLCV-ready price data +* source information +* instruments that ARGUS can analyze + +PostgreSQL and SQLGate become more relevant later. + +For the first DuckDB phase, the goal is to build a clean local analytics workflow. + +--- + +## Developer Interaction Workflow + +ARGUS should use a practical developer workflow for DuckDB. + +The goal is to make the database easy to inspect, explore and validate before logic is moved into production code. + +### Notebook Exploration + +Notebooks should be the main exploration layer. + +They are useful for: + +* opening the DuckDB database +* testing SQL queries +* validating imported data +* comparing SQL results with pandas calculations +* exploring metric logic +* documenting research assumptions + +This workflow is especially useful before turning queries into reusable project code. + +Notebook exploration should be preferred over a GUI database tool in the first phase. + +### DuckDB CLI + +The DuckDB CLI should be used for quick database inspection. + +It is useful for: + +* checking available tables +* running small SQL queries +* validating stored records +* debugging the local database file + +The CLI is not the main research environment, but it is useful as a fast inspection tool. + +--- + +## First Data Model Direction + +The first data model should support FX data now and broader market data later. + +ARGUS should not use a narrow `date | value` table as the main market-data model. + +That would work for simple exchange rates, but it would become limiting once ARGUS adds stocks, ETFs, indices or broader market APIs. + +The first model focuses on two primary metadata entities, supported by standard operational schemas and communication interfaces: + +```text +DataSource +Instrument +``` + +### DataSource + +Stores where data came from. + +Current operational fields: + +```text +name +provider_kind +requires_api_key (defaults to False) +``` + +### Instrument + +Stores what ARGUS can analyze. +Current operational fields: + +```text +symbol +name +asset_class +currency (optional) +exchange (optional) +base_currency (optional) +quote_currency (optional) +``` + +### Internal Operational Models + +To support decoupled runtime communication and execution pipelines, ARGUS utilizes specialized Python dataclasses. These structures orchestrate active data flows between data-fetching clients and services. + +**MarketDataRequest** +Encapsulates parameters passed to fetching engines and downstream query handlers. + +* `source`: The targeted `DataSource` instance +* `instrument`: The targeted `Instrument` instance +* `timeframe`: Granularity of requested bars (like `"1d"`, `"1h"`). +* `start`: Temporal lower bound (`datetime.date`) +* `end`: Temporal upper bound (`datetime.date`) + +**MarketDataResponse** +Encapsulates runtime data payloads, structural enforcement, and pipeline feedback. + +* `source`: The originating `DataSource` instance +* `instrument`: The corresponding `Instrument` instance +* `bars`: A `pandas.DataFrame` holding the time-series payload +* `message`: Pipeline feedback or error context (defaults to `""`) + +### The Price Bar Schema (Data Uniformity) + +Crucially, **Price Bars are not treated as a standalone domain metadata model or database entity.** Instead, the Price Bar specification acts purely as an internal **uniformity schema** to normalize incoming time-series data across disparate third-party APIs. + +All responses must be mapped into this standard schema layout within the `bars` DataFrame. + +#### Provider Mapping Layer + +Third-party structures are converted to this unified standard upon ingest. For example, `yfinance` fields map to the uniform schema via `YFINANCE_PRICE_BAR_MAPPING`: + +* `Date` $\rightarrow$ `timestamp` +* `Open` $\rightarrow$ `open` +* `High` $\rightarrow$ `high` +* `Low` $\rightarrow$ `low` +* `Close` $\rightarrow$ `close` +* `Adj Close` $\rightarrow$ `adjusted_close` +* `Volume` $\rightarrow$ `volume` + +--- + +## Future Direction + +Later sprints can expand the storage layer step by step. + +Possible later additions: + +| Future Area | Possible Additions | +| --- | --- | +| Better source mapping | source-specific symbols, provider metadata | +| Watchlists | user-selected instruments | +| Reports | generated report metadata and history | +| Macro data | FRED indicators and observations | +| Paper trading | simulated orders, positions and portfolio history | +| Server architecture | PostgreSQL | +| SQL tooling | SQLGate with PostgreSQL | +| Cloud direction | managed PostgreSQL or cloud storage | + +SQLGate should be kept for a later PostgreSQL phase. + +It becomes useful when ARGUS moves toward: + +* server-based storage +* stronger database management +* richer metadata +* more stable application state +* user-facing features +* report history +* cloud-ready architecture + +Additional metadata such as documentation links, terms links or provider governance fields can also become useful later. + +For the first DuckDB phase, these details should stay in research documentation instead of the database schema. + +--- diff --git a/docs/research-databases-and-storage.md b/docs/research-databases-and-storage.md deleted file mode 100644 index 484cd61..0000000 --- a/docs/research-databases-and-storage.md +++ /dev/null @@ -1,388 +0,0 @@ -# ARGUS Storage Research - -## Goal - -Research what ARGUS should store and which database/storage approach fits the project. - -ARGUS is moving from live API requests and in-memory analytics toward real data workflows. -The first storage decision should support local market analytics, SQL practice and future dashboard features without adding unnecessary infrastructure too early. - ---- - -## Storage Use Cases - -ARGUS should eventually store different kinds of data, but not all of them need to be implemented at once. - -Relevant storage use cases are: - -* historical exchange rates -* cleaned historical market data -* source information -* instruments that ARGUS can analyze -* later watchlists -* later generated reports -* later macroeconomic data -* later paper-trading history - -The first implementation should focus on historical market data and the basic entities needed to query it. - ---- - -## Storage Candidates - -ARGUS should compare storage options based on the current project phase. - -The project currently needs local analytical storage, not a full server or cloud database. - -### DuckDB - -DuckDB is a local analytical database. - -It is a strong fit for ARGUS because it supports SQL-based analytics without requiring a database server. - -Useful for: - -* historical market data -* local time-series analysis -* SQL practice -* Python-based analytics -* notebook-based exploration -* dashboard data preparation - -Limitations: - -* not a server database -* less suitable for multi-user product features later - ---- - -### SQLite - -SQLite is a simple local database. - -It is strong for small app storage and simple persistence. - -Useful for: - -* settings -* small app-state data -* simple local tables -* later watchlists -* lightweight metadata - -Limitations: - -* less analytics-focused than DuckDB -* not ideal as the main storage layer for historical market data -* better for app-state than analytical time-series queries - ---- - -### PostgreSQL - -PostgreSQL is a server-based relational database. - -It is a strong long-term option when ARGUS becomes more product-like. - -Useful for: - -* server-based storage -* user-facing features -* report history -* watchlists -* paper-trading history -* richer metadata -* cloud-ready architecture -* SQLGate usage later - -Limitations: - -* more setup than needed right now -* requires server or Docker setup -* adds infrastructure complexity too early - -Fit for ARGUS: - -PostgreSQL should be introduced later when ARGUS moves toward a server-based or cloud-ready architecture. - ---- - -## Local, Server and Cloud Options - -| Option | Meaning | Fit Now | Fit Later | -| --- | --- | ---: | ---: | -| Local storage | Database runs locally inside or next to the project | High | High | -| Server database | Database runs as a separate service, for example PostgreSQL | Medium | High | -| Cloud storage/database | Managed storage or database in the cloud | Low | High | - -ARGUS should start with local storage. - -Reason: - -* simpler setup -* easier learning curve -* good fit for a Python analytics project -* no cloud or server infrastructure required yet -* enough for historical data, metrics and dashboard development - -Server and cloud storage should come later when ARGUS has stronger product features such as reports, user state, paper-trading history or deployment needs. - ---- - -## Recommended First Storage Approach - -DuckDB should be the first storage technology for ARGUS. - -Reason: - -* ARGUS currently needs local analytical storage, not a full server database -* DuckDB fits historical time-series analysis well -* it supports SQL-based analytics without requiring a database server -* it works well with Python and notebook-based exploration -* it keeps the first storage implementation manageable -* it can later be replaced or complemented by PostgreSQL if ARGUS becomes more product-like - -The first storage implementation should focus on: - -* historical market data -* cleaned OHLCV-ready price data -* source information -* instruments that ARGUS can analyze - -PostgreSQL and SQLGate become more relevant later. - -For the first DuckDB phase, the goal is to build a clean local analytics workflow. - ---- - -## Developer Interaction Workflow - -ARGUS should use a practical developer workflow for DuckDB. - -The goal is to make the database easy to inspect, explore and validate before logic is moved into production code. - -### Notebook Exploration - -Notebooks should be the main exploration layer. - -They are useful for: - -* opening the DuckDB database -* testing SQL queries -* validating imported data -* comparing SQL results with pandas calculations -* exploring metric logic -* documenting research assumptions - -This workflow is especially useful before turning queries into reusable project code. - -Notebook exploration should be preferred over a GUI database tool in the first phase. - -### DuckDB CLI - -The DuckDB CLI should be used for quick database inspection. - -It is useful for: - -* checking available tables -* running small SQL queries -* validating stored records -* debugging the local database file - -The CLI is not the main research environment, but it is useful as a fast inspection tool. - -A GUI tool such as DBeaver can be tested if needed, but it should stay optional. - ---- - -## First Data Model Direction - -The first data model should support FX data now and broader market data later. - -ARGUS should not use a narrow `date | value` table as the main market-data model. - -That would work for simple exchange rates, but it would become limiting once ARGUS adds stocks, ETFs, indices or broader market APIs. - -The first model should focus on three related entities: - -```text -data_sources -instruments -price_bars -``` - -> [!NOTE] -> The fields below describe the future database-oriented structure. -> Technical fields such as `id`, `instrument_id`, `source_id`, `created_at` and `updated_at` are expected to appear in the database layer. -> Internal Python models may reference related objects directly, for example `source` and `instrument`, before database IDs exist. - -### data_sources - -Stores where data came from. - -Recommended first database fields: - -```text -id -name -provider_kind -requires_api_key -created_at -updated_at -``` - -Example internal/source records: - -| name | provider_kind | requires_api_key | -| ---------------- | ------------- | ---------------: | -| ExchangeRate API | fx_rates | true | -| yfinance | market_prices | false | -| FRED | macro_data | true | - -### instruments - -Stores what ARGUS can analyze. - -Examples: - -* EUR/USD -* AAPL -* SPY -* S&P 500 -* BTC-USD - -Recommended first database fields: - -```text -id -symbol -name -asset_class -currency -exchange -base_currency -quote_currency -created_at -updated_at -``` - -Example instrument records: - -| symbol | name | asset_class | currency | exchange | base_currency | quote_currency | -| ------- | ---------------- | ----------- | -------- | --------- | ------------- | -------------- | -| EUR/USD | Euro / US Dollar | fx | null | null | EUR | USD | -| AAPL | Apple Inc. | stock | USD | NASDAQ | null | null | -| SPY | SPDR S&P 500 ETF | etf | USD | NYSE Arca | null | null | - -### price_bars - -Stores historical market data in an OHLCV-ready structure. - -Recommended first database fields: - -```text -id -instrument_id -source_id -timestamp -timeframe -open -high -low -close -adjusted_close -volume -created_at -updated_at -``` - -FX-style exchange-rate data can be represented as a price bar by storing the rate in `close`. - -The other OHLCV fields can stay empty until ARGUS uses data sources that provide them. - -Example price bar records shown with joined source and instrument information for readability: - -| source | instrument | timestamp | timeframe | open | high | low | close | adjusted_close | volume | -| -------- | ---------- | ---------- | --------- | -----: | -----: | -----: | -----: | -------------: | -------: | -| yfinance | EUR/USD | 2024-01-02 | 1d | null | null | null | 1.095 | null | null | -| yfinance | AAPL | 2024-01-02 | 1d | 187.15 | 188.44 | 183.89 | 185.64 | 184.25 | 50200000 | - ---- - -## Recommended First Implementation Step - -The first storage implementation should not be tied to one specific data provider. - -ARGUS currently works with an existing ExchangeRate API client and evaluates broader market data through yfinance. -Frankfurter may be added later as a stronger FX-oriented historical data source. - -The storage layer should therefore focus on a normalized internal market-data format instead of depending on one API response structure. - -Recommended first step: - -```text -active data client -→ normalize into instruments and price_bars -→ store in DuckDB -→ query with SQL -→ use results for analytics and charts -``` - ---- - -## Future Direction - -Later sprints can expand the storage layer step by step. - -Possible later additions: - -| Future Area | Possible Additions | -| --- | --- | -| Better source mapping | source-specific symbols, provider metadata | -| Watchlists | user-selected instruments | -| Reports | generated report metadata and history | -| Macro data | FRED indicators and observations | -| Paper trading | simulated orders, positions and portfolio history | -| Server architecture | PostgreSQL | -| SQL tooling | SQLGate with PostgreSQL | -| Cloud direction | managed PostgreSQL or cloud storage | - -SQLGate should be kept for a later PostgreSQL phase. - -It becomes useful when ARGUS moves toward: - -* server-based storage -* stronger database management -* richer metadata -* more stable application state -* user-facing features -* report history -* cloud-ready architecture - -Additional metadata such as documentation links, terms links or provider governance fields can also become useful later. - -For the first DuckDB phase, these details should stay in research documentation instead of the database schema. - ---- - -## Final Recommendation - -ARGUS should start with DuckDB as the first local analytics storage layer. - -DuckDB fits the current phase best because ARGUS needs local analytical SQL workflows, not a full server database yet. - -The first implementation should store historical market data in an OHLCV-ready structure. - -The recommended first data model is: - -```text -data_sources -instruments -price_bars -``` - -Notebook exploration should be the main developer workflow before SQL logic is moved into application code. - -The DuckDB CLI can be used for quick inspection. - -PostgreSQL and SQLGate should be introduced later when ARGUS moves toward a more product-like or cloud-based architecture. diff --git a/tests/test_trend_analysis_service.py b/tests/test_trend_analysis_service.py index ab6c972..84f8ccd 100644 --- a/tests/test_trend_analysis_service.py +++ b/tests/test_trend_analysis_service.py @@ -1,3 +1,4 @@ +""" import pytest import pandas as pd from datetime import date @@ -12,7 +13,7 @@ from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database -""" + @pytest.fixture def sample_source(): return DataSource( From b849c342e3bf5a6dba1dae393bf196c55ec9847c Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:06:15 +0200 Subject: [PATCH 13/14] chore(#70): ruff formatting again --- src/argus/clients/yfinance_client.py | 3 +-- src/argus/storage/database.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index bf1100a..468b87b 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,6 +1,5 @@ import yfinance as yf import pandas as pd -import logging from argus.domain.internal_models import ( MarketDataRequest, PRICE_BAR_COLUMNS, @@ -36,7 +35,7 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: or "Close" not in raw_resp.columns or raw_resp["Close"].dropna().empty ): - raise ValueError(f"Quote not found or no data available for symbol") + raise ValueError("Quote not found or no data available for symbol") resp = normalize_yfinance_bars(raw_resp) return resp diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index c14d05f..67efe8d 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -4,8 +4,7 @@ DataSource, Instrument, MarketDataRequest, - MarketDataResponse, - PRICE_BAR_COLUMNS, + MarketDataResponse ) From 469c45833d3ac2ebb508ab0e3dc3aac769ec18ca Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:07:13 +0200 Subject: [PATCH 14/14] chore(#70): ruff formatting again --- src/argus/storage/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index 67efe8d..aa00c78 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -4,7 +4,7 @@ DataSource, Instrument, MarketDataRequest, - MarketDataResponse + MarketDataResponse, )