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.
- {mt5cli-0.6.0 → mt5cli-0.6.1}/PKG-INFO +60 -2
- {mt5cli-0.6.0 → mt5cli-0.6.1}/README.md +59 -1
- {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/index.md +7 -2
- {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/sdk.md +14 -3
- mt5cli-0.6.1/docs/api/trading.md +70 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mkdocs.yml +1 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/__init__.py +10 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/sdk.py +85 -9
- mt5cli-0.6.1/mt5cli/trading.py +210 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/pyproject.toml +1 -1
- {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_sdk.py +139 -2
- mt5cli-0.6.1/tests/test_trading.py +356 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/uv.lock +1 -1
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.claude/agents/codex.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.claude/settings.json +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/FUNDING.yml +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/dependabot.yml +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/renovate.json +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.github/workflows/release.yml +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/.gitignore +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/AGENTS.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/CLAUDE.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/LICENSE +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/cli.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/history.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/api/utils.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/docs/index.md +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/__main__.py +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/cli.py +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/history.py +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/mt5cli/utils.py +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/__init__.py +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_cli.py +0 -0
- {mt5cli-0.6.0 → mt5cli-0.6.1}/tests/test_history.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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. **
|
|
32
|
-
4. **
|
|
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`,
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
@@ -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``,
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
|
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.
|
|
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()
|
|
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
|