feat: add fetch_recent_history_deals_for_trading_client to stable SDK (#90)

* refactor: collapse repeated tests with pytest.mark.parametrize

Collapse 13 near-identical test methods into 4 parametrized tests
across test_cli.py and test_sdk.py, keeping all 1045 cases passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add fetch_recent_history_deals_for_trading_client to stable SDK

Adds a generic history deal retrieval helper for active trading clients,
a _HistoryDealsClientProtocol describing the minimal required interface,
clarified create_trading_client() docs (returns pdmt5.Mt5DataClient, not
MT5Client), 9 unit tests at 100% coverage, and updated trading.md and
public-contract.md with examples and out-of-scope strategy semantics note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: narrow Mt5CliClient protocol claim and preserve empty deal DataFrame schema

- _HistoryDealsClientProtocol docstring and fetch_recent_history_deals_for_trading_client
  docstring now explicitly state that Mt5CliClient (mt5_session) exposes
  history_deals() not history_deals_get_as_df() and does not satisfy the protocol;
  the function is for trading-client sessions (pdmt5.Mt5DataClient) only
- Empty DataFrames with columns are now passed through with reset_index rather
  than replaced by a bare pd.DataFrame(), preserving schema for callers that rely
  on stable column names even in no-deal windows
- Tests updated to assert schema preservation on empty results and bare empty on None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add combined protocol so create_trading_client() is type-safe with history deals helper

Adds _TradingHistoryDealsClientProtocol combining _Mt5ClientProtocol and
_HistoryDealsClientProtocol, and updates create_trading_client() and
mt5_trading_session() to return/yield this combined type so the natural SDK
flow `client = create_trading_client(...); fetch_recent_history_deals_for_trading_client(client)`
is type-safe under pyright strict without casts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: validate hours is finite before timedelta in fetch_recent_history_deals_for_trading_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bump version to 1.1.1

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daichi Narushima
2026-06-30 05:28:34 +09:00
committed by GitHub
co-authored by Claude Sonnet 4.6 agent
parent 1ffac45d57
commit 513eb7617d
10 changed files with 434 additions and 195 deletions
+176
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast, get_args
from unittest.mock import MagicMock
@@ -41,6 +42,7 @@ from mt5cli.trading import (
extract_tick_price,
fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed,
fetch_recent_history_deals_for_trading_client,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
@@ -3817,3 +3819,177 @@ class TestCalculatePositionsMarginSafe:
client = _mock_trade_client()
total = calculate_positions_margin_safe(client, symbols=[])
_assert_close(total, 0.0)
class TestFetchRecentHistoryDealsForTradingClient:
"""Tests for fetch_recent_history_deals_for_trading_client."""
def _fake_client(self, return_value: pd.DataFrame | None) -> MagicMock:
client = MagicMock()
client.history_deals_get_as_df.return_value = return_value
return client
def test_passes_correct_date_range_and_filters(self) -> None:
"""Calls history_deals_get_as_df with derived date_from/date_to."""
anchor = datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)
client = self._fake_client(pd.DataFrame())
fetch_recent_history_deals_for_trading_client(
client,
symbol="JP225",
group="FX*",
hours=6.0,
date_to=anchor,
)
client.history_deals_get_as_df.assert_called_once_with(
date_from=anchor - timedelta(hours=6.0),
date_to=anchor,
group="FX*",
symbol="JP225",
)
def test_raises_for_zero_hours(self) -> None:
"""hours=0 raises ValueError."""
client = self._fake_client(pd.DataFrame())
with pytest.raises(ValueError, match="hours must be finite and positive"):
fetch_recent_history_deals_for_trading_client(client, hours=0)
def test_raises_for_negative_hours(self) -> None:
"""Negative hours raises ValueError."""
client = self._fake_client(pd.DataFrame())
with pytest.raises(ValueError, match="hours must be finite and positive"):
fetch_recent_history_deals_for_trading_client(client, hours=-1.0)
@pytest.mark.parametrize("bad_hours", [float("nan"), float("inf"), float("-inf")])
def test_raises_for_non_finite_hours(self, bad_hours: float) -> None:
"""nan, inf, and -inf raise ValueError before reaching timedelta."""
client = self._fake_client(pd.DataFrame())
with pytest.raises(ValueError, match="hours must be finite and positive"):
fetch_recent_history_deals_for_trading_client(client, hours=bad_hours)
def test_none_result_returns_empty_dataframe(self) -> None:
"""None from underlying client becomes an empty DataFrame."""
client = self._fake_client(None)
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
assert isinstance(result, pd.DataFrame)
assert result.empty
def test_empty_dataframe_result_preserves_schema(self) -> None:
"""Empty DataFrame from client is returned with its columns intact."""
schema_df = pd.DataFrame(columns=["time", "symbol", "profit", "volume"])
client = self._fake_client(schema_df)
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
assert result.empty
assert list(result.columns) == ["time", "symbol", "profit", "volume"]
def test_none_result_returns_bare_empty_dataframe(self) -> None:
"""None from client becomes a bare empty DataFrame (no columns)."""
client = self._fake_client(None)
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
assert result.empty
assert list(result.columns) == []
def test_sorts_by_time_and_resets_index(self) -> None:
"""Unsorted time rows are sorted chronologically and index is reset."""
t1 = datetime(2024, 6, 1, 9, 0, tzinfo=UTC)
t2 = datetime(2024, 6, 1, 10, 0, tzinfo=UTC)
t3 = datetime(2024, 6, 1, 11, 0, tzinfo=UTC)
df = pd.DataFrame({"time": [t3, t1, t2], "profit": [3.0, 1.0, 2.0]})
client = self._fake_client(df)
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
assert list(result["time"]) == [t1, t2, t3]
assert list(result.index) == [0, 1, 2]
def test_preserves_all_columns(self) -> None:
"""No columns are dropped from the underlying client result."""
anchor = datetime(2024, 6, 1, 12, 0, tzinfo=UTC)
df = pd.DataFrame({
"time": [anchor],
"symbol": ["JP225"],
"type": [0],
"entry": [1],
"volume": [0.1],
"profit": [50.0],
"position_id": [123456],
"commission": [-0.5],
})
client = self._fake_client(df)
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
assert set(result.columns) == {
"time",
"symbol",
"type",
"entry",
"volume",
"profit",
"position_id",
"commission",
}
def test_no_time_column_still_returns_data(self) -> None:
"""DataFrames without a time column are returned with RangeIndex."""
df = pd.DataFrame({"profit": [1.0, 2.0], "ticket": [10, 11]})
client = self._fake_client(df)
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
assert list(result["profit"]) == [1.0, 2.0]
assert list(result.index) == [0, 1]
def test_defaults_date_to_to_utc_now(self, mocker: MockerFixture) -> None:
"""When date_to is omitted, the window end is datetime.now(UTC)."""
frozen = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
mock_dt = mocker.patch("mt5cli.trading.datetime")
mock_dt.now.return_value = frozen
client = self._fake_client(pd.DataFrame())
fetch_recent_history_deals_for_trading_client(client, hours=1.0)
mock_dt.now.assert_called_once_with(UTC)
client.history_deals_get_as_df.assert_called_once_with(
date_from=frozen - timedelta(hours=1.0),
date_to=frozen,
group=None,
symbol=None,
)
class TestCreateTradingClientHistoryDealsIntegration:
"""Verify create_trading_client() result satisfies fetch_recent_history_deals."""
def test_create_trading_client_result_usable_with_history_deals_helper(
self,
mocker: MockerFixture,
) -> None:
"""create_trading_client() result passes directly to fetch_recent_history_deals.
This exercises the intended SDK call path without a live MT5 terminal.
The mock satisfies both _Mt5ClientProtocol and _HistoryDealsClientProtocol.
"""
mock_raw_client = MagicMock()
mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_raw_client)
anchor = datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)
expected_df = pd.DataFrame({"time": [anchor], "profit": [10.0]})
mock_raw_client.history_deals_get_as_df.return_value = expected_df
client = create_trading_client(login=12345, server="Demo")
result = fetch_recent_history_deals_for_trading_client(
client,
symbol="EURUSD",
hours=6.0,
date_to=anchor,
)
mock_raw_client.history_deals_get_as_df.assert_called_once_with(
date_from=anchor - timedelta(hours=6.0),
date_to=anchor,
group=None,
symbol="EURUSD",
)
assert list(result["profit"]) == [10.0]