diff --git a/docs/api/public-contract.md b/docs/api/public-contract.md index 9887e82..dabfae7 100644 --- a/docs/api/public-contract.md +++ b/docs/api/public-contract.md @@ -45,7 +45,7 @@ These names are exported from `mt5cli` and enumerated in | `MT5Client` | Read-only data client with optional `order_check` / `order_send` | | `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` | | `mt5_session` | Context manager: initialize, login, yield client, shutdown | -| `create_trading_client`, `mt5_trading_session` | Trading-capable MT5 client lifecycle; returns a client supporting order execution and account management | +| `create_trading_client`, `mt5_trading_session` | Trading-capable MT5 client lifecycle; returns a raw `pdmt5.Mt5DataClient` (not `MT5Client`) supporting order execution, account management, and history deal retrieval | | `AccountSpec` | Generic account group: symbols plus optional credentials | | `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` | @@ -99,6 +99,7 @@ strategy entries, exits, Kelly sizing, or signal logic. | `determine_order_limits` | SL/TP price levels from ratios | | `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan | | `ensure_symbol_selected` | Select/verify Market Watch visibility | +| `fetch_recent_history_deals_for_trading_client` | Recent deal history from a connected trading client | | `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) | | `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers | | `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers | @@ -254,6 +255,9 @@ The following belong in consuming applications, not in mt5cli: - Backtesting, walk-forward analysis, or parameter optimization - Strategy-specific risk policy, position sizing systems, or Kelly fractions - Entry/exit decision logic or YAML strategy semantics +- Entry-deal classification, Kelly fractions, or betting-specific deal transformations + (use `fetch_recent_history_deals_for_trading_client` to retrieve raw deal data, then + apply downstream transformations in your own adapter layer) - Application-specific credential schema keys wired into mt5cli internals mt5cli provides connection lifecycle, normalized data access, SQLite history diff --git a/docs/api/trading.md b/docs/api/trading.md index ccb22a5..e4d2ed3 100644 --- a/docs/api/trading.md +++ b/docs/api/trading.md @@ -10,6 +10,11 @@ client supporting order execution and account management, use `Mt5Config.path` to launch the terminal when configured, and `mt5_trading_session()` always calls `shutdown()` on exit. +`create_trading_client()` returns a raw `pdmt5.Mt5DataClient` instance, not the +higher-level `MT5Client` wrapper. Use `mt5_session()` / `MT5Client` for +read-only data collection; use `mt5_trading_session()` only where order +placement or trading calculations are required. + ```python from mt5cli import create_trading_client, mt5_trading_session @@ -182,6 +187,55 @@ updates: list[OrderExecutionResult] = update_sltp_for_open_positions( Closes issue #33: strategy-neutral order planning and execution helpers exposed through the stable package root without embedding entry/exit policy. +## Retrieving recent history deals + +`fetch_recent_history_deals_for_trading_client()` fetches history deals from an +already-connected trading client over a trailing time window. It works directly +with the object returned by `create_trading_client()` (a raw +`pdmt5.Mt5DataClient`) without requiring any additional wrapping. + +The helper returns a chronologically sorted DataFrame with a `RangeIndex` and +all columns from the underlying client (`time`, `symbol`, `type`, `entry`, +`volume`, `profit`, `position_id`, etc.). It does **not** apply any +strategy-specific transformations — entry/exit classification, Kelly fractions, +and betting semantics belong in downstream applications. + +```python +from mt5cli import ( + create_trading_client, + fetch_recent_history_deals_for_trading_client, +) + +client = create_trading_client(login=12345, server="Broker-Demo") +try: + deals_df = fetch_recent_history_deals_for_trading_client( + client, + symbol="JP225", + hours=24, + ) +finally: + client.shutdown() +``` + +Or inside a managed session: + +```python +from mt5cli import fetch_recent_history_deals_for_trading_client, mt5_trading_session + +with mt5_trading_session(login=12345, server="Broker-Demo") as client: + deals_df = fetch_recent_history_deals_for_trading_client( + client, + symbol="JP225", + hours=48, + ) +``` + +`hours` must be positive; `date_to` defaults to `datetime.now(UTC)`. An empty +or `None` result from the underlying client is normalized to an empty DataFrame. + +Downstream packages own all strategy-specific transformations. mt5cli does not +provide entry-deal classification, Kelly sizing, or any betting-specific helpers. + ## Migration from application-local helpers | Application-local concern | mt5cli replacement | @@ -192,6 +246,7 @@ through the stable package root without embedding entry/exit policy. | Local broker volume step normalization | `normalize_order_volume()` | | Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` | | Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` | +| Local recent deal history fetch from a trading session | `fetch_recent_history_deals_for_trading_client()` | | Local SL/TP price derivation | `determine_order_limits()` | | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` | diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index ff6cb0e..f5a822d 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -68,6 +68,7 @@ from .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, @@ -128,6 +129,7 @@ __all__ = [ "fetch_latest_closed_rates", "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", diff --git a/mt5cli/contract.py b/mt5cli/contract.py index dcac983..b688b6f 100644 --- a/mt5cli/contract.py +++ b/mt5cli/contract.py @@ -48,6 +48,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ "fetch_latest_closed_rates", "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", diff --git a/mt5cli/trading.py b/mt5cli/trading.py index 114854f..0e47b7a 100644 --- a/mt5cli/trading.py +++ b/mt5cli/trading.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from contextlib import contextmanager +from datetime import UTC, datetime, timedelta from math import floor, isfinite from numbers import Integral, Real from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast @@ -80,6 +81,43 @@ class _Mt5ClientProtocol(Protocol): ... +class _HistoryDealsClientProtocol(Protocol): + """Minimal protocol for MT5 clients capable of retrieving history deals. + + Describes the single method required by + :func:`fetch_recent_history_deals_for_trading_client`. The raw + ``pdmt5.Mt5DataClient`` returned by :func:`create_trading_client` + satisfies this protocol. ``mt5cli.sdk.Mt5CliClient`` (used via + ``mt5_session()``) exposes ``history_deals()`` instead and does not + satisfy this protocol. + """ + + def history_deals_get_as_df( + self, + date_from: datetime, + date_to: datetime, + group: str | None = None, + symbol: str | None = None, + ticket: int | None = None, + position: int | None = None, + ) -> pd.DataFrame | None: + """Return historical deals as a DataFrame, or None when none exist.""" + ... + + +class _TradingHistoryDealsClientProtocol( + _Mt5ClientProtocol, + _HistoryDealsClientProtocol, + Protocol, +): + """Combined protocol for trading clients that also support history deal retrieval. + + The raw ``pdmt5.Mt5DataClient`` returned by :func:`create_trading_client` + satisfies both :class:`_Mt5ClientProtocol` and + :class:`_HistoryDealsClientProtocol`, so it satisfies this combined protocol. + """ + + PositionSide = Literal["long", "short"] OrderSide = Literal["BUY", "SELL"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"] @@ -209,6 +247,7 @@ __all__ = [ "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", @@ -579,11 +618,25 @@ def create_trading_client( path: str | None = None, timeout: int | None = None, retry_count: int = 0, -) -> _Mt5ClientProtocol: +) -> _TradingHistoryDealsClientProtocol: """Return an initialized and logged-in trading client. + The returned object is a raw ``pdmt5.Mt5DataClient`` instance, not the + higher-level ``mt5cli.MT5Client`` wrapper. Use ``mt5_session()`` / + ``MT5Client`` for read-only data collection. For live trading helpers + (margin, volume, order execution, position management) pass the returned + client to the strategy-agnostic helpers in this module. + + For history deal retrieval use + :func:`fetch_recent_history_deals_for_trading_client`; the returned client + satisfies :class:`_HistoryDealsClientProtocol` so no additional wrapping is + required. + Returns: - A client instance supporting the required MT5 trading methods. + A ``pdmt5.Mt5DataClient`` instance satisfying ``_Mt5ClientProtocol`` + and ``_HistoryDealsClientProtocol``. Caller is responsible for calling + ``client.shutdown()`` when done; prefer ``mt5_trading_session()`` to + manage lifetime automatically. """ mt5_config = _resolve_config( config=config, @@ -1744,6 +1797,86 @@ def fetch_latest_closed_rates_indexed( return result +def fetch_recent_history_deals_for_trading_client( + client: _HistoryDealsClientProtocol, + *, + symbol: str | None = None, + group: str | None = None, + hours: float = 24.0, + date_to: datetime | None = None, +) -> pd.DataFrame: + """Fetch recent history deals from an already-connected trading client. + + Computes a trailing window ending at ``date_to`` (or ``datetime.now(UTC)`` + when omitted) and delegates to the client's ``history_deals_get_as_df`` + method. The object returned by :func:`create_trading_client` (a raw + ``pdmt5.Mt5DataClient``) satisfies this protocol directly. Note that + ``mt5cli.sdk.Mt5CliClient`` (used via ``mt5_session()``) exposes + ``history_deals()``, not ``history_deals_get_as_df()``, and therefore does + not satisfy this protocol; use this helper with trading-client sessions only. + + The returned DataFrame preserves every column from the underlying client + (``time``, ``symbol``, ``type``, ``entry``, ``volume``, ``profit``, + ``position_id``, etc.). No strategy-specific transformations are applied; + downstream packages own entry/exit classification, Kelly fractions, and + any other betting or signal semantics. + + Args: + client: Connected ``pdmt5.Mt5DataClient`` (or compatible) with + ``history_deals_get_as_df`` capability, as returned by + :func:`create_trading_client`. + symbol: Optional symbol filter passed to the underlying client. + group: Optional symbol group filter passed to the underlying client. + hours: Trailing window length in hours. Must be positive. + date_to: Window end timestamp. Defaults to ``datetime.now(UTC)``. + + Returns: + DataFrame ordered chronologically by ``time`` (when the column + exists) with a ``RangeIndex``. Schema-preserving empty DataFrames + (zero rows but columns present) are passed through with a reset + index. Returns a bare empty DataFrame only when the underlying + client returns ``None``. + + Raises: + ValueError: If ``hours`` is not positive. + + Example:: + + from mt5cli import ( + create_trading_client, + fetch_recent_history_deals_for_trading_client, + ) + + client = create_trading_client(login=12345, server="Broker-Demo") + try: + deals_df = fetch_recent_history_deals_for_trading_client( + client, + symbol="JP225", + hours=24, + ) + finally: + client.shutdown() + """ + if not isfinite(hours) or hours <= 0: + msg = "hours must be finite and positive." + raise ValueError(msg) + end = date_to if date_to is not None else datetime.now(UTC) + start = end - timedelta(hours=hours) + raw = client.history_deals_get_as_df( + date_from=start, + date_to=end, + group=group, + symbol=symbol, + ) + if raw is None: + return pd.DataFrame() + if raw.empty: + return raw.reset_index(drop=True) + if "time" in raw.columns: + raw = raw.sort_values("time") + return raw.reset_index(drop=True) + + @contextmanager def mt5_trading_session( config: Mt5Config | None = None, @@ -1754,7 +1887,7 @@ def mt5_trading_session( path: str | None = None, timeout: int | None = None, retry_count: int = 0, -) -> Iterator[_Mt5ClientProtocol]: +) -> Iterator[_TradingHistoryDealsClientProtocol]: """Open a trading-capable MT5 session and always shut down safely. Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set, diff --git a/pyproject.toml b/pyproject.toml index 2e4de7c..37811af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "1.1.0" +version = "1.1.1" 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_cli.py b/tests/test_cli.py index 861ec38..3bdef2c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,35 +72,30 @@ class TestExecuteExport: class TestCommands: """Tests for all CLI subcommands via CliRunner.""" - def test_account_info( + @pytest.mark.parametrize( + ("command", "method"), + [ + ("account-info", "account_info_as_df"), + ("terminal-info", "terminal_info_as_df"), + ("positions", "positions_get_as_df"), + ("version", "version_as_df"), + ("last-error", "last_error_as_df"), + ], + ) + def test_simple_command( self, tmp_path: Path, mock_client: MagicMock, + command: str, + method: str, ) -> None: - """Test account-info command.""" + """Simple no-arg commands invoke the expected client method.""" output = tmp_path / "out.csv" - result = runner.invoke( - app, - ["-o", str(output), "account-info"], - ) + result = runner.invoke(app, ["-o", str(output), command]) assert result.exit_code == 0, result.output - mock_client.account_info_as_df.assert_called_once() + getattr(mock_client, method).assert_called_once() assert output.exists() - def test_terminal_info( - self, - tmp_path: Path, - mock_client: MagicMock, - ) -> None: - """Test terminal-info command.""" - output = tmp_path / "out.csv" - result = runner.invoke( - app, - ["-o", str(output), "terminal-info"], - ) - assert result.exit_code == 0, result.output - mock_client.terminal_info_as_df.assert_called_once() - def test_symbols( self, tmp_path: Path, @@ -398,20 +393,6 @@ class TestCommands: assert result.exit_code == 0, result.output mock_client.orders_get_as_df.assert_called_once() - def test_positions( - self, - tmp_path: Path, - mock_client: MagicMock, - ) -> None: - """Test positions command.""" - output = tmp_path / "out.csv" - result = runner.invoke( - app, - ["-o", str(output), "positions"], - ) - assert result.exit_code == 0, result.output - mock_client.positions_get_as_df.assert_called_once() - def test_history_orders( self, tmp_path: Path, @@ -532,28 +513,6 @@ class TestCommands: "symbols_total": 42, } - def test_version( - self, - tmp_path: Path, - mock_client: MagicMock, - ) -> None: - """Test version command.""" - output = tmp_path / "out.csv" - result = runner.invoke(app, ["-o", str(output), "version"]) - assert result.exit_code == 0, result.output - mock_client.version_as_df.assert_called_once() - - def test_last_error( - self, - tmp_path: Path, - mock_client: MagicMock, - ) -> None: - """Test last-error command.""" - output = tmp_path / "out.csv" - result = runner.invoke(app, ["-o", str(output), "last-error"]) - assert result.exit_code == 0, result.output - mock_client.last_error_as_df.assert_called_once() - def test_symbol_info_tick( self, tmp_path: Path, @@ -755,38 +714,19 @@ class TestHelpText: output = normalize_cli_output(result.output) assert "execution" in output.lower() - def test_top_level_help_has_execution_panel(self) -> None: - """Top-level help must show an Execution command group.""" + @pytest.mark.parametrize("panel", ["Execution", "Data / Export"]) + def test_top_level_help_has_panel(self, panel: str) -> None: + """Top-level help must show all command group panels.""" result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - assert "Execution" in result.output + assert panel in result.output - def test_top_level_help_has_data_export_panel(self) -> None: - """Top-level help must show a Data / Export command group.""" - result = runner.invoke(app, ["--help"]) + @pytest.mark.parametrize("keyword", ["raw", "expert", "live"]) + def test_order_send_help_keywords(self, keyword: str) -> None: + """order-send help must mention raw, expert, and live.""" + result = runner.invoke(app, ["-o", "out.csv", "order-send", "--help"]) assert result.exit_code == 0 - assert "Data / Export" in result.output - - def test_order_send_help_mentions_expert_and_raw(self) -> None: - """order-send help must communicate it is the expert raw-request path.""" - result2 = runner.invoke( - app, - ["-o", "out.csv", "order-send", "--help"], - ) - assert result2.exit_code == 0 - output = normalize_cli_output(result2.output) - assert "raw" in output.lower() - assert "expert" in output.lower() - - def test_order_send_help_mentions_live_execution(self) -> None: - """order-send help must warn about live execution.""" - result = runner.invoke( - app, - ["-o", "out.csv", "order-send", "--help"], - ) - assert result.exit_code == 0 - output = normalize_cli_output(result.output) - assert "live" in output.lower() + assert keyword in normalize_cli_output(result.output).lower() def test_close_positions_help_mentions_dry_run_and_yes(self) -> None: """close-positions help must document both safety gates.""" @@ -1952,75 +1892,31 @@ class TestSnapshotCommand: kwargs = updater.call_args.kwargs assert kwargs["symbols"] == ["EURUSD", "GBPUSD"] - def test_snapshot_with_no_account_flag( + @pytest.mark.parametrize( + ("flag", "kwarg"), + [ + ("--no-account", "include_account"), + ("--no-positions", "include_positions"), + ("--no-orders", "include_orders"), + ("--no-terminal", "include_terminal"), + ("--no-grafana-schema", "with_grafana_schema"), + ], + ) + def test_snapshot_with_no_flag( self, tmp_path: Path, mocker: MockerFixture, + flag: str, + kwarg: str, ) -> None: - """--no-account disables account snapshotting.""" + """Snapshot --no-X flags disable the corresponding snapshot component.""" updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config") result = runner.invoke( app, - ["-o", str(tmp_path / "out.db"), "snapshot", "--no-account"], + ["-o", str(tmp_path / "out.db"), "snapshot", flag], ) assert result.exit_code == 0, result.output - assert updater.call_args.kwargs["include_account"] is False - - def test_snapshot_with_no_positions_flag( - self, - tmp_path: Path, - mocker: MockerFixture, - ) -> None: - """--no-positions disables position snapshotting.""" - updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config") - result = runner.invoke( - app, - ["-o", str(tmp_path / "out.db"), "snapshot", "--no-positions"], - ) - assert result.exit_code == 0, result.output - assert updater.call_args.kwargs["include_positions"] is False - - def test_snapshot_with_no_orders_flag( - self, - tmp_path: Path, - mocker: MockerFixture, - ) -> None: - """--no-orders disables order snapshotting.""" - updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config") - result = runner.invoke( - app, - ["-o", str(tmp_path / "out.db"), "snapshot", "--no-orders"], - ) - assert result.exit_code == 0, result.output - assert updater.call_args.kwargs["include_orders"] is False - - def test_snapshot_with_no_terminal_flag( - self, - tmp_path: Path, - mocker: MockerFixture, - ) -> None: - """--no-terminal disables terminal snapshotting.""" - updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config") - result = runner.invoke( - app, - ["-o", str(tmp_path / "out.db"), "snapshot", "--no-terminal"], - ) - assert result.exit_code == 0, result.output - assert updater.call_args.kwargs["include_terminal"] is False - - def test_snapshot_with_no_grafana_schema_flag( - self, - tmp_path: Path, - mocker: MockerFixture, - ) -> None: - """--no-grafana-schema disables Grafana schema creation.""" - updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config") - result = runner.invoke( - app, - ["-o", str(tmp_path / "out.db"), "snapshot", "--no-grafana-schema"], - ) - assert result.exit_code == 0, result.output - assert updater.call_args.kwargs["with_grafana_schema"] is False + assert updater.call_args.kwargs[kwarg] is False def test_snapshot_with_publish_copy( self, diff --git a/tests/test_sdk.py b/tests/test_sdk.py index a82f452..71b394f 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -2861,57 +2861,29 @@ class TestUpdateObservability: ) spy.assert_called_once() - def test_update_observability_skips_account_when_disabled( + @pytest.mark.parametrize( + ("kwarg", "method"), + [ + ("include_account", "account_info_as_df"), + ("include_positions", "positions_get_as_df"), + ("include_orders", "orders_get_as_df"), + ("include_terminal", "terminal_info_as_df"), + ], + ) + def test_update_observability_skips_when_disabled( self, mock_client: MagicMock, tmp_path: Path, + kwarg: str, + method: str, ) -> None: - """include_account=False does not call account_info_as_df.""" + """include_X=False does not call the corresponding client method.""" update_observability( client=mock_client, output=tmp_path / "obs.db", - include_account=False, + **{kwarg: False}, # type: ignore[arg-type] ) - mock_client.account_info_as_df.assert_not_called() - - def test_update_observability_skips_positions_when_disabled( - self, - mock_client: MagicMock, - tmp_path: Path, - ) -> None: - """include_positions=False does not call positions_get_as_df.""" - update_observability( - client=mock_client, - output=tmp_path / "obs.db", - include_positions=False, - ) - mock_client.positions_get_as_df.assert_not_called() - - def test_update_observability_skips_orders_when_disabled( - self, - mock_client: MagicMock, - tmp_path: Path, - ) -> None: - """include_orders=False does not call orders_get_as_df.""" - update_observability( - client=mock_client, - output=tmp_path / "obs.db", - include_orders=False, - ) - mock_client.orders_get_as_df.assert_not_called() - - def test_update_observability_skips_terminal_when_disabled( - self, - mock_client: MagicMock, - tmp_path: Path, - ) -> None: - """include_terminal=False does not call terminal_info_as_df.""" - update_observability( - client=mock_client, - output=tmp_path / "obs.db", - include_terminal=False, - ) - mock_client.terminal_info_as_df.assert_not_called() + getattr(mock_client, method).assert_not_called() def test_update_observability_with_positions_rows( self, diff --git a/tests/test_trading.py b/tests/test_trading.py index 07237a9..acd3ff1 100644 --- a/tests/test_trading.py +++ b/tests/test_trading.py @@ -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] diff --git a/uv.lock b/uv.lock index 78b4b95..68ee68f 100644 --- a/uv.lock +++ b/uv.lock @@ -370,7 +370,7 @@ name = "metatrader5" version = "5.0.5640" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", marker = "sys_platform == 'win32'" }, + { name = "numpy" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a0/3b764c6743ef601ff12f7d8d62ca5768eb25e90d3758ac7e1d08e667af85/metatrader5-5.0.5640-cp311-cp311-win_amd64.whl", hash = "sha256:4057255f2d63138a3ea1a5d492715038a71d3177cad793fca97a5c58771b4eb1", size = 48091, upload-time = "2026-02-20T23:31:12.289Z" }, @@ -499,7 +499,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "1.1.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "click" },