mt5cli 0.8.3__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {mt5cli-0.8.3 → mt5cli-0.9.1}/.agents/skills/pr-feedback-triage/SKILL.md +2 -2
  2. {mt5cli-0.8.3 → mt5cli-0.9.1}/PKG-INFO +1 -1
  3. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/public-contract.md +27 -19
  4. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/sdk.md +22 -0
  5. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/trading.md +19 -10
  6. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/__init__.py +2 -0
  7. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/contract.py +1 -0
  8. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/sdk.py +77 -9
  9. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/trading.py +124 -9
  10. {mt5cli-0.8.3 → mt5cli-0.9.1}/pyproject.toml +1 -2
  11. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/test_contracts.py +30 -0
  12. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/test_sdk.py +185 -0
  13. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/test_trading.py +330 -1
  14. {mt5cli-0.8.3 → mt5cli-0.9.1}/uv.lock +1 -1
  15. {mt5cli-0.8.3 → mt5cli-0.9.1}/.agents/skills/local-qa/SKILL.md +0 -0
  16. {mt5cli-0.8.3 → mt5cli-0.9.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  17. {mt5cli-0.8.3 → mt5cli-0.9.1}/.agents/skills/mt5cli/SKILL.md +0 -0
  18. {mt5cli-0.8.3 → mt5cli-0.9.1}/.claude/agents/codex.md +0 -0
  19. {mt5cli-0.8.3 → mt5cli-0.9.1}/.claude/settings.json +0 -0
  20. {mt5cli-0.8.3 → mt5cli-0.9.1}/.github/FUNDING.yml +0 -0
  21. {mt5cli-0.8.3 → mt5cli-0.9.1}/.github/dependabot.yml +0 -0
  22. {mt5cli-0.8.3 → mt5cli-0.9.1}/.github/renovate.json +0 -0
  23. {mt5cli-0.8.3 → mt5cli-0.9.1}/.github/workflows/ci.yml +0 -0
  24. {mt5cli-0.8.3 → mt5cli-0.9.1}/.github/workflows/claude.yml +0 -0
  25. {mt5cli-0.8.3 → mt5cli-0.9.1}/.github/workflows/release.yml +0 -0
  26. {mt5cli-0.8.3 → mt5cli-0.9.1}/.gitignore +0 -0
  27. {mt5cli-0.8.3 → mt5cli-0.9.1}/AGENTS.md +0 -0
  28. {mt5cli-0.8.3 → mt5cli-0.9.1}/CLAUDE.md +0 -0
  29. {mt5cli-0.8.3 → mt5cli-0.9.1}/LICENSE +0 -0
  30. {mt5cli-0.8.3 → mt5cli-0.9.1}/README.md +0 -0
  31. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/cli.md +0 -0
  32. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/client.md +0 -0
  33. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/converters.md +0 -0
  34. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/exceptions.md +0 -0
  35. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/history.md +0 -0
  36. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/index.md +0 -0
  37. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/schemas.md +0 -0
  38. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/storage.md +0 -0
  39. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/api/utils.md +0 -0
  40. {mt5cli-0.8.3 → mt5cli-0.9.1}/docs/index.md +0 -0
  41. {mt5cli-0.8.3 → mt5cli-0.9.1}/mkdocs.yml +0 -0
  42. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/__main__.py +0 -0
  43. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/cli.py +0 -0
  44. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/client.py +0 -0
  45. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/converters.py +0 -0
  46. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/exceptions.py +0 -0
  47. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/history.py +0 -0
  48. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/retry.py +0 -0
  49. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/schemas.py +0 -0
  50. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/storage.py +0 -0
  51. {mt5cli-0.8.3 → mt5cli-0.9.1}/mt5cli/utils.py +0 -0
  52. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/__init__.py +0 -0
  53. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/conftest.py +0 -0
  54. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/test_cli.py +0 -0
  55. {mt5cli-0.8.3 → mt5cli-0.9.1}/tests/test_history.py +0 -0
  56. {mt5cli-0.8.3 → mt5cli-0.9.1}/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.8.3
3
+ Version: 0.9.1
4
4
  Summary: Generic MT5 data and execution infrastructure for Python applications
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -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
- | `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
65
- | `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
66
- | `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
67
- | `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
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` | Broker volume normalization and margin totals |
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(value: str) -> str:
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(override: str | None, account_value: str | None) -> str | None:
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(value)
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(override)
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(account_login)
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(login, account.login),
1473
- password=_resolve_field(password, account.password),
1474
- server=_resolve_field(server, account.server),
1475
- path=_resolve_field(path, account.path),
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",
@@ -829,7 +830,9 @@ def calculate_volume_by_margin(
829
830
  """Calculate max normalized volume affordable for one side.
830
831
 
831
832
  Returns:
832
- Affordable volume rounded down to symbol volume constraints.
833
+ Largest stepped volume whose actual margin (from ``order_calc_margin``)
834
+ fits within ``available_margin``, rounded down to symbol volume
835
+ constraints; ``0.0`` when no affordable step exists.
833
836
 
834
837
  Raises:
835
838
  Mt5TradingError: If symbol volume constraints or tick data are invalid.
@@ -844,8 +847,7 @@ def calculate_volume_by_margin(
844
847
  msg = f"Invalid volume constraints for {symbol!r}."
845
848
  raise Mt5TradingError(msg)
846
849
  side = _normalize_order_side(order_side)
847
- tick = get_tick_snapshot(client, symbol)
848
- price = tick["ask"] if side == "BUY" else tick["bid"]
850
+ price = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "bid"]
849
851
  if not isinstance(price, int | float) or price <= 0:
850
852
  msg = f"Tick price is unavailable for {symbol!r}."
851
853
  raise Mt5TradingError(msg)
@@ -855,11 +857,38 @@ def calculate_volume_by_margin(
855
857
  min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
856
858
  if min_margin <= 0 or min_margin > available_margin:
857
859
  return 0.0
858
- raw_volume = available_margin / min_margin * volume_min
859
- capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
860
- steps = floor(((capped - volume_min) / volume_step) + 1e-12)
861
- normalized = volume_min + max(0, steps) * volume_step
862
- return round(normalized, 10) if normalized >= volume_min else 0.0
860
+ lo = 0
861
+ hi = int(
862
+ max(
863
+ 0,
864
+ floor(
865
+ (
866
+ (
867
+ min(available_margin / min_margin * volume_min, volume_max)
868
+ if volume_max > 0
869
+ else available_margin / min_margin * volume_min
870
+ )
871
+ - volume_min
872
+ )
873
+ / volume_step
874
+ + 1e-12
875
+ ),
876
+ )
877
+ )
878
+ best = -1
879
+
880
+ while lo <= hi:
881
+ mid = (lo + hi) // 2
882
+ normalized = round(volume_min + mid * volume_step, 10)
883
+ actual = float(client.order_calc_margin(order_type, symbol, normalized, price))
884
+
885
+ if actual > 0 and actual <= available_margin:
886
+ best = mid
887
+ lo = mid + 1
888
+ else:
889
+ hi = mid - 1
890
+
891
+ return round(volume_min + best * volume_step, 10) if best >= 0 else 0.0
863
892
 
864
893
 
865
894
  def determine_order_limits(
@@ -1202,6 +1231,92 @@ def fetch_latest_closed_rates_for_trading_client(
1202
1231
  return closed.tail(count).reset_index(drop=True)
1203
1232
 
1204
1233
 
1234
+ def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
1235
+ """Convert a rate time series to a UTC-aware DatetimeIndex.
1236
+
1237
+ Handles MT5 epoch seconds (including object-dtype Python numbers), timezone-
1238
+ naive datetime-like values, and timezone-aware datetime-like values.
1239
+
1240
+ Returns:
1241
+ UTC-aware DatetimeIndex.
1242
+
1243
+ Raises:
1244
+ ValueError: If the time data is invalid, unparseable, or contains NaT.
1245
+ """
1246
+ try:
1247
+ arr = series.to_numpy()
1248
+ non_null = series.dropna()
1249
+ object_numbers = (
1250
+ pd.api.types.is_object_dtype(series)
1251
+ and non_null.map(
1252
+ lambda value: type(value) is not bool and isinstance(value, Real),
1253
+ ).all()
1254
+ )
1255
+ numeric_dtype = pd.api.types.is_numeric_dtype(
1256
+ series
1257
+ ) and not pd.api.types.is_bool_dtype(
1258
+ series,
1259
+ )
1260
+ if numeric_dtype or object_numbers:
1261
+ idx = pd.to_datetime(arr, unit="s", utc=True)
1262
+ else:
1263
+ idx = pd.to_datetime(arr, utc=True)
1264
+ except Exception as exc:
1265
+ msg = f"Rate data for {symbol!r} has invalid or unparseable time data."
1266
+ raise ValueError(msg) from exc
1267
+ if any(idx.isna()):
1268
+ msg = f"Rate data for {symbol!r} contains missing (NaT) timestamp values."
1269
+ raise ValueError(msg)
1270
+ return idx
1271
+
1272
+
1273
+ def fetch_latest_closed_rates_indexed(
1274
+ client: Mt5TradingClient,
1275
+ *,
1276
+ symbol: str,
1277
+ granularity: str,
1278
+ count: int,
1279
+ ) -> pd.DataFrame:
1280
+ """Fetch the latest closed bars with a UTC DatetimeIndex from a trading client.
1281
+
1282
+ Internally reuses :func:`fetch_latest_closed_rates_for_trading_client` for
1283
+ closed-bar detection and validation, then converts the ``time`` column to a
1284
+ UTC-aware :class:`~pandas.DatetimeIndex` named ``"time"`` and drops the
1285
+ original column. Intended for downstream time-series consumers that require
1286
+ a datetime index rather than a ``time`` column.
1287
+
1288
+ Args:
1289
+ client: Connected trading client with rate-fetch capability.
1290
+ symbol: Symbol name.
1291
+ granularity: Timeframe string (for example ``"M1"``, ``"H1"``).
1292
+ count: Maximum number of closed bars to return.
1293
+
1294
+ Returns:
1295
+ Up to ``count`` closed bars ordered oldest to newest, with a
1296
+ UTC-aware ``DatetimeIndex`` named ``"time"``. The original ``time``
1297
+ column is dropped.
1298
+
1299
+ Raises:
1300
+ ValueError: If ``count`` is not positive, rate data is empty or
1301
+ malformed, the ``time`` column is missing, or timestamp data
1302
+ is invalid or unparseable.
1303
+ """
1304
+ frame = fetch_latest_closed_rates_for_trading_client(
1305
+ client,
1306
+ symbol=symbol,
1307
+ granularity=granularity,
1308
+ count=count,
1309
+ )
1310
+ if "time" not in frame.columns:
1311
+ msg = f"Rate data is missing a time column for {symbol!r}."
1312
+ raise ValueError(msg)
1313
+ idx = _rate_time_to_utc(frame["time"], symbol)
1314
+ idx.name = "time"
1315
+ result = frame.drop(columns=["time"])
1316
+ result.index = idx
1317
+ return result
1318
+
1319
+
1205
1320
  @contextmanager
1206
1321
  def mt5_trading_session(
1207
1322
  config: Mt5Config | None = None,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.8.3"
3
+ version = "0.9.1"
4
4
  description = "Generic MT5 data and execution infrastructure for Python applications"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -124,7 +124,6 @@ ignore = [
124
124
  ]
125
125
 
126
126
  [tool.ruff.lint.per-file-ignores]
127
- "mt5cli/history.py" = ["TC003"]
128
127
  "tests/**/*.py" = [
129
128
  "DOC201", # Missing return documentation
130
129
  "DOC501", # Raised exception missing from docstring
@@ -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