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
parent 1ffac45d57
commit 513eb7617d
10 changed files with 434 additions and 195 deletions
+5 -1
View File
@@ -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
+55
View File
@@ -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)` |
+2
View File
@@ -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",
+1
View File
@@ -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",
+136 -3
View File
@@ -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,
+1 -1
View File
@@ -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"}]
+41 -145
View File
@@ -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,
+15 -43
View File
@@ -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,
+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]
Generated
+2 -2
View File
@@ -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" },