mt5cli 1.1.0__tar.gz → 1.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-1.1.0 → mt5cli-1.1.1}/PKG-INFO +1 -1
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/public-contract.md +5 -1
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/trading.md +55 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/__init__.py +2 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/contract.py +1 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/trading.py +136 -3
- {mt5cli-1.1.0 → mt5cli-1.1.1}/pyproject.toml +1 -1
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_cli.py +41 -145
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_sdk.py +15 -43
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_trading.py +176 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/uv.lock +2 -2
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.claude/agents/codex.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.claude/settings.json +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/FUNDING.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/dependabot.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/renovate.json +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/workflows/ci.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/workflows/claude.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/workflows/release.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/.gitignore +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/AGENTS.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/CLAUDE.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/LICENSE +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/README.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/cli.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/client.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/converters.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/exceptions.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/history.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/index.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/schemas.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/sdk.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/utils.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/index.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/README.md +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/compose.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/dashboards/mt5cli-market.json +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mkdocs.yml +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/__main__.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/cli.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/client.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/converters.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/exceptions.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/grafana.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/history.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/retry.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/schemas.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/sdk.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/telemetry.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/utils.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/__init__.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/conftest.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_contracts.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_examples.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_grafana.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_history.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_telemetry.py +0 -0
- {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -45,7 +45,7 @@ These names are exported from `mt5cli` and enumerated in
|
|
|
45
45
|
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
|
46
46
|
| `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` |
|
|
47
47
|
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
|
48
|
-
| `create_trading_client`, `mt5_trading_session` | Trading-capable MT5 client lifecycle; returns a
|
|
48
|
+
| `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 |
|
|
49
49
|
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
|
50
50
|
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
|
51
51
|
|
|
@@ -99,6 +99,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
|
|
|
99
99
|
| `determine_order_limits` | SL/TP price levels from ratios |
|
|
100
100
|
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
|
101
101
|
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
|
102
|
+
| `fetch_recent_history_deals_for_trading_client` | Recent deal history from a connected trading client |
|
|
102
103
|
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
|
103
104
|
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
|
104
105
|
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
|
@@ -254,6 +255,9 @@ The following belong in consuming applications, not in mt5cli:
|
|
|
254
255
|
- Backtesting, walk-forward analysis, or parameter optimization
|
|
255
256
|
- Strategy-specific risk policy, position sizing systems, or Kelly fractions
|
|
256
257
|
- Entry/exit decision logic or YAML strategy semantics
|
|
258
|
+
- Entry-deal classification, Kelly fractions, or betting-specific deal transformations
|
|
259
|
+
(use `fetch_recent_history_deals_for_trading_client` to retrieve raw deal data, then
|
|
260
|
+
apply downstream transformations in your own adapter layer)
|
|
257
261
|
- Application-specific credential schema keys wired into mt5cli internals
|
|
258
262
|
|
|
259
263
|
mt5cli provides connection lifecycle, normalized data access, SQLite history
|
|
@@ -10,6 +10,11 @@ client supporting order execution and account management, use `Mt5Config.path`
|
|
|
10
10
|
to launch the terminal when configured, and `mt5_trading_session()` always
|
|
11
11
|
calls `shutdown()` on exit.
|
|
12
12
|
|
|
13
|
+
`create_trading_client()` returns a raw `pdmt5.Mt5DataClient` instance, not the
|
|
14
|
+
higher-level `MT5Client` wrapper. Use `mt5_session()` / `MT5Client` for
|
|
15
|
+
read-only data collection; use `mt5_trading_session()` only where order
|
|
16
|
+
placement or trading calculations are required.
|
|
17
|
+
|
|
13
18
|
```python
|
|
14
19
|
from mt5cli import create_trading_client, mt5_trading_session
|
|
15
20
|
|
|
@@ -182,6 +187,55 @@ updates: list[OrderExecutionResult] = update_sltp_for_open_positions(
|
|
|
182
187
|
Closes issue #33: strategy-neutral order planning and execution helpers exposed
|
|
183
188
|
through the stable package root without embedding entry/exit policy.
|
|
184
189
|
|
|
190
|
+
## Retrieving recent history deals
|
|
191
|
+
|
|
192
|
+
`fetch_recent_history_deals_for_trading_client()` fetches history deals from an
|
|
193
|
+
already-connected trading client over a trailing time window. It works directly
|
|
194
|
+
with the object returned by `create_trading_client()` (a raw
|
|
195
|
+
`pdmt5.Mt5DataClient`) without requiring any additional wrapping.
|
|
196
|
+
|
|
197
|
+
The helper returns a chronologically sorted DataFrame with a `RangeIndex` and
|
|
198
|
+
all columns from the underlying client (`time`, `symbol`, `type`, `entry`,
|
|
199
|
+
`volume`, `profit`, `position_id`, etc.). It does **not** apply any
|
|
200
|
+
strategy-specific transformations — entry/exit classification, Kelly fractions,
|
|
201
|
+
and betting semantics belong in downstream applications.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from mt5cli import (
|
|
205
|
+
create_trading_client,
|
|
206
|
+
fetch_recent_history_deals_for_trading_client,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
client = create_trading_client(login=12345, server="Broker-Demo")
|
|
210
|
+
try:
|
|
211
|
+
deals_df = fetch_recent_history_deals_for_trading_client(
|
|
212
|
+
client,
|
|
213
|
+
symbol="JP225",
|
|
214
|
+
hours=24,
|
|
215
|
+
)
|
|
216
|
+
finally:
|
|
217
|
+
client.shutdown()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Or inside a managed session:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from mt5cli import fetch_recent_history_deals_for_trading_client, mt5_trading_session
|
|
224
|
+
|
|
225
|
+
with mt5_trading_session(login=12345, server="Broker-Demo") as client:
|
|
226
|
+
deals_df = fetch_recent_history_deals_for_trading_client(
|
|
227
|
+
client,
|
|
228
|
+
symbol="JP225",
|
|
229
|
+
hours=48,
|
|
230
|
+
)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`hours` must be positive; `date_to` defaults to `datetime.now(UTC)`. An empty
|
|
234
|
+
or `None` result from the underlying client is normalized to an empty DataFrame.
|
|
235
|
+
|
|
236
|
+
Downstream packages own all strategy-specific transformations. mt5cli does not
|
|
237
|
+
provide entry-deal classification, Kelly sizing, or any betting-specific helpers.
|
|
238
|
+
|
|
185
239
|
## Migration from application-local helpers
|
|
186
240
|
|
|
187
241
|
| Application-local concern | mt5cli replacement |
|
|
@@ -192,6 +246,7 @@ through the stable package root without embedding entry/exit policy.
|
|
|
192
246
|
| Local broker volume step normalization | `normalize_order_volume()` |
|
|
193
247
|
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
|
194
248
|
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
|
|
249
|
+
| Local recent deal history fetch from a trading session | `fetch_recent_history_deals_for_trading_client()` |
|
|
195
250
|
| Local SL/TP price derivation | `determine_order_limits()` |
|
|
196
251
|
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
|
197
252
|
|
|
@@ -68,6 +68,7 @@ from .trading import (
|
|
|
68
68
|
extract_tick_price,
|
|
69
69
|
fetch_latest_closed_rates_for_trading_client,
|
|
70
70
|
fetch_latest_closed_rates_indexed,
|
|
71
|
+
fetch_recent_history_deals_for_trading_client,
|
|
71
72
|
get_account_snapshot,
|
|
72
73
|
get_positions_frame,
|
|
73
74
|
get_symbol_snapshot,
|
|
@@ -128,6 +129,7 @@ __all__ = [
|
|
|
128
129
|
"fetch_latest_closed_rates",
|
|
129
130
|
"fetch_latest_closed_rates_for_trading_client",
|
|
130
131
|
"fetch_latest_closed_rates_indexed",
|
|
132
|
+
"fetch_recent_history_deals_for_trading_client",
|
|
131
133
|
"get_account_snapshot",
|
|
132
134
|
"get_positions_frame",
|
|
133
135
|
"get_symbol_snapshot",
|
|
@@ -48,6 +48,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|
|
48
48
|
"fetch_latest_closed_rates",
|
|
49
49
|
"fetch_latest_closed_rates_for_trading_client",
|
|
50
50
|
"fetch_latest_closed_rates_indexed",
|
|
51
|
+
"fetch_recent_history_deals_for_trading_client",
|
|
51
52
|
"get_account_snapshot",
|
|
52
53
|
"get_positions_frame",
|
|
53
54
|
"get_symbol_snapshot",
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
from contextlib import contextmanager
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
7
8
|
from math import floor, isfinite
|
|
8
9
|
from numbers import Integral, Real
|
|
9
10
|
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast
|
|
@@ -80,6 +81,43 @@ class _Mt5ClientProtocol(Protocol):
|
|
|
80
81
|
...
|
|
81
82
|
|
|
82
83
|
|
|
84
|
+
class _HistoryDealsClientProtocol(Protocol):
|
|
85
|
+
"""Minimal protocol for MT5 clients capable of retrieving history deals.
|
|
86
|
+
|
|
87
|
+
Describes the single method required by
|
|
88
|
+
:func:`fetch_recent_history_deals_for_trading_client`. The raw
|
|
89
|
+
``pdmt5.Mt5DataClient`` returned by :func:`create_trading_client`
|
|
90
|
+
satisfies this protocol. ``mt5cli.sdk.Mt5CliClient`` (used via
|
|
91
|
+
``mt5_session()``) exposes ``history_deals()`` instead and does not
|
|
92
|
+
satisfy this protocol.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def history_deals_get_as_df(
|
|
96
|
+
self,
|
|
97
|
+
date_from: datetime,
|
|
98
|
+
date_to: datetime,
|
|
99
|
+
group: str | None = None,
|
|
100
|
+
symbol: str | None = None,
|
|
101
|
+
ticket: int | None = None,
|
|
102
|
+
position: int | None = None,
|
|
103
|
+
) -> pd.DataFrame | None:
|
|
104
|
+
"""Return historical deals as a DataFrame, or None when none exist."""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _TradingHistoryDealsClientProtocol(
|
|
109
|
+
_Mt5ClientProtocol,
|
|
110
|
+
_HistoryDealsClientProtocol,
|
|
111
|
+
Protocol,
|
|
112
|
+
):
|
|
113
|
+
"""Combined protocol for trading clients that also support history deal retrieval.
|
|
114
|
+
|
|
115
|
+
The raw ``pdmt5.Mt5DataClient`` returned by :func:`create_trading_client`
|
|
116
|
+
satisfies both :class:`_Mt5ClientProtocol` and
|
|
117
|
+
:class:`_HistoryDealsClientProtocol`, so it satisfies this combined protocol.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
|
|
83
121
|
PositionSide = Literal["long", "short"]
|
|
84
122
|
OrderSide = Literal["BUY", "SELL"]
|
|
85
123
|
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
|
@@ -209,6 +247,7 @@ __all__ = [
|
|
|
209
247
|
"extract_tick_price",
|
|
210
248
|
"fetch_latest_closed_rates_for_trading_client",
|
|
211
249
|
"fetch_latest_closed_rates_indexed",
|
|
250
|
+
"fetch_recent_history_deals_for_trading_client",
|
|
212
251
|
"get_account_snapshot",
|
|
213
252
|
"get_positions_frame",
|
|
214
253
|
"get_symbol_snapshot",
|
|
@@ -579,11 +618,25 @@ def create_trading_client(
|
|
|
579
618
|
path: str | None = None,
|
|
580
619
|
timeout: int | None = None,
|
|
581
620
|
retry_count: int = 0,
|
|
582
|
-
) ->
|
|
621
|
+
) -> _TradingHistoryDealsClientProtocol:
|
|
583
622
|
"""Return an initialized and logged-in trading client.
|
|
584
623
|
|
|
624
|
+
The returned object is a raw ``pdmt5.Mt5DataClient`` instance, not the
|
|
625
|
+
higher-level ``mt5cli.MT5Client`` wrapper. Use ``mt5_session()`` /
|
|
626
|
+
``MT5Client`` for read-only data collection. For live trading helpers
|
|
627
|
+
(margin, volume, order execution, position management) pass the returned
|
|
628
|
+
client to the strategy-agnostic helpers in this module.
|
|
629
|
+
|
|
630
|
+
For history deal retrieval use
|
|
631
|
+
:func:`fetch_recent_history_deals_for_trading_client`; the returned client
|
|
632
|
+
satisfies :class:`_HistoryDealsClientProtocol` so no additional wrapping is
|
|
633
|
+
required.
|
|
634
|
+
|
|
585
635
|
Returns:
|
|
586
|
-
A
|
|
636
|
+
A ``pdmt5.Mt5DataClient`` instance satisfying ``_Mt5ClientProtocol``
|
|
637
|
+
and ``_HistoryDealsClientProtocol``. Caller is responsible for calling
|
|
638
|
+
``client.shutdown()`` when done; prefer ``mt5_trading_session()`` to
|
|
639
|
+
manage lifetime automatically.
|
|
587
640
|
"""
|
|
588
641
|
mt5_config = _resolve_config(
|
|
589
642
|
config=config,
|
|
@@ -1744,6 +1797,86 @@ def fetch_latest_closed_rates_indexed(
|
|
|
1744
1797
|
return result
|
|
1745
1798
|
|
|
1746
1799
|
|
|
1800
|
+
def fetch_recent_history_deals_for_trading_client(
|
|
1801
|
+
client: _HistoryDealsClientProtocol,
|
|
1802
|
+
*,
|
|
1803
|
+
symbol: str | None = None,
|
|
1804
|
+
group: str | None = None,
|
|
1805
|
+
hours: float = 24.0,
|
|
1806
|
+
date_to: datetime | None = None,
|
|
1807
|
+
) -> pd.DataFrame:
|
|
1808
|
+
"""Fetch recent history deals from an already-connected trading client.
|
|
1809
|
+
|
|
1810
|
+
Computes a trailing window ending at ``date_to`` (or ``datetime.now(UTC)``
|
|
1811
|
+
when omitted) and delegates to the client's ``history_deals_get_as_df``
|
|
1812
|
+
method. The object returned by :func:`create_trading_client` (a raw
|
|
1813
|
+
``pdmt5.Mt5DataClient``) satisfies this protocol directly. Note that
|
|
1814
|
+
``mt5cli.sdk.Mt5CliClient`` (used via ``mt5_session()``) exposes
|
|
1815
|
+
``history_deals()``, not ``history_deals_get_as_df()``, and therefore does
|
|
1816
|
+
not satisfy this protocol; use this helper with trading-client sessions only.
|
|
1817
|
+
|
|
1818
|
+
The returned DataFrame preserves every column from the underlying client
|
|
1819
|
+
(``time``, ``symbol``, ``type``, ``entry``, ``volume``, ``profit``,
|
|
1820
|
+
``position_id``, etc.). No strategy-specific transformations are applied;
|
|
1821
|
+
downstream packages own entry/exit classification, Kelly fractions, and
|
|
1822
|
+
any other betting or signal semantics.
|
|
1823
|
+
|
|
1824
|
+
Args:
|
|
1825
|
+
client: Connected ``pdmt5.Mt5DataClient`` (or compatible) with
|
|
1826
|
+
``history_deals_get_as_df`` capability, as returned by
|
|
1827
|
+
:func:`create_trading_client`.
|
|
1828
|
+
symbol: Optional symbol filter passed to the underlying client.
|
|
1829
|
+
group: Optional symbol group filter passed to the underlying client.
|
|
1830
|
+
hours: Trailing window length in hours. Must be positive.
|
|
1831
|
+
date_to: Window end timestamp. Defaults to ``datetime.now(UTC)``.
|
|
1832
|
+
|
|
1833
|
+
Returns:
|
|
1834
|
+
DataFrame ordered chronologically by ``time`` (when the column
|
|
1835
|
+
exists) with a ``RangeIndex``. Schema-preserving empty DataFrames
|
|
1836
|
+
(zero rows but columns present) are passed through with a reset
|
|
1837
|
+
index. Returns a bare empty DataFrame only when the underlying
|
|
1838
|
+
client returns ``None``.
|
|
1839
|
+
|
|
1840
|
+
Raises:
|
|
1841
|
+
ValueError: If ``hours`` is not positive.
|
|
1842
|
+
|
|
1843
|
+
Example::
|
|
1844
|
+
|
|
1845
|
+
from mt5cli import (
|
|
1846
|
+
create_trading_client,
|
|
1847
|
+
fetch_recent_history_deals_for_trading_client,
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
client = create_trading_client(login=12345, server="Broker-Demo")
|
|
1851
|
+
try:
|
|
1852
|
+
deals_df = fetch_recent_history_deals_for_trading_client(
|
|
1853
|
+
client,
|
|
1854
|
+
symbol="JP225",
|
|
1855
|
+
hours=24,
|
|
1856
|
+
)
|
|
1857
|
+
finally:
|
|
1858
|
+
client.shutdown()
|
|
1859
|
+
"""
|
|
1860
|
+
if not isfinite(hours) or hours <= 0:
|
|
1861
|
+
msg = "hours must be finite and positive."
|
|
1862
|
+
raise ValueError(msg)
|
|
1863
|
+
end = date_to if date_to is not None else datetime.now(UTC)
|
|
1864
|
+
start = end - timedelta(hours=hours)
|
|
1865
|
+
raw = client.history_deals_get_as_df(
|
|
1866
|
+
date_from=start,
|
|
1867
|
+
date_to=end,
|
|
1868
|
+
group=group,
|
|
1869
|
+
symbol=symbol,
|
|
1870
|
+
)
|
|
1871
|
+
if raw is None:
|
|
1872
|
+
return pd.DataFrame()
|
|
1873
|
+
if raw.empty:
|
|
1874
|
+
return raw.reset_index(drop=True)
|
|
1875
|
+
if "time" in raw.columns:
|
|
1876
|
+
raw = raw.sort_values("time")
|
|
1877
|
+
return raw.reset_index(drop=True)
|
|
1878
|
+
|
|
1879
|
+
|
|
1747
1880
|
@contextmanager
|
|
1748
1881
|
def mt5_trading_session(
|
|
1749
1882
|
config: Mt5Config | None = None,
|
|
@@ -1754,7 +1887,7 @@ def mt5_trading_session(
|
|
|
1754
1887
|
path: str | None = None,
|
|
1755
1888
|
timeout: int | None = None,
|
|
1756
1889
|
retry_count: int = 0,
|
|
1757
|
-
) -> Iterator[
|
|
1890
|
+
) -> Iterator[_TradingHistoryDealsClientProtocol]:
|
|
1758
1891
|
"""Open a trading-capable MT5 session and always shut down safely.
|
|
1759
1892
|
|
|
1760
1893
|
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "1.1.
|
|
3
|
+
version = "1.1.1"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -72,35 +72,30 @@ class TestExecuteExport:
|
|
|
72
72
|
class TestCommands:
|
|
73
73
|
"""Tests for all CLI subcommands via CliRunner."""
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
@pytest.mark.parametrize(
|
|
76
|
+
("command", "method"),
|
|
77
|
+
[
|
|
78
|
+
("account-info", "account_info_as_df"),
|
|
79
|
+
("terminal-info", "terminal_info_as_df"),
|
|
80
|
+
("positions", "positions_get_as_df"),
|
|
81
|
+
("version", "version_as_df"),
|
|
82
|
+
("last-error", "last_error_as_df"),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
def test_simple_command(
|
|
76
86
|
self,
|
|
77
87
|
tmp_path: Path,
|
|
78
88
|
mock_client: MagicMock,
|
|
89
|
+
command: str,
|
|
90
|
+
method: str,
|
|
79
91
|
) -> None:
|
|
80
|
-
"""
|
|
92
|
+
"""Simple no-arg commands invoke the expected client method."""
|
|
81
93
|
output = tmp_path / "out.csv"
|
|
82
|
-
result = runner.invoke(
|
|
83
|
-
app,
|
|
84
|
-
["-o", str(output), "account-info"],
|
|
85
|
-
)
|
|
94
|
+
result = runner.invoke(app, ["-o", str(output), command])
|
|
86
95
|
assert result.exit_code == 0, result.output
|
|
87
|
-
mock_client.
|
|
96
|
+
getattr(mock_client, method).assert_called_once()
|
|
88
97
|
assert output.exists()
|
|
89
98
|
|
|
90
|
-
def test_terminal_info(
|
|
91
|
-
self,
|
|
92
|
-
tmp_path: Path,
|
|
93
|
-
mock_client: MagicMock,
|
|
94
|
-
) -> None:
|
|
95
|
-
"""Test terminal-info command."""
|
|
96
|
-
output = tmp_path / "out.csv"
|
|
97
|
-
result = runner.invoke(
|
|
98
|
-
app,
|
|
99
|
-
["-o", str(output), "terminal-info"],
|
|
100
|
-
)
|
|
101
|
-
assert result.exit_code == 0, result.output
|
|
102
|
-
mock_client.terminal_info_as_df.assert_called_once()
|
|
103
|
-
|
|
104
99
|
def test_symbols(
|
|
105
100
|
self,
|
|
106
101
|
tmp_path: Path,
|
|
@@ -398,20 +393,6 @@ class TestCommands:
|
|
|
398
393
|
assert result.exit_code == 0, result.output
|
|
399
394
|
mock_client.orders_get_as_df.assert_called_once()
|
|
400
395
|
|
|
401
|
-
def test_positions(
|
|
402
|
-
self,
|
|
403
|
-
tmp_path: Path,
|
|
404
|
-
mock_client: MagicMock,
|
|
405
|
-
) -> None:
|
|
406
|
-
"""Test positions command."""
|
|
407
|
-
output = tmp_path / "out.csv"
|
|
408
|
-
result = runner.invoke(
|
|
409
|
-
app,
|
|
410
|
-
["-o", str(output), "positions"],
|
|
411
|
-
)
|
|
412
|
-
assert result.exit_code == 0, result.output
|
|
413
|
-
mock_client.positions_get_as_df.assert_called_once()
|
|
414
|
-
|
|
415
396
|
def test_history_orders(
|
|
416
397
|
self,
|
|
417
398
|
tmp_path: Path,
|
|
@@ -532,28 +513,6 @@ class TestCommands:
|
|
|
532
513
|
"symbols_total": 42,
|
|
533
514
|
}
|
|
534
515
|
|
|
535
|
-
def test_version(
|
|
536
|
-
self,
|
|
537
|
-
tmp_path: Path,
|
|
538
|
-
mock_client: MagicMock,
|
|
539
|
-
) -> None:
|
|
540
|
-
"""Test version command."""
|
|
541
|
-
output = tmp_path / "out.csv"
|
|
542
|
-
result = runner.invoke(app, ["-o", str(output), "version"])
|
|
543
|
-
assert result.exit_code == 0, result.output
|
|
544
|
-
mock_client.version_as_df.assert_called_once()
|
|
545
|
-
|
|
546
|
-
def test_last_error(
|
|
547
|
-
self,
|
|
548
|
-
tmp_path: Path,
|
|
549
|
-
mock_client: MagicMock,
|
|
550
|
-
) -> None:
|
|
551
|
-
"""Test last-error command."""
|
|
552
|
-
output = tmp_path / "out.csv"
|
|
553
|
-
result = runner.invoke(app, ["-o", str(output), "last-error"])
|
|
554
|
-
assert result.exit_code == 0, result.output
|
|
555
|
-
mock_client.last_error_as_df.assert_called_once()
|
|
556
|
-
|
|
557
516
|
def test_symbol_info_tick(
|
|
558
517
|
self,
|
|
559
518
|
tmp_path: Path,
|
|
@@ -755,38 +714,19 @@ class TestHelpText:
|
|
|
755
714
|
output = normalize_cli_output(result.output)
|
|
756
715
|
assert "execution" in output.lower()
|
|
757
716
|
|
|
758
|
-
|
|
759
|
-
|
|
717
|
+
@pytest.mark.parametrize("panel", ["Execution", "Data / Export"])
|
|
718
|
+
def test_top_level_help_has_panel(self, panel: str) -> None:
|
|
719
|
+
"""Top-level help must show all command group panels."""
|
|
760
720
|
result = runner.invoke(app, ["--help"])
|
|
761
721
|
assert result.exit_code == 0
|
|
762
|
-
assert
|
|
763
|
-
|
|
764
|
-
def test_top_level_help_has_data_export_panel(self) -> None:
|
|
765
|
-
"""Top-level help must show a Data / Export command group."""
|
|
766
|
-
result = runner.invoke(app, ["--help"])
|
|
767
|
-
assert result.exit_code == 0
|
|
768
|
-
assert "Data / Export" in result.output
|
|
769
|
-
|
|
770
|
-
def test_order_send_help_mentions_expert_and_raw(self) -> None:
|
|
771
|
-
"""order-send help must communicate it is the expert raw-request path."""
|
|
772
|
-
result2 = runner.invoke(
|
|
773
|
-
app,
|
|
774
|
-
["-o", "out.csv", "order-send", "--help"],
|
|
775
|
-
)
|
|
776
|
-
assert result2.exit_code == 0
|
|
777
|
-
output = normalize_cli_output(result2.output)
|
|
778
|
-
assert "raw" in output.lower()
|
|
779
|
-
assert "expert" in output.lower()
|
|
722
|
+
assert panel in result.output
|
|
780
723
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
["-o", "out.csv", "order-send", "--help"],
|
|
786
|
-
)
|
|
724
|
+
@pytest.mark.parametrize("keyword", ["raw", "expert", "live"])
|
|
725
|
+
def test_order_send_help_keywords(self, keyword: str) -> None:
|
|
726
|
+
"""order-send help must mention raw, expert, and live."""
|
|
727
|
+
result = runner.invoke(app, ["-o", "out.csv", "order-send", "--help"])
|
|
787
728
|
assert result.exit_code == 0
|
|
788
|
-
|
|
789
|
-
assert "live" in output.lower()
|
|
729
|
+
assert keyword in normalize_cli_output(result.output).lower()
|
|
790
730
|
|
|
791
731
|
def test_close_positions_help_mentions_dry_run_and_yes(self) -> None:
|
|
792
732
|
"""close-positions help must document both safety gates."""
|
|
@@ -1952,75 +1892,31 @@ class TestSnapshotCommand:
|
|
|
1952
1892
|
kwargs = updater.call_args.kwargs
|
|
1953
1893
|
assert kwargs["symbols"] == ["EURUSD", "GBPUSD"]
|
|
1954
1894
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
assert result.exit_code == 0, result.output
|
|
1967
|
-
assert updater.call_args.kwargs["include_account"] is False
|
|
1968
|
-
|
|
1969
|
-
def test_snapshot_with_no_positions_flag(
|
|
1970
|
-
self,
|
|
1971
|
-
tmp_path: Path,
|
|
1972
|
-
mocker: MockerFixture,
|
|
1973
|
-
) -> None:
|
|
1974
|
-
"""--no-positions disables position snapshotting."""
|
|
1975
|
-
updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
|
|
1976
|
-
result = runner.invoke(
|
|
1977
|
-
app,
|
|
1978
|
-
["-o", str(tmp_path / "out.db"), "snapshot", "--no-positions"],
|
|
1979
|
-
)
|
|
1980
|
-
assert result.exit_code == 0, result.output
|
|
1981
|
-
assert updater.call_args.kwargs["include_positions"] is False
|
|
1982
|
-
|
|
1983
|
-
def test_snapshot_with_no_orders_flag(
|
|
1984
|
-
self,
|
|
1985
|
-
tmp_path: Path,
|
|
1986
|
-
mocker: MockerFixture,
|
|
1987
|
-
) -> None:
|
|
1988
|
-
"""--no-orders disables order snapshotting."""
|
|
1989
|
-
updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
|
|
1990
|
-
result = runner.invoke(
|
|
1991
|
-
app,
|
|
1992
|
-
["-o", str(tmp_path / "out.db"), "snapshot", "--no-orders"],
|
|
1993
|
-
)
|
|
1994
|
-
assert result.exit_code == 0, result.output
|
|
1995
|
-
assert updater.call_args.kwargs["include_orders"] is False
|
|
1996
|
-
|
|
1997
|
-
def test_snapshot_with_no_terminal_flag(
|
|
1998
|
-
self,
|
|
1999
|
-
tmp_path: Path,
|
|
2000
|
-
mocker: MockerFixture,
|
|
2001
|
-
) -> None:
|
|
2002
|
-
"""--no-terminal disables terminal snapshotting."""
|
|
2003
|
-
updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
|
|
2004
|
-
result = runner.invoke(
|
|
2005
|
-
app,
|
|
2006
|
-
["-o", str(tmp_path / "out.db"), "snapshot", "--no-terminal"],
|
|
2007
|
-
)
|
|
2008
|
-
assert result.exit_code == 0, result.output
|
|
2009
|
-
assert updater.call_args.kwargs["include_terminal"] is False
|
|
2010
|
-
|
|
2011
|
-
def test_snapshot_with_no_grafana_schema_flag(
|
|
1895
|
+
@pytest.mark.parametrize(
|
|
1896
|
+
("flag", "kwarg"),
|
|
1897
|
+
[
|
|
1898
|
+
("--no-account", "include_account"),
|
|
1899
|
+
("--no-positions", "include_positions"),
|
|
1900
|
+
("--no-orders", "include_orders"),
|
|
1901
|
+
("--no-terminal", "include_terminal"),
|
|
1902
|
+
("--no-grafana-schema", "with_grafana_schema"),
|
|
1903
|
+
],
|
|
1904
|
+
)
|
|
1905
|
+
def test_snapshot_with_no_flag(
|
|
2012
1906
|
self,
|
|
2013
1907
|
tmp_path: Path,
|
|
2014
1908
|
mocker: MockerFixture,
|
|
1909
|
+
flag: str,
|
|
1910
|
+
kwarg: str,
|
|
2015
1911
|
) -> None:
|
|
2016
|
-
"""--no-
|
|
1912
|
+
"""Snapshot --no-X flags disable the corresponding snapshot component."""
|
|
2017
1913
|
updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
|
|
2018
1914
|
result = runner.invoke(
|
|
2019
1915
|
app,
|
|
2020
|
-
["-o", str(tmp_path / "out.db"), "snapshot",
|
|
1916
|
+
["-o", str(tmp_path / "out.db"), "snapshot", flag],
|
|
2021
1917
|
)
|
|
2022
1918
|
assert result.exit_code == 0, result.output
|
|
2023
|
-
assert updater.call_args.kwargs[
|
|
1919
|
+
assert updater.call_args.kwargs[kwarg] is False
|
|
2024
1920
|
|
|
2025
1921
|
def test_snapshot_with_publish_copy(
|
|
2026
1922
|
self,
|
|
@@ -2861,57 +2861,29 @@ class TestUpdateObservability:
|
|
|
2861
2861
|
)
|
|
2862
2862
|
spy.assert_called_once()
|
|
2863
2863
|
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
)
|
|
2875
|
-
mock_client.account_info_as_df.assert_not_called()
|
|
2876
|
-
|
|
2877
|
-
def test_update_observability_skips_positions_when_disabled(
|
|
2878
|
-
self,
|
|
2879
|
-
mock_client: MagicMock,
|
|
2880
|
-
tmp_path: Path,
|
|
2881
|
-
) -> None:
|
|
2882
|
-
"""include_positions=False does not call positions_get_as_df."""
|
|
2883
|
-
update_observability(
|
|
2884
|
-
client=mock_client,
|
|
2885
|
-
output=tmp_path / "obs.db",
|
|
2886
|
-
include_positions=False,
|
|
2887
|
-
)
|
|
2888
|
-
mock_client.positions_get_as_df.assert_not_called()
|
|
2889
|
-
|
|
2890
|
-
def test_update_observability_skips_orders_when_disabled(
|
|
2891
|
-
self,
|
|
2892
|
-
mock_client: MagicMock,
|
|
2893
|
-
tmp_path: Path,
|
|
2894
|
-
) -> None:
|
|
2895
|
-
"""include_orders=False does not call orders_get_as_df."""
|
|
2896
|
-
update_observability(
|
|
2897
|
-
client=mock_client,
|
|
2898
|
-
output=tmp_path / "obs.db",
|
|
2899
|
-
include_orders=False,
|
|
2900
|
-
)
|
|
2901
|
-
mock_client.orders_get_as_df.assert_not_called()
|
|
2902
|
-
|
|
2903
|
-
def test_update_observability_skips_terminal_when_disabled(
|
|
2864
|
+
@pytest.mark.parametrize(
|
|
2865
|
+
("kwarg", "method"),
|
|
2866
|
+
[
|
|
2867
|
+
("include_account", "account_info_as_df"),
|
|
2868
|
+
("include_positions", "positions_get_as_df"),
|
|
2869
|
+
("include_orders", "orders_get_as_df"),
|
|
2870
|
+
("include_terminal", "terminal_info_as_df"),
|
|
2871
|
+
],
|
|
2872
|
+
)
|
|
2873
|
+
def test_update_observability_skips_when_disabled(
|
|
2904
2874
|
self,
|
|
2905
2875
|
mock_client: MagicMock,
|
|
2906
2876
|
tmp_path: Path,
|
|
2877
|
+
kwarg: str,
|
|
2878
|
+
method: str,
|
|
2907
2879
|
) -> None:
|
|
2908
|
-
"""
|
|
2880
|
+
"""include_X=False does not call the corresponding client method."""
|
|
2909
2881
|
update_observability(
|
|
2910
2882
|
client=mock_client,
|
|
2911
2883
|
output=tmp_path / "obs.db",
|
|
2912
|
-
|
|
2884
|
+
**{kwarg: False}, # type: ignore[arg-type]
|
|
2913
2885
|
)
|
|
2914
|
-
mock_client.
|
|
2886
|
+
getattr(mock_client, method).assert_not_called()
|
|
2915
2887
|
|
|
2916
2888
|
def test_update_observability_with_positions_rows(
|
|
2917
2889
|
self,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
6
7
|
from types import SimpleNamespace
|
|
7
8
|
from typing import Any, cast, get_args
|
|
8
9
|
from unittest.mock import MagicMock
|
|
@@ -41,6 +42,7 @@ from mt5cli.trading import (
|
|
|
41
42
|
extract_tick_price,
|
|
42
43
|
fetch_latest_closed_rates_for_trading_client,
|
|
43
44
|
fetch_latest_closed_rates_indexed,
|
|
45
|
+
fetch_recent_history_deals_for_trading_client,
|
|
44
46
|
get_account_snapshot,
|
|
45
47
|
get_positions_frame,
|
|
46
48
|
get_symbol_snapshot,
|
|
@@ -3817,3 +3819,177 @@ class TestCalculatePositionsMarginSafe:
|
|
|
3817
3819
|
client = _mock_trade_client()
|
|
3818
3820
|
total = calculate_positions_margin_safe(client, symbols=[])
|
|
3819
3821
|
_assert_close(total, 0.0)
|
|
3822
|
+
|
|
3823
|
+
|
|
3824
|
+
class TestFetchRecentHistoryDealsForTradingClient:
|
|
3825
|
+
"""Tests for fetch_recent_history_deals_for_trading_client."""
|
|
3826
|
+
|
|
3827
|
+
def _fake_client(self, return_value: pd.DataFrame | None) -> MagicMock:
|
|
3828
|
+
client = MagicMock()
|
|
3829
|
+
client.history_deals_get_as_df.return_value = return_value
|
|
3830
|
+
return client
|
|
3831
|
+
|
|
3832
|
+
def test_passes_correct_date_range_and_filters(self) -> None:
|
|
3833
|
+
"""Calls history_deals_get_as_df with derived date_from/date_to."""
|
|
3834
|
+
anchor = datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)
|
|
3835
|
+
client = self._fake_client(pd.DataFrame())
|
|
3836
|
+
|
|
3837
|
+
fetch_recent_history_deals_for_trading_client(
|
|
3838
|
+
client,
|
|
3839
|
+
symbol="JP225",
|
|
3840
|
+
group="FX*",
|
|
3841
|
+
hours=6.0,
|
|
3842
|
+
date_to=anchor,
|
|
3843
|
+
)
|
|
3844
|
+
|
|
3845
|
+
client.history_deals_get_as_df.assert_called_once_with(
|
|
3846
|
+
date_from=anchor - timedelta(hours=6.0),
|
|
3847
|
+
date_to=anchor,
|
|
3848
|
+
group="FX*",
|
|
3849
|
+
symbol="JP225",
|
|
3850
|
+
)
|
|
3851
|
+
|
|
3852
|
+
def test_raises_for_zero_hours(self) -> None:
|
|
3853
|
+
"""hours=0 raises ValueError."""
|
|
3854
|
+
client = self._fake_client(pd.DataFrame())
|
|
3855
|
+
with pytest.raises(ValueError, match="hours must be finite and positive"):
|
|
3856
|
+
fetch_recent_history_deals_for_trading_client(client, hours=0)
|
|
3857
|
+
|
|
3858
|
+
def test_raises_for_negative_hours(self) -> None:
|
|
3859
|
+
"""Negative hours raises ValueError."""
|
|
3860
|
+
client = self._fake_client(pd.DataFrame())
|
|
3861
|
+
with pytest.raises(ValueError, match="hours must be finite and positive"):
|
|
3862
|
+
fetch_recent_history_deals_for_trading_client(client, hours=-1.0)
|
|
3863
|
+
|
|
3864
|
+
@pytest.mark.parametrize("bad_hours", [float("nan"), float("inf"), float("-inf")])
|
|
3865
|
+
def test_raises_for_non_finite_hours(self, bad_hours: float) -> None:
|
|
3866
|
+
"""nan, inf, and -inf raise ValueError before reaching timedelta."""
|
|
3867
|
+
client = self._fake_client(pd.DataFrame())
|
|
3868
|
+
with pytest.raises(ValueError, match="hours must be finite and positive"):
|
|
3869
|
+
fetch_recent_history_deals_for_trading_client(client, hours=bad_hours)
|
|
3870
|
+
|
|
3871
|
+
def test_none_result_returns_empty_dataframe(self) -> None:
|
|
3872
|
+
"""None from underlying client becomes an empty DataFrame."""
|
|
3873
|
+
client = self._fake_client(None)
|
|
3874
|
+
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
|
|
3875
|
+
assert isinstance(result, pd.DataFrame)
|
|
3876
|
+
assert result.empty
|
|
3877
|
+
|
|
3878
|
+
def test_empty_dataframe_result_preserves_schema(self) -> None:
|
|
3879
|
+
"""Empty DataFrame from client is returned with its columns intact."""
|
|
3880
|
+
schema_df = pd.DataFrame(columns=["time", "symbol", "profit", "volume"])
|
|
3881
|
+
client = self._fake_client(schema_df)
|
|
3882
|
+
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
|
|
3883
|
+
assert result.empty
|
|
3884
|
+
assert list(result.columns) == ["time", "symbol", "profit", "volume"]
|
|
3885
|
+
|
|
3886
|
+
def test_none_result_returns_bare_empty_dataframe(self) -> None:
|
|
3887
|
+
"""None from client becomes a bare empty DataFrame (no columns)."""
|
|
3888
|
+
client = self._fake_client(None)
|
|
3889
|
+
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
|
|
3890
|
+
assert result.empty
|
|
3891
|
+
assert list(result.columns) == []
|
|
3892
|
+
|
|
3893
|
+
def test_sorts_by_time_and_resets_index(self) -> None:
|
|
3894
|
+
"""Unsorted time rows are sorted chronologically and index is reset."""
|
|
3895
|
+
t1 = datetime(2024, 6, 1, 9, 0, tzinfo=UTC)
|
|
3896
|
+
t2 = datetime(2024, 6, 1, 10, 0, tzinfo=UTC)
|
|
3897
|
+
t3 = datetime(2024, 6, 1, 11, 0, tzinfo=UTC)
|
|
3898
|
+
df = pd.DataFrame({"time": [t3, t1, t2], "profit": [3.0, 1.0, 2.0]})
|
|
3899
|
+
client = self._fake_client(df)
|
|
3900
|
+
|
|
3901
|
+
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
|
|
3902
|
+
|
|
3903
|
+
assert list(result["time"]) == [t1, t2, t3]
|
|
3904
|
+
assert list(result.index) == [0, 1, 2]
|
|
3905
|
+
|
|
3906
|
+
def test_preserves_all_columns(self) -> None:
|
|
3907
|
+
"""No columns are dropped from the underlying client result."""
|
|
3908
|
+
anchor = datetime(2024, 6, 1, 12, 0, tzinfo=UTC)
|
|
3909
|
+
df = pd.DataFrame({
|
|
3910
|
+
"time": [anchor],
|
|
3911
|
+
"symbol": ["JP225"],
|
|
3912
|
+
"type": [0],
|
|
3913
|
+
"entry": [1],
|
|
3914
|
+
"volume": [0.1],
|
|
3915
|
+
"profit": [50.0],
|
|
3916
|
+
"position_id": [123456],
|
|
3917
|
+
"commission": [-0.5],
|
|
3918
|
+
})
|
|
3919
|
+
client = self._fake_client(df)
|
|
3920
|
+
|
|
3921
|
+
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
|
|
3922
|
+
|
|
3923
|
+
assert set(result.columns) == {
|
|
3924
|
+
"time",
|
|
3925
|
+
"symbol",
|
|
3926
|
+
"type",
|
|
3927
|
+
"entry",
|
|
3928
|
+
"volume",
|
|
3929
|
+
"profit",
|
|
3930
|
+
"position_id",
|
|
3931
|
+
"commission",
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
def test_no_time_column_still_returns_data(self) -> None:
|
|
3935
|
+
"""DataFrames without a time column are returned with RangeIndex."""
|
|
3936
|
+
df = pd.DataFrame({"profit": [1.0, 2.0], "ticket": [10, 11]})
|
|
3937
|
+
client = self._fake_client(df)
|
|
3938
|
+
|
|
3939
|
+
result = fetch_recent_history_deals_for_trading_client(client, hours=24.0)
|
|
3940
|
+
|
|
3941
|
+
assert list(result["profit"]) == [1.0, 2.0]
|
|
3942
|
+
assert list(result.index) == [0, 1]
|
|
3943
|
+
|
|
3944
|
+
def test_defaults_date_to_to_utc_now(self, mocker: MockerFixture) -> None:
|
|
3945
|
+
"""When date_to is omitted, the window end is datetime.now(UTC)."""
|
|
3946
|
+
frozen = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
|
|
3947
|
+
mock_dt = mocker.patch("mt5cli.trading.datetime")
|
|
3948
|
+
mock_dt.now.return_value = frozen
|
|
3949
|
+
client = self._fake_client(pd.DataFrame())
|
|
3950
|
+
|
|
3951
|
+
fetch_recent_history_deals_for_trading_client(client, hours=1.0)
|
|
3952
|
+
|
|
3953
|
+
mock_dt.now.assert_called_once_with(UTC)
|
|
3954
|
+
client.history_deals_get_as_df.assert_called_once_with(
|
|
3955
|
+
date_from=frozen - timedelta(hours=1.0),
|
|
3956
|
+
date_to=frozen,
|
|
3957
|
+
group=None,
|
|
3958
|
+
symbol=None,
|
|
3959
|
+
)
|
|
3960
|
+
|
|
3961
|
+
|
|
3962
|
+
class TestCreateTradingClientHistoryDealsIntegration:
|
|
3963
|
+
"""Verify create_trading_client() result satisfies fetch_recent_history_deals."""
|
|
3964
|
+
|
|
3965
|
+
def test_create_trading_client_result_usable_with_history_deals_helper(
|
|
3966
|
+
self,
|
|
3967
|
+
mocker: MockerFixture,
|
|
3968
|
+
) -> None:
|
|
3969
|
+
"""create_trading_client() result passes directly to fetch_recent_history_deals.
|
|
3970
|
+
|
|
3971
|
+
This exercises the intended SDK call path without a live MT5 terminal.
|
|
3972
|
+
The mock satisfies both _Mt5ClientProtocol and _HistoryDealsClientProtocol.
|
|
3973
|
+
"""
|
|
3974
|
+
mock_raw_client = MagicMock()
|
|
3975
|
+
mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_raw_client)
|
|
3976
|
+
|
|
3977
|
+
anchor = datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)
|
|
3978
|
+
expected_df = pd.DataFrame({"time": [anchor], "profit": [10.0]})
|
|
3979
|
+
mock_raw_client.history_deals_get_as_df.return_value = expected_df
|
|
3980
|
+
|
|
3981
|
+
client = create_trading_client(login=12345, server="Demo")
|
|
3982
|
+
result = fetch_recent_history_deals_for_trading_client(
|
|
3983
|
+
client,
|
|
3984
|
+
symbol="EURUSD",
|
|
3985
|
+
hours=6.0,
|
|
3986
|
+
date_to=anchor,
|
|
3987
|
+
)
|
|
3988
|
+
|
|
3989
|
+
mock_raw_client.history_deals_get_as_df.assert_called_once_with(
|
|
3990
|
+
date_from=anchor - timedelta(hours=6.0),
|
|
3991
|
+
date_to=anchor,
|
|
3992
|
+
group=None,
|
|
3993
|
+
symbol="EURUSD",
|
|
3994
|
+
)
|
|
3995
|
+
assert list(result["profit"]) == [10.0]
|
|
@@ -370,7 +370,7 @@ name = "metatrader5"
|
|
|
370
370
|
version = "5.0.5640"
|
|
371
371
|
source = { registry = "https://pypi.org/simple" }
|
|
372
372
|
dependencies = [
|
|
373
|
-
{ name = "numpy"
|
|
373
|
+
{ name = "numpy" },
|
|
374
374
|
]
|
|
375
375
|
wheels = [
|
|
376
376
|
{ 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 = [
|
|
|
499
499
|
|
|
500
500
|
[[package]]
|
|
501
501
|
name = "mt5cli"
|
|
502
|
-
version = "1.1.
|
|
502
|
+
version = "1.1.1"
|
|
503
503
|
source = { editable = "." }
|
|
504
504
|
dependencies = [
|
|
505
505
|
{ name = "click" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|