mt5cli 0.9.4__tar.gz → 0.9.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {mt5cli-0.9.4 → mt5cli-0.9.5}/PKG-INFO +8 -3
  2. {mt5cli-0.9.4 → mt5cli-0.9.5}/README.md +7 -2
  3. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/public-contract.md +13 -11
  4. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/__init__.py +2 -0
  5. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/contract.py +1 -0
  6. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/sdk.py +83 -6
  7. {mt5cli-0.9.4 → mt5cli-0.9.5}/pyproject.toml +1 -1
  8. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/test_sdk.py +261 -0
  9. {mt5cli-0.9.4 → mt5cli-0.9.5}/uv.lock +1 -1
  10. {mt5cli-0.9.4 → mt5cli-0.9.5}/.agents/skills/local-qa/SKILL.md +0 -0
  11. {mt5cli-0.9.4 → mt5cli-0.9.5}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  12. {mt5cli-0.9.4 → mt5cli-0.9.5}/.agents/skills/mt5cli/SKILL.md +0 -0
  13. {mt5cli-0.9.4 → mt5cli-0.9.5}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  14. {mt5cli-0.9.4 → mt5cli-0.9.5}/.claude/agents/codex.md +0 -0
  15. {mt5cli-0.9.4 → mt5cli-0.9.5}/.claude/settings.json +0 -0
  16. {mt5cli-0.9.4 → mt5cli-0.9.5}/.github/FUNDING.yml +0 -0
  17. {mt5cli-0.9.4 → mt5cli-0.9.5}/.github/dependabot.yml +0 -0
  18. {mt5cli-0.9.4 → mt5cli-0.9.5}/.github/renovate.json +0 -0
  19. {mt5cli-0.9.4 → mt5cli-0.9.5}/.github/workflows/ci.yml +0 -0
  20. {mt5cli-0.9.4 → mt5cli-0.9.5}/.github/workflows/claude.yml +0 -0
  21. {mt5cli-0.9.4 → mt5cli-0.9.5}/.github/workflows/release.yml +0 -0
  22. {mt5cli-0.9.4 → mt5cli-0.9.5}/.gitignore +0 -0
  23. {mt5cli-0.9.4 → mt5cli-0.9.5}/AGENTS.md +0 -0
  24. {mt5cli-0.9.4 → mt5cli-0.9.5}/CLAUDE.md +0 -0
  25. {mt5cli-0.9.4 → mt5cli-0.9.5}/LICENSE +0 -0
  26. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/cli.md +0 -0
  27. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/client.md +0 -0
  28. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/converters.md +0 -0
  29. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/exceptions.md +0 -0
  30. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/history.md +0 -0
  31. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/index.md +0 -0
  32. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/schemas.md +0 -0
  33. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/sdk.md +0 -0
  34. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/storage.md +0 -0
  35. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/trading.md +0 -0
  36. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/api/utils.md +0 -0
  37. {mt5cli-0.9.4 → mt5cli-0.9.5}/docs/index.md +0 -0
  38. {mt5cli-0.9.4 → mt5cli-0.9.5}/mkdocs.yml +0 -0
  39. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/__main__.py +0 -0
  40. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/cli.py +0 -0
  41. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/client.py +0 -0
  42. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/converters.py +0 -0
  43. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/exceptions.py +0 -0
  44. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/history.py +0 -0
  45. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/retry.py +0 -0
  46. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/schemas.py +0 -0
  47. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/storage.py +0 -0
  48. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/trading.py +0 -0
  49. {mt5cli-0.9.4 → mt5cli-0.9.5}/mt5cli/utils.py +0 -0
  50. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/__init__.py +0 -0
  51. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/conftest.py +0 -0
  52. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/test_cli.py +0 -0
  53. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/test_contracts.py +0 -0
  54. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/test_history.py +0 -0
  55. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/test_trading.py +0 -0
  56. {mt5cli-0.9.4 → mt5cli-0.9.5}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.9.4
3
+ Version: 0.9.5
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>
@@ -116,16 +116,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
116
116
  Trading applications can depend on `mt5cli` imports only; terminal path,
117
117
  credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
118
118
  login strings are coerced to integers, and empty login strings are treated as
119
- unset.
119
+ unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
120
+ `$ENV_NAME` placeholders in connection string parameters before coercion.
120
121
 
121
122
  ```python
122
123
  from mt5cli import (
124
+ build_config,
123
125
  calculate_spread_ratio,
124
126
  create_trading_client,
125
127
  get_account_snapshot,
126
128
  mt5_trading_session,
127
129
  )
128
130
 
131
+ # Login from environment — numeric string is coerced to int automatically
132
+ config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
133
+
129
134
  with mt5_trading_session(
130
135
  path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
131
136
  login="12345",
@@ -272,7 +277,7 @@ rates = collect_latest_closed_rates_by_granularity(
272
277
  eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
273
278
  ```
274
279
 
275
- - **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.
280
+ - **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. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
276
281
  - **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). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
277
282
  - **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. Keep read-only collection on `mt5_session()` / `MT5Client`.
278
283
  - **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.
@@ -92,16 +92,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
92
92
  Trading applications can depend on `mt5cli` imports only; terminal path,
93
93
  credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
94
94
  login strings are coerced to integers, and empty login strings are treated as
95
- unset.
95
+ unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
96
+ `$ENV_NAME` placeholders in connection string parameters before coercion.
96
97
 
97
98
  ```python
98
99
  from mt5cli import (
100
+ build_config,
99
101
  calculate_spread_ratio,
100
102
  create_trading_client,
101
103
  get_account_snapshot,
102
104
  mt5_trading_session,
103
105
  )
104
106
 
107
+ # Login from environment — numeric string is coerced to int automatically
108
+ config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
109
+
105
110
  with mt5_trading_session(
106
111
  path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
107
112
  login="12345",
@@ -248,7 +253,7 @@ rates = collect_latest_closed_rates_by_granularity(
248
253
  eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
249
254
  ```
250
255
 
251
- - **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.
256
+ - **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. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
252
257
  - **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). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
253
258
  - **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. Keep read-only collection on `mt5_session()` / `MT5Client`.
254
259
  - **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.
@@ -28,23 +28,25 @@ These names are exported from `mt5cli` and covered by the contract in
28
28
 
29
29
  ### Session lifecycle and configuration
30
30
 
31
- | Symbol | Role |
32
- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
33
- | `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
34
- | `build_config` | Build `pdmt5.Mt5Config` from connection fields |
35
- | `mt5_session` | Context manager: initialize, login, yield client, shutdown |
36
- | `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
37
- | `AccountSpec` | Generic account group: symbols plus optional credentials |
38
- | `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
39
- | `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
31
+ | Symbol | Role |
32
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
34
+ | `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
35
+ | `mt5_session` | Context manager: initialize, login, yield client, shutdown |
36
+ | `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
37
+ | `AccountSpec` | Generic account group: symbols plus optional credentials |
38
+ | `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
39
+ | `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
40
+ | `substitute_mapping_values` | Recursively traverse a dict/list/scalar structure and substitute `${ENV_VAR}` placeholders for caller-selected mapping keys only; optionally normalise blank strings to `None` for a separate caller-selected key set; does not hard-code any application-specific key names |
40
41
 
41
42
  Credential resolution is generic: any environment variable name may appear inside
42
43
  `${...}`. mt5cli does not hard-code application-specific keys such as
43
44
  `mt5_login` or `mt5_exe`.
44
45
 
45
46
  Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
46
- `resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
47
- additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
47
+ `substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
48
+ and `build_config()` to additionally expand strings whose entire value is a bare
49
+ `$ENV_NAME` identifier.
48
50
  Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
49
51
  **never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
50
52
  Default is `False` to preserve backward compatibility.
@@ -92,6 +92,7 @@ from .sdk import (
92
92
  resolve_account_spec,
93
93
  resolve_account_specs,
94
94
  substitute_env_placeholders,
95
+ substitute_mapping_values,
95
96
  symbol_info,
96
97
  symbol_info_tick,
97
98
  symbols,
@@ -283,6 +284,7 @@ __all__ = [
283
284
  "resolve_rate_view_names",
284
285
  "schema_columns",
285
286
  "substitute_env_placeholders",
287
+ "substitute_mapping_values",
286
288
  "symbol_info",
287
289
  "symbol_info_tick",
288
290
  "symbols",
@@ -77,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
77
77
  "resolve_rate_view_name",
78
78
  "resolve_rate_view_names",
79
79
  "substitute_env_placeholders",
80
+ "substitute_mapping_values",
80
81
  "update_history",
81
82
  "update_history_with_config",
82
83
  "update_sltp_for_open_positions",
@@ -40,7 +40,7 @@ from .utils import (
40
40
  from .utils import coerce_login as _coerce_login
41
41
 
42
42
  if TYPE_CHECKING:
43
- from collections.abc import Callable, Iterator, Sequence
43
+ from collections.abc import Callable, Collection, Iterator, Sequence
44
44
 
45
45
  UpdateHistoryBackend = Callable[..., None]
46
46
 
@@ -142,6 +142,7 @@ __all__ = [
142
142
  "resolve_account_spec",
143
143
  "resolve_account_specs",
144
144
  "substitute_env_placeholders",
145
+ "substitute_mapping_values",
145
146
  "symbol_info",
146
147
  "symbol_info_tick",
147
148
  "symbols",
@@ -305,7 +306,7 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
305
306
  def build_config(
306
307
  *,
307
308
  path: str | None = None,
308
- login: int | None = None,
309
+ login: int | str | None = None,
309
310
  password: str | None = None,
310
311
  server: str | None = None,
311
312
  timeout: int | None = None,
@@ -315,14 +316,19 @@ def build_config(
315
316
 
316
317
  Args:
317
318
  path: Optional terminal executable path.
318
- login: Optional trading account login.
319
+ login: Optional trading account login. Integers are preserved. String
320
+ values are coerced: empty or whitespace-only strings become
321
+ ``None``; numeric strings such as ``"12345"`` are converted to
322
+ ``int``; non-numeric strings raise ``ValueError``. When
323
+ ``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
324
+ ``${ENV_NAME}`` placeholders are expanded before coercion.
319
325
  password: Optional trading account password.
320
326
  server: Optional trading server name.
321
327
  timeout: Optional connection timeout in milliseconds.
322
328
  allow_whole_dollar_env: When ``True``, string parameters that are
323
329
  exactly ``$ENV_NAME`` are expanded from the environment. Applies
324
- to ``path``, ``password``, and ``server``. Default ``False``
325
- preserves existing behavior.
330
+ to ``path``, ``login``, ``password``, and ``server``. Default
331
+ ``False`` preserves existing behavior.
326
332
 
327
333
  Returns:
328
334
  Configured ``Mt5Config`` instance.
@@ -330,6 +336,8 @@ def build_config(
330
336
  if allow_whole_dollar_env:
331
337
  if path is not None:
332
338
  path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
339
+ if isinstance(login, str):
340
+ login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
333
341
  if password is not None:
334
342
  password = substitute_env_placeholders(
335
343
  password, allow_whole_dollar_env=True
@@ -338,7 +346,7 @@ def build_config(
338
346
  server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
339
347
  return Mt5Config(
340
348
  path=path,
341
- login=login,
349
+ login=_coerce_login(login),
342
350
  password=password,
343
351
  server=server,
344
352
  timeout=timeout,
@@ -1442,6 +1450,75 @@ def substitute_env_placeholders(
1442
1450
  return "".join(parts)
1443
1451
 
1444
1452
 
1453
+ def substitute_mapping_values(
1454
+ data: object,
1455
+ *,
1456
+ keys: Collection[str],
1457
+ allow_whole_dollar_env: bool = False,
1458
+ blank_string_keys_as_none: Collection[str] = (),
1459
+ ) -> object:
1460
+ """Recursively substitute environment placeholders for selected mapping keys.
1461
+
1462
+ Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
1463
+ ``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
1464
+ whose immediate parent dict key is in ``keys``. Fields whose key is
1465
+ not in ``keys`` are preserved exactly, including literal dollar signs.
1466
+ Strings that are direct elements of a list are never substituted;
1467
+ substitution only applies to strings that are immediate dict values.
1468
+
1469
+ This is a generic downstream config utility. Key names such as
1470
+ ``mt5_login`` or ``mt5_password`` must be supplied by the caller;
1471
+ mt5cli does not hard-code any application-specific key names.
1472
+ Callers are responsible for ensuring ``data`` has bounded nesting depth;
1473
+ deeply nested or self-referential structures will hit Python's recursion
1474
+ limit.
1475
+
1476
+ Args:
1477
+ data: Arbitrarily nested dict/list/scalar value to process.
1478
+ keys: Mapping keys whose string values receive placeholder
1479
+ substitution.
1480
+ allow_whole_dollar_env: When ``True``, a string that is exactly
1481
+ ``$ENV_NAME`` (whole value) is also expanded from the
1482
+ environment in addition to ``${ENV_NAME}`` placeholders.
1483
+ Default ``False`` expands ``${ENV_NAME}`` only.
1484
+ blank_string_keys_as_none: Mapping keys for which blank strings
1485
+ (after any substitution) are normalised to ``None``. A key
1486
+ may appear in ``blank_string_keys_as_none`` without also
1487
+ appearing in ``keys``.
1488
+
1489
+ Returns:
1490
+ The processed value. Dicts and lists are rebuilt into new
1491
+ containers with selected string values substituted and
1492
+ blank-normalised. Scalar inputs (non-dict, non-list) are
1493
+ returned as-is.
1494
+ """
1495
+ keys_set: frozenset[str] = frozenset(keys)
1496
+ blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
1497
+
1498
+ def _visit(node: object, current_key: str | None) -> object:
1499
+ if isinstance(node, dict):
1500
+ typed = cast("dict[object, object]", node)
1501
+ return {
1502
+ k: _visit(v, k if isinstance(k, str) else None)
1503
+ for k, v in typed.items()
1504
+ }
1505
+ if isinstance(node, list):
1506
+ typed_list = cast("list[object]", node)
1507
+ return [_visit(item, None) for item in typed_list]
1508
+ if not isinstance(node, str):
1509
+ return node
1510
+ text = node
1511
+ if current_key in keys_set:
1512
+ text = substitute_env_placeholders(
1513
+ node, allow_whole_dollar_env=allow_whole_dollar_env
1514
+ )
1515
+ if current_key in blank_keys_set and not text.strip():
1516
+ return None
1517
+ return text
1518
+
1519
+ return _visit(data, None)
1520
+
1521
+
1445
1522
  def _resolve_field(
1446
1523
  override: str | None,
1447
1524
  account_value: str | None,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.9.4"
3
+ version = "0.9.5"
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"}]
@@ -54,6 +54,7 @@ from mt5cli.sdk import (
54
54
  resolve_account_spec,
55
55
  resolve_account_specs,
56
56
  substitute_env_placeholders,
57
+ substitute_mapping_values,
57
58
  symbol_info,
58
59
  symbol_info_tick,
59
60
  symbols,
@@ -2436,3 +2437,263 @@ class TestThrottledHistoryUpdater:
2436
2437
  updater.update(MagicMock(), ["EURUSD"])
2437
2438
 
2438
2439
  assert updater.last_update_monotonic is None
2440
+
2441
+
2442
+ class TestBuildConfigStringLogin:
2443
+ """Tests for build_config() string login coercion (issue #61)."""
2444
+
2445
+ @pytest.mark.parametrize(
2446
+ ("login", "expected"),
2447
+ [
2448
+ (None, None),
2449
+ (12345, 12345),
2450
+ ("12345", 12345),
2451
+ (" 12345 ", 12345),
2452
+ ("", None),
2453
+ (" ", None),
2454
+ ],
2455
+ )
2456
+ def test_coerces_login_from_string(
2457
+ self,
2458
+ login: int | str | None,
2459
+ expected: int | None,
2460
+ ) -> None:
2461
+ """Test build_config coerces string login to int or None."""
2462
+ config = build_config(login=login)
2463
+ assert config.login == expected
2464
+
2465
+ def test_rejects_non_numeric_string_login(self) -> None:
2466
+ """Test build_config raises ValueError for non-numeric string login."""
2467
+ with pytest.raises(ValueError, match="invalid literal"):
2468
+ build_config(login="abc")
2469
+
2470
+ def test_expands_dollar_brace_login_with_opt_in(
2471
+ self,
2472
+ monkeypatch: pytest.MonkeyPatch,
2473
+ ) -> None:
2474
+ """Test build_config expands ${MT5_LOGIN} and coerces with opt-in."""
2475
+ monkeypatch.setenv("MT5_LOGIN", "12345")
2476
+ config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
2477
+ assert config.login == 12345
2478
+
2479
+ def test_expands_whole_dollar_login_with_opt_in(
2480
+ self,
2481
+ monkeypatch: pytest.MonkeyPatch,
2482
+ ) -> None:
2483
+ """Test build_config expands $MT5_LOGIN and coerces with opt-in."""
2484
+ monkeypatch.setenv("MT5_LOGIN", "99999")
2485
+ config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
2486
+ assert config.login == 99999
2487
+
2488
+ def test_missing_env_variable_raises(
2489
+ self,
2490
+ monkeypatch: pytest.MonkeyPatch,
2491
+ ) -> None:
2492
+ """Test build_config raises ValueError when referenced env var is not set."""
2493
+ monkeypatch.delenv("MT5_LOGIN", raising=False)
2494
+ with pytest.raises(ValueError, match="'MT5_LOGIN' is not set"):
2495
+ build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
2496
+
2497
+ def test_env_expands_to_blank_becomes_none(
2498
+ self,
2499
+ monkeypatch: pytest.MonkeyPatch,
2500
+ ) -> None:
2501
+ """Test build_config coerces blank env-expanded login to None."""
2502
+ monkeypatch.setenv("MT5_LOGIN", "")
2503
+ config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
2504
+ assert config.login is None
2505
+
2506
+ def test_dollar_brace_login_not_expanded_without_opt_in(self) -> None:
2507
+ """Test ${MT5_LOGIN} is not expanded when allow_whole_dollar_env=False."""
2508
+ with pytest.raises(ValueError, match="invalid literal"):
2509
+ build_config(login="${MT5_LOGIN}")
2510
+
2511
+ def test_integer_login_preserved_backward_compat(self) -> None:
2512
+ """Test existing int login callers remain backward-compatible."""
2513
+ config = build_config(login=54321)
2514
+ assert config.login == 54321
2515
+
2516
+ def test_none_login_preserved_backward_compat(self) -> None:
2517
+ """Test existing None login callers remain backward-compatible."""
2518
+ config = build_config(login=None)
2519
+ assert config.login is None
2520
+
2521
+
2522
+ class TestSubstituteMappingValues:
2523
+ """Tests for substitute_mapping_values() (issue #62)."""
2524
+
2525
+ def test_substitutes_selected_keys_in_flat_dict(
2526
+ self,
2527
+ monkeypatch: pytest.MonkeyPatch,
2528
+ ) -> None:
2529
+ """Test selected keys are substituted in a flat mapping."""
2530
+ monkeypatch.setenv("MT5_LOGIN", "12345")
2531
+ data: dict[str, object] = {
2532
+ "mt5_login": "${MT5_LOGIN}",
2533
+ "strategy_name": "${MT5_LOGIN}",
2534
+ }
2535
+ result = substitute_mapping_values(data, keys={"mt5_login"})
2536
+ assert result == {"mt5_login": "12345", "strategy_name": "${MT5_LOGIN}"}
2537
+
2538
+ def test_preserves_non_selected_literal_dollar_signs(
2539
+ self,
2540
+ monkeypatch: pytest.MonkeyPatch,
2541
+ ) -> None:
2542
+ """Test literal dollar signs in non-selected fields are preserved exactly."""
2543
+ monkeypatch.setenv("MT5_PASSWORD", "secret")
2544
+ data: dict[str, object] = {
2545
+ "mt5_password": "${MT5_PASSWORD}",
2546
+ "notes": "$NOT_EXPANDED",
2547
+ }
2548
+ result = substitute_mapping_values(data, keys={"mt5_password"})
2549
+ assert result == {"mt5_password": "secret", "notes": "$NOT_EXPANDED"}
2550
+
2551
+ def test_nested_dict_traversal_substitutes_selected_keys(
2552
+ self,
2553
+ monkeypatch: pytest.MonkeyPatch,
2554
+ ) -> None:
2555
+ """Test selected keys inside nested dicts are substituted."""
2556
+ monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
2557
+ data: dict[str, object] = {
2558
+ "outer": {
2559
+ "mt5_server": "${MT5_SERVER}",
2560
+ "other": "${MT5_SERVER}",
2561
+ }
2562
+ }
2563
+ result = substitute_mapping_values(data, keys={"mt5_server"})
2564
+ assert result == {
2565
+ "outer": {"mt5_server": "Broker-Demo", "other": "${MT5_SERVER}"}
2566
+ }
2567
+
2568
+ def test_nested_list_traversal_substitutes_selected_keys(
2569
+ self,
2570
+ monkeypatch: pytest.MonkeyPatch,
2571
+ ) -> None:
2572
+ """Test selected keys inside list elements are substituted."""
2573
+ monkeypatch.setenv("MT5_LOGIN", "42")
2574
+ data: dict[str, object] = {
2575
+ "accounts": [
2576
+ {"mt5_login": "${MT5_LOGIN}", "name": "${MT5_LOGIN}"},
2577
+ {"mt5_login": "${MT5_LOGIN}", "name": "fixed"},
2578
+ ]
2579
+ }
2580
+ result = substitute_mapping_values(data, keys={"mt5_login"})
2581
+ assert result == {
2582
+ "accounts": [
2583
+ {"mt5_login": "42", "name": "${MT5_LOGIN}"},
2584
+ {"mt5_login": "42", "name": "fixed"},
2585
+ ]
2586
+ }
2587
+
2588
+ def test_whole_dollar_expanded_with_opt_in(
2589
+ self,
2590
+ monkeypatch: pytest.MonkeyPatch,
2591
+ ) -> None:
2592
+ """Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
2593
+ monkeypatch.setenv("MT5_PASSWORD", "secret")
2594
+ data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
2595
+ result = substitute_mapping_values(
2596
+ data,
2597
+ keys={"mt5_password"},
2598
+ allow_whole_dollar_env=True,
2599
+ )
2600
+ assert result == {"mt5_password": "secret"}
2601
+
2602
+ def test_whole_dollar_not_expanded_by_default(
2603
+ self,
2604
+ monkeypatch: pytest.MonkeyPatch,
2605
+ ) -> None:
2606
+ """Test $ENV_NAME in a selected key is preserved when opt-in is False."""
2607
+ monkeypatch.setenv("MT5_PASSWORD", "secret")
2608
+ data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
2609
+ result = substitute_mapping_values(data, keys={"mt5_password"})
2610
+ assert result == {"mt5_password": "$MT5_PASSWORD"}
2611
+
2612
+ def test_blank_string_becomes_none_for_blank_keys(self) -> None:
2613
+ """Test blank strings are normalised to None for blank_string_keys_as_none."""
2614
+ data: dict[str, object] = {
2615
+ "mt5_login": "",
2616
+ "mt5_password": " ",
2617
+ "other": "",
2618
+ }
2619
+ result = substitute_mapping_values(
2620
+ data,
2621
+ keys=set(),
2622
+ blank_string_keys_as_none={"mt5_login", "mt5_password"},
2623
+ )
2624
+ assert result == {"mt5_login": None, "mt5_password": None, "other": ""}
2625
+
2626
+ def test_env_expanded_blank_becomes_none(
2627
+ self,
2628
+ monkeypatch: pytest.MonkeyPatch,
2629
+ ) -> None:
2630
+ """Test env-expanded blank string is normalised to None."""
2631
+ monkeypatch.setenv("MT5_LOGIN", "")
2632
+ data: dict[str, object] = {"mt5_login": "${MT5_LOGIN}"}
2633
+ result = substitute_mapping_values(
2634
+ data,
2635
+ keys={"mt5_login"},
2636
+ blank_string_keys_as_none={"mt5_login"},
2637
+ )
2638
+ assert result == {"mt5_login": None}
2639
+
2640
+ def test_missing_env_variable_raises_for_selected_key(
2641
+ self,
2642
+ monkeypatch: pytest.MonkeyPatch,
2643
+ ) -> None:
2644
+ """Test missing env var for a selected key raises ValueError."""
2645
+ monkeypatch.delenv("MT5_MISSING", raising=False)
2646
+ data: dict[str, object] = {"mt5_login": "${MT5_MISSING}"}
2647
+ with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
2648
+ substitute_mapping_values(data, keys={"mt5_login"})
2649
+
2650
+ def test_non_string_values_preserved(self) -> None:
2651
+ """Test non-string values under selected or non-selected keys are preserved."""
2652
+ data: dict[str, object] = {
2653
+ "mt5_login": 12345,
2654
+ "timeout": 5000,
2655
+ "enabled": True,
2656
+ "ratio": 1.5,
2657
+ "nothing": None,
2658
+ }
2659
+ result = substitute_mapping_values(
2660
+ data, keys={"mt5_login", "timeout", "enabled", "ratio", "nothing"}
2661
+ )
2662
+ assert result == data
2663
+
2664
+ def test_caller_supplied_key_set_substitutes_correctly(
2665
+ self,
2666
+ monkeypatch: pytest.MonkeyPatch,
2667
+ ) -> None:
2668
+ """Test helper works with any caller-supplied key set."""
2669
+ monkeypatch.setenv("APP_LOGIN", "77777")
2670
+ monkeypatch.setenv("APP_PASSWORD", "p4ss")
2671
+ data: dict[str, object] = {
2672
+ "app_login": "${APP_LOGIN}",
2673
+ "app_password": "${APP_PASSWORD}",
2674
+ "unrelated": "${APP_LOGIN}",
2675
+ }
2676
+ credential_keys = {"app_login", "app_password"}
2677
+ result = substitute_mapping_values(data, keys=credential_keys)
2678
+ assert result == {
2679
+ "app_login": "77777",
2680
+ "app_password": "p4ss",
2681
+ "unrelated": "${APP_LOGIN}",
2682
+ }
2683
+
2684
+ def test_scalar_data_returned_unchanged(self) -> None:
2685
+ """Test a scalar (non-dict, non-list) value is returned as-is."""
2686
+ assert substitute_mapping_values("hello", keys={"x"}) == "hello"
2687
+ assert substitute_mapping_values(42, keys={"x"}) == 42
2688
+ assert substitute_mapping_values(None, keys={"x"}) is None
2689
+
2690
+ def test_tuple_container_not_traversed(
2691
+ self,
2692
+ monkeypatch: pytest.MonkeyPatch,
2693
+ ) -> None:
2694
+ """Test tuple containers are returned as-is without traversal."""
2695
+ monkeypatch.setenv("MT5_LOGIN", "42")
2696
+ data: dict[str, object] = {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
2697
+ result = substitute_mapping_values(data, keys={"mt5_login"})
2698
+ # tuple is returned as-is; inner dict is NOT visited
2699
+ assert result == {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.9.4"
490
+ version = "0.9.5"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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