mt5cli 0.5.2__tar.gz → 0.5.3__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 (38) hide show
  1. {mt5cli-0.5.2 → mt5cli-0.5.3}/PKG-INFO +5 -2
  2. {mt5cli-0.5.2 → mt5cli-0.5.3}/README.md +4 -1
  3. {mt5cli-0.5.2 → mt5cli-0.5.3}/docs/api/history.md +12 -0
  4. mt5cli-0.5.3/docs/api/sdk.md +83 -0
  5. {mt5cli-0.5.2 → mt5cli-0.5.3}/mt5cli/__init__.py +12 -0
  6. {mt5cli-0.5.2 → mt5cli-0.5.3}/mt5cli/history.py +49 -0
  7. {mt5cli-0.5.2 → mt5cli-0.5.3}/mt5cli/sdk.py +323 -2
  8. {mt5cli-0.5.2 → mt5cli-0.5.3}/pyproject.toml +1 -1
  9. {mt5cli-0.5.2 → mt5cli-0.5.3}/tests/test_history.py +51 -0
  10. {mt5cli-0.5.2 → mt5cli-0.5.3}/tests/test_sdk.py +312 -0
  11. {mt5cli-0.5.2 → mt5cli-0.5.3}/uv.lock +1 -1
  12. mt5cli-0.5.2/docs/api/sdk.md +0 -3
  13. {mt5cli-0.5.2 → mt5cli-0.5.3}/.agents/skills/local-qa/SKILL.md +0 -0
  14. {mt5cli-0.5.2 → mt5cli-0.5.3}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  15. {mt5cli-0.5.2 → mt5cli-0.5.3}/.agents/skills/mt5cli/SKILL.md +0 -0
  16. {mt5cli-0.5.2 → mt5cli-0.5.3}/.claude/agents/codex.md +0 -0
  17. {mt5cli-0.5.2 → mt5cli-0.5.3}/.claude/settings.json +0 -0
  18. {mt5cli-0.5.2 → mt5cli-0.5.3}/.github/FUNDING.yml +0 -0
  19. {mt5cli-0.5.2 → mt5cli-0.5.3}/.github/dependabot.yml +0 -0
  20. {mt5cli-0.5.2 → mt5cli-0.5.3}/.github/renovate.json +0 -0
  21. {mt5cli-0.5.2 → mt5cli-0.5.3}/.github/workflows/ci.yml +0 -0
  22. {mt5cli-0.5.2 → mt5cli-0.5.3}/.github/workflows/claude.yml +0 -0
  23. {mt5cli-0.5.2 → mt5cli-0.5.3}/.github/workflows/release.yml +0 -0
  24. {mt5cli-0.5.2 → mt5cli-0.5.3}/.gitignore +0 -0
  25. {mt5cli-0.5.2 → mt5cli-0.5.3}/AGENTS.md +0 -0
  26. {mt5cli-0.5.2 → mt5cli-0.5.3}/CLAUDE.md +0 -0
  27. {mt5cli-0.5.2 → mt5cli-0.5.3}/LICENSE +0 -0
  28. {mt5cli-0.5.2 → mt5cli-0.5.3}/docs/api/cli.md +0 -0
  29. {mt5cli-0.5.2 → mt5cli-0.5.3}/docs/api/index.md +0 -0
  30. {mt5cli-0.5.2 → mt5cli-0.5.3}/docs/api/utils.md +0 -0
  31. {mt5cli-0.5.2 → mt5cli-0.5.3}/docs/index.md +0 -0
  32. {mt5cli-0.5.2 → mt5cli-0.5.3}/mkdocs.yml +0 -0
  33. {mt5cli-0.5.2 → mt5cli-0.5.3}/mt5cli/__main__.py +0 -0
  34. {mt5cli-0.5.2 → mt5cli-0.5.3}/mt5cli/cli.py +0 -0
  35. {mt5cli-0.5.2 → mt5cli-0.5.3}/mt5cli/utils.py +0 -0
  36. {mt5cli-0.5.2 → mt5cli-0.5.3}/tests/__init__.py +0 -0
  37. {mt5cli-0.5.2 → mt5cli-0.5.3}/tests/test_cli.py +0 -0
  38. {mt5cli-0.5.2 → mt5cli-0.5.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Command-line tool for MetaTrader 5
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -160,7 +160,10 @@ update_history_with_config(
160
160
  - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
161
161
  - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
162
162
  - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
163
- - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
163
+ - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
164
+ - **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.
165
+ - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`/`sqlite3.Error` and let the caller decide logging.
166
+ - **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.
164
167
  - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
165
168
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
166
169
  - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
@@ -136,7 +136,10 @@ update_history_with_config(
136
136
  - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
137
137
  - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
138
138
  - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
139
- - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
139
+ - **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
140
+ - **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.
141
+ - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`/`sqlite3.Error` and let the caller decide logging.
142
+ - **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.
140
143
  - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
141
144
  - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
142
145
  - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
@@ -215,3 +215,15 @@ frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
215
215
  requires existing managed `rate_*` compatibility views and raises
216
216
  `ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
217
217
  are rejected.
218
+ - `load_rate_series_by_granularity()` is a thin wrapper that builds the targets,
219
+ loads the series, and rekeys the result by granularity name to avoid
220
+ converting integer timeframes downstream:
221
+
222
+ ```python
223
+ from mt5cli import load_rate_series_by_granularity
224
+
225
+ series = load_rate_series_by_granularity(
226
+ "history.db", ["EURUSD"], ["M1", "H1"], count=1000
227
+ )
228
+ frame = series["EURUSD", "M1"] # keyed by (symbol | None, granularity_name)
229
+ ```
@@ -0,0 +1,83 @@
1
+ # SDK Module
2
+
3
+ ::: mt5cli.sdk
4
+
5
+ ## Resilient multi-account orchestration
6
+
7
+ The SDK ships strategy-agnostic helpers for building long-running collectors on
8
+ top of the read-only client. None of them depend on a particular trading
9
+ application.
10
+
11
+ ### Retrying transient rate collection
12
+
13
+ `collect_latest_rates_for_accounts_with_retries()` wraps
14
+ `collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
15
+ `pdmt5.Mt5TradingError` and `pdmt5.Mt5RuntimeError` are retried; the final
16
+ failure is re-raised once `retry_count` is exhausted.
17
+
18
+ ```python
19
+ from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
20
+
21
+ accounts = [AccountSpec(symbols=["EURUSD"], login=12345)]
22
+ rates = collect_latest_rates_for_accounts_with_retries(
23
+ accounts,
24
+ ["M1", "H1"],
25
+ count=500,
26
+ retry_count=3,
27
+ backoff_base=2, # sleeps 2s, 4s, 8s between attempts
28
+ )
29
+ ```
30
+
31
+ ### Resolving credentials and `${ENV_VAR}` placeholders
32
+
33
+ `resolve_account_spec()` / `resolve_account_specs()` merge explicit override
34
+ values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders, keeping
35
+ secrets out of plan/config files. A missing environment variable raises
36
+ `ValueError`.
37
+
38
+ ```python
39
+ import os
40
+
41
+ from mt5cli import AccountSpec, resolve_account_specs
42
+
43
+ os.environ["MT5_LOGIN"] = "12345"
44
+ os.environ["MT5_PASSWORD"] = "secret"
45
+ accounts = [
46
+ AccountSpec(symbols=["EURUSD"], login="${MT5_LOGIN}", password="${MT5_PASSWORD}")
47
+ ]
48
+
49
+ resolved = resolve_account_specs(accounts, server="Broker-Demo")
50
+ # resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
51
+ ```
52
+
53
+ ### Throttled incremental history updates
54
+
55
+ `ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
56
+ between successful runs (using a monotonic clock), so an application loop can
57
+ call it every iteration without over-fetching.
58
+
59
+ ```python
60
+ from pdmt5 import Mt5Config, Mt5DataClient
61
+
62
+ from mt5cli import Dataset, ThrottledHistoryUpdater
63
+
64
+ updater = ThrottledHistoryUpdater(
65
+ output="history.db",
66
+ datasets={Dataset.rates},
67
+ timeframes=["M1"],
68
+ interval_seconds=60, # <= 0 updates on every call
69
+ )
70
+
71
+ client = Mt5DataClient(config=Mt5Config(login=12345))
72
+ client.initialize_and_login_mt5()
73
+ try:
74
+ while True:
75
+ updater.update(client, ["EURUSD", "GBPUSD"]) # no-op until 60s elapse
76
+ # ... do other work; break when shutting down ...
77
+ finally:
78
+ client.shutdown()
79
+ ```
80
+
81
+ By default `Mt5TradingError`, `Mt5RuntimeError`, and `sqlite3.Error` propagate so
82
+ the caller controls logging; pass `suppress_errors=True` to swallow them and
83
+ return `False` without advancing the throttle.
@@ -8,6 +8,7 @@ from .history import (
8
8
  build_rate_view_name,
9
9
  load_rate_data,
10
10
  load_rate_data_from_connection,
11
+ load_rate_series_by_granularity,
11
12
  load_rate_series_from_sqlite,
12
13
  resolve_history_datasets,
13
14
  resolve_history_tick_flags,
@@ -19,11 +20,13 @@ from .history import (
19
20
  from .sdk import (
20
21
  AccountSpec,
21
22
  Mt5CliClient,
23
+ ThrottledHistoryUpdater,
22
24
  account_info,
23
25
  build_config,
24
26
  collect_history,
25
27
  collect_latest_rates,
26
28
  collect_latest_rates_for_accounts,
29
+ collect_latest_rates_for_accounts_with_retries,
27
30
  copy_rates_from,
28
31
  copy_rates_from_pos,
29
32
  copy_rates_range,
@@ -42,6 +45,9 @@ from .sdk import (
42
45
  positions,
43
46
  recent_history_deals,
44
47
  recent_ticks,
48
+ resolve_account_spec,
49
+ resolve_account_specs,
50
+ substitute_env_placeholders,
45
51
  symbol_info,
46
52
  symbol_info_tick,
47
53
  symbols,
@@ -75,6 +81,7 @@ __all__ = [
75
81
  "IfExists",
76
82
  "Mt5CliClient",
77
83
  "RateTarget",
84
+ "ThrottledHistoryUpdater",
78
85
  "account_info",
79
86
  "build_config",
80
87
  "build_rate_targets",
@@ -82,6 +89,7 @@ __all__ = [
82
89
  "collect_history",
83
90
  "collect_latest_rates",
84
91
  "collect_latest_rates_for_accounts",
92
+ "collect_latest_rates_for_accounts_with_retries",
85
93
  "copy_rates_from",
86
94
  "copy_rates_from_pos",
87
95
  "copy_rates_range",
@@ -96,6 +104,7 @@ __all__ = [
96
104
  "latest_rates",
97
105
  "load_rate_data",
98
106
  "load_rate_data_from_connection",
107
+ "load_rate_series_by_granularity",
99
108
  "load_rate_series_from_sqlite",
100
109
  "market_book",
101
110
  "minimum_margins",
@@ -110,12 +119,15 @@ __all__ = [
110
119
  "positions",
111
120
  "recent_history_deals",
112
121
  "recent_ticks",
122
+ "resolve_account_spec",
123
+ "resolve_account_specs",
113
124
  "resolve_history_datasets",
114
125
  "resolve_history_tick_flags",
115
126
  "resolve_history_timeframes",
116
127
  "resolve_rate_tables",
117
128
  "resolve_rate_view_name",
118
129
  "resolve_rate_view_names",
130
+ "substitute_env_placeholders",
119
131
  "symbol_info",
120
132
  "symbol_info_tick",
121
133
  "symbols",
@@ -706,6 +706,55 @@ def load_rate_series_from_sqlite(
706
706
  conn.close()
707
707
 
708
708
 
709
+ def load_rate_series_by_granularity(
710
+ conn_or_path: SqliteConnOrPath,
711
+ symbols: Sequence[str],
712
+ granularities: Sequence[int | str],
713
+ count: int,
714
+ *,
715
+ explicit_tables: Sequence[str] | None = None,
716
+ allow_missing_symbol: bool = False,
717
+ ) -> dict[tuple[str | None, str], pd.DataFrame]:
718
+ """Load rate series keyed by symbol and string granularity name.
719
+
720
+ Builds targets with :func:`build_rate_targets` and loads them with
721
+ :func:`load_rate_series_from_sqlite`, then rekeys the result by granularity
722
+ name (for example ``M1``) instead of the integer timeframe to reduce
723
+ downstream boilerplate.
724
+
725
+ Args:
726
+ conn_or_path: SQLite database path or open connection.
727
+ symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
728
+ granularities: MT5 timeframes as integers or names (for example ``M1``).
729
+ count: Number of most recent rows to load per series.
730
+ explicit_tables: Optional explicit table or view names matching the
731
+ built targets in row-major order. Required when symbols are omitted.
732
+ allow_missing_symbol: When True and ``symbols`` is empty, build targets
733
+ with ``symbol=None`` for each granularity instead of raising.
734
+
735
+ Returns:
736
+ Mapping keyed by ``(symbol | None, granularity_name)`` to each rate
737
+ DataFrame. Propagates ``ValueError`` (via :func:`build_rate_targets` and
738
+ :func:`load_rate_series_from_sqlite`) when inputs are empty or invalid,
739
+ table resolution fails, or duplicate targets are present.
740
+ """
741
+ targets = build_rate_targets(
742
+ symbols,
743
+ granularities,
744
+ allow_missing_symbol=allow_missing_symbol,
745
+ )
746
+ series = load_rate_series_from_sqlite(
747
+ conn_or_path,
748
+ targets,
749
+ count,
750
+ explicit_tables=explicit_tables,
751
+ )
752
+ return {
753
+ (symbol, resolve_granularity_name(timeframe)): frame
754
+ for (symbol, timeframe), frame in series.items()
755
+ }
756
+
757
+
709
758
  def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
710
759
  """Return existing SQLite columns for a table."""
711
760
  quoted_table = quote_sqlite_identifier(table)
@@ -4,7 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
+ import os
8
+ import re
7
9
  import sqlite3
10
+ import time
8
11
  from contextlib import contextmanager
9
12
  from dataclasses import dataclass, field
10
13
  from datetime import UTC, datetime, timedelta
@@ -12,7 +15,7 @@ from pathlib import Path
12
15
  from typing import TYPE_CHECKING, Self, TypeVar, cast
13
16
 
14
17
  import pandas as pd
15
- from pdmt5 import Mt5Config, Mt5DataClient
18
+ from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError, Mt5TradingError
16
19
 
17
20
  from .history import (
18
21
  create_cash_events_view,
@@ -42,11 +45,13 @@ logger = logging.getLogger(__name__)
42
45
  __all__ = [
43
46
  "AccountSpec",
44
47
  "Mt5CliClient",
48
+ "ThrottledHistoryUpdater",
45
49
  "account_info",
46
50
  "build_config",
47
51
  "collect_history",
48
52
  "collect_latest_rates",
49
53
  "collect_latest_rates_for_accounts",
54
+ "collect_latest_rates_for_accounts_with_retries",
50
55
  "copy_rates_from",
51
56
  "copy_rates_from_pos",
52
57
  "copy_rates_range",
@@ -65,6 +70,9 @@ __all__ = [
65
70
  "positions",
66
71
  "recent_history_deals",
67
72
  "recent_ticks",
73
+ "resolve_account_spec",
74
+ "resolve_account_specs",
75
+ "substitute_env_placeholders",
68
76
  "symbol_info",
69
77
  "symbol_info_tick",
70
78
  "symbols",
@@ -960,6 +968,115 @@ def update_history_with_config( # noqa: PLR0913
960
968
  )
961
969
 
962
970
 
971
+ class ThrottledHistoryUpdater:
972
+ """Throttled incremental SQLite history updater for long-running apps.
973
+
974
+ Wraps :func:`update_history` with a minimum interval between successful
975
+ updates, so a tight application loop can call :meth:`update` every
976
+ iteration without re-fetching MT5 history more often than desired. Timing
977
+ uses a monotonic clock, so it is unaffected by wall-clock changes.
978
+ """
979
+
980
+ def __init__(
981
+ self,
982
+ *,
983
+ output: Path | str,
984
+ datasets: set[Dataset] | None = None,
985
+ timeframes: Sequence[int | str] | None = None,
986
+ flags: int | str = "ALL",
987
+ lookback_hours: float = 24.0,
988
+ with_views: bool = False,
989
+ include_account_events: bool = True,
990
+ interval_seconds: float = 0.0,
991
+ suppress_errors: bool = False,
992
+ ) -> None:
993
+ """Initialize the throttled updater.
994
+
995
+ Args:
996
+ output: SQLite database path.
997
+ datasets: Datasets to include (defaults to all).
998
+ timeframes: Rate timeframes to update (defaults to all fixed MT5
999
+ timeframes).
1000
+ flags: Tick copy flags as integer or name (e.g. ``ALL``).
1001
+ lookback_hours: First-run lookback when a table has no prior rows.
1002
+ with_views: Create ``cash_events`` and ``positions_reconstructed``
1003
+ views.
1004
+ include_account_events: Include account-level cash events.
1005
+ interval_seconds: Minimum seconds between successful updates. Values
1006
+ ``<= 0`` update on every call.
1007
+ suppress_errors: When True, ``Mt5TradingError``, ``Mt5RuntimeError``,
1008
+ and ``sqlite3.Error`` raised during an update are swallowed and
1009
+ :meth:`update` returns False without advancing the throttle. When
1010
+ False (default), such errors propagate so callers control logging.
1011
+ """
1012
+ self.output = output
1013
+ self.datasets = datasets
1014
+ self.timeframes = timeframes
1015
+ self.flags = flags
1016
+ self.lookback_hours = lookback_hours
1017
+ self.with_views = with_views
1018
+ self.include_account_events = include_account_events
1019
+ self.interval_seconds = interval_seconds
1020
+ self.suppress_errors = suppress_errors
1021
+ self._last_update_monotonic: float | None = None
1022
+
1023
+ @property
1024
+ def last_update_monotonic(self) -> float | None:
1025
+ """Return the monotonic timestamp of the last successful update."""
1026
+ return self._last_update_monotonic
1027
+
1028
+ def should_update(self) -> bool:
1029
+ """Return whether enough time has elapsed to run another update.
1030
+
1031
+ Returns:
1032
+ True when ``interval_seconds <= 0``, when no update has succeeded
1033
+ yet, or when at least ``interval_seconds`` have elapsed since the
1034
+ last successful update.
1035
+ """
1036
+ if self.interval_seconds <= 0 or self._last_update_monotonic is None:
1037
+ return True
1038
+ return (time.monotonic() - self._last_update_monotonic) >= self.interval_seconds
1039
+
1040
+ def update(self, client: Mt5DataClient, symbols: Sequence[str]) -> bool:
1041
+ """Run a throttled incremental history update.
1042
+
1043
+ Args:
1044
+ client: Connected MT5 data client.
1045
+ symbols: Symbols to update.
1046
+
1047
+ Returns:
1048
+ True if an update ran successfully, False if it was throttled or
1049
+ (when ``suppress_errors`` is True) failed with a recoverable error.
1050
+
1051
+ Raises:
1052
+ Mt5TradingError: If the update fails and ``suppress_errors`` is False.
1053
+ Mt5RuntimeError: If the update fails and ``suppress_errors`` is False.
1054
+ sqlite3.Error: If the SQLite write fails and ``suppress_errors`` is
1055
+ False.
1056
+ """
1057
+ if not self.should_update():
1058
+ return False
1059
+ try:
1060
+ update_history(
1061
+ client=client,
1062
+ output=self.output,
1063
+ symbols=symbols,
1064
+ datasets=self.datasets,
1065
+ timeframes=self.timeframes,
1066
+ flags=self.flags,
1067
+ lookback_hours=self.lookback_hours,
1068
+ with_views=self.with_views,
1069
+ include_account_events=self.include_account_events,
1070
+ )
1071
+ except (Mt5TradingError, Mt5RuntimeError, sqlite3.Error):
1072
+ if self.suppress_errors:
1073
+ logger.warning("Suppressed history update error", exc_info=True)
1074
+ return False
1075
+ raise
1076
+ self._last_update_monotonic = time.monotonic()
1077
+ return True
1078
+
1079
+
963
1080
  def collect_history(
964
1081
  output: Path,
965
1082
  symbols: list[str],
@@ -1113,13 +1230,155 @@ class AccountSpec:
1113
1230
  """
1114
1231
 
1115
1232
  symbols: Sequence[str]
1116
- login: int | str | None = None
1233
+ login: int | str | None = field(default=None, repr=False)
1117
1234
  password: str | None = field(default=None, repr=False)
1118
1235
  server: str | None = None
1119
1236
  path: str | None = None
1120
1237
  timeout: int | None = None
1121
1238
 
1122
1239
 
1240
+ _ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
1241
+
1242
+
1243
+ def substitute_env_placeholders(value: str) -> str:
1244
+ """Replace ``${ENV_VAR}`` placeholders in a string with environment values.
1245
+
1246
+ Args:
1247
+ value: String that may contain one or more ``${ENV_VAR}`` placeholders.
1248
+
1249
+ Returns:
1250
+ The string with every placeholder replaced by its environment value.
1251
+
1252
+ Raises:
1253
+ ValueError: If a referenced environment variable is not set.
1254
+ """
1255
+ parts: list[str] = []
1256
+ last_end = 0
1257
+ for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
1258
+ parts.append(value[last_end : match.start()])
1259
+ name = match.group("name")
1260
+ if name not in os.environ:
1261
+ msg = f"Environment variable {name!r} is not set."
1262
+ raise ValueError(msg)
1263
+ parts.append(os.environ[name])
1264
+ last_end = match.end()
1265
+ parts.append(value[last_end:])
1266
+ return "".join(parts)
1267
+
1268
+
1269
+ def _resolve_field(override: str | None, account_value: str | None) -> str | None:
1270
+ """Resolve a string field from an override or account value with env subst.
1271
+
1272
+ Returns:
1273
+ The explicit override when provided, otherwise the account value, with
1274
+ any ``${ENV_VAR}`` placeholders substituted.
1275
+ """
1276
+ value = override if override is not None else account_value
1277
+ if value is None:
1278
+ return None
1279
+ return substitute_env_placeholders(value)
1280
+
1281
+
1282
+ def _resolve_login(
1283
+ override: int | str | None,
1284
+ account_login: int | str | None,
1285
+ ) -> int | str | None:
1286
+ """Resolve a login from an override or account value with env substitution.
1287
+
1288
+ Returns:
1289
+ The explicit override when provided, otherwise the account login.
1290
+ Integer values are preserved; string values have ``${ENV_VAR}``
1291
+ placeholders substituted.
1292
+ """
1293
+ if override is not None:
1294
+ if isinstance(override, int):
1295
+ return override
1296
+ return substitute_env_placeholders(override)
1297
+ if account_login is None or isinstance(account_login, int):
1298
+ return account_login
1299
+ return substitute_env_placeholders(account_login)
1300
+
1301
+
1302
+ def resolve_account_spec(
1303
+ account: AccountSpec,
1304
+ *,
1305
+ login: int | str | None = None,
1306
+ password: str | None = None,
1307
+ server: str | None = None,
1308
+ path: str | None = None,
1309
+ timeout: int | None = None,
1310
+ ) -> AccountSpec:
1311
+ """Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
1312
+
1313
+ Explicit override arguments take precedence over the corresponding
1314
+ :class:`AccountSpec` fields. The resolved string fields (``login``,
1315
+ ``password``, ``server``, ``path``) have any ``${ENV_VAR}`` placeholders
1316
+ substituted from the environment.
1317
+
1318
+ Args:
1319
+ account: Source account specification.
1320
+ login: Optional explicit login override.
1321
+ password: Optional explicit password override.
1322
+ server: Optional explicit server override.
1323
+ path: Optional explicit terminal path override.
1324
+ timeout: Optional explicit connection timeout override.
1325
+
1326
+ Returns:
1327
+ A new :class:`AccountSpec` with resolved credentials and the original
1328
+ symbols preserved. Raises ``ValueError`` (via
1329
+ :func:`substitute_env_placeholders`) if a referenced environment
1330
+ variable is not set.
1331
+ """
1332
+ return AccountSpec(
1333
+ symbols=account.symbols,
1334
+ login=_resolve_login(login, account.login),
1335
+ password=_resolve_field(password, account.password),
1336
+ server=_resolve_field(server, account.server),
1337
+ path=_resolve_field(path, account.path),
1338
+ timeout=timeout if timeout is not None else account.timeout,
1339
+ )
1340
+
1341
+
1342
+ def resolve_account_specs(
1343
+ accounts: Sequence[AccountSpec],
1344
+ *,
1345
+ login: int | str | None = None,
1346
+ password: str | None = None,
1347
+ server: str | None = None,
1348
+ path: str | None = None,
1349
+ timeout: int | None = None,
1350
+ ) -> list[AccountSpec]:
1351
+ """Resolve credentials for multiple accounts.
1352
+
1353
+ Applies the same overrides and ``${ENV_VAR}`` substitution as
1354
+ :func:`resolve_account_spec` to every account.
1355
+
1356
+ Args:
1357
+ accounts: Source account specifications.
1358
+ login: Optional explicit login override applied to each account.
1359
+ password: Optional explicit password override applied to each account.
1360
+ server: Optional explicit server override applied to each account.
1361
+ path: Optional explicit terminal path override applied to each account.
1362
+ timeout: Optional explicit timeout override applied to each account.
1363
+
1364
+ Returns:
1365
+ Resolved account specifications in the original order. Raises
1366
+ ``ValueError`` (via :func:`substitute_env_placeholders`) if a referenced
1367
+ environment variable is not set.
1368
+ """
1369
+ return [
1370
+ resolve_account_spec(
1371
+ account,
1372
+ login=login,
1373
+ password=password,
1374
+ server=server,
1375
+ path=path,
1376
+ timeout=timeout,
1377
+ )
1378
+ for account in accounts
1379
+ ]
1380
+
1381
+
1123
1382
  def _coerce_login(login: int | str | None) -> int | None:
1124
1383
  """Coerce a login value to int, treating empty strings as unset.
1125
1384
 
@@ -1212,6 +1471,68 @@ def collect_latest_rates_for_accounts(
1212
1471
  return result
1213
1472
 
1214
1473
 
1474
+ def collect_latest_rates_for_accounts_with_retries(
1475
+ accounts: Sequence[AccountSpec],
1476
+ timeframes: Sequence[int | str],
1477
+ count: int,
1478
+ *,
1479
+ start_pos: int = 0,
1480
+ base_config: Mt5Config | None = None,
1481
+ retry_count: int = 0,
1482
+ backoff_base: float = 2.0,
1483
+ ) -> dict[tuple[str, int], pd.DataFrame]:
1484
+ """Collect latest rates across accounts, retrying transient MT5 failures.
1485
+
1486
+ Wraps :func:`collect_latest_rates_for_accounts` with bounded exponential
1487
+ backoff. Only ``pdmt5.Mt5TradingError`` and ``pdmt5.Mt5RuntimeError`` are
1488
+ retried; other exceptions propagate immediately. The final failure is
1489
+ re-raised once retries are exhausted.
1490
+
1491
+ Args:
1492
+ accounts: Account groups to read. Each must define at least one symbol.
1493
+ timeframes: MT5 timeframes as integers or names (for example ``M1``).
1494
+ count: Number of most recent bars to read per symbol/timeframe.
1495
+ start_pos: Initial bar position offset.
1496
+ base_config: Optional base configuration whose fields fill any value not
1497
+ set on an individual account.
1498
+ retry_count: Maximum number of retries after the first attempt. ``0``
1499
+ disables retries.
1500
+ backoff_base: Base for exponential backoff. The delay before retry
1501
+ attempt ``n`` (1-indexed) is ``backoff_base ** n`` seconds.
1502
+
1503
+ Returns:
1504
+ Mapping keyed by ``(symbol, timeframe_int)``. Propagates ``ValueError``
1505
+ for invalid inputs (see :func:`collect_latest_rates_for_accounts`) and
1506
+ re-raises the last ``pdmt5.Mt5TradingError`` or ``pdmt5.Mt5RuntimeError``
1507
+ once retries are exhausted.
1508
+ """
1509
+ attempts = max(retry_count, 0) + 1
1510
+
1511
+ def _collect() -> dict[tuple[str, int], pd.DataFrame]:
1512
+ return collect_latest_rates_for_accounts(
1513
+ accounts,
1514
+ timeframes,
1515
+ count,
1516
+ start_pos=start_pos,
1517
+ base_config=base_config,
1518
+ )
1519
+
1520
+ for attempt in range(attempts - 1):
1521
+ try:
1522
+ return _collect()
1523
+ except (Mt5TradingError, Mt5RuntimeError) as exc:
1524
+ delay = backoff_base ** (attempt + 1)
1525
+ logger.warning(
1526
+ "Rate collection failed (attempt %d/%d): %s; retrying in %.1fs",
1527
+ attempt + 1,
1528
+ attempts,
1529
+ exc,
1530
+ delay,
1531
+ )
1532
+ time.sleep(delay)
1533
+ return _collect()
1534
+
1535
+
1215
1536
  def copy_rates_range(
1216
1537
  symbol: str,
1217
1538
  timeframe: int | str,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.5.2"
3
+ version = "0.5.3"
4
4
  description = "Command-line tool for MetaTrader 5"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -38,6 +38,7 @@ from mt5cli.history import (
38
38
  load_incremental_start_datetimes,
39
39
  load_rate_data,
40
40
  load_rate_data_from_connection,
41
+ load_rate_series_by_granularity,
41
42
  load_rate_series_from_sqlite,
42
43
  parse_sqlite_timestamp,
43
44
  quote_sqlite_identifier,
@@ -2257,6 +2258,56 @@ class TestRateSourceHelpers:
2257
2258
  assert set(result) == {("EURUSD", 1)}
2258
2259
  assert len(result["EURUSD", 1]) == 2
2259
2260
 
2261
+ def test_load_rate_series_by_granularity(self, tmp_path: Path) -> None:
2262
+ """Test loading rate series keyed by symbol and granularity name."""
2263
+ db_path = tmp_path / "granularity.db"
2264
+ with sqlite3.connect(db_path) as conn:
2265
+ conn.execute(
2266
+ "CREATE TABLE rates("
2267
+ " symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
2268
+ )
2269
+ conn.executemany(
2270
+ "INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
2271
+ [
2272
+ ("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
2273
+ ("EURUSD", 16385, "2024-01-01T00:00:00+00:00", 1.1),
2274
+ ],
2275
+ )
2276
+ create_rate_compatibility_views(conn)
2277
+
2278
+ result = load_rate_series_by_granularity(
2279
+ db_path,
2280
+ ["EURUSD"],
2281
+ ["M1", "H1"],
2282
+ count=1,
2283
+ )
2284
+
2285
+ assert set(result) == {("EURUSD", "M1"), ("EURUSD", "H1")}
2286
+
2287
+ def test_load_rate_series_by_granularity_explicit_tables(
2288
+ self,
2289
+ tmp_path: Path,
2290
+ ) -> None:
2291
+ """Test explicit tables with None-symbol targets key by granularity."""
2292
+ db_path = tmp_path / "granularity-explicit.db"
2293
+ with sqlite3.connect(db_path) as conn:
2294
+ conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
2295
+ conn.execute(
2296
+ "INSERT INTO custom_view(time, close) VALUES (?, ?)",
2297
+ ("2024-01-01T00:00:00+00:00", 1.0),
2298
+ )
2299
+
2300
+ result = load_rate_series_by_granularity(
2301
+ db_path,
2302
+ [],
2303
+ ["M1"],
2304
+ count=1,
2305
+ explicit_tables=["custom_view"],
2306
+ allow_missing_symbol=True,
2307
+ )
2308
+
2309
+ assert set(result) == {(None, "M1")}
2310
+
2260
2311
  def test_load_rate_series_reuses_path_connection(
2261
2312
  self,
2262
2313
  tmp_path: Path,
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, call
10
10
 
11
11
  import pandas as pd
12
12
  import pytest
13
+ from pdmt5 import Mt5RuntimeError, Mt5TradingError
13
14
  from pytest_mock import MockerFixture # noqa: TC002
14
15
 
15
16
  if TYPE_CHECKING:
@@ -22,11 +23,13 @@ from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES
22
23
  from mt5cli.sdk import (
23
24
  AccountSpec,
24
25
  Mt5CliClient,
26
+ ThrottledHistoryUpdater,
25
27
  account_info,
26
28
  build_config,
27
29
  collect_history,
28
30
  collect_latest_rates,
29
31
  collect_latest_rates_for_accounts,
32
+ collect_latest_rates_for_accounts_with_retries,
30
33
  copy_rates_from,
31
34
  copy_rates_from_pos,
32
35
  copy_rates_range,
@@ -45,6 +48,9 @@ from mt5cli.sdk import (
45
48
  positions,
46
49
  recent_history_deals,
47
50
  recent_ticks,
51
+ resolve_account_spec,
52
+ resolve_account_specs,
53
+ substitute_env_placeholders,
48
54
  symbol_info,
49
55
  symbol_info_tick,
50
56
  symbols,
@@ -1425,3 +1431,309 @@ class TestCollectLatestRatesForAccounts:
1425
1431
  collect_latest_rates_for_accounts(accounts, ["M1"], count=1)
1426
1432
 
1427
1433
  mt5_data_client.assert_not_called()
1434
+
1435
+
1436
+ class TestCollectLatestRatesForAccountsWithRetries:
1437
+ """Tests for collect_latest_rates_for_accounts_with_retries."""
1438
+
1439
+ def test_returns_result_on_first_success(self, mocker: MockerFixture) -> None:
1440
+ """Test no retry happens when the first attempt succeeds."""
1441
+ expected = {("EURUSD", 1): pd.DataFrame()}
1442
+ wrapped = mocker.patch(
1443
+ "mt5cli.sdk.collect_latest_rates_for_accounts",
1444
+ return_value=expected,
1445
+ )
1446
+ sleep = mocker.patch("mt5cli.sdk.time.sleep")
1447
+ accounts = [AccountSpec(symbols=["EURUSD"])]
1448
+
1449
+ result = collect_latest_rates_for_accounts_with_retries(
1450
+ accounts,
1451
+ ["M1"],
1452
+ count=1,
1453
+ retry_count=3,
1454
+ )
1455
+
1456
+ assert result is expected
1457
+ assert wrapped.call_count == 1
1458
+ sleep.assert_not_called()
1459
+
1460
+ def test_retries_then_succeeds(self, mocker: MockerFixture) -> None:
1461
+ """Test transient MT5 errors are retried with exponential backoff."""
1462
+ expected = {("EURUSD", 1): pd.DataFrame()}
1463
+ wrapped = mocker.patch(
1464
+ "mt5cli.sdk.collect_latest_rates_for_accounts",
1465
+ side_effect=[
1466
+ Mt5TradingError("boom"),
1467
+ Mt5RuntimeError("boom"),
1468
+ expected,
1469
+ ],
1470
+ )
1471
+ sleep = mocker.patch("mt5cli.sdk.time.sleep")
1472
+ accounts = [AccountSpec(symbols=["EURUSD"])]
1473
+
1474
+ result = collect_latest_rates_for_accounts_with_retries(
1475
+ accounts,
1476
+ ["M1"],
1477
+ count=1,
1478
+ retry_count=2,
1479
+ backoff_base=2,
1480
+ )
1481
+
1482
+ assert result is expected
1483
+ assert wrapped.call_count == 3
1484
+ assert sleep.call_args_list == [call(2), call(4)]
1485
+
1486
+ def test_reraises_after_exhausting_retries(self, mocker: MockerFixture) -> None:
1487
+ """Test the final error is re-raised once retries are exhausted."""
1488
+ wrapped = mocker.patch(
1489
+ "mt5cli.sdk.collect_latest_rates_for_accounts",
1490
+ side_effect=Mt5RuntimeError("boom"),
1491
+ )
1492
+ sleep = mocker.patch("mt5cli.sdk.time.sleep")
1493
+ accounts = [AccountSpec(symbols=["EURUSD"])]
1494
+
1495
+ with pytest.raises(Mt5RuntimeError, match="boom"):
1496
+ collect_latest_rates_for_accounts_with_retries(
1497
+ accounts,
1498
+ ["M1"],
1499
+ count=1,
1500
+ retry_count=2,
1501
+ )
1502
+
1503
+ assert wrapped.call_count == 3
1504
+ assert sleep.call_count == 2
1505
+
1506
+ def test_does_not_retry_unrelated_errors(self, mocker: MockerFixture) -> None:
1507
+ """Test non-MT5 errors propagate without retrying."""
1508
+ wrapped = mocker.patch(
1509
+ "mt5cli.sdk.collect_latest_rates_for_accounts",
1510
+ side_effect=ValueError("bad input"),
1511
+ )
1512
+ sleep = mocker.patch("mt5cli.sdk.time.sleep")
1513
+
1514
+ with pytest.raises(ValueError, match="bad input"):
1515
+ collect_latest_rates_for_accounts_with_retries(
1516
+ [AccountSpec(symbols=["EURUSD"])],
1517
+ ["M1"],
1518
+ count=1,
1519
+ retry_count=3,
1520
+ )
1521
+
1522
+ assert wrapped.call_count == 1
1523
+ sleep.assert_not_called()
1524
+
1525
+
1526
+ class TestSubstituteEnvPlaceholders:
1527
+ """Tests for ${ENV_VAR} substitution."""
1528
+
1529
+ def test_substitutes_known_variables(
1530
+ self,
1531
+ monkeypatch: pytest.MonkeyPatch,
1532
+ ) -> None:
1533
+ """Test placeholders are replaced with environment values."""
1534
+ monkeypatch.setenv("MT5_LOGIN", "12345")
1535
+ monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
1536
+
1537
+ assert substitute_env_placeholders("${MT5_LOGIN}") == "12345"
1538
+ assert substitute_env_placeholders("srv=${MT5_SERVER}!") == "srv=Broker-Demo!"
1539
+
1540
+ def test_returns_plain_strings_unchanged(self) -> None:
1541
+ """Test strings without placeholders are returned as-is."""
1542
+ assert substitute_env_placeholders("plain") == "plain"
1543
+
1544
+ def test_raises_on_missing_variable(
1545
+ self,
1546
+ monkeypatch: pytest.MonkeyPatch,
1547
+ ) -> None:
1548
+ """Test a missing environment variable raises a clear error."""
1549
+ monkeypatch.delenv("MT5_MISSING", raising=False)
1550
+
1551
+ with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
1552
+ substitute_env_placeholders("${MT5_MISSING}")
1553
+
1554
+
1555
+ class TestResolveAccountSpec:
1556
+ """Tests for resolve_account_spec and resolve_account_specs."""
1557
+
1558
+ def test_substitutes_env_placeholders_in_account(
1559
+ self,
1560
+ monkeypatch: pytest.MonkeyPatch,
1561
+ ) -> None:
1562
+ """Test account string fields resolve ${ENV_VAR} placeholders."""
1563
+ monkeypatch.setenv("MT5_PASSWORD", "secret")
1564
+ account = AccountSpec(
1565
+ symbols=["EURUSD"],
1566
+ login="${MT5_LOGIN}",
1567
+ password="${MT5_PASSWORD}",
1568
+ )
1569
+ monkeypatch.setenv("MT5_LOGIN", "999")
1570
+
1571
+ resolved = resolve_account_spec(account)
1572
+
1573
+ assert resolved.login == "999"
1574
+ assert resolved.password == "secret" # noqa: S105
1575
+ assert resolved.symbols == ["EURUSD"]
1576
+
1577
+ def test_explicit_overrides_take_precedence(self) -> None:
1578
+ """Test explicit override values win over account fields."""
1579
+ account = AccountSpec(symbols=["EURUSD"], login=111, server="Acct")
1580
+
1581
+ resolved = resolve_account_spec(
1582
+ account,
1583
+ login=222,
1584
+ server="Override",
1585
+ timeout=5000,
1586
+ )
1587
+
1588
+ assert resolved.login == 222
1589
+ assert resolved.server == "Override"
1590
+ assert resolved.timeout == 5000
1591
+
1592
+ def test_resolves_string_login_override(
1593
+ self,
1594
+ monkeypatch: pytest.MonkeyPatch,
1595
+ ) -> None:
1596
+ """Test string login overrides expand ${ENV_VAR} placeholders."""
1597
+ monkeypatch.setenv("MT5_LOGIN", "777")
1598
+ account = AccountSpec(symbols=["EURUSD"], login=111)
1599
+
1600
+ resolved = resolve_account_spec(account, login="${MT5_LOGIN}")
1601
+
1602
+ assert resolved.login == "777"
1603
+
1604
+ def test_preserves_integer_login_without_coercion(self) -> None:
1605
+ """Test integer logins remain integers after resolution."""
1606
+ account = AccountSpec(symbols=["EURUSD"], login=111)
1607
+
1608
+ resolved = resolve_account_spec(account)
1609
+
1610
+ assert resolved.login == 111
1611
+ assert isinstance(resolved.login, int)
1612
+
1613
+ def test_raises_on_missing_env_variable(
1614
+ self,
1615
+ monkeypatch: pytest.MonkeyPatch,
1616
+ ) -> None:
1617
+ """Test missing environment variables raise ValueError."""
1618
+ monkeypatch.delenv("MT5_NOPE", raising=False)
1619
+ account = AccountSpec(symbols=["EURUSD"], server="${MT5_NOPE}")
1620
+
1621
+ with pytest.raises(ValueError, match="'MT5_NOPE' is not set"):
1622
+ resolve_account_spec(account)
1623
+
1624
+ def test_resolve_account_specs_applies_to_all(
1625
+ self,
1626
+ monkeypatch: pytest.MonkeyPatch,
1627
+ ) -> None:
1628
+ """Test resolve_account_specs resolves every account in order."""
1629
+ monkeypatch.setenv("MT5_SERVER", "Shared")
1630
+ accounts = [
1631
+ AccountSpec(symbols=["EURUSD"], server="${MT5_SERVER}"),
1632
+ AccountSpec(symbols=["GBPUSD"], server="Fixed"),
1633
+ ]
1634
+
1635
+ resolved = resolve_account_specs(accounts, timeout=1000)
1636
+
1637
+ assert [a.server for a in resolved] == ["Shared", "Fixed"]
1638
+ assert all(a.timeout == 1000 for a in resolved)
1639
+
1640
+
1641
+ class TestThrottledHistoryUpdater:
1642
+ """Tests for the throttled incremental history updater."""
1643
+
1644
+ def test_updates_every_call_when_interval_non_positive(
1645
+ self,
1646
+ mocker: MockerFixture,
1647
+ ) -> None:
1648
+ """Test interval_seconds <= 0 updates on every call."""
1649
+ update = mocker.patch("mt5cli.sdk.update_history")
1650
+ client = MagicMock()
1651
+ updater = ThrottledHistoryUpdater(output="history.db", interval_seconds=0)
1652
+
1653
+ assert updater.update(client, ["EURUSD"]) is True
1654
+ assert updater.update(client, ["EURUSD"]) is True
1655
+ assert update.call_count == 2
1656
+
1657
+ def test_throttles_within_interval(self, mocker: MockerFixture) -> None:
1658
+ """Test updates are skipped until the interval elapses."""
1659
+ update = mocker.patch("mt5cli.sdk.update_history")
1660
+ monotonic = mocker.patch("mt5cli.sdk.time.monotonic")
1661
+ # Calls: set(t=100), check(t=105), check(t=200), set(t=200).
1662
+ monotonic.side_effect = [100.0, 105.0, 200.0, 200.0]
1663
+ client = MagicMock()
1664
+ updater = ThrottledHistoryUpdater(output="history.db", interval_seconds=60)
1665
+
1666
+ assert updater.update(client, ["EURUSD"]) is True # first update at t=100
1667
+ assert updater.update(client, ["EURUSD"]) is False # t=105, throttled
1668
+ assert updater.update(client, ["EURUSD"]) is True # t=200, elapsed
1669
+ assert update.call_count == 2
1670
+
1671
+ def test_update_passes_expected_arguments(
1672
+ self,
1673
+ mocker: MockerFixture,
1674
+ ) -> None:
1675
+ """Test update_history is called with the configured arguments."""
1676
+ update = mocker.patch("mt5cli.sdk.update_history")
1677
+ client = MagicMock()
1678
+ updater = ThrottledHistoryUpdater(
1679
+ output="history.db",
1680
+ datasets={Dataset.rates},
1681
+ timeframes=["M1", "H1"],
1682
+ flags="INFO",
1683
+ lookback_hours=12.0,
1684
+ with_views=True,
1685
+ include_account_events=False,
1686
+ )
1687
+
1688
+ updater.update(client, ["EURUSD", "GBPUSD"])
1689
+
1690
+ update.assert_called_once_with(
1691
+ client=client,
1692
+ output="history.db",
1693
+ symbols=["EURUSD", "GBPUSD"],
1694
+ datasets={Dataset.rates},
1695
+ timeframes=["M1", "H1"],
1696
+ flags="INFO",
1697
+ lookback_hours=12.0,
1698
+ with_views=True,
1699
+ include_account_events=False,
1700
+ )
1701
+
1702
+ def test_propagates_errors_by_default(self, mocker: MockerFixture) -> None:
1703
+ """Test MT5/SQLite errors propagate and do not advance the throttle."""
1704
+ mocker.patch(
1705
+ "mt5cli.sdk.update_history",
1706
+ side_effect=Mt5RuntimeError("boom"),
1707
+ )
1708
+ updater = ThrottledHistoryUpdater(output="history.db")
1709
+
1710
+ with pytest.raises(Mt5RuntimeError, match="boom"):
1711
+ updater.update(MagicMock(), ["EURUSD"])
1712
+
1713
+ assert updater.last_update_monotonic is None
1714
+
1715
+ @pytest.mark.parametrize(
1716
+ "error",
1717
+ [
1718
+ Mt5RuntimeError("boom"),
1719
+ Mt5TradingError("trade failed"),
1720
+ sqlite3.OperationalError("locked"),
1721
+ ],
1722
+ )
1723
+ def test_suppresses_errors_when_requested(
1724
+ self,
1725
+ mocker: MockerFixture,
1726
+ error: Exception,
1727
+ ) -> None:
1728
+ """Test suppress_errors swallows recoverable errors and returns False."""
1729
+ mocker.patch(
1730
+ "mt5cli.sdk.update_history",
1731
+ side_effect=error,
1732
+ )
1733
+ updater = ThrottledHistoryUpdater(
1734
+ output="history.db",
1735
+ suppress_errors=True,
1736
+ )
1737
+
1738
+ assert updater.update(MagicMock(), ["EURUSD"]) is False
1739
+ assert updater.last_update_monotonic is None
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.5.2"
490
+ version = "0.5.3"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { name = "click" },
@@ -1,3 +0,0 @@
1
- # SDK Module
2
-
3
- ::: mt5cli.sdk
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