From 4bc36d09d2e25434b0d5ef2ad05ca453ac4c1951 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:18:54 +0900 Subject: [PATCH] fix: add copy_rates_from_pos_as_df fallback for trading client rate fetch (#88) * fix: add copy_rates_from_pos_as_df fallback in fetch_latest_closed_rates_for_trading_client Mt5DataClient (returned by create_trading_client) exposes copy_rates_from_pos_as_df, not fetch_latest_rates_as_df. Adds a fallback path that resolves the granularity string to an integer timeframe via parse_timeframe, fetches count+1 bars from start_pos=0, and applies the same drop_forming_rate_bar + tail(count) logic so callers that use the client returned by create_trading_client no longer need a compatibility shim. Co-Authored-By: Claude Sonnet 4.6 * Bump version to v1.0.3 * fix: hoist parse_timeframe before dispatch and add invalid-granularity test Hoisting parse_timeframe(granularity) before the fetch_latest_rates_as_df / copy_rates_from_pos_as_df dispatch ensures invalid granularity strings fail consistently on both paths with a clear ValueError, rather than only when the fallback branch is taken. Adds test_copy_rates_from_pos_fallback_raises_on_invalid_granularity to pin the early-validation contract and confirm the underlying method is never called. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: agent Co-authored-by: Claude Sonnet 4.6 --- mt5cli/trading.py | 12 +++++- pyproject.toml | 2 +- tests/test_trading.py | 88 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/mt5cli/trading.py b/mt5cli/trading.py index ae5cbdf..114854f 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -15,6 +15,7 @@ from .exceptions import Mt5OperationError from .history import drop_forming_rate_bar from .sdk import build_config from .utils import coerce_login as _coerce_login +from .utils import parse_timeframe if TYPE_CHECKING: from collections.abc import Iterator, Mapping, Sequence @@ -1624,11 +1625,18 @@ def fetch_latest_closed_rates_for_trading_client( if count <= 0: msg = "count must be positive." raise ValueError(msg) + timeframe = parse_timeframe(granularity) fetch_method = getattr(client, "fetch_latest_rates_as_df", None) - if not callable(fetch_method): + copy_method = getattr(client, "copy_rates_from_pos_as_df", None) + if callable(fetch_method): + fetched = fetch_method(symbol, granularity, count + 1) + elif callable(copy_method): + fetched = copy_method( + symbol=symbol, timeframe=timeframe, start_pos=0, count=count + 1 + ) + else: msg = "MT5 trading client cannot fetch rate data." raise Mt5OperationError(msg) - fetched = fetch_method(symbol, granularity, count + 1) if not isinstance(fetched, pd.DataFrame): msg = ( f"Malformed rate data for {symbol!r} at granularity {granularity!r}: " diff --git a/pyproject.toml b/pyproject.toml index 2532463..9db47a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "1.0.2" +version = "1.0.3" description = "Generic MT5 data and execution infrastructure for Python applications" authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] diff --git a/tests/test_trading.py b/tests/test_trading.py index c9b89e3..07237a9 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -3198,6 +3198,71 @@ class TestFetchLatestClosedRatesForTradingClient: count=2, ) + def test_copy_rates_from_pos_fallback_drops_forming_bar(self) -> None: + """Regression: client with only copy_rates_from_pos_as_df works end-to-end.""" + client = MagicMock(spec=["copy_rates_from_pos_as_df"]) + client.copy_rates_from_pos_as_df.return_value = pd.DataFrame( + { + "time": [1700000000, 1700000060, 1700000120], + "close": [1.0, 1.1, 1.2], + }, + ) + + result = fetch_latest_closed_rates_for_trading_client( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + client.copy_rates_from_pos_as_df.assert_called_once_with( + symbol="EURUSD", timeframe=1, start_pos=0, count=3 + ) + assert list(result["close"]) == [1.0, 1.1] + assert list(result["time"]) == [1700000000, 1700000060] + + def test_copy_rates_from_pos_fallback_resolves_granularity(self) -> None: + """Fallback path resolves granularity string to integer timeframe.""" + client = MagicMock(spec=["copy_rates_from_pos_as_df"]) + client.copy_rates_from_pos_as_df.return_value = pd.DataFrame( + {"time": [1, 2, 3, 4], "close": [1.0, 1.1, 1.2, 1.3]}, + ) + + fetch_latest_closed_rates_for_trading_client( + client, symbol="USDJPY", granularity="H1", count=3 + ) + + call_kwargs = client.copy_rates_from_pos_as_df.call_args.kwargs + assert call_kwargs["symbol"] == "USDJPY" + assert call_kwargs["timeframe"] == 16385 + assert call_kwargs["start_pos"] == 0 + assert call_kwargs["count"] == 4 + + def test_copy_rates_from_pos_fallback_returns_count_closed_rows(self) -> None: + """Fallback path trims to exactly count closed rows after forming-bar drop.""" + client = MagicMock(spec=["copy_rates_from_pos_as_df"]) + client.copy_rates_from_pos_as_df.return_value = pd.DataFrame( + {"time": list(range(6)), "close": [float(i) for i in range(6)]}, + ) + + result = fetch_latest_closed_rates_for_trading_client( + client, symbol="EURUSD", granularity="M1", count=4 + ) + + assert len(result) == 4 + assert list(result["close"]) == [1.0, 2.0, 3.0, 4.0] + + def test_copy_rates_from_pos_fallback_raises_on_invalid_granularity(self) -> None: + """Invalid granularity raises ValueError before calling the fallback method.""" + client = MagicMock(spec=["copy_rates_from_pos_as_df"]) + + with pytest.raises(ValueError, match="Invalid timeframe"): + fetch_latest_closed_rates_for_trading_client( + client, symbol="EURUSD", granularity="BADGRAN", count=1 + ) + + client.copy_rates_from_pos_as_df.assert_not_called() + def test_raises_when_trading_client_cannot_fetch_rates(self) -> None: """Test missing rate-fetch methods raise Mt5TradingError.""" client = MagicMock(spec=[]) @@ -3528,6 +3593,29 @@ class TestFetchLatestClosedRatesIndexed: assert "close" in result.columns assert isinstance(result.index, pd.DatetimeIndex) + def test_copy_rates_from_pos_fallback_produces_utc_datetime_index(self) -> None: + """Fallback path via copy_rates_from_pos_as_df produces a UTC DatetimeIndex.""" + client = MagicMock(spec=["copy_rates_from_pos_as_df"]) + client.copy_rates_from_pos_as_df.return_value = pd.DataFrame( + { + "time": [1700000000, 1700003600, 1700007200], + "close": [1.1, 1.2, 1.3], + }, + ) + + result = fetch_latest_closed_rates_indexed( + client, + symbol="EURUSD", + granularity="M1", + count=2, + ) + + assert isinstance(result.index, pd.DatetimeIndex) + assert result.index.name == "time" + assert str(result.index.tz) == "UTC" + assert "time" not in result.columns + assert list(result["close"]) == [1.1, 1.2] + class TestExtractTickPrice: """Tests for the public extract_tick_price helper.""" diff --git a/uv.lock b/uv.lock index 0bc514d..2cb34a5 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "1.0.2" +version = "1.0.3" source = { editable = "." } dependencies = [ { name = "click" },