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.
Files changed (66) hide show
  1. {mt5cli-1.1.0 → mt5cli-1.1.1}/PKG-INFO +1 -1
  2. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/public-contract.md +5 -1
  3. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/trading.md +55 -0
  4. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/__init__.py +2 -0
  5. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/contract.py +1 -0
  6. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/trading.py +136 -3
  7. {mt5cli-1.1.0 → mt5cli-1.1.1}/pyproject.toml +1 -1
  8. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_cli.py +41 -145
  9. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_sdk.py +15 -43
  10. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_trading.py +176 -0
  11. {mt5cli-1.1.0 → mt5cli-1.1.1}/uv.lock +2 -2
  12. {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/local-qa/SKILL.md +0 -0
  13. {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  14. {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/mt5cli/SKILL.md +0 -0
  15. {mt5cli-1.1.0 → mt5cli-1.1.1}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  16. {mt5cli-1.1.0 → mt5cli-1.1.1}/.claude/agents/codex.md +0 -0
  17. {mt5cli-1.1.0 → mt5cli-1.1.1}/.claude/settings.json +0 -0
  18. {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/FUNDING.yml +0 -0
  19. {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/dependabot.yml +0 -0
  20. {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/renovate.json +0 -0
  21. {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/workflows/ci.yml +0 -0
  22. {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/workflows/claude.yml +0 -0
  23. {mt5cli-1.1.0 → mt5cli-1.1.1}/.github/workflows/release.yml +0 -0
  24. {mt5cli-1.1.0 → mt5cli-1.1.1}/.gitignore +0 -0
  25. {mt5cli-1.1.0 → mt5cli-1.1.1}/AGENTS.md +0 -0
  26. {mt5cli-1.1.0 → mt5cli-1.1.1}/CLAUDE.md +0 -0
  27. {mt5cli-1.1.0 → mt5cli-1.1.1}/LICENSE +0 -0
  28. {mt5cli-1.1.0 → mt5cli-1.1.1}/README.md +0 -0
  29. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/cli.md +0 -0
  30. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/client.md +0 -0
  31. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/converters.md +0 -0
  32. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/exceptions.md +0 -0
  33. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/history.md +0 -0
  34. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/index.md +0 -0
  35. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/schemas.md +0 -0
  36. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/sdk.md +0 -0
  37. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/api/utils.md +0 -0
  38. {mt5cli-1.1.0 → mt5cli-1.1.1}/docs/index.md +0 -0
  39. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/README.md +0 -0
  40. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/compose.yml +0 -0
  41. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  42. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  43. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  44. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  45. {mt5cli-1.1.0 → mt5cli-1.1.1}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  46. {mt5cli-1.1.0 → mt5cli-1.1.1}/mkdocs.yml +0 -0
  47. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/__main__.py +0 -0
  48. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/cli.py +0 -0
  49. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/client.py +0 -0
  50. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/converters.py +0 -0
  51. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/exceptions.py +0 -0
  52. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/grafana.py +0 -0
  53. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/history.py +0 -0
  54. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/retry.py +0 -0
  55. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/schemas.py +0 -0
  56. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/sdk.py +0 -0
  57. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/telemetry.py +0 -0
  58. {mt5cli-1.1.0 → mt5cli-1.1.1}/mt5cli/utils.py +0 -0
  59. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/__init__.py +0 -0
  60. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/conftest.py +0 -0
  61. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_contracts.py +0 -0
  62. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_examples.py +0 -0
  63. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_grafana.py +0 -0
  64. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_history.py +0 -0
  65. {mt5cli-1.1.0 → mt5cli-1.1.1}/tests/test_telemetry.py +0 -0
  66. {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.0
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 client supporting order execution and account management |
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
- ) -> _Mt5ClientProtocol:
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 client instance supporting the required MT5 trading methods.
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[_Mt5ClientProtocol]:
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.0"
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
- def test_account_info(
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
- """Test account-info command."""
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.account_info_as_df.assert_called_once()
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
- def test_top_level_help_has_execution_panel(self) -> None:
759
- """Top-level help must show an Execution command group."""
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 "Execution" in result.output
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
- def test_order_send_help_mentions_live_execution(self) -> None:
782
- """order-send help must warn about live execution."""
783
- result = runner.invoke(
784
- app,
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
- output = normalize_cli_output(result.output)
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
- def test_snapshot_with_no_account_flag(
1956
- self,
1957
- tmp_path: Path,
1958
- mocker: MockerFixture,
1959
- ) -> None:
1960
- """--no-account disables account snapshotting."""
1961
- updater = mocker.patch("mt5cli.cli.sdk.update_observability_with_config")
1962
- result = runner.invoke(
1963
- app,
1964
- ["-o", str(tmp_path / "out.db"), "snapshot", "--no-account"],
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-grafana-schema disables Grafana schema creation."""
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", "--no-grafana-schema"],
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["with_grafana_schema"] is False
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
- def test_update_observability_skips_account_when_disabled(
2865
- self,
2866
- mock_client: MagicMock,
2867
- tmp_path: Path,
2868
- ) -> None:
2869
- """include_account=False does not call account_info_as_df."""
2870
- update_observability(
2871
- client=mock_client,
2872
- output=tmp_path / "obs.db",
2873
- include_account=False,
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
- """include_terminal=False does not call terminal_info_as_df."""
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
- include_terminal=False,
2884
+ **{kwarg: False}, # type: ignore[arg-type]
2913
2885
  )
2914
- mock_client.terminal_info_as_df.assert_not_called()
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", marker = "sys_platform == 'win32'" },
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.0"
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