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:
@@ -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
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user