Compare commits

..

1 Commits

Author SHA1 Message Date
Daichi Narushima 513eb7617d 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>
2026-06-30 05:28:34 +09:00
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` | | `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` | | `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 | | `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 | | `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` | | `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 | | `determine_order_limits` | SL/TP price levels from ratios |
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan | | `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
| `ensure_symbol_selected` | Select/verify Market Watch visibility | | `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) | | `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 | | `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums 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 - Backtesting, walk-forward analysis, or parameter optimization
- Strategy-specific risk policy, position sizing systems, or Kelly fractions - Strategy-specific risk policy, position sizing systems, or Kelly fractions
- Entry/exit decision logic or YAML strategy semantics - 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 - Application-specific credential schema keys wired into mt5cli internals
mt5cli provides connection lifecycle, normalized data access, SQLite history 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 to launch the terminal when configured, and `mt5_trading_session()` always
calls `shutdown()` on exit. 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 ```python
from mt5cli import create_trading_client, mt5_trading_session 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 Closes issue #33: strategy-neutral order planning and execution helpers exposed
through the stable package root without embedding entry/exit policy. 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 ## Migration from application-local helpers
| Application-local concern | mt5cli replacement | | 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 broker volume step normalization | `normalize_order_volume()` |
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` | | 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 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()` | | Local SL/TP price derivation | `determine_order_limits()` |
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` | | 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, extract_tick_price,
fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed, fetch_latest_closed_rates_indexed,
fetch_recent_history_deals_for_trading_client,
get_account_snapshot, get_account_snapshot,
get_positions_frame, get_positions_frame,
get_symbol_snapshot, get_symbol_snapshot,
@@ -128,6 +129,7 @@ __all__ = [
"fetch_latest_closed_rates", "fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed", "fetch_latest_closed_rates_indexed",
"fetch_recent_history_deals_for_trading_client",
"get_account_snapshot", "get_account_snapshot",
"get_positions_frame", "get_positions_frame",
"get_symbol_snapshot", "get_symbol_snapshot",
+1
View File
@@ -48,6 +48,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"fetch_latest_closed_rates", "fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed", "fetch_latest_closed_rates_indexed",
"fetch_recent_history_deals_for_trading_client",
"get_account_snapshot", "get_account_snapshot",
"get_positions_frame", "get_positions_frame",
"get_symbol_snapshot", "get_symbol_snapshot",
+136 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
from contextlib import contextmanager from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from math import floor, isfinite from math import floor, isfinite
from numbers import Integral, Real from numbers import Integral, Real
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast 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"] PositionSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"] OrderSide = Literal["BUY", "SELL"]
OrderFillingMode = Literal["IOC", "FOK", "RETURN"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
@@ -209,6 +247,7 @@ __all__ = [
"extract_tick_price", "extract_tick_price",
"fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed", "fetch_latest_closed_rates_indexed",
"fetch_recent_history_deals_for_trading_client",
"get_account_snapshot", "get_account_snapshot",
"get_positions_frame", "get_positions_frame",
"get_symbol_snapshot", "get_symbol_snapshot",
@@ -579,11 +618,25 @@ def create_trading_client(
path: str | None = None, path: str | None = None,
timeout: int | None = None, timeout: int | None = None,
retry_count: int = 0, retry_count: int = 0,
) -> _Mt5ClientProtocol: ) -> _TradingHistoryDealsClientProtocol:
"""Return an initialized and logged-in trading client. """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: 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( mt5_config = _resolve_config(
config=config, config=config,
@@ -1744,6 +1797,86 @@ def fetch_latest_closed_rates_indexed(
return result 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 @contextmanager
def mt5_trading_session( def mt5_trading_session(
config: Mt5Config | None = None, config: Mt5Config | None = None,
@@ -1754,7 +1887,7 @@ def mt5_trading_session(
path: str | None = None, path: str | None = None,
timeout: int | None = None, timeout: int | None = None,
retry_count: int = 0, retry_count: int = 0,
) -> Iterator[_Mt5ClientProtocol]: ) -> Iterator[_TradingHistoryDealsClientProtocol]:
"""Open a trading-capable MT5 session and always shut down safely. """Open a trading-capable MT5 session and always shut down safely.
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set, Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "1.1.0" version = "1.1.1"
description = "Generic MT5 data and execution infrastructure for Python applications" description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{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: class TestCommands:
"""Tests for all CLI subcommands via CliRunner.""" """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, self,
tmp_path: Path, tmp_path: Path,
mock_client: MagicMock, mock_client: MagicMock,
command: str,
method: str,
) -> None: ) -> None:
"""Test account-info command.""" """Simple no-arg commands invoke the expected client method."""
output = tmp_path / "out.csv" output = tmp_path / "out.csv"
result = runner.invoke( result = runner.invoke(app, ["-o", str(output), command])
app,
["-o", str(output), "account-info"],
)
assert result.exit_code == 0, result.output 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() 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( def test_symbols(
self, self,
tmp_path: Path, tmp_path: Path,
@@ -398,20 +393,6 @@ class TestCommands:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
mock_client.orders_get_as_df.assert_called_once() 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( def test_history_orders(
self, self,
tmp_path: Path, tmp_path: Path,
@@ -532,28 +513,6 @@ class TestCommands:
"symbols_total": 42, "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( def test_symbol_info_tick(
self, self,
tmp_path: Path, tmp_path: Path,
@@ -755,38 +714,19 @@ class TestHelpText:
output = normalize_cli_output(result.output) output = normalize_cli_output(result.output)
assert "execution" in output.lower() assert "execution" in output.lower()
def test_top_level_help_has_execution_panel(self) -> None: @pytest.mark.parametrize("panel", ["Execution", "Data / Export"])
"""Top-level help must show an Execution command group.""" 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"]) result = runner.invoke(app, ["--help"])
assert result.exit_code == 0 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: @pytest.mark.parametrize("keyword", ["raw", "expert", "live"])
"""Top-level help must show a Data / Export command group.""" def test_order_send_help_keywords(self, keyword: str) -> None:
result = runner.invoke(app, ["--help"]) """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 result.exit_code == 0
assert "Data / Export" in result.output assert keyword in normalize_cli_output(result.output).lower()
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()
def test_close_positions_help_mentions_dry_run_and_yes(self) -> None: def test_close_positions_help_mentions_dry_run_and_yes(self) -> None:
"""close-positions help must document both safety gates.""" """close-positions help must document both safety gates."""
@@ -1952,75 +1892,31 @@ class TestSnapshotCommand:
kwargs = updater.call_args.kwargs kwargs = updater.call_args.kwargs
assert kwargs["symbols"] == ["EURUSD", "GBPUSD"] 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, self,
tmp_path: Path, tmp_path: Path,
mocker: MockerFixture, mocker: MockerFixture,
flag: str,
kwarg: str,
) -> None: ) -> 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") updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
result = runner.invoke( result = runner.invoke(
app, 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 result.exit_code == 0, result.output
assert updater.call_args.kwargs["include_account"] is False assert updater.call_args.kwargs[kwarg] 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
def test_snapshot_with_publish_copy( def test_snapshot_with_publish_copy(
self, self,
+15 -43
View File
@@ -2861,57 +2861,29 @@ class TestUpdateObservability:
) )
spy.assert_called_once() 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, self,
mock_client: MagicMock, mock_client: MagicMock,
tmp_path: Path, tmp_path: Path,
kwarg: str,
method: str,
) -> None: ) -> None:
"""include_account=False does not call account_info_as_df.""" """include_X=False does not call the corresponding client method."""
update_observability( update_observability(
client=mock_client, client=mock_client,
output=tmp_path / "obs.db", output=tmp_path / "obs.db",
include_account=False, **{kwarg: False}, # type: ignore[arg-type]
) )
mock_client.account_info_as_df.assert_not_called() getattr(mock_client, method).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()
def test_update_observability_with_positions_rows( def test_update_observability_with_positions_rows(
self, self,
+176
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast, get_args from typing import Any, cast, get_args
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -41,6 +42,7 @@ from mt5cli.trading import (
extract_tick_price, extract_tick_price,
fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed, fetch_latest_closed_rates_indexed,
fetch_recent_history_deals_for_trading_client,
get_account_snapshot, get_account_snapshot,
get_positions_frame, get_positions_frame,
get_symbol_snapshot, get_symbol_snapshot,
@@ -3817,3 +3819,177 @@ class TestCalculatePositionsMarginSafe:
client = _mock_trade_client() client = _mock_trade_client()
total = calculate_positions_margin_safe(client, symbols=[]) total = calculate_positions_margin_safe(client, symbols=[])
_assert_close(total, 0.0) _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" version = "5.0.5640"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy", marker = "sys_platform == 'win32'" }, { name = "numpy" },
] ]
wheels = [ 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" }, { 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]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "1.1.0" version = "1.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },