mt5cli 0.6.0__tar.gz → 0.6.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 (40) hide show
  1. {mt5cli-0.6.0 → mt5cli-0.6.1}/PKG-INFO +60 -2
  2. {mt5cli-0.6.0 → mt5cli-0.6.1}/README.md +59 -1
  3. {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/index.md +7 -2
  4. {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/sdk.md +14 -3
  5. mt5cli-0.6.1/docs/api/trading.md +70 -0
  6. {mt5cli-0.6.0 → mt5cli-0.6.1}/mkdocs.yml +1 -0
  7. {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/__init__.py +10 -0
  8. {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/sdk.py +85 -9
  9. mt5cli-0.6.1/mt5cli/trading.py +210 -0
  10. {mt5cli-0.6.0 → mt5cli-0.6.1}/pyproject.toml +1 -1
  11. {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_sdk.py +139 -2
  12. mt5cli-0.6.1/tests/test_trading.py +356 -0
  13. {mt5cli-0.6.0 → mt5cli-0.6.1}/uv.lock +1 -1
  14. {mt5cli-0.6.0 → mt5cli-0.6.1}/.agents/skills/local-qa/SKILL.md +0 -0
  15. {mt5cli-0.6.0 → mt5cli-0.6.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  16. {mt5cli-0.6.0 → mt5cli-0.6.1}/.agents/skills/mt5cli/SKILL.md +0 -0
  17. {mt5cli-0.6.0 → mt5cli-0.6.1}/.claude/agents/codex.md +0 -0
  18. {mt5cli-0.6.0 → mt5cli-0.6.1}/.claude/settings.json +0 -0
  19. {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/FUNDING.yml +0 -0
  20. {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/dependabot.yml +0 -0
  21. {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/renovate.json +0 -0
  22. {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/workflows/ci.yml +0 -0
  23. {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/workflows/claude.yml +0 -0
  24. {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/workflows/release.yml +0 -0
  25. {mt5cli-0.6.0 → mt5cli-0.6.1}/.gitignore +0 -0
  26. {mt5cli-0.6.0 → mt5cli-0.6.1}/AGENTS.md +0 -0
  27. {mt5cli-0.6.0 → mt5cli-0.6.1}/CLAUDE.md +0 -0
  28. {mt5cli-0.6.0 → mt5cli-0.6.1}/LICENSE +0 -0
  29. {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/cli.md +0 -0
  30. {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/history.md +0 -0
  31. {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/utils.md +0 -0
  32. {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/index.md +0 -0
  33. {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/__main__.py +0 -0
  34. {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/cli.py +0 -0
  35. {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/history.py +0 -0
  36. {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/utils.py +0 -0
  37. {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/__init__.py +0 -0
  38. {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_cli.py +0 -0
  39. {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_history.py +0 -0
  40. {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Command-line tool for MetaTrader 5
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -176,7 +176,8 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
176
176
  ```
177
177
 
178
178
  - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
179
- - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`/`sqlite3.Error` and let the caller decide logging.
179
+ - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate).
180
+ - **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
180
181
  - **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
181
182
  - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
182
183
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
@@ -188,6 +189,63 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
188
189
  - Windows OS (MetaTrader 5 requirement)
189
190
  - MetaTrader 5 platform installed
190
191
 
192
+ ### Migration note for mteor
193
+
194
+ Replace local MT5 lifecycle and trading helper code with mt5cli imports:
195
+
196
+ ```python
197
+ # Before (local mteor helpers)
198
+ # with local_mt5_trading_session(config) as client:
199
+ # side = local_detect_position_side(client, symbol)
200
+ # sizing = local_calculate_margin_and_volume(client, symbol, unit_ratio, preserved_ratio)
201
+ # limits = local_determine_order_limits(client, symbol, side, sl_ratio, tp_ratio)
202
+
203
+ # After (mt5cli shared layer)
204
+ from pdmt5 import Mt5Config
205
+ from mt5cli import (
206
+ calculate_margin_and_volume,
207
+ detect_position_side,
208
+ determine_order_limits,
209
+ mt5_trading_session,
210
+ )
211
+
212
+ with mt5_trading_session(
213
+ Mt5Config(path=terminal_path, login=login), retry_count=2
214
+ ) as client:
215
+ side = detect_position_side(client, symbol)
216
+ sizing = calculate_margin_and_volume(
217
+ client, symbol, unit_margin_ratio=0.5, preserved_margin_ratio=0.2
218
+ )
219
+ if side is not None:
220
+ limits = determine_order_limits(
221
+ client,
222
+ symbol,
223
+ side,
224
+ stop_loss_limit_ratio=0.01,
225
+ take_profit_limit_ratio=0.02,
226
+ )
227
+ ```
228
+
229
+ Throttled history updates use a separate read-only session:
230
+
231
+ ```python
232
+ from pdmt5 import Mt5Config, Mt5DataClient
233
+
234
+ from mt5cli import ThrottledHistoryUpdater
235
+
236
+ updater = ThrottledHistoryUpdater(
237
+ output="history.db", interval_seconds=60, suppress_errors=True
238
+ )
239
+ client = Mt5DataClient(config=Mt5Config(login=login))
240
+ client.initialize_and_login_mt5()
241
+ try:
242
+ updater.update(client, ["EURUSD"])
243
+ finally:
244
+ client.shutdown()
245
+ ```
246
+
247
+ Read-only collectors can keep using `mt5_session()` and `Mt5CliClient` without changes.
248
+
191
249
  ## Development
192
250
 
193
251
  ```bash
@@ -152,7 +152,8 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
152
152
  ```
153
153
 
154
154
  - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
155
- - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`/`sqlite3.Error` and let the caller decide logging.
155
+ - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate).
156
+ - **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
156
157
  - **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
157
158
  - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
158
159
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
@@ -164,6 +165,63 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
164
165
  - Windows OS (MetaTrader 5 requirement)
165
166
  - MetaTrader 5 platform installed
166
167
 
168
+ ### Migration note for mteor
169
+
170
+ Replace local MT5 lifecycle and trading helper code with mt5cli imports:
171
+
172
+ ```python
173
+ # Before (local mteor helpers)
174
+ # with local_mt5_trading_session(config) as client:
175
+ # side = local_detect_position_side(client, symbol)
176
+ # sizing = local_calculate_margin_and_volume(client, symbol, unit_ratio, preserved_ratio)
177
+ # limits = local_determine_order_limits(client, symbol, side, sl_ratio, tp_ratio)
178
+
179
+ # After (mt5cli shared layer)
180
+ from pdmt5 import Mt5Config
181
+ from mt5cli import (
182
+ calculate_margin_and_volume,
183
+ detect_position_side,
184
+ determine_order_limits,
185
+ mt5_trading_session,
186
+ )
187
+
188
+ with mt5_trading_session(
189
+ Mt5Config(path=terminal_path, login=login), retry_count=2
190
+ ) as client:
191
+ side = detect_position_side(client, symbol)
192
+ sizing = calculate_margin_and_volume(
193
+ client, symbol, unit_margin_ratio=0.5, preserved_margin_ratio=0.2
194
+ )
195
+ if side is not None:
196
+ limits = determine_order_limits(
197
+ client,
198
+ symbol,
199
+ side,
200
+ stop_loss_limit_ratio=0.01,
201
+ take_profit_limit_ratio=0.02,
202
+ )
203
+ ```
204
+
205
+ Throttled history updates use a separate read-only session:
206
+
207
+ ```python
208
+ from pdmt5 import Mt5Config, Mt5DataClient
209
+
210
+ from mt5cli import ThrottledHistoryUpdater
211
+
212
+ updater = ThrottledHistoryUpdater(
213
+ output="history.db", interval_seconds=60, suppress_errors=True
214
+ )
215
+ client = Mt5DataClient(config=Mt5Config(login=login))
216
+ client.initialize_and_login_mt5()
217
+ try:
218
+ updater.update(client, ["EURUSD"])
219
+ finally:
220
+ client.shutdown()
221
+ ```
222
+
223
+ Read-only collectors can keep using `mt5_session()` and `Mt5CliClient` without changes.
224
+
167
225
  ## Development
168
226
 
169
227
  ```bash
@@ -18,6 +18,10 @@ Utility module providing constants, enums, Click parameter types, and helper fun
18
18
 
19
19
  Programmatic SDK for read-only MetaTrader 5 data collection. Returns pandas DataFrames and provides `collect_history` for SQLite bulk collection.
20
20
 
21
+ ### [Trading](trading.md)
22
+
23
+ Trading-capable session management and operational helpers built on `pdmt5.Mt5TradingClient`. Complements the read-only SDK without changing existing `Mt5CliClient` behavior.
24
+
21
25
  ### [History Collection (SQLite)](history.md)
22
26
 
23
27
  SQLite storage helpers for the `collect-history` command schema, incremental updates, deduplication, indexes, and optional views.
@@ -28,8 +32,9 @@ The package follows a simple architecture built on top of pdmt5:
28
32
 
29
33
  1. **CLI Layer** (`cli.py`): Typer application with subcommands that delegate to the SDK and export results.
30
34
  2. **SDK Layer** (`sdk.py`): Read-only data access functions, `Mt5CliClient`, and `collect_history` orchestration.
31
- 3. **Utils Layer** (`utils.py`): Constants, enums, custom Click parameter types, parsing helpers, and format detection/export utilities.
32
- 4. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient` and `Mt5Config` from the pdmt5 package for all MetaTrader 5 data access.
35
+ 3. **Trading Layer** (`trading.py`): Trading-capable sessions and operational helpers on `Mt5TradingClient`.
36
+ 4. **Utils Layer** (`utils.py`): Constants, enums, custom Click parameter types, parsing helpers, and format detection/export utilities.
37
+ 5. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient`, `Mt5TradingClient`, and `Mt5Config` from the pdmt5 package for MetaTrader 5 access.
33
38
 
34
39
  ## Usage Guidelines
35
40
 
@@ -98,6 +98,17 @@ finally:
98
98
  client.shutdown()
99
99
  ```
100
100
 
101
- By default `Mt5TradingError`, `Mt5RuntimeError`, and `sqlite3.Error` propagate so
102
- the caller controls logging; pass `suppress_errors=True` to swallow them and
103
- return `False` without advancing the throttle.
101
+ By default recoverable errors (`Mt5TradingError`, `Mt5RuntimeError`,
102
+ `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability
103
+ `AttributeError` / `TypeError` for history API methods) propagate so the caller
104
+ controls logging; pass `suppress_errors=True` to swallow them and return
105
+ `False` without advancing the throttle. Other `AttributeError` / `TypeError`
106
+ values always propagate. Input validation (`_resolve_update_history_request`)
107
+ runs before any MT5 or SQLite calls, but when `suppress_errors=True` the
108
+ resulting `ValueError` is suppressed along with other recoverable errors.
109
+
110
+ ## Trading-capable sessions
111
+
112
+ For order placement and trading calculations, use the dedicated
113
+ [Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
114
+ helpers in this module are unchanged.
@@ -0,0 +1,70 @@
1
+ # Trading Module
2
+
3
+ ::: mt5cli.trading
4
+
5
+ ## Trading-capable MT5 sessions
6
+
7
+ `mt5_trading_session()` complements the read-only `mt5_session()` helper in
8
+ `sdk.py`. It yields a connected `pdmt5.Mt5TradingClient`, uses
9
+ `Mt5Config.path` to launch the terminal when configured, and always calls
10
+ `shutdown()` on exit.
11
+
12
+ ```python
13
+ from pdmt5 import Mt5Config
14
+
15
+ from mt5cli import mt5_trading_session
16
+
17
+ with mt5_trading_session(
18
+ Mt5Config(path=r"C:\Program Files\MetaTrader 5\terminal64.exe", login=12345),
19
+ retry_count=2,
20
+ ) as client:
21
+ positions = client.positions_get_as_df(symbol="EURUSD")
22
+ ```
23
+
24
+ The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
25
+
26
+ ## Operational trading helpers
27
+
28
+ These helpers are strategy-agnostic and do not depend on signal detection,
29
+ betting logic, or scheduling code in downstream applications.
30
+
31
+ ```python
32
+ from mt5cli import (
33
+ calculate_margin_and_volume,
34
+ detect_position_side,
35
+ determine_order_limits,
36
+ )
37
+
38
+ side = detect_position_side(client, "EURUSD")
39
+ sizing = calculate_margin_and_volume(
40
+ client,
41
+ "EURUSD",
42
+ unit_margin_ratio=0.5,
43
+ preserved_margin_ratio=0.2,
44
+ )
45
+ limits = determine_order_limits(
46
+ client,
47
+ "EURUSD",
48
+ side="long",
49
+ stop_loss_limit_ratio=0.01,
50
+ take_profit_limit_ratio=0.02,
51
+ )
52
+ ```
53
+
54
+ Protective ratios must satisfy `0 <= ratio < 1`; `0` omits that level.
55
+ `calculate_margin_and_volume()` clamps negative `margin_free` to `0.0`
56
+ before sizing.
57
+
58
+ ## Migration from mteor-local helpers
59
+
60
+ | mteor-local concern | mt5cli replacement |
61
+ | -------------------------------------------------------- | ----------------------------------------------- |
62
+ | Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
63
+ | Local position-side detection | `detect_position_side()` |
64
+ | Local margin/volume sizing | `calculate_margin_and_volume()` |
65
+ | Local SL/TP price derivation | `determine_order_limits()` |
66
+ | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
67
+
68
+ Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
69
+ `mt5_trading_session()` only where order placement or trading calculations are
70
+ required.
@@ -58,6 +58,7 @@ nav:
58
58
  - Overview: api/index.md
59
59
  - CLI: api/cli.md
60
60
  - SDK: api/sdk.md
61
+ - Trading: api/trading.md
61
62
  - History Collection (SQLite): api/history.md
62
63
  - Utils: api/utils.md
63
64
 
@@ -61,6 +61,12 @@ from .sdk import (
61
61
  from .sdk import (
62
62
  version as mt5_version,
63
63
  )
64
+ from .trading import (
65
+ calculate_margin_and_volume,
66
+ detect_position_side,
67
+ determine_order_limits,
68
+ mt5_trading_session,
69
+ )
64
70
  from .utils import (
65
71
  TICK_FLAG_MAP,
66
72
  TIMEFRAME_MAP,
@@ -89,6 +95,7 @@ __all__ = [
89
95
  "build_config",
90
96
  "build_rate_targets",
91
97
  "build_rate_view_name",
98
+ "calculate_margin_and_volume",
92
99
  "collect_history",
93
100
  "collect_latest_closed_rates_by_granularity",
94
101
  "collect_latest_closed_rates_for_accounts",
@@ -101,6 +108,8 @@ __all__ = [
101
108
  "copy_ticks_from",
102
109
  "copy_ticks_range",
103
110
  "detect_format",
111
+ "detect_position_side",
112
+ "determine_order_limits",
104
113
  "drop_forming_rate_bar",
105
114
  "export_dataframe",
106
115
  "export_dataframe_to_sqlite",
@@ -117,6 +126,7 @@ __all__ = [
117
126
  "mt5_session",
118
127
  "mt5_summary",
119
128
  "mt5_summary_as_df",
129
+ "mt5_trading_session",
120
130
  "mt5_version",
121
131
  "orders",
122
132
  "parse_datetime",
@@ -44,6 +44,62 @@ T = TypeVar("T")
44
44
 
45
45
  logger = logging.getLogger(__name__)
46
46
 
47
+ _RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
48
+ Mt5TradingError,
49
+ Mt5RuntimeError,
50
+ sqlite3.Error,
51
+ ValueError,
52
+ OSError,
53
+ )
54
+
55
+ _MT5_CLIENT_CAPABILITY_METHODS: frozenset[str] = frozenset({
56
+ "copy_rates_range_as_df",
57
+ "copy_ticks_range_as_df",
58
+ "history_deals_get_as_df",
59
+ "history_orders_get_as_df",
60
+ })
61
+ _MT5_HISTORY_MODULE = Path(__file__).with_name("history.py").resolve()
62
+ _MT5_HISTORY_CLIENT_CALL_FUNCTIONS: frozenset[str] = frozenset({
63
+ "write_rates_dataset",
64
+ "write_ticks_dataset",
65
+ "write_history_dataset",
66
+ "_write_incremental_history_deals",
67
+ })
68
+ _NON_CALLABLE_TYPE_ERROR = re.compile(r"^'[^']+' object is not callable$")
69
+
70
+
71
+ def _is_non_callable_history_client_type_error(exc: TypeError) -> bool:
72
+ """Return whether a TypeError came from calling a history client API attribute."""
73
+ if not _NON_CALLABLE_TYPE_ERROR.match(str(exc)):
74
+ return False
75
+ tb = exc.__traceback__
76
+ if tb is None:
77
+ return False
78
+ while tb.tb_next is not None:
79
+ tb = tb.tb_next
80
+ frame = tb.tb_frame
81
+ return (
82
+ frame.f_code.co_name in _MT5_HISTORY_CLIENT_CALL_FUNCTIONS
83
+ and Path(frame.f_code.co_filename).resolve() == _MT5_HISTORY_MODULE
84
+ )
85
+
86
+
87
+ def _is_mt5_client_capability_error(exc: BaseException) -> bool:
88
+ """Return whether an error indicates an incompatible MT5 client API surface."""
89
+ if isinstance(exc, AttributeError):
90
+ msg = str(exc)
91
+ if msg.startswith("MT5 client is missing required method:"):
92
+ return True
93
+ name = getattr(exc, "name", None)
94
+ return isinstance(name, str) and name in _MT5_CLIENT_CAPABILITY_METHODS
95
+ if isinstance(exc, TypeError):
96
+ msg = str(exc)
97
+ if msg.startswith("MT5 client attribute is not callable:"):
98
+ return True
99
+ return _is_non_callable_history_client_type_error(exc)
100
+ return False
101
+
102
+
47
103
  __all__ = [
48
104
  "AccountSpec",
49
105
  "Mt5CliClient",
@@ -1014,10 +1070,14 @@ class ThrottledHistoryUpdater:
1014
1070
  include_account_events: Include account-level cash events.
1015
1071
  interval_seconds: Minimum seconds between successful updates. Values
1016
1072
  ``<= 0`` update on every call.
1017
- suppress_errors: When True, ``Mt5TradingError``, ``Mt5RuntimeError``,
1018
- and ``sqlite3.Error`` raised during an update are swallowed and
1019
- :meth:`update` returns False without advancing the throttle. When
1020
- False (default), such errors propagate so callers control logging.
1073
+ suppress_errors: When True, recoverable errors (``Mt5TradingError``,
1074
+ ``Mt5RuntimeError``, ``sqlite3.Error``, ``ValueError``,
1075
+ ``OSError``, and MT5 client capability ``AttributeError`` /
1076
+ ``TypeError`` for history API methods) raised during an update
1077
+ are swallowed and :meth:`update` returns False without advancing
1078
+ the throttle. Other ``AttributeError`` / ``TypeError`` values
1079
+ always propagate. When False (default), recoverable errors
1080
+ propagate so callers control logging.
1021
1081
  """
1022
1082
  self.output = output
1023
1083
  self.datasets = datasets
@@ -1057,16 +1117,27 @@ class ThrottledHistoryUpdater:
1057
1117
  Returns:
1058
1118
  True if an update ran successfully, False if it was throttled or
1059
1119
  (when ``suppress_errors`` is True) failed with a recoverable error.
1120
+ When ``suppress_errors`` is False, recoverable update failures
1121
+ propagate to the caller.
1060
1122
 
1061
1123
  Raises:
1062
- Mt5TradingError: If the update fails and ``suppress_errors`` is False.
1063
- Mt5RuntimeError: If the update fails and ``suppress_errors`` is False.
1064
- sqlite3.Error: If the SQLite write fails and ``suppress_errors`` is
1065
- False.
1124
+ AttributeError: MT5 client capability mismatch when
1125
+ ``suppress_errors`` is False, or any other attribute error.
1126
+ TypeError: MT5 client capability mismatch when ``suppress_errors``
1127
+ is False, or any other type error.
1066
1128
  """
1067
1129
  if not self.should_update():
1068
1130
  return False
1069
1131
  try:
1132
+ _resolve_update_history_request(
1133
+ output=self.output,
1134
+ symbols=symbols,
1135
+ datasets=self.datasets,
1136
+ timeframes=self.timeframes,
1137
+ flags=self.flags,
1138
+ lookback_hours=self.lookback_hours,
1139
+ date_to=None,
1140
+ )
1070
1141
  update_history(
1071
1142
  client=client,
1072
1143
  output=self.output,
@@ -1078,11 +1149,16 @@ class ThrottledHistoryUpdater:
1078
1149
  with_views=self.with_views,
1079
1150
  include_account_events=self.include_account_events,
1080
1151
  )
1081
- except (Mt5TradingError, Mt5RuntimeError, sqlite3.Error):
1152
+ except _RECOVERABLE_HISTORY_UPDATE_ERRORS:
1082
1153
  if self.suppress_errors:
1083
1154
  logger.warning("Suppressed history update error", exc_info=True)
1084
1155
  return False
1085
1156
  raise
1157
+ except (AttributeError, TypeError) as exc:
1158
+ if self.suppress_errors and _is_mt5_client_capability_error(exc):
1159
+ logger.warning("Suppressed history update error", exc_info=True)
1160
+ return False
1161
+ raise
1086
1162
  self._last_update_monotonic = time.monotonic()
1087
1163
  return True
1088
1164
 
@@ -0,0 +1,210 @@
1
+ """Trading-capable MetaTrader 5 session helpers and operational utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from typing import TYPE_CHECKING, Literal
7
+
8
+ from pdmt5 import Mt5Config, Mt5TradingClient
9
+
10
+ from .sdk import build_config
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+ import pandas as pd
16
+
17
+ PositionSide = Literal["long", "short"]
18
+ OrderSide = Literal["long", "short"]
19
+
20
+ __all__ = [
21
+ "OrderSide",
22
+ "PositionSide",
23
+ "calculate_margin_and_volume",
24
+ "detect_position_side",
25
+ "determine_order_limits",
26
+ "mt5_trading_session",
27
+ ]
28
+
29
+
30
+ def _require_unit_ratio(value: float, name: str) -> None:
31
+ if not 0.0 <= value <= 1.0:
32
+ msg = f"{name} must be between 0 and 1 inclusive."
33
+ raise ValueError(msg)
34
+
35
+
36
+ def _require_protective_ratio(value: float, name: str) -> None:
37
+ if not 0.0 <= value < 1.0:
38
+ msg = f"{name} must be at least 0 and less than 1."
39
+ raise ValueError(msg)
40
+
41
+
42
+ def _sum_position_volume(positions: pd.DataFrame, position_type: object) -> float:
43
+ matched = positions.loc[positions["type"] == position_type, "volume"]
44
+ if matched.empty:
45
+ return 0.0
46
+ return float(matched.to_numpy(dtype=float).sum())
47
+
48
+
49
+ def _normalize_order_side(side: str) -> OrderSide:
50
+ normalized = side.lower()
51
+ if normalized in {"long", "buy"}:
52
+ return "long"
53
+ if normalized in {"short", "sell"}:
54
+ return "short"
55
+ msg = (
56
+ f"Unsupported order side: {side!r}. Expected 'long', 'short', 'buy', or 'sell'."
57
+ )
58
+ raise ValueError(msg)
59
+
60
+
61
+ def detect_position_side(
62
+ client: Mt5TradingClient,
63
+ symbol: str,
64
+ ) -> PositionSide | None:
65
+ """Detect the net open position side for a symbol.
66
+
67
+ Args:
68
+ client: Connected ``Mt5TradingClient`` instance.
69
+ symbol: Symbol to inspect.
70
+
71
+ Returns:
72
+ ``"long"`` when net buy volume exceeds sell volume, ``"short"`` when
73
+ net sell volume exceeds buy volume, or ``None`` when no positions exist
74
+ or buy/sell volumes are exactly balanced.
75
+ """
76
+ positions = client.positions_get_as_df(symbol=symbol)
77
+ if positions.empty:
78
+ return None
79
+
80
+ buy_type = client.mt5.POSITION_TYPE_BUY
81
+ sell_type = client.mt5.POSITION_TYPE_SELL
82
+ buy_volume = _sum_position_volume(positions, buy_type)
83
+ sell_volume = _sum_position_volume(positions, sell_type)
84
+ net_volume = buy_volume - sell_volume
85
+ if net_volume > 0:
86
+ return "long"
87
+ if net_volume < 0:
88
+ return "short"
89
+ return None
90
+
91
+
92
+ def calculate_margin_and_volume(
93
+ client: Mt5TradingClient,
94
+ symbol: str,
95
+ unit_margin_ratio: float,
96
+ preserved_margin_ratio: float,
97
+ ) -> dict[str, float]:
98
+ """Calculate tradable margin and volumes from account free margin.
99
+
100
+ Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
101
+ then allocates ``unit_margin_ratio`` of the remainder as the margin budget
102
+ for volume sizing on both buy and sell sides.
103
+
104
+ Args:
105
+ client: Connected ``Mt5TradingClient`` instance.
106
+ symbol: Symbol used for minimum-lot margin and volume calculations.
107
+ unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
108
+ preserved_margin_ratio: Fraction of ``margin_free`` to preserve.
109
+
110
+ Returns:
111
+ Dictionary with ``margin_free``, ``available_margin``, ``trade_margin``,
112
+ ``buy_volume``, and ``sell_volume``. Negative ``margin_free`` values are
113
+ clamped to ``0.0`` before sizing.
114
+ """
115
+ _require_unit_ratio(unit_margin_ratio, "unit_margin_ratio")
116
+ _require_unit_ratio(preserved_margin_ratio, "preserved_margin_ratio")
117
+
118
+ account = client.account_info_as_dict()
119
+ margin_free = max(0.0, float(account.get("margin_free") or 0.0))
120
+ available_margin = margin_free * (1.0 - preserved_margin_ratio)
121
+ trade_margin = available_margin * unit_margin_ratio
122
+ buy_volume = client.calculate_volume_by_margin(symbol, trade_margin, "BUY")
123
+ sell_volume = client.calculate_volume_by_margin(symbol, trade_margin, "SELL")
124
+ return {
125
+ "margin_free": margin_free,
126
+ "available_margin": available_margin,
127
+ "trade_margin": trade_margin,
128
+ "buy_volume": buy_volume,
129
+ "sell_volume": sell_volume,
130
+ }
131
+
132
+
133
+ def determine_order_limits(
134
+ client: Mt5TradingClient,
135
+ symbol: str,
136
+ side: OrderSide | str,
137
+ stop_loss_limit_ratio: float,
138
+ take_profit_limit_ratio: float,
139
+ ) -> dict[str, float | None]:
140
+ """Derive entry and protective order prices from current market quotes.
141
+
142
+ Args:
143
+ client: Connected ``Mt5TradingClient`` instance.
144
+ symbol: Symbol used for the quote lookup.
145
+ side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
146
+ aliases are accepted).
147
+ stop_loss_limit_ratio: Relative distance from entry for stop loss in
148
+ ``[0, 1)``. A value of ``0`` omits the stop loss.
149
+ take_profit_limit_ratio: Relative distance from entry for take profit in
150
+ ``[0, 1)``. A value of ``0`` omits the take profit.
151
+
152
+ Returns:
153
+ Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys.
154
+ Omitted protective levels are returned as ``None``.
155
+ """
156
+ _require_protective_ratio(stop_loss_limit_ratio, "stop_loss_limit_ratio")
157
+ _require_protective_ratio(take_profit_limit_ratio, "take_profit_limit_ratio")
158
+ normalized_side = _normalize_order_side(side)
159
+ tick = client.symbol_info_tick_as_dict(symbol=symbol)
160
+ entry = float(tick["ask"] if normalized_side == "long" else tick["bid"])
161
+
162
+ stop_loss: float | None = None
163
+ if stop_loss_limit_ratio > 0:
164
+ if normalized_side == "long":
165
+ stop_loss = entry * (1.0 - stop_loss_limit_ratio)
166
+ else:
167
+ stop_loss = entry * (1.0 + stop_loss_limit_ratio)
168
+
169
+ take_profit: float | None = None
170
+ if take_profit_limit_ratio > 0:
171
+ if normalized_side == "long":
172
+ take_profit = entry * (1.0 + take_profit_limit_ratio)
173
+ else:
174
+ take_profit = entry * (1.0 - take_profit_limit_ratio)
175
+
176
+ return {
177
+ "entry": entry,
178
+ "stop_loss": stop_loss,
179
+ "take_profit": take_profit,
180
+ }
181
+
182
+
183
+ @contextmanager
184
+ def mt5_trading_session(
185
+ config: Mt5Config | None = None,
186
+ retry_count: int = 0,
187
+ ) -> Iterator[Mt5TradingClient]:
188
+ """Open a trading-capable MT5 session and always shut down safely.
189
+
190
+ Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
191
+ initializes and logs in via ``initialize_and_login_mt5()``, yields a
192
+ connected :class:`~pdmt5.Mt5TradingClient`, and calls ``shutdown()`` on
193
+ exit even when an error is raised inside the context.
194
+
195
+ Args:
196
+ config: MT5 connection configuration. Defaults to an empty config that
197
+ attaches to a running terminal.
198
+ retry_count: Number of initialization retries passed to
199
+ ``Mt5TradingClient``.
200
+
201
+ Yields:
202
+ Connected ``Mt5TradingClient`` bound to the session.
203
+ """
204
+ mt5_config = config or build_config()
205
+ client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
206
+ try:
207
+ client.initialize_and_login_mt5()
208
+ yield client
209
+ finally:
210
+ client.shutdown()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.6.0"
3
+ version = "0.6.1"
4
4
  description = "Command-line tool for MetaTrader 5"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
19
19
  from pdmt5 import Mt5Config, Mt5DataClient
20
20
 
21
21
  from mt5cli import sdk
22
- from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES
22
+ from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES, write_rates_dataset
23
23
  from mt5cli.sdk import (
24
24
  AccountSpec,
25
25
  Mt5CliClient,
@@ -61,7 +61,7 @@ from mt5cli.sdk import (
61
61
  update_history_with_config,
62
62
  version,
63
63
  )
64
- from mt5cli.utils import Dataset
64
+ from mt5cli.utils import Dataset, IfExists
65
65
 
66
66
 
67
67
  class _TerminalInfo(NamedTuple):
@@ -1913,6 +1913,16 @@ class TestThrottledHistoryUpdater:
1913
1913
  Mt5RuntimeError("boom"),
1914
1914
  Mt5TradingError("trade failed"),
1915
1915
  sqlite3.OperationalError("locked"),
1916
+ ValueError("invalid symbols"),
1917
+ OSError("disk full"),
1918
+ AttributeError(
1919
+ "'StubClient' object has no attribute 'copy_rates_range_as_df'",
1920
+ name="copy_rates_range_as_df",
1921
+ ),
1922
+ AttributeError(
1923
+ "MT5 client is missing required method: copy_ticks_range_as_df"
1924
+ ),
1925
+ TypeError("MT5 client attribute is not callable: history_orders_get_as_df"),
1916
1926
  ],
1917
1927
  )
1918
1928
  def test_suppresses_errors_when_requested(
@@ -1932,3 +1942,130 @@ class TestThrottledHistoryUpdater:
1932
1942
 
1933
1943
  assert updater.update(MagicMock(), ["EURUSD"]) is False
1934
1944
  assert updater.last_update_monotonic is None
1945
+
1946
+ @pytest.mark.parametrize(
1947
+ "error",
1948
+ [
1949
+ AttributeError("'dict' object has no attribute 'typo'"),
1950
+ TypeError("unsupported operand types"),
1951
+ ],
1952
+ )
1953
+ def test_suppress_errors_does_not_hide_programming_errors(
1954
+ self,
1955
+ mocker: MockerFixture,
1956
+ error: Exception,
1957
+ ) -> None:
1958
+ """Test generic AttributeError/TypeError still propagate when suppressed."""
1959
+ mocker.patch(
1960
+ "mt5cli.sdk.update_history",
1961
+ side_effect=error,
1962
+ )
1963
+ updater = ThrottledHistoryUpdater(
1964
+ output="history.db",
1965
+ suppress_errors=True,
1966
+ )
1967
+
1968
+ with pytest.raises(type(error)):
1969
+ updater.update(MagicMock(), ["EURUSD"])
1970
+
1971
+ assert updater.last_update_monotonic is None
1972
+
1973
+ @pytest.mark.parametrize(
1974
+ ("error", "expected"),
1975
+ [
1976
+ (AttributeError("MT5 client is missing required method: version"), True),
1977
+ (
1978
+ AttributeError(
1979
+ "'Stub' object has no attribute 'copy_rates_range_as_df'",
1980
+ name="copy_rates_range_as_df",
1981
+ ),
1982
+ True,
1983
+ ),
1984
+ (AttributeError("'dict' object has no attribute 'typo'"), False),
1985
+ (TypeError("MT5 client attribute is not callable: version"), True),
1986
+ (TypeError("unsupported operand types"), False),
1987
+ (TypeError("'NoneType' object is not callable"), False),
1988
+ (ValueError("invalid"), False),
1989
+ ],
1990
+ )
1991
+ def test_is_mt5_client_capability_error(
1992
+ self,
1993
+ error: BaseException,
1994
+ expected: bool,
1995
+ ) -> None:
1996
+ """Test MT5 client capability error detection."""
1997
+ assert sdk._is_mt5_client_capability_error(error) is expected # type: ignore[reportPrivateUsage]
1998
+
1999
+ def test_is_mt5_client_capability_error_for_non_callable_history_client(
2000
+ self,
2001
+ ) -> None:
2002
+ """Test non-callable history client attributes are capability errors."""
2003
+ client = MagicMock()
2004
+ client.copy_rates_range_as_df = None
2005
+ with (
2006
+ sqlite3.connect(":memory:") as conn,
2007
+ pytest.raises(TypeError, match="not callable") as exc_info,
2008
+ ):
2009
+ write_rates_dataset(
2010
+ conn,
2011
+ client,
2012
+ ["EURUSD"],
2013
+ 1,
2014
+ datetime.now(UTC),
2015
+ datetime.now(UTC),
2016
+ IfExists.APPEND,
2017
+ {},
2018
+ )
2019
+
2020
+ assert sdk._is_mt5_client_capability_error(exc_info.value) is True # type: ignore[reportPrivateUsage]
2021
+
2022
+ def test_suppresses_non_callable_history_client_method(
2023
+ self,
2024
+ tmp_path: Path,
2025
+ ) -> None:
2026
+ """Test suppress_errors swallows non-callable history client API attributes."""
2027
+ client = MagicMock()
2028
+ client.copy_rates_range_as_df = None
2029
+ updater = ThrottledHistoryUpdater(
2030
+ output=tmp_path / "history.db",
2031
+ datasets={Dataset.rates},
2032
+ timeframes=["M1"],
2033
+ suppress_errors=True,
2034
+ )
2035
+
2036
+ assert updater.update(client, ["EURUSD"]) is False
2037
+ assert updater.last_update_monotonic is None
2038
+
2039
+ def test_suppress_errors_does_not_hide_internal_client_type_error(
2040
+ self,
2041
+ mocker: MockerFixture,
2042
+ ) -> None:
2043
+ """Test TypeError raised inside a callable client method still propagates."""
2044
+ mocker.patch(
2045
+ "mt5cli.sdk.update_history",
2046
+ side_effect=TypeError("'int' object is not callable"),
2047
+ )
2048
+ updater = ThrottledHistoryUpdater(
2049
+ output="history.db",
2050
+ suppress_errors=True,
2051
+ )
2052
+
2053
+ with pytest.raises(TypeError, match="not callable"):
2054
+ updater.update(MagicMock(), ["EURUSD"])
2055
+
2056
+ assert updater.last_update_monotonic is None
2057
+
2058
+ def test_suppresses_validation_errors_before_update(
2059
+ self,
2060
+ mocker: MockerFixture,
2061
+ ) -> None:
2062
+ """Test validation failures are suppressed without calling update_history."""
2063
+ update = mocker.patch("mt5cli.sdk.update_history")
2064
+ updater = ThrottledHistoryUpdater(
2065
+ output="history.db",
2066
+ suppress_errors=True,
2067
+ )
2068
+
2069
+ assert updater.update(MagicMock(), []) is False
2070
+ update.assert_not_called()
2071
+ assert updater.last_update_monotonic is None
@@ -0,0 +1,356 @@
1
+ """Tests for trading session helpers and operational utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock
6
+
7
+ import pandas as pd
8
+ import pytest
9
+ from pdmt5 import Mt5RuntimeError
10
+ from pytest_mock import MockerFixture # noqa: TC002
11
+
12
+ from mt5cli.sdk import build_config
13
+ from mt5cli.trading import (
14
+ calculate_margin_and_volume,
15
+ detect_position_side,
16
+ determine_order_limits,
17
+ mt5_trading_session,
18
+ )
19
+
20
+
21
+ class TestDetectPositionSide:
22
+ """Tests for detect_position_side."""
23
+
24
+ def test_returns_none_when_no_positions(self) -> None:
25
+ """Test None is returned when no open positions exist."""
26
+ client = MagicMock()
27
+ client.positions_get_as_df.return_value = pd.DataFrame()
28
+
29
+ assert detect_position_side(client, "EURUSD") is None
30
+
31
+ def test_returns_long_for_net_buy_volume(self) -> None:
32
+ """Test long is returned when buy volume exceeds sell volume."""
33
+ client = MagicMock()
34
+ client.mt5.POSITION_TYPE_BUY = 0
35
+ client.mt5.POSITION_TYPE_SELL = 1
36
+ client.positions_get_as_df.return_value = pd.DataFrame(
37
+ {
38
+ "type": [0, 0, 1],
39
+ "volume": [0.2, 0.1, 0.05],
40
+ },
41
+ )
42
+
43
+ assert detect_position_side(client, "EURUSD") == "long"
44
+
45
+ def test_returns_short_for_net_sell_volume(self) -> None:
46
+ """Test short is returned when sell volume exceeds buy volume."""
47
+ client = MagicMock()
48
+ client.mt5.POSITION_TYPE_BUY = 0
49
+ client.mt5.POSITION_TYPE_SELL = 1
50
+ client.positions_get_as_df.return_value = pd.DataFrame(
51
+ {
52
+ "type": [1, 1],
53
+ "volume": [0.3, 0.1],
54
+ },
55
+ )
56
+
57
+ assert detect_position_side(client, "EURUSD") == "short"
58
+
59
+ def test_returns_none_for_balanced_hedged_positions(self) -> None:
60
+ """Test None is returned when buy and sell volumes net to zero."""
61
+ client = MagicMock()
62
+ client.mt5.POSITION_TYPE_BUY = 0
63
+ client.mt5.POSITION_TYPE_SELL = 1
64
+ client.positions_get_as_df.return_value = pd.DataFrame(
65
+ {
66
+ "type": [0, 1],
67
+ "volume": [0.2, 0.2],
68
+ },
69
+ )
70
+
71
+ assert detect_position_side(client, "EURUSD") is None
72
+
73
+
74
+ class TestCalculateMarginAndVolume:
75
+ """Tests for calculate_margin_and_volume."""
76
+
77
+ def test_calculates_margin_budget_and_volumes(self) -> None:
78
+ """Test margin budget and buy/sell volumes are derived from ratios."""
79
+ client = MagicMock()
80
+ client.account_info_as_dict.return_value = {"margin_free": 1000.0}
81
+ client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
82
+
83
+ result = calculate_margin_and_volume(
84
+ client,
85
+ "EURUSD",
86
+ unit_margin_ratio=0.5,
87
+ preserved_margin_ratio=0.2,
88
+ )
89
+
90
+ assert result == {
91
+ "margin_free": 1000.0,
92
+ "available_margin": 800.0,
93
+ "trade_margin": 400.0,
94
+ "buy_volume": 0.3,
95
+ "sell_volume": 0.2,
96
+ }
97
+ client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "BUY")
98
+ client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "SELL")
99
+
100
+ @pytest.mark.parametrize(
101
+ ("account_dict", "expected_margin_free"),
102
+ [
103
+ ({"margin_free": 0.0}, 0.0),
104
+ ({}, 0.0),
105
+ ({"margin_free": None}, 0.0),
106
+ ],
107
+ )
108
+ def test_zero_or_missing_margin_free(
109
+ self,
110
+ account_dict: dict[str, float | None],
111
+ expected_margin_free: float,
112
+ ) -> None:
113
+ """Test missing or zero margin_free yields zero trade margin."""
114
+ client = MagicMock()
115
+ client.account_info_as_dict.return_value = account_dict
116
+ client.calculate_volume_by_margin.return_value = 0.0
117
+
118
+ result = calculate_margin_and_volume(
119
+ client,
120
+ "EURUSD",
121
+ unit_margin_ratio=0.5,
122
+ preserved_margin_ratio=0.2,
123
+ )
124
+
125
+ assert result["margin_free"] == expected_margin_free
126
+ client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
127
+ client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
128
+
129
+ def test_clamps_negative_margin_free_to_zero(self) -> None:
130
+ """Test negative margin_free is clamped to zero before sizing."""
131
+ client = MagicMock()
132
+ client.account_info_as_dict.return_value = {"margin_free": -500.0}
133
+ client.calculate_volume_by_margin.return_value = 0.0
134
+
135
+ result = calculate_margin_and_volume(
136
+ client,
137
+ "EURUSD",
138
+ unit_margin_ratio=0.5,
139
+ preserved_margin_ratio=0.2,
140
+ )
141
+
142
+ expected_margin_free = 0.0
143
+ assert result["margin_free"] == expected_margin_free
144
+ client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
145
+ client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
146
+
147
+ @pytest.mark.parametrize(
148
+ ("unit_ratio", "preserved_ratio"),
149
+ [
150
+ (-0.1, 0.0),
151
+ (1.1, 0.0),
152
+ (0.5, -0.1),
153
+ (0.5, 1.1),
154
+ ],
155
+ )
156
+ def test_rejects_invalid_ratios(
157
+ self,
158
+ unit_ratio: float,
159
+ preserved_ratio: float,
160
+ ) -> None:
161
+ """Test invalid ratio values raise ValueError."""
162
+ with pytest.raises(ValueError, match="must be between 0 and 1"):
163
+ calculate_margin_and_volume(
164
+ MagicMock(),
165
+ "EURUSD",
166
+ unit_margin_ratio=unit_ratio,
167
+ preserved_margin_ratio=preserved_ratio,
168
+ )
169
+
170
+
171
+ class TestDetermineOrderLimits:
172
+ """Tests for determine_order_limits."""
173
+
174
+ @pytest.mark.parametrize(
175
+ ("side", "expected_entry_key"),
176
+ [
177
+ ("long", "ask"),
178
+ ("short", "bid"),
179
+ ("buy", "ask"),
180
+ ("sell", "bid"),
181
+ ],
182
+ )
183
+ def test_uses_expected_quote_for_entry(
184
+ self,
185
+ side: str,
186
+ expected_entry_key: str,
187
+ ) -> None:
188
+ """Test entry price is taken from ask for long/buy and bid for short/sell."""
189
+ client = MagicMock()
190
+ client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
191
+
192
+ result = determine_order_limits(
193
+ client,
194
+ "EURUSD",
195
+ side,
196
+ stop_loss_limit_ratio=0.0,
197
+ take_profit_limit_ratio=0.0,
198
+ )
199
+
200
+ assert (
201
+ result["entry"]
202
+ == client.symbol_info_tick_as_dict.return_value[expected_entry_key]
203
+ )
204
+ assert result["stop_loss"] is None
205
+ assert result["take_profit"] is None
206
+
207
+ def test_calculates_long_protective_levels(self) -> None:
208
+ """Test long stop loss and take profit are placed below/above entry."""
209
+ client = MagicMock()
210
+ client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
211
+
212
+ result = determine_order_limits(
213
+ client,
214
+ "EURUSD",
215
+ "long",
216
+ stop_loss_limit_ratio=0.02,
217
+ take_profit_limit_ratio=0.03,
218
+ )
219
+
220
+ assert result == {
221
+ "entry": 100.0,
222
+ "stop_loss": 98.0,
223
+ "take_profit": 103.0,
224
+ }
225
+
226
+ def test_calculates_short_protective_levels(self) -> None:
227
+ """Test short stop loss and take profit are placed above/below entry."""
228
+ client = MagicMock()
229
+ client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
230
+
231
+ result = determine_order_limits(
232
+ client,
233
+ "EURUSD",
234
+ "short",
235
+ stop_loss_limit_ratio=0.02,
236
+ take_profit_limit_ratio=0.03,
237
+ )
238
+
239
+ assert result == {
240
+ "entry": 99.0,
241
+ "stop_loss": 100.98,
242
+ "take_profit": 96.03,
243
+ }
244
+
245
+ def test_rejects_unknown_side(self) -> None:
246
+ """Test unsupported side values raise ValueError."""
247
+ with pytest.raises(ValueError, match="Unsupported order side"):
248
+ determine_order_limits(
249
+ MagicMock(),
250
+ "EURUSD",
251
+ "flat",
252
+ stop_loss_limit_ratio=0.01,
253
+ take_profit_limit_ratio=0.01,
254
+ )
255
+
256
+ @pytest.mark.parametrize(
257
+ ("stop_loss_ratio", "take_profit_ratio"),
258
+ [
259
+ (-0.05, 0.01),
260
+ (0.01, 2.0),
261
+ ],
262
+ )
263
+ def test_rejects_invalid_protective_ratios(
264
+ self,
265
+ stop_loss_ratio: float,
266
+ take_profit_ratio: float,
267
+ ) -> None:
268
+ """Test out-of-range protective ratios raise ValueError."""
269
+ with pytest.raises(ValueError, match="must be at least 0 and less than 1"):
270
+ determine_order_limits(
271
+ MagicMock(),
272
+ "EURUSD",
273
+ "long",
274
+ stop_loss_limit_ratio=stop_loss_ratio,
275
+ take_profit_limit_ratio=take_profit_ratio,
276
+ )
277
+
278
+ @pytest.mark.parametrize(
279
+ ("field", "ratio"),
280
+ [
281
+ ("stop_loss_limit_ratio", 1.0),
282
+ ("take_profit_limit_ratio", 1.0),
283
+ ],
284
+ )
285
+ def test_rejects_unit_boundary_protective_ratios(
286
+ self,
287
+ field: str,
288
+ ratio: float,
289
+ ) -> None:
290
+ """Test protective ratios of exactly 1.0 are rejected."""
291
+ kwargs = {
292
+ "stop_loss_limit_ratio": 0.01,
293
+ "take_profit_limit_ratio": 0.01,
294
+ field: ratio,
295
+ }
296
+ with pytest.raises(ValueError, match="must be at least 0 and less than 1"):
297
+ determine_order_limits(
298
+ MagicMock(),
299
+ "EURUSD",
300
+ "long",
301
+ **kwargs,
302
+ )
303
+
304
+
305
+ class TestMt5TradingSession:
306
+ """Tests for the mt5_trading_session context manager."""
307
+
308
+ def test_yields_connected_client_and_shuts_down(
309
+ self,
310
+ mocker: MockerFixture,
311
+ ) -> None:
312
+ """Test mt5_trading_session connects, yields a client, and shuts down."""
313
+ mock_client = MagicMock()
314
+ trading_client = mocker.patch(
315
+ "mt5cli.trading.Mt5TradingClient",
316
+ return_value=mock_client,
317
+ )
318
+
319
+ with mt5_trading_session(
320
+ build_config(path="/opt/mt5/terminal64.exe"),
321
+ retry_count=2,
322
+ ) as client:
323
+ mock_client.initialize_and_login_mt5.assert_called_once()
324
+ assert client is mock_client
325
+
326
+ trading_client.assert_called_once()
327
+ assert trading_client.call_args.kwargs["retry_count"] == 2
328
+ assert (
329
+ trading_client.call_args.kwargs["config"].path == "/opt/mt5/terminal64.exe"
330
+ )
331
+ mock_client.shutdown.assert_called_once()
332
+
333
+ def test_shuts_down_when_initialize_raises(
334
+ self,
335
+ mocker: MockerFixture,
336
+ ) -> None:
337
+ """Test shutdown is called when initialization fails."""
338
+ mock_client = MagicMock()
339
+ mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
340
+ mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
341
+
342
+ with pytest.raises(Mt5RuntimeError, match="boom"), mt5_trading_session():
343
+ pass
344
+
345
+ mock_client.shutdown.assert_called_once()
346
+
347
+ def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None:
348
+ """Test shutdown is called when the context body raises."""
349
+ mock_client = MagicMock()
350
+ mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
351
+
352
+ body_error = "body error"
353
+ with pytest.raises(RuntimeError, match=body_error), mt5_trading_session():
354
+ raise RuntimeError(body_error)
355
+
356
+ mock_client.shutdown.assert_called_once()
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.6.0"
490
+ version = "0.6.1"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { 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