mt5cli 0.8.3__tar.gz → 0.9.0__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.8.3 → mt5cli-0.9.0}/.agents/skills/pr-feedback-triage/SKILL.md +2 -2
- {mt5cli-0.8.3 → mt5cli-0.9.0}/PKG-INFO +1 -1
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/public-contract.md +27 -19
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/sdk.md +22 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/trading.md +19 -10
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/__init__.py +2 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/contract.py +1 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/sdk.py +77 -9
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/trading.py +88 -1
- {mt5cli-0.8.3 → mt5cli-0.9.0}/pyproject.toml +1 -1
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/test_contracts.py +30 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/test_sdk.py +185 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/test_trading.py +268 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/uv.lock +1 -1
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.claude/agents/codex.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.claude/settings.json +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.github/FUNDING.yml +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.github/dependabot.yml +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.github/renovate.json +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.github/workflows/release.yml +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/.gitignore +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/AGENTS.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/CLAUDE.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/LICENSE +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/README.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/cli.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/client.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/converters.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/exceptions.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/history.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/index.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/schemas.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/storage.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/api/utils.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/docs/index.md +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mkdocs.yml +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/__main__.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/cli.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/client.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/converters.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/exceptions.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/history.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/retry.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/schemas.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/storage.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/mt5cli/utils.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/__init__.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/conftest.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/test_cli.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/test_history.py +0 -0
- {mt5cli-0.8.3 → mt5cli-0.9.0}/tests/test_utils.py +0 -0
|
@@ -104,8 +104,8 @@ A reliable pattern is:
|
|
|
104
104
|
Example GraphQL mutation shape:
|
|
105
105
|
|
|
106
106
|
```graphql
|
|
107
|
-
mutation($threadId: ID!) {
|
|
108
|
-
resolveReviewThread(input: {threadId: $threadId}) {
|
|
107
|
+
mutation ($threadId: ID!) {
|
|
108
|
+
resolveReviewThread(input: { threadId: $threadId }) {
|
|
109
109
|
thread {
|
|
110
110
|
id
|
|
111
111
|
isResolved
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -19,20 +19,27 @@ alias for new code.
|
|
|
19
19
|
|
|
20
20
|
### Session lifecycle and configuration
|
|
21
21
|
|
|
22
|
-
| Symbol | Role
|
|
23
|
-
| ----------------------------------------------- |
|
|
24
|
-
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send`
|
|
25
|
-
| `build_config` | Build `pdmt5.Mt5Config` from connection fields
|
|
26
|
-
| `mt5_session` | Context manager: initialize, login, yield client, shutdown
|
|
27
|
-
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle
|
|
28
|
-
| `AccountSpec` | Generic account group: symbols plus optional credentials
|
|
29
|
-
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders
|
|
30
|
-
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment
|
|
22
|
+
| Symbol | Role |
|
|
23
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
|
25
|
+
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
|
26
|
+
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
|
27
|
+
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
|
28
|
+
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
|
29
|
+
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
|
30
|
+
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
|
31
31
|
|
|
32
32
|
Credential resolution is generic: any environment variable name may appear inside
|
|
33
33
|
`${...}`. mt5cli does not hard-code application-specific keys such as
|
|
34
34
|
`mt5_login` or `mt5_exe`.
|
|
35
35
|
|
|
36
|
+
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
|
37
|
+
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
|
|
38
|
+
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
|
|
39
|
+
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
|
40
|
+
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
|
41
|
+
Default is `False` to preserve backward compatibility.
|
|
42
|
+
|
|
36
43
|
### Read-only MT5 data access
|
|
37
44
|
|
|
38
45
|
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
|
@@ -56,15 +63,16 @@ MetaTrader 5 returns the still-forming bar as the last row when
|
|
|
56
63
|
`start_pos=0`. Use these helpers instead of reimplementing bar trimming or
|
|
57
64
|
timestamp normalization in downstream apps.
|
|
58
65
|
|
|
59
|
-
| Symbol | Role
|
|
60
|
-
| ------------------------------------------------ |
|
|
61
|
-
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data
|
|
62
|
-
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar
|
|
63
|
-
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
66
|
+
| Symbol | Role |
|
|
67
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
|
68
|
+
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
|
69
|
+
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
|
70
|
+
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
|
|
71
|
+
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
|
72
|
+
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
|
73
|
+
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
|
74
|
+
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
|
75
|
+
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
|
68
76
|
|
|
69
77
|
### SQLite history collection and rate loading
|
|
70
78
|
|
|
@@ -97,7 +105,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
|
|
|
97
105
|
| `detect_position_side` | Net long / short / flat from open positions |
|
|
98
106
|
| `calculate_spread_ratio` | Relative bid-ask spread |
|
|
99
107
|
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
|
100
|
-
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin`
|
|
108
|
+
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
|
101
109
|
| `determine_order_limits` | SL/TP price levels from ratios |
|
|
102
110
|
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
|
103
111
|
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
|
@@ -86,6 +86,28 @@ resolved = resolve_account_specs(accounts, server="Broker-Demo")
|
|
|
86
86
|
# resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
Pass `allow_whole_dollar_env=True` to also expand strings whose **entire value**
|
|
90
|
+
is a bare `$ENV_NAME` identifier (no braces). This opt-in covers
|
|
91
|
+
`substitute_env_placeholders()`, `resolve_account_spec()`,
|
|
92
|
+
`resolve_account_specs()`, and `build_config()`. Note: `build_config` cannot
|
|
93
|
+
expand `login` because that parameter is `int | None`; use
|
|
94
|
+
`resolve_account_spec` for a string `login` placeholder. Partial strings such as
|
|
95
|
+
`"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are never expanded — only an
|
|
96
|
+
exact `$IDENTIFIER` whole-string match qualifies. The default is `False` to
|
|
97
|
+
preserve backward compatibility.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import os
|
|
101
|
+
|
|
102
|
+
from mt5cli import AccountSpec, resolve_account_specs
|
|
103
|
+
|
|
104
|
+
os.environ["MT5_PASSWORD"] = "secret"
|
|
105
|
+
accounts = [AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")]
|
|
106
|
+
|
|
107
|
+
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
|
108
|
+
# resolved[0].password == "secret"
|
|
109
|
+
```
|
|
110
|
+
|
|
89
111
|
### Throttled incremental history updates
|
|
90
112
|
|
|
91
113
|
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
|
|
@@ -48,6 +48,7 @@ from mt5cli import (
|
|
|
48
48
|
determine_order_limits,
|
|
49
49
|
estimate_order_margin,
|
|
50
50
|
fetch_latest_closed_rates_for_trading_client,
|
|
51
|
+
fetch_latest_closed_rates_indexed,
|
|
51
52
|
get_account_snapshot,
|
|
52
53
|
get_positions_frame,
|
|
53
54
|
get_symbol_snapshot,
|
|
@@ -78,6 +79,14 @@ closed_bars = fetch_latest_closed_rates_for_trading_client(
|
|
|
78
79
|
granularity="M1",
|
|
79
80
|
count=100,
|
|
80
81
|
)
|
|
82
|
+
# Or fetch with a UTC DatetimeIndex instead of a "time" column:
|
|
83
|
+
indexed_bars = fetch_latest_closed_rates_indexed(
|
|
84
|
+
client,
|
|
85
|
+
symbol="EURUSD",
|
|
86
|
+
granularity="M1",
|
|
87
|
+
count=100,
|
|
88
|
+
)
|
|
89
|
+
# indexed_bars.index is a UTC-aware DatetimeIndex named "time"
|
|
81
90
|
sizing = calculate_margin_and_volume(
|
|
82
91
|
client,
|
|
83
92
|
"EURUSD",
|
|
@@ -174,16 +183,16 @@ through the stable package root without embedding entry/exit policy.
|
|
|
174
183
|
|
|
175
184
|
## Migration from application-local helpers
|
|
176
185
|
|
|
177
|
-
| Application-local concern | mt5cli replacement
|
|
178
|
-
| -------------------------------------------------------- |
|
|
179
|
-
| Manual terminal spawn/kill around trading code | `mt5_trading_session()`
|
|
180
|
-
| Local position-side detection | `detect_position_side()`
|
|
181
|
-
| Local margin/volume sizing | `calculate_margin_and_volume()`
|
|
182
|
-
| Local broker volume step normalization | `normalize_order_volume()`
|
|
183
|
-
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()`
|
|
184
|
-
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()` |
|
|
185
|
-
| Local SL/TP price derivation | `determine_order_limits()`
|
|
186
|
-
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)`
|
|
186
|
+
| Application-local concern | mt5cli replacement |
|
|
187
|
+
| -------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
188
|
+
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
|
189
|
+
| Local position-side detection | `detect_position_side()` |
|
|
190
|
+
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
|
191
|
+
| Local broker volume step normalization | `normalize_order_volume()` |
|
|
192
|
+
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
|
193
|
+
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
|
|
194
|
+
| Local SL/TP price derivation | `determine_order_limits()` |
|
|
195
|
+
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
|
187
196
|
|
|
188
197
|
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
|
|
189
198
|
`mt5_trading_session()` only where order placement or trading calculations are
|
|
@@ -128,6 +128,7 @@ from .trading import (
|
|
|
128
128
|
ensure_symbol_selected,
|
|
129
129
|
estimate_order_margin,
|
|
130
130
|
fetch_latest_closed_rates_for_trading_client,
|
|
131
|
+
fetch_latest_closed_rates_indexed,
|
|
131
132
|
get_account_snapshot,
|
|
132
133
|
get_positions_frame,
|
|
133
134
|
get_symbol_snapshot,
|
|
@@ -214,6 +215,7 @@ __all__ = [
|
|
|
214
215
|
"export_dataframe_to_sqlite",
|
|
215
216
|
"fetch_latest_closed_rates",
|
|
216
217
|
"fetch_latest_closed_rates_for_trading_client",
|
|
218
|
+
"fetch_latest_closed_rates_indexed",
|
|
217
219
|
"get_account_snapshot",
|
|
218
220
|
"get_positions_frame",
|
|
219
221
|
"get_symbol_snapshot",
|
|
@@ -56,6 +56,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|
|
56
56
|
"export_dataframe_to_sqlite",
|
|
57
57
|
"fetch_latest_closed_rates",
|
|
58
58
|
"fetch_latest_closed_rates_for_trading_client",
|
|
59
|
+
"fetch_latest_closed_rates_indexed",
|
|
59
60
|
"get_account_snapshot",
|
|
60
61
|
"get_positions_frame",
|
|
61
62
|
"get_symbol_snapshot",
|
|
@@ -309,12 +309,33 @@ def build_config(
|
|
|
309
309
|
password: str | None = None,
|
|
310
310
|
server: str | None = None,
|
|
311
311
|
timeout: int | None = None,
|
|
312
|
+
allow_whole_dollar_env: bool = False,
|
|
312
313
|
) -> Mt5Config:
|
|
313
314
|
"""Build an ``Mt5Config`` from optional connection parameters.
|
|
314
315
|
|
|
316
|
+
Args:
|
|
317
|
+
path: Optional terminal executable path.
|
|
318
|
+
login: Optional trading account login.
|
|
319
|
+
password: Optional trading account password.
|
|
320
|
+
server: Optional trading server name.
|
|
321
|
+
timeout: Optional connection timeout in milliseconds.
|
|
322
|
+
allow_whole_dollar_env: When ``True``, string parameters that are
|
|
323
|
+
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
|
324
|
+
to ``path``, ``password``, and ``server``. Default ``False``
|
|
325
|
+
preserves existing behavior.
|
|
326
|
+
|
|
315
327
|
Returns:
|
|
316
328
|
Configured ``Mt5Config`` instance.
|
|
317
329
|
"""
|
|
330
|
+
if allow_whole_dollar_env:
|
|
331
|
+
if path is not None:
|
|
332
|
+
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
|
333
|
+
if password is not None:
|
|
334
|
+
password = substitute_env_placeholders(
|
|
335
|
+
password, allow_whole_dollar_env=True
|
|
336
|
+
)
|
|
337
|
+
if server is not None:
|
|
338
|
+
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
|
318
339
|
return Mt5Config(
|
|
319
340
|
path=path,
|
|
320
341
|
login=login,
|
|
@@ -1376,13 +1397,22 @@ class AccountSpec:
|
|
|
1376
1397
|
|
|
1377
1398
|
|
|
1378
1399
|
_ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
|
1400
|
+
_WHOLE_DOLLAR_PATTERN = re.compile(r"^\$(?P<name>[A-Za-z_][A-Za-z0-9_]*)$")
|
|
1379
1401
|
|
|
1380
1402
|
|
|
1381
|
-
def substitute_env_placeholders(
|
|
1403
|
+
def substitute_env_placeholders(
|
|
1404
|
+
value: str,
|
|
1405
|
+
*,
|
|
1406
|
+
allow_whole_dollar_env: bool = False,
|
|
1407
|
+
) -> str:
|
|
1382
1408
|
"""Replace ``${ENV_VAR}`` placeholders in a string with environment values.
|
|
1383
1409
|
|
|
1384
1410
|
Args:
|
|
1385
1411
|
value: String that may contain one or more ``${ENV_VAR}`` placeholders.
|
|
1412
|
+
allow_whole_dollar_env: When ``True``, a string that is exactly
|
|
1413
|
+
``$ENV_NAME`` (the whole value and nothing else) is also expanded
|
|
1414
|
+
from the environment. Partial occurrences such as ``"plan$pass"``
|
|
1415
|
+
or ``"$ENV-suffix"`` are left unchanged.
|
|
1386
1416
|
|
|
1387
1417
|
Returns:
|
|
1388
1418
|
The string with every placeholder replaced by its environment value.
|
|
@@ -1390,6 +1420,14 @@ def substitute_env_placeholders(value: str) -> str:
|
|
|
1390
1420
|
Raises:
|
|
1391
1421
|
ValueError: If a referenced environment variable is not set.
|
|
1392
1422
|
"""
|
|
1423
|
+
if allow_whole_dollar_env:
|
|
1424
|
+
m = _WHOLE_DOLLAR_PATTERN.match(value)
|
|
1425
|
+
if m:
|
|
1426
|
+
name = m.group("name")
|
|
1427
|
+
if name not in os.environ:
|
|
1428
|
+
msg = f"Environment variable {name!r} is not set."
|
|
1429
|
+
raise ValueError(msg)
|
|
1430
|
+
return os.environ[name]
|
|
1393
1431
|
parts: list[str] = []
|
|
1394
1432
|
last_end = 0
|
|
1395
1433
|
for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
|
|
@@ -1404,7 +1442,12 @@ def substitute_env_placeholders(value: str) -> str:
|
|
|
1404
1442
|
return "".join(parts)
|
|
1405
1443
|
|
|
1406
1444
|
|
|
1407
|
-
def _resolve_field(
|
|
1445
|
+
def _resolve_field(
|
|
1446
|
+
override: str | None,
|
|
1447
|
+
account_value: str | None,
|
|
1448
|
+
*,
|
|
1449
|
+
allow_whole_dollar_env: bool = False,
|
|
1450
|
+
) -> str | None:
|
|
1408
1451
|
"""Resolve a string field from an override or account value with env subst.
|
|
1409
1452
|
|
|
1410
1453
|
Returns:
|
|
@@ -1414,12 +1457,16 @@ def _resolve_field(override: str | None, account_value: str | None) -> str | Non
|
|
|
1414
1457
|
value = override if override is not None else account_value
|
|
1415
1458
|
if value is None:
|
|
1416
1459
|
return None
|
|
1417
|
-
return substitute_env_placeholders(
|
|
1460
|
+
return substitute_env_placeholders(
|
|
1461
|
+
value, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1462
|
+
)
|
|
1418
1463
|
|
|
1419
1464
|
|
|
1420
1465
|
def _resolve_login(
|
|
1421
1466
|
override: int | str | None,
|
|
1422
1467
|
account_login: int | str | None,
|
|
1468
|
+
*,
|
|
1469
|
+
allow_whole_dollar_env: bool = False,
|
|
1423
1470
|
) -> int | str | None:
|
|
1424
1471
|
"""Resolve a login from an override or account value with env substitution.
|
|
1425
1472
|
|
|
@@ -1431,10 +1478,14 @@ def _resolve_login(
|
|
|
1431
1478
|
if override is not None:
|
|
1432
1479
|
if isinstance(override, int):
|
|
1433
1480
|
return override
|
|
1434
|
-
return substitute_env_placeholders(
|
|
1481
|
+
return substitute_env_placeholders(
|
|
1482
|
+
override, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1483
|
+
)
|
|
1435
1484
|
if account_login is None or isinstance(account_login, int):
|
|
1436
1485
|
return account_login
|
|
1437
|
-
return substitute_env_placeholders(
|
|
1486
|
+
return substitute_env_placeholders(
|
|
1487
|
+
account_login, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1488
|
+
)
|
|
1438
1489
|
|
|
1439
1490
|
|
|
1440
1491
|
def resolve_account_spec(
|
|
@@ -1445,6 +1496,7 @@ def resolve_account_spec(
|
|
|
1445
1496
|
server: str | None = None,
|
|
1446
1497
|
path: str | None = None,
|
|
1447
1498
|
timeout: int | None = None,
|
|
1499
|
+
allow_whole_dollar_env: bool = False,
|
|
1448
1500
|
) -> AccountSpec:
|
|
1449
1501
|
"""Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
|
|
1450
1502
|
|
|
@@ -1460,6 +1512,9 @@ def resolve_account_spec(
|
|
|
1460
1512
|
server: Optional explicit server override.
|
|
1461
1513
|
path: Optional explicit terminal path override.
|
|
1462
1514
|
timeout: Optional explicit connection timeout override.
|
|
1515
|
+
allow_whole_dollar_env: When ``True``, string fields that are exactly
|
|
1516
|
+
``$ENV_NAME`` are also expanded from the environment. Default
|
|
1517
|
+
``False`` preserves existing behavior.
|
|
1463
1518
|
|
|
1464
1519
|
Returns:
|
|
1465
1520
|
A new :class:`AccountSpec` with resolved credentials and the original
|
|
@@ -1469,10 +1524,18 @@ def resolve_account_spec(
|
|
|
1469
1524
|
"""
|
|
1470
1525
|
return AccountSpec(
|
|
1471
1526
|
symbols=account.symbols,
|
|
1472
|
-
login=_resolve_login(
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1527
|
+
login=_resolve_login(
|
|
1528
|
+
login, account.login, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1529
|
+
),
|
|
1530
|
+
password=_resolve_field(
|
|
1531
|
+
password, account.password, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1532
|
+
),
|
|
1533
|
+
server=_resolve_field(
|
|
1534
|
+
server, account.server, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1535
|
+
),
|
|
1536
|
+
path=_resolve_field(
|
|
1537
|
+
path, account.path, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1538
|
+
),
|
|
1476
1539
|
timeout=timeout if timeout is not None else account.timeout,
|
|
1477
1540
|
)
|
|
1478
1541
|
|
|
@@ -1485,6 +1548,7 @@ def resolve_account_specs(
|
|
|
1485
1548
|
server: str | None = None,
|
|
1486
1549
|
path: str | None = None,
|
|
1487
1550
|
timeout: int | None = None,
|
|
1551
|
+
allow_whole_dollar_env: bool = False,
|
|
1488
1552
|
) -> list[AccountSpec]:
|
|
1489
1553
|
"""Resolve credentials for multiple accounts.
|
|
1490
1554
|
|
|
@@ -1498,6 +1562,9 @@ def resolve_account_specs(
|
|
|
1498
1562
|
server: Optional explicit server override applied to each account.
|
|
1499
1563
|
path: Optional explicit terminal path override applied to each account.
|
|
1500
1564
|
timeout: Optional explicit timeout override applied to each account.
|
|
1565
|
+
allow_whole_dollar_env: When ``True``, string fields that are exactly
|
|
1566
|
+
``$ENV_NAME`` are also expanded from the environment. Default
|
|
1567
|
+
``False`` preserves existing behavior.
|
|
1501
1568
|
|
|
1502
1569
|
Returns:
|
|
1503
1570
|
Resolved account specifications in the original order. Raises
|
|
@@ -1512,6 +1579,7 @@ def resolve_account_specs(
|
|
|
1512
1579
|
server=server,
|
|
1513
1580
|
path=path,
|
|
1514
1581
|
timeout=timeout,
|
|
1582
|
+
allow_whole_dollar_env=allow_whole_dollar_env,
|
|
1515
1583
|
)
|
|
1516
1584
|
for account in accounts
|
|
1517
1585
|
]
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from contextlib import contextmanager
|
|
6
6
|
from math import floor, isfinite
|
|
7
|
-
from numbers import Integral
|
|
7
|
+
from numbers import Integral, Real
|
|
8
8
|
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
|
9
9
|
|
|
10
10
|
import pandas as pd
|
|
@@ -137,6 +137,7 @@ __all__ = [
|
|
|
137
137
|
"ensure_symbol_selected",
|
|
138
138
|
"estimate_order_margin",
|
|
139
139
|
"fetch_latest_closed_rates_for_trading_client",
|
|
140
|
+
"fetch_latest_closed_rates_indexed",
|
|
140
141
|
"get_account_snapshot",
|
|
141
142
|
"get_positions_frame",
|
|
142
143
|
"get_symbol_snapshot",
|
|
@@ -1202,6 +1203,92 @@ def fetch_latest_closed_rates_for_trading_client(
|
|
|
1202
1203
|
return closed.tail(count).reset_index(drop=True)
|
|
1203
1204
|
|
|
1204
1205
|
|
|
1206
|
+
def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
|
|
1207
|
+
"""Convert a rate time series to a UTC-aware DatetimeIndex.
|
|
1208
|
+
|
|
1209
|
+
Handles MT5 epoch seconds (including object-dtype Python numbers), timezone-
|
|
1210
|
+
naive datetime-like values, and timezone-aware datetime-like values.
|
|
1211
|
+
|
|
1212
|
+
Returns:
|
|
1213
|
+
UTC-aware DatetimeIndex.
|
|
1214
|
+
|
|
1215
|
+
Raises:
|
|
1216
|
+
ValueError: If the time data is invalid, unparseable, or contains NaT.
|
|
1217
|
+
"""
|
|
1218
|
+
try:
|
|
1219
|
+
arr = series.to_numpy()
|
|
1220
|
+
non_null = series.dropna()
|
|
1221
|
+
object_numbers = (
|
|
1222
|
+
pd.api.types.is_object_dtype(series)
|
|
1223
|
+
and non_null.map(
|
|
1224
|
+
lambda value: type(value) is not bool and isinstance(value, Real),
|
|
1225
|
+
).all()
|
|
1226
|
+
)
|
|
1227
|
+
numeric_dtype = pd.api.types.is_numeric_dtype(
|
|
1228
|
+
series
|
|
1229
|
+
) and not pd.api.types.is_bool_dtype(
|
|
1230
|
+
series,
|
|
1231
|
+
)
|
|
1232
|
+
if numeric_dtype or object_numbers:
|
|
1233
|
+
idx = pd.to_datetime(arr, unit="s", utc=True)
|
|
1234
|
+
else:
|
|
1235
|
+
idx = pd.to_datetime(arr, utc=True)
|
|
1236
|
+
except Exception as exc:
|
|
1237
|
+
msg = f"Rate data for {symbol!r} has invalid or unparseable time data."
|
|
1238
|
+
raise ValueError(msg) from exc
|
|
1239
|
+
if any(idx.isna()):
|
|
1240
|
+
msg = f"Rate data for {symbol!r} contains missing (NaT) timestamp values."
|
|
1241
|
+
raise ValueError(msg)
|
|
1242
|
+
return idx
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def fetch_latest_closed_rates_indexed(
|
|
1246
|
+
client: Mt5TradingClient,
|
|
1247
|
+
*,
|
|
1248
|
+
symbol: str,
|
|
1249
|
+
granularity: str,
|
|
1250
|
+
count: int,
|
|
1251
|
+
) -> pd.DataFrame:
|
|
1252
|
+
"""Fetch the latest closed bars with a UTC DatetimeIndex from a trading client.
|
|
1253
|
+
|
|
1254
|
+
Internally reuses :func:`fetch_latest_closed_rates_for_trading_client` for
|
|
1255
|
+
closed-bar detection and validation, then converts the ``time`` column to a
|
|
1256
|
+
UTC-aware :class:`~pandas.DatetimeIndex` named ``"time"`` and drops the
|
|
1257
|
+
original column. Intended for downstream time-series consumers that require
|
|
1258
|
+
a datetime index rather than a ``time`` column.
|
|
1259
|
+
|
|
1260
|
+
Args:
|
|
1261
|
+
client: Connected trading client with rate-fetch capability.
|
|
1262
|
+
symbol: Symbol name.
|
|
1263
|
+
granularity: Timeframe string (for example ``"M1"``, ``"H1"``).
|
|
1264
|
+
count: Maximum number of closed bars to return.
|
|
1265
|
+
|
|
1266
|
+
Returns:
|
|
1267
|
+
Up to ``count`` closed bars ordered oldest to newest, with a
|
|
1268
|
+
UTC-aware ``DatetimeIndex`` named ``"time"``. The original ``time``
|
|
1269
|
+
column is dropped.
|
|
1270
|
+
|
|
1271
|
+
Raises:
|
|
1272
|
+
ValueError: If ``count`` is not positive, rate data is empty or
|
|
1273
|
+
malformed, the ``time`` column is missing, or timestamp data
|
|
1274
|
+
is invalid or unparseable.
|
|
1275
|
+
"""
|
|
1276
|
+
frame = fetch_latest_closed_rates_for_trading_client(
|
|
1277
|
+
client,
|
|
1278
|
+
symbol=symbol,
|
|
1279
|
+
granularity=granularity,
|
|
1280
|
+
count=count,
|
|
1281
|
+
)
|
|
1282
|
+
if "time" not in frame.columns:
|
|
1283
|
+
msg = f"Rate data is missing a time column for {symbol!r}."
|
|
1284
|
+
raise ValueError(msg)
|
|
1285
|
+
idx = _rate_time_to_utc(frame["time"], symbol)
|
|
1286
|
+
idx.name = "time"
|
|
1287
|
+
result = frame.drop(columns=["time"])
|
|
1288
|
+
result.index = idx
|
|
1289
|
+
return result
|
|
1290
|
+
|
|
1291
|
+
|
|
1205
1292
|
@contextmanager
|
|
1206
1293
|
def mt5_trading_session(
|
|
1207
1294
|
config: Mt5Config | None = None,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -44,6 +44,7 @@ from mt5cli import (
|
|
|
44
44
|
export_dataframe_to_sqlite,
|
|
45
45
|
fetch_latest_closed_rates,
|
|
46
46
|
fetch_latest_closed_rates_for_trading_client,
|
|
47
|
+
fetch_latest_closed_rates_indexed,
|
|
47
48
|
granularity_name,
|
|
48
49
|
is_recoverable_mt5_error,
|
|
49
50
|
load_rate_data,
|
|
@@ -734,3 +735,32 @@ class TestStableSdkContract:
|
|
|
734
735
|
raise RuntimeError(message)
|
|
735
736
|
|
|
736
737
|
mock_client.shutdown.assert_called_once()
|
|
738
|
+
|
|
739
|
+
def test_fetch_latest_closed_rates_indexed_from_package_root(
|
|
740
|
+
self,
|
|
741
|
+
mocker: MockerFixture,
|
|
742
|
+
) -> None:
|
|
743
|
+
"""Indexed closed-bar helper returns a UTC DatetimeIndex named 'time'."""
|
|
744
|
+
client = MagicMock()
|
|
745
|
+
mocker.patch(
|
|
746
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
747
|
+
return_value=pd.DataFrame(
|
|
748
|
+
{
|
|
749
|
+
"time": [1704067200, 1704153600, 1704240000],
|
|
750
|
+
"close": [1.0, 1.1, 1.2],
|
|
751
|
+
},
|
|
752
|
+
),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
result = fetch_latest_closed_rates_indexed(
|
|
756
|
+
client,
|
|
757
|
+
symbol="EURUSD",
|
|
758
|
+
granularity="M1",
|
|
759
|
+
count=2,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
assert isinstance(result.index, pd.DatetimeIndex)
|
|
763
|
+
assert result.index.name == "time"
|
|
764
|
+
assert result.index.tz is not None
|
|
765
|
+
assert "time" not in result.columns
|
|
766
|
+
assert "close" in result.columns
|
|
@@ -1777,6 +1777,80 @@ class TestSubstituteEnvPlaceholders:
|
|
|
1777
1777
|
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
|
1778
1778
|
substitute_env_placeholders("${MT5_MISSING}")
|
|
1779
1779
|
|
|
1780
|
+
def test_whole_dollar_not_substituted_by_default(
|
|
1781
|
+
self,
|
|
1782
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1783
|
+
) -> None:
|
|
1784
|
+
"""Test $ENV_NAME is not expanded without allow_whole_dollar_env=True."""
|
|
1785
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
1786
|
+
|
|
1787
|
+
assert substitute_env_placeholders("$MT5_PASSWORD") == "$MT5_PASSWORD"
|
|
1788
|
+
|
|
1789
|
+
def test_whole_dollar_substituted_with_opt_in(
|
|
1790
|
+
self,
|
|
1791
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1792
|
+
) -> None:
|
|
1793
|
+
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
|
1794
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
1795
|
+
|
|
1796
|
+
result = substitute_env_placeholders(
|
|
1797
|
+
"$MT5_PASSWORD", allow_whole_dollar_env=True
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
assert result == "secret"
|
|
1801
|
+
|
|
1802
|
+
def test_whole_dollar_missing_variable_raises_value_error(
|
|
1803
|
+
self,
|
|
1804
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1805
|
+
) -> None:
|
|
1806
|
+
"""Test missing $ENV_NAME raises ValueError when opt-in is enabled."""
|
|
1807
|
+
monkeypatch.delenv("MT5_MISSING", raising=False)
|
|
1808
|
+
|
|
1809
|
+
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
|
1810
|
+
substitute_env_placeholders("$MT5_MISSING", allow_whole_dollar_env=True)
|
|
1811
|
+
|
|
1812
|
+
def test_partial_dollar_not_expanded_with_opt_in(
|
|
1813
|
+
self,
|
|
1814
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1815
|
+
) -> None:
|
|
1816
|
+
"""Test $ENV embedded in a larger string is not expanded."""
|
|
1817
|
+
monkeypatch.setenv("pass", "secret")
|
|
1818
|
+
monkeypatch.setenv("ENV", "val")
|
|
1819
|
+
|
|
1820
|
+
assert (
|
|
1821
|
+
substitute_env_placeholders("plan$pass", allow_whole_dollar_env=True)
|
|
1822
|
+
== "plan$pass"
|
|
1823
|
+
)
|
|
1824
|
+
assert (
|
|
1825
|
+
substitute_env_placeholders("abc$ENV", allow_whole_dollar_env=True)
|
|
1826
|
+
== "abc$ENV"
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
def test_dollar_with_suffix_not_expanded_with_opt_in(
|
|
1830
|
+
self,
|
|
1831
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1832
|
+
) -> None:
|
|
1833
|
+
"""Test $ENV-suffix is not expanded (not a whole-value placeholder)."""
|
|
1834
|
+
monkeypatch.setenv("ENV", "val")
|
|
1835
|
+
|
|
1836
|
+
assert (
|
|
1837
|
+
substitute_env_placeholders("$ENV-suffix", allow_whole_dollar_env=True)
|
|
1838
|
+
== "$ENV-suffix"
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
def test_brace_format_works_with_opt_in(
|
|
1842
|
+
self,
|
|
1843
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1844
|
+
) -> None:
|
|
1845
|
+
"""Test ${ENV_VAR} substitution still works when allow_whole_dollar_env=True."""
|
|
1846
|
+
monkeypatch.setenv("MT5_LOGIN", "12345")
|
|
1847
|
+
|
|
1848
|
+
result = substitute_env_placeholders(
|
|
1849
|
+
"${MT5_LOGIN}", allow_whole_dollar_env=True
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
assert result == "12345"
|
|
1853
|
+
|
|
1780
1854
|
|
|
1781
1855
|
class TestResolveAccountSpec:
|
|
1782
1856
|
"""Tests for resolve_account_spec and resolve_account_specs."""
|
|
@@ -1863,6 +1937,117 @@ class TestResolveAccountSpec:
|
|
|
1863
1937
|
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
|
1864
1938
|
assert all(a.timeout == 1000 for a in resolved)
|
|
1865
1939
|
|
|
1940
|
+
def test_resolve_account_spec_with_whole_dollar_env(
|
|
1941
|
+
self,
|
|
1942
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1943
|
+
) -> None:
|
|
1944
|
+
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
|
|
1945
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
1946
|
+
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
|
1947
|
+
|
|
1948
|
+
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
|
1949
|
+
|
|
1950
|
+
assert resolved.password == "secret" # noqa: S105
|
|
1951
|
+
|
|
1952
|
+
def test_resolve_account_spec_whole_dollar_not_expanded_by_default(
|
|
1953
|
+
self,
|
|
1954
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1955
|
+
) -> None:
|
|
1956
|
+
"""Test resolve_account_spec leaves $ENV_NAME literal by default."""
|
|
1957
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
1958
|
+
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
|
1959
|
+
|
|
1960
|
+
resolved = resolve_account_spec(account)
|
|
1961
|
+
|
|
1962
|
+
assert resolved.password == "$MT5_PASSWORD" # noqa: S105
|
|
1963
|
+
|
|
1964
|
+
def test_resolve_account_specs_with_whole_dollar_env(
|
|
1965
|
+
self,
|
|
1966
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1967
|
+
) -> None:
|
|
1968
|
+
"""Test resolve_account_specs threads allow_whole_dollar_env to each account."""
|
|
1969
|
+
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
|
1970
|
+
accounts = [
|
|
1971
|
+
AccountSpec(symbols=["EURUSD"], server="$MT5_SERVER"),
|
|
1972
|
+
AccountSpec(symbols=["GBPUSD"], server="Fixed"),
|
|
1973
|
+
]
|
|
1974
|
+
|
|
1975
|
+
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
|
1976
|
+
|
|
1977
|
+
assert resolved[0].server == "Broker-Demo"
|
|
1978
|
+
assert resolved[1].server == "Fixed"
|
|
1979
|
+
|
|
1980
|
+
def test_resolve_account_spec_whole_dollar_login(
|
|
1981
|
+
self,
|
|
1982
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1983
|
+
) -> None:
|
|
1984
|
+
"""Test $ENV_NAME login string is expanded when allow_whole_dollar_env=True."""
|
|
1985
|
+
monkeypatch.setenv("MT5_LOGIN", "12345")
|
|
1986
|
+
account = AccountSpec(symbols=["EURUSD"], login="$MT5_LOGIN")
|
|
1987
|
+
|
|
1988
|
+
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
|
1989
|
+
|
|
1990
|
+
assert resolved.login == "12345"
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
class TestBuildConfigWholeDollarEnv:
|
|
1994
|
+
"""Tests for build_config with allow_whole_dollar_env."""
|
|
1995
|
+
|
|
1996
|
+
def test_build_config_substitutes_server_with_opt_in(
|
|
1997
|
+
self,
|
|
1998
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1999
|
+
) -> None:
|
|
2000
|
+
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
|
|
2001
|
+
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
|
2002
|
+
|
|
2003
|
+
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
|
|
2004
|
+
|
|
2005
|
+
assert config.server == "Broker-Demo"
|
|
2006
|
+
|
|
2007
|
+
def test_build_config_substitutes_password_with_opt_in(
|
|
2008
|
+
self,
|
|
2009
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2010
|
+
) -> None:
|
|
2011
|
+
"""build_config expands $ENV_NAME password when allow_whole_dollar_env=True."""
|
|
2012
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
2013
|
+
|
|
2014
|
+
config = build_config(password="$MT5_PASSWORD", allow_whole_dollar_env=True)
|
|
2015
|
+
|
|
2016
|
+
assert config.password == "secret" # noqa: S105
|
|
2017
|
+
|
|
2018
|
+
def test_build_config_substitutes_path_with_opt_in(
|
|
2019
|
+
self,
|
|
2020
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2021
|
+
) -> None:
|
|
2022
|
+
"""Test build_config expands $ENV_NAME path when allow_whole_dollar_env=True."""
|
|
2023
|
+
monkeypatch.setenv("MT5_PATH", "/opt/mt5/terminal64.exe")
|
|
2024
|
+
|
|
2025
|
+
config = build_config(path="$MT5_PATH", allow_whole_dollar_env=True)
|
|
2026
|
+
|
|
2027
|
+
assert config.path == "/opt/mt5/terminal64.exe"
|
|
2028
|
+
|
|
2029
|
+
def test_build_config_leaves_dollar_literal_by_default(
|
|
2030
|
+
self,
|
|
2031
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2032
|
+
) -> None:
|
|
2033
|
+
"""Test build_config does not substitute $ENV without opt-in."""
|
|
2034
|
+
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
|
2035
|
+
|
|
2036
|
+
config = build_config(server="$MT5_SERVER")
|
|
2037
|
+
|
|
2038
|
+
assert config.server == "$MT5_SERVER"
|
|
2039
|
+
|
|
2040
|
+
def test_build_config_none_params_not_substituted(
|
|
2041
|
+
self,
|
|
2042
|
+
monkeypatch: pytest.MonkeyPatch, # noqa: ARG002
|
|
2043
|
+
) -> None:
|
|
2044
|
+
"""Test build_config with None params does not raise even with opt-in."""
|
|
2045
|
+
config = build_config(allow_whole_dollar_env=True)
|
|
2046
|
+
|
|
2047
|
+
assert config.server is None
|
|
2048
|
+
assert config.password is None
|
|
2049
|
+
assert config.path is None
|
|
2050
|
+
|
|
1866
2051
|
|
|
1867
2052
|
class TestThrottledHistoryUpdater:
|
|
1868
2053
|
"""Tests for the throttled incremental history updater."""
|
|
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
|
|
|
8
8
|
|
|
9
9
|
import pandas as pd
|
|
10
10
|
import pytest
|
|
11
|
+
from numpy import float64 as np_float64
|
|
11
12
|
from numpy import int64 as np_int64
|
|
12
13
|
from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
|
13
14
|
from pytest_mock import MockerFixture # noqa: TC002
|
|
@@ -29,6 +30,7 @@ from mt5cli.trading import (
|
|
|
29
30
|
ensure_symbol_selected,
|
|
30
31
|
estimate_order_margin,
|
|
31
32
|
fetch_latest_closed_rates_for_trading_client,
|
|
33
|
+
fetch_latest_closed_rates_indexed,
|
|
32
34
|
get_account_snapshot,
|
|
33
35
|
get_positions_frame,
|
|
34
36
|
get_symbol_snapshot,
|
|
@@ -2627,3 +2629,269 @@ class TestFetchLatestClosedRatesForTradingClient:
|
|
|
2627
2629
|
)
|
|
2628
2630
|
|
|
2629
2631
|
client.fetch_latest_rates_as_df.assert_not_called()
|
|
2632
|
+
|
|
2633
|
+
def test_returns_range_index_and_time_column_for_backward_compat(self) -> None:
|
|
2634
|
+
"""Test original helper returns RangeIndex with a time column."""
|
|
2635
|
+
client = MagicMock()
|
|
2636
|
+
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
|
2637
|
+
{"time": [1700000000, 1700003600, 1700007200], "close": [1.1, 1.2, 1.3]},
|
|
2638
|
+
)
|
|
2639
|
+
|
|
2640
|
+
result = fetch_latest_closed_rates_for_trading_client(
|
|
2641
|
+
client,
|
|
2642
|
+
symbol="EURUSD",
|
|
2643
|
+
granularity="M1",
|
|
2644
|
+
count=2,
|
|
2645
|
+
)
|
|
2646
|
+
|
|
2647
|
+
assert isinstance(result.index, pd.RangeIndex)
|
|
2648
|
+
assert "time" in result.columns
|
|
2649
|
+
assert len(result) == 2
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
class TestFetchLatestClosedRatesIndexed:
|
|
2653
|
+
"""Tests for fetch_latest_closed_rates_indexed."""
|
|
2654
|
+
|
|
2655
|
+
def test_converts_epoch_seconds_to_utc_datetime_index(
|
|
2656
|
+
self, mocker: MockerFixture
|
|
2657
|
+
) -> None:
|
|
2658
|
+
"""Test integer epoch second timestamps become a UTC DatetimeIndex."""
|
|
2659
|
+
frame = pd.DataFrame(
|
|
2660
|
+
{"time": [1700000000, 1700003600], "close": [1.1, 1.2]},
|
|
2661
|
+
)
|
|
2662
|
+
mocker.patch(
|
|
2663
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2664
|
+
return_value=frame,
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2668
|
+
MagicMock(),
|
|
2669
|
+
symbol="EURUSD",
|
|
2670
|
+
granularity="M1",
|
|
2671
|
+
count=2,
|
|
2672
|
+
)
|
|
2673
|
+
|
|
2674
|
+
assert isinstance(result.index, pd.DatetimeIndex)
|
|
2675
|
+
assert result.index.name == "time"
|
|
2676
|
+
assert result.index.tz is not None
|
|
2677
|
+
assert str(result.index.tz) == "UTC"
|
|
2678
|
+
assert "time" not in result.columns
|
|
2679
|
+
assert list(result["close"]) == [1.1, 1.2]
|
|
2680
|
+
|
|
2681
|
+
def test_converts_float_epoch_seconds_to_utc_datetime_index(
|
|
2682
|
+
self, mocker: MockerFixture
|
|
2683
|
+
) -> None:
|
|
2684
|
+
"""Test float64 epoch second timestamps (after concat/NA upcast) become UTC."""
|
|
2685
|
+
frame = pd.DataFrame(
|
|
2686
|
+
{"time": [1700000000.0, 1700003600.0], "close": [1.1, 1.2]},
|
|
2687
|
+
)
|
|
2688
|
+
mocker.patch(
|
|
2689
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2690
|
+
return_value=frame,
|
|
2691
|
+
)
|
|
2692
|
+
|
|
2693
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2694
|
+
MagicMock(),
|
|
2695
|
+
symbol="EURUSD",
|
|
2696
|
+
granularity="M1",
|
|
2697
|
+
count=2,
|
|
2698
|
+
)
|
|
2699
|
+
|
|
2700
|
+
assert isinstance(result.index, pd.DatetimeIndex)
|
|
2701
|
+
assert result.index.tz is not None
|
|
2702
|
+
assert str(result.index.tz) == "UTC"
|
|
2703
|
+
assert result.index[0].year == 2023
|
|
2704
|
+
|
|
2705
|
+
@pytest.mark.parametrize(
|
|
2706
|
+
"timestamps",
|
|
2707
|
+
[
|
|
2708
|
+
[1700000000, 1700003600],
|
|
2709
|
+
[1700000000.0, 1700003600.0],
|
|
2710
|
+
[np_int64(1700000000), np_int64(1700003600)],
|
|
2711
|
+
[np_float64(1700000000.0), np_float64(1700003600.0)],
|
|
2712
|
+
],
|
|
2713
|
+
ids=["integers", "floats", "numpy-integers", "numpy-floats"],
|
|
2714
|
+
)
|
|
2715
|
+
def test_converts_object_numeric_epoch_seconds_to_utc_datetime_index(
|
|
2716
|
+
self,
|
|
2717
|
+
mocker: MockerFixture,
|
|
2718
|
+
timestamps: list[int] | list[float] | list[np_int64] | list[np_float64],
|
|
2719
|
+
) -> None:
|
|
2720
|
+
"""Test object-dtype real numbers are interpreted as epoch seconds."""
|
|
2721
|
+
frame = pd.DataFrame(
|
|
2722
|
+
{
|
|
2723
|
+
"time": pd.Series(timestamps, dtype=object),
|
|
2724
|
+
"close": [1.1, 1.2],
|
|
2725
|
+
},
|
|
2726
|
+
)
|
|
2727
|
+
mocker.patch(
|
|
2728
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2729
|
+
return_value=frame,
|
|
2730
|
+
)
|
|
2731
|
+
|
|
2732
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2733
|
+
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
|
2734
|
+
)
|
|
2735
|
+
|
|
2736
|
+
assert list(result.index) == list(
|
|
2737
|
+
pd.to_datetime([1700000000, 1700003600], unit="s", utc=True)
|
|
2738
|
+
)
|
|
2739
|
+
|
|
2740
|
+
def test_parses_mixed_datetime_like_strings(self, mocker: MockerFixture) -> None:
|
|
2741
|
+
"""Test object-dtype datetime strings retain datetime-like parsing."""
|
|
2742
|
+
timestamps = ["2024-01-01T00:00:00Z", "2024-01-01T01:00:00+01:00"]
|
|
2743
|
+
frame = pd.DataFrame({"time": timestamps, "close": [1.1, 1.2]})
|
|
2744
|
+
mocker.patch(
|
|
2745
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2746
|
+
return_value=frame,
|
|
2747
|
+
)
|
|
2748
|
+
|
|
2749
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2750
|
+
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
|
2751
|
+
)
|
|
2752
|
+
|
|
2753
|
+
assert list(result.index) == list(pd.to_datetime(timestamps, utc=True))
|
|
2754
|
+
|
|
2755
|
+
def test_does_not_treat_bool_as_epoch_seconds(self, mocker: MockerFixture) -> None:
|
|
2756
|
+
"""Test bool timestamps do not enter the numeric epoch-seconds path."""
|
|
2757
|
+
frame = pd.DataFrame(
|
|
2758
|
+
{"time": pd.Series([True, False], dtype=object), "close": [1.1, 1.2]},
|
|
2759
|
+
)
|
|
2760
|
+
mocker.patch(
|
|
2761
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2762
|
+
return_value=frame,
|
|
2763
|
+
)
|
|
2764
|
+
|
|
2765
|
+
with pytest.raises(ValueError, match="invalid or unparseable time data"):
|
|
2766
|
+
fetch_latest_closed_rates_indexed(
|
|
2767
|
+
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
|
2768
|
+
)
|
|
2769
|
+
|
|
2770
|
+
def test_converts_naive_datetime_to_utc_datetime_index(
|
|
2771
|
+
self, mocker: MockerFixture
|
|
2772
|
+
) -> None:
|
|
2773
|
+
"""Test timezone-naive datetime values are localized to UTC."""
|
|
2774
|
+
from datetime import datetime # noqa: PLC0415
|
|
2775
|
+
|
|
2776
|
+
frame = pd.DataFrame(
|
|
2777
|
+
{
|
|
2778
|
+
"time": [datetime(2024, 1, 1, 0, 0), datetime(2024, 1, 1, 1, 0)], # noqa: DTZ001
|
|
2779
|
+
"close": [1.1, 1.2],
|
|
2780
|
+
},
|
|
2781
|
+
)
|
|
2782
|
+
mocker.patch(
|
|
2783
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2784
|
+
return_value=frame,
|
|
2785
|
+
)
|
|
2786
|
+
|
|
2787
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2788
|
+
MagicMock(),
|
|
2789
|
+
symbol="EURUSD",
|
|
2790
|
+
granularity="M1",
|
|
2791
|
+
count=2,
|
|
2792
|
+
)
|
|
2793
|
+
|
|
2794
|
+
assert isinstance(result.index, pd.DatetimeIndex)
|
|
2795
|
+
assert result.index.tz is not None
|
|
2796
|
+
assert str(result.index.tz) == "UTC"
|
|
2797
|
+
assert result.index[0].year == 2024
|
|
2798
|
+
|
|
2799
|
+
def test_converts_aware_datetime_to_utc(self, mocker: MockerFixture) -> None:
|
|
2800
|
+
"""Test timezone-aware datetime values are converted to UTC."""
|
|
2801
|
+
from datetime import datetime, timedelta, timezone # noqa: PLC0415
|
|
2802
|
+
|
|
2803
|
+
tz_plus5 = timezone(timedelta(hours=5))
|
|
2804
|
+
frame = pd.DataFrame(
|
|
2805
|
+
{
|
|
2806
|
+
"time": [datetime(2024, 1, 1, 5, 0, tzinfo=tz_plus5)],
|
|
2807
|
+
"close": [1.1],
|
|
2808
|
+
},
|
|
2809
|
+
)
|
|
2810
|
+
mocker.patch(
|
|
2811
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2812
|
+
return_value=frame,
|
|
2813
|
+
)
|
|
2814
|
+
|
|
2815
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2816
|
+
MagicMock(),
|
|
2817
|
+
symbol="EURUSD",
|
|
2818
|
+
granularity="M1",
|
|
2819
|
+
count=1,
|
|
2820
|
+
)
|
|
2821
|
+
|
|
2822
|
+
assert isinstance(result.index, pd.DatetimeIndex)
|
|
2823
|
+
assert str(result.index.tz) == "UTC"
|
|
2824
|
+
assert result.index[0].hour == 0
|
|
2825
|
+
|
|
2826
|
+
def test_raises_on_missing_time_column(self, mocker: MockerFixture) -> None:
|
|
2827
|
+
"""Test missing time column after the underlying fetch raises ValueError."""
|
|
2828
|
+
mocker.patch(
|
|
2829
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2830
|
+
return_value=pd.DataFrame({"close": [1.1]}),
|
|
2831
|
+
)
|
|
2832
|
+
|
|
2833
|
+
with pytest.raises(ValueError, match="missing a time column"):
|
|
2834
|
+
fetch_latest_closed_rates_indexed(
|
|
2835
|
+
MagicMock(),
|
|
2836
|
+
symbol="EURUSD",
|
|
2837
|
+
granularity="M1",
|
|
2838
|
+
count=1,
|
|
2839
|
+
)
|
|
2840
|
+
|
|
2841
|
+
def test_raises_on_unparseable_time_column(self, mocker: MockerFixture) -> None:
|
|
2842
|
+
"""Test unparseable time data raises a clear ValueError."""
|
|
2843
|
+
mocker.patch(
|
|
2844
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2845
|
+
return_value=pd.DataFrame({"time": ["not-a-date"], "close": [1.1]}),
|
|
2846
|
+
)
|
|
2847
|
+
|
|
2848
|
+
with pytest.raises(ValueError, match="invalid or unparseable time data"):
|
|
2849
|
+
fetch_latest_closed_rates_indexed(
|
|
2850
|
+
MagicMock(),
|
|
2851
|
+
symbol="EURUSD",
|
|
2852
|
+
granularity="M1",
|
|
2853
|
+
count=1,
|
|
2854
|
+
)
|
|
2855
|
+
|
|
2856
|
+
def test_raises_on_nat_time_column(self, mocker: MockerFixture) -> None:
|
|
2857
|
+
"""Test NaT in the time column raises ValueError instead of silently passing."""
|
|
2858
|
+
mocker.patch(
|
|
2859
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2860
|
+
return_value=pd.DataFrame(
|
|
2861
|
+
{"time": [1700000000, None], "close": [1.1, 1.2]},
|
|
2862
|
+
),
|
|
2863
|
+
)
|
|
2864
|
+
|
|
2865
|
+
with pytest.raises(ValueError, match=r"missing.*NaT.*timestamp"):
|
|
2866
|
+
fetch_latest_closed_rates_indexed(
|
|
2867
|
+
MagicMock(),
|
|
2868
|
+
symbol="EURUSD",
|
|
2869
|
+
granularity="M1",
|
|
2870
|
+
count=2,
|
|
2871
|
+
)
|
|
2872
|
+
|
|
2873
|
+
def test_drops_time_column_and_sets_index(self, mocker: MockerFixture) -> None:
|
|
2874
|
+
"""Test the returned DataFrame has the DatetimeIndex and no time column."""
|
|
2875
|
+
frame = pd.DataFrame(
|
|
2876
|
+
{
|
|
2877
|
+
"time": [1700000000, 1700003600],
|
|
2878
|
+
"open": [1.0, 1.1],
|
|
2879
|
+
"close": [1.1, 1.2],
|
|
2880
|
+
},
|
|
2881
|
+
)
|
|
2882
|
+
mocker.patch(
|
|
2883
|
+
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
2884
|
+
return_value=frame,
|
|
2885
|
+
)
|
|
2886
|
+
|
|
2887
|
+
result = fetch_latest_closed_rates_indexed(
|
|
2888
|
+
MagicMock(),
|
|
2889
|
+
symbol="EURUSD",
|
|
2890
|
+
granularity="M1",
|
|
2891
|
+
count=2,
|
|
2892
|
+
)
|
|
2893
|
+
|
|
2894
|
+
assert "time" not in result.columns
|
|
2895
|
+
assert "open" in result.columns
|
|
2896
|
+
assert "close" in result.columns
|
|
2897
|
+
assert isinstance(result.index, pd.DatetimeIndex)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|