mt5cli 0.5.3__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {mt5cli-0.5.3 → mt5cli-0.6.0}/PKG-INFO +15 -1
  2. {mt5cli-0.5.3 → mt5cli-0.6.0}/README.md +14 -0
  3. {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/sdk.md +20 -0
  4. {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/__init__.py +6 -0
  5. {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/history.py +17 -0
  6. {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/sdk.py +119 -0
  7. {mt5cli-0.5.3 → mt5cli-0.6.0}/pyproject.toml +1 -1
  8. {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_history.py +41 -0
  9. {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_sdk.py +195 -0
  10. {mt5cli-0.5.3 → mt5cli-0.6.0}/uv.lock +4 -4
  11. {mt5cli-0.5.3 → mt5cli-0.6.0}/.agents/skills/local-qa/SKILL.md +0 -0
  12. {mt5cli-0.5.3 → mt5cli-0.6.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  13. {mt5cli-0.5.3 → mt5cli-0.6.0}/.agents/skills/mt5cli/SKILL.md +0 -0
  14. {mt5cli-0.5.3 → mt5cli-0.6.0}/.claude/agents/codex.md +0 -0
  15. {mt5cli-0.5.3 → mt5cli-0.6.0}/.claude/settings.json +0 -0
  16. {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/FUNDING.yml +0 -0
  17. {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/dependabot.yml +0 -0
  18. {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/renovate.json +0 -0
  19. {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/workflows/ci.yml +0 -0
  20. {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/workflows/claude.yml +0 -0
  21. {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/workflows/release.yml +0 -0
  22. {mt5cli-0.5.3 → mt5cli-0.6.0}/.gitignore +0 -0
  23. {mt5cli-0.5.3 → mt5cli-0.6.0}/AGENTS.md +0 -0
  24. {mt5cli-0.5.3 → mt5cli-0.6.0}/CLAUDE.md +0 -0
  25. {mt5cli-0.5.3 → mt5cli-0.6.0}/LICENSE +0 -0
  26. {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/cli.md +0 -0
  27. {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/history.md +0 -0
  28. {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/index.md +0 -0
  29. {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/utils.md +0 -0
  30. {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/index.md +0 -0
  31. {mt5cli-0.5.3 → mt5cli-0.6.0}/mkdocs.yml +0 -0
  32. {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/__main__.py +0 -0
  33. {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/cli.py +0 -0
  34. {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/utils.py +0 -0
  35. {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/__init__.py +0 -0
  36. {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_cli.py +0 -0
  37. {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.5.3
3
+ Version: 0.6.0
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>
@@ -161,6 +161,20 @@ update_history_with_config(
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
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
+ - **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
165
+
166
+ ```python
167
+ from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
168
+
169
+ rates = collect_latest_closed_rates_by_granularity(
170
+ [AccountSpec(symbols=["EURUSD", "GBPUSD"], login=12345)],
171
+ ["M1", "H1"],
172
+ count=500,
173
+ retry_count=3,
174
+ )
175
+ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
176
+ ```
177
+
164
178
  - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
165
179
  - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`/`sqlite3.Error` and let the caller decide logging.
166
180
  - **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.
@@ -137,6 +137,20 @@ update_history_with_config(
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
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
+ - **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
141
+
142
+ ```python
143
+ from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
144
+
145
+ rates = collect_latest_closed_rates_by_granularity(
146
+ [AccountSpec(symbols=["EURUSD", "GBPUSD"], login=12345)],
147
+ ["M1", "H1"],
148
+ count=500,
149
+ retry_count=3,
150
+ )
151
+ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
152
+ ```
153
+
140
154
  - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
141
155
  - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`/`sqlite3.Error` and let the caller decide logging.
142
156
  - **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,6 +28,26 @@ rates = collect_latest_rates_for_accounts_with_retries(
28
28
  )
29
29
  ```
30
30
 
31
+ ### Latest closed rate bars
32
+
33
+ MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
34
+ row. `collect_latest_closed_rates_for_accounts()` fetches `count + 1` bars,
35
+ drops that row with `drop_forming_rate_bar()`, and validates each series is
36
+ non-empty. Use `collect_latest_closed_rates_by_granularity()` when callers
37
+ prefer keys such as `("EURUSD", "M1")` instead of integer timeframes.
38
+
39
+ ```python
40
+ from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
41
+
42
+ rates = collect_latest_closed_rates_by_granularity(
43
+ [AccountSpec(symbols=["EURUSD"], login=12345)],
44
+ ["M1", "H1"],
45
+ count=500,
46
+ retry_count=3,
47
+ )
48
+ closed_m1 = rates["EURUSD", "M1"]
49
+ ```
50
+
31
51
  ### Resolving credentials and `${ENV_VAR}` placeholders
32
52
 
33
53
  `resolve_account_spec()` / `resolve_account_specs()` merge explicit override
@@ -6,6 +6,7 @@ from .history import (
6
6
  RateTarget,
7
7
  build_rate_targets,
8
8
  build_rate_view_name,
9
+ drop_forming_rate_bar,
9
10
  load_rate_data,
10
11
  load_rate_data_from_connection,
11
12
  load_rate_series_by_granularity,
@@ -24,6 +25,8 @@ from .sdk import (
24
25
  account_info,
25
26
  build_config,
26
27
  collect_history,
28
+ collect_latest_closed_rates_by_granularity,
29
+ collect_latest_closed_rates_for_accounts,
27
30
  collect_latest_rates,
28
31
  collect_latest_rates_for_accounts,
29
32
  collect_latest_rates_for_accounts_with_retries,
@@ -87,6 +90,8 @@ __all__ = [
87
90
  "build_rate_targets",
88
91
  "build_rate_view_name",
89
92
  "collect_history",
93
+ "collect_latest_closed_rates_by_granularity",
94
+ "collect_latest_closed_rates_for_accounts",
90
95
  "collect_latest_rates",
91
96
  "collect_latest_rates_for_accounts",
92
97
  "collect_latest_rates_for_accounts_with_retries",
@@ -96,6 +101,7 @@ __all__ = [
96
101
  "copy_ticks_from",
97
102
  "copy_ticks_range",
98
103
  "detect_format",
104
+ "drop_forming_rate_bar",
99
105
  "export_dataframe",
100
106
  "export_dataframe_to_sqlite",
101
107
  "history_deals",
@@ -106,6 +106,23 @@ def resolve_granularity_name(timeframe: int) -> str:
106
106
  return str(timeframe)
107
107
 
108
108
 
109
+ def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame:
110
+ """Return closed bars from chronologically ordered MT5 rate data.
111
+
112
+ MetaTrader 5 ``copy_rates_from_pos(start_pos=0)`` includes the still-forming
113
+ current bar as the last row. Slice it off so downstream logic only sees
114
+ completed bars. Empty frames and single-row frames return empty results.
115
+
116
+ Args:
117
+ df_rate: Rate data ordered oldest-to-newest with the forming bar last.
118
+
119
+ Returns:
120
+ A new DataFrame with all rows except the last. Index and columns are
121
+ preserved. The input frame is not modified.
122
+ """
123
+ return df_rate.iloc[:-1].copy()
124
+
125
+
109
126
  def build_rate_view_name(
110
127
  *,
111
128
  symbol: str,
@@ -21,6 +21,8 @@ from .history import (
21
21
  create_cash_events_view,
22
22
  create_history_indexes,
23
23
  create_positions_reconstructed_view,
24
+ drop_forming_rate_bar,
25
+ resolve_granularity_name,
24
26
  resolve_history_datasets,
25
27
  resolve_history_tick_flags,
26
28
  resolve_history_timeframes,
@@ -49,6 +51,8 @@ __all__ = [
49
51
  "account_info",
50
52
  "build_config",
51
53
  "collect_history",
54
+ "collect_latest_closed_rates_by_granularity",
55
+ "collect_latest_closed_rates_for_accounts",
52
56
  "collect_latest_rates",
53
57
  "collect_latest_rates_for_accounts",
54
58
  "collect_latest_rates_for_accounts_with_retries",
@@ -129,6 +133,12 @@ def _require_positive(value: float, name: str) -> None:
129
133
  raise ValueError(msg)
130
134
 
131
135
 
136
+ def _require_non_negative(value: int, name: str) -> None:
137
+ if value < 0:
138
+ msg = f"{name} must be non-negative."
139
+ raise ValueError(msg)
140
+
141
+
132
142
  def _call_required_client_method(client: Mt5DataClient, name: str) -> object:
133
143
  try:
134
144
  method = getattr(client, name)
@@ -1533,6 +1543,115 @@ def collect_latest_rates_for_accounts_with_retries(
1533
1543
  return _collect()
1534
1544
 
1535
1545
 
1546
+ def collect_latest_closed_rates_for_accounts(
1547
+ accounts: Sequence[AccountSpec],
1548
+ timeframes: Sequence[int | str],
1549
+ count: int,
1550
+ *,
1551
+ start_pos: int = 0,
1552
+ base_config: Mt5Config | None = None,
1553
+ retry_count: int = 0,
1554
+ backoff_base: float = 2.0,
1555
+ ) -> dict[tuple[str, int], pd.DataFrame]:
1556
+ """Collect latest closed rate bars across multiple MT5 account groups.
1557
+
1558
+ When ``start_pos`` is ``0`` (the default), MetaTrader 5 includes the
1559
+ still-forming current bar as the last row. This helper fetches
1560
+ ``count + 1`` bars, drops that bar with :func:`drop_forming_rate_bar`, and
1561
+ validates that each resulting frame is non-empty. When ``start_pos`` is
1562
+ greater than zero the forming bar is not in range, so only ``count`` bars
1563
+ are fetched and no row is dropped.
1564
+
1565
+ Wraps :func:`collect_latest_rates_for_accounts_with_retries` for transient
1566
+ MT5 error handling.
1567
+
1568
+ Args:
1569
+ accounts: Account groups to read. Each must define at least one symbol.
1570
+ timeframes: MT5 timeframes as integers or names (for example ``M1``).
1571
+ count: Number of closed bars to return per symbol/timeframe.
1572
+ start_pos: Initial bar position offset passed to the underlying collector.
1573
+ base_config: Optional base configuration whose fields fill any value not
1574
+ set on an individual account.
1575
+ retry_count: Maximum number of retries after the first attempt. ``0``
1576
+ disables retries.
1577
+ backoff_base: Base for exponential backoff between retry attempts.
1578
+
1579
+ Returns:
1580
+ Mapping keyed by ``(symbol, timeframe_int)``.
1581
+
1582
+ Raises:
1583
+ ValueError: If inputs are invalid, or any series is empty (after
1584
+ dropping the still-forming bar when ``start_pos`` is ``0``).
1585
+ """
1586
+ _require_positive(count, "count")
1587
+ _require_non_negative(start_pos, "start_pos")
1588
+ fetch_count = count + 1 if start_pos == 0 else count
1589
+ loaded = collect_latest_rates_for_accounts_with_retries(
1590
+ accounts,
1591
+ timeframes,
1592
+ fetch_count,
1593
+ start_pos=start_pos,
1594
+ base_config=base_config,
1595
+ retry_count=retry_count,
1596
+ backoff_base=backoff_base,
1597
+ )
1598
+ result: dict[tuple[str, int], pd.DataFrame] = {}
1599
+ for key, df_rate in loaded.items():
1600
+ closed = drop_forming_rate_bar(df_rate) if start_pos == 0 else df_rate
1601
+ if closed.empty:
1602
+ symbol, timeframe = key
1603
+ msg = f"Rate data is empty for {symbol!r} at timeframe {timeframe}."
1604
+ raise ValueError(msg)
1605
+ result[key] = closed
1606
+ return result
1607
+
1608
+
1609
+ def collect_latest_closed_rates_by_granularity(
1610
+ accounts: Sequence[AccountSpec],
1611
+ granularities: Sequence[int | str],
1612
+ count: int,
1613
+ *,
1614
+ start_pos: int = 0,
1615
+ base_config: Mt5Config | None = None,
1616
+ retry_count: int = 0,
1617
+ backoff_base: float = 2.0,
1618
+ ) -> dict[tuple[str, str], pd.DataFrame]:
1619
+ """Collect latest closed rate bars keyed by symbol and granularity name.
1620
+
1621
+ Thin wrapper around :func:`collect_latest_closed_rates_for_accounts` that
1622
+ rekeys the result by granularity name (for example ``M1``) instead of the
1623
+ integer timeframe.
1624
+
1625
+ Args:
1626
+ accounts: Account groups to read. Each must define at least one symbol.
1627
+ granularities: MT5 timeframes as integers or names (for example ``M1``).
1628
+ count: Number of closed bars to return per symbol/timeframe.
1629
+ start_pos: Initial bar position offset passed to the underlying collector.
1630
+ base_config: Optional base configuration whose fields fill any value not
1631
+ set on an individual account.
1632
+ retry_count: Maximum number of retries after the first attempt. ``0``
1633
+ disables retries.
1634
+ backoff_base: Base for exponential backoff between retry attempts.
1635
+
1636
+ Returns:
1637
+ Mapping keyed by ``(symbol, granularity_name)``. Propagates
1638
+ ``ValueError`` from :func:`collect_latest_closed_rates_for_accounts`.
1639
+ """
1640
+ loaded = collect_latest_closed_rates_for_accounts(
1641
+ accounts,
1642
+ granularities,
1643
+ count,
1644
+ start_pos=start_pos,
1645
+ base_config=base_config,
1646
+ retry_count=retry_count,
1647
+ backoff_base=backoff_base,
1648
+ )
1649
+ return {
1650
+ (symbol, resolve_granularity_name(timeframe)): frame
1651
+ for (symbol, timeframe), frame in loaded.items()
1652
+ }
1653
+
1654
+
1536
1655
  def copy_rates_range(
1537
1656
  symbol: str,
1538
1657
  timeframe: int | str,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.5.3"
3
+ version = "0.6.0"
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"}]
@@ -30,6 +30,7 @@ from mt5cli.history import (
30
30
  create_rate_compatibility_views,
31
31
  deduplicate_history_tables,
32
32
  drop_duplicates_in_table,
33
+ drop_forming_rate_bar,
33
34
  filter_incremental_history_deals_frame,
34
35
  filter_trade_history_frame,
35
36
  get_history_deals_account_event_start_datetime,
@@ -534,6 +535,46 @@ class TestResolveHistorySettings:
534
535
  assert resolve_granularity_name(1) == "M1"
535
536
 
536
537
 
538
+ class TestDropFormingRateBar:
539
+ """Tests for drop_forming_rate_bar."""
540
+
541
+ def test_drops_still_forming_last_bar(self) -> None:
542
+ """Test the still-forming last bar is removed."""
543
+ df_rate = pd.DataFrame(
544
+ {"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]},
545
+ index=pd.Index(["a", "b", "c"], name="idx"),
546
+ )
547
+
548
+ result = drop_forming_rate_bar(df_rate)
549
+
550
+ pd.testing.assert_frame_equal(
551
+ result,
552
+ pd.DataFrame(
553
+ {"time": [1, 2], "close": [1.1, 1.2]},
554
+ index=pd.Index(["a", "b"], name="idx"),
555
+ ),
556
+ )
557
+ assert df_rate.shape == (3, 2)
558
+
559
+ def test_returns_empty_frame_when_input_empty(self) -> None:
560
+ """Test empty frames stay empty."""
561
+ df_rate = pd.DataFrame(columns=["time", "close"])
562
+
563
+ result = drop_forming_rate_bar(df_rate)
564
+
565
+ assert result.empty
566
+ assert list(result.columns) == ["time", "close"]
567
+
568
+ def test_returns_empty_frame_when_only_forming_bar_present(self) -> None:
569
+ """Test a single-bar frame becomes empty after dropping the forming bar."""
570
+ df_rate = pd.DataFrame({"time": [1], "close": [1.1]})
571
+
572
+ result = drop_forming_rate_bar(df_rate)
573
+
574
+ assert result.empty
575
+ assert list(result.columns) == ["time", "close"]
576
+
577
+
537
578
  class TestParseSqliteTimestamp:
538
579
  """Tests for parse_sqlite_timestamp."""
539
580
 
@@ -27,6 +27,8 @@ from mt5cli.sdk import (
27
27
  account_info,
28
28
  build_config,
29
29
  collect_history,
30
+ collect_latest_closed_rates_by_granularity,
31
+ collect_latest_closed_rates_for_accounts,
30
32
  collect_latest_rates,
31
33
  collect_latest_rates_for_accounts,
32
34
  collect_latest_rates_for_accounts_with_retries,
@@ -1523,6 +1525,199 @@ class TestCollectLatestRatesForAccountsWithRetries:
1523
1525
  sleep.assert_not_called()
1524
1526
 
1525
1527
 
1528
+ class TestCollectLatestClosedRatesForAccounts:
1529
+ """Tests for collect_latest_closed_rates_for_accounts."""
1530
+
1531
+ def test_fetches_count_plus_one_and_drops_forming_bar(
1532
+ self,
1533
+ mocker: MockerFixture,
1534
+ ) -> None:
1535
+ """Test closed-bar collection requests one extra bar at start_pos=0."""
1536
+ df_rate = pd.DataFrame({"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]})
1537
+ wrapped = mocker.patch(
1538
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1539
+ return_value={("EURUSD", 1): df_rate},
1540
+ )
1541
+ accounts = [AccountSpec(symbols=["EURUSD"])]
1542
+
1543
+ result = collect_latest_closed_rates_for_accounts(
1544
+ accounts,
1545
+ ["M1"],
1546
+ count=2,
1547
+ retry_count=1,
1548
+ backoff_base=3,
1549
+ )
1550
+
1551
+ wrapped.assert_called_once_with(
1552
+ accounts,
1553
+ ["M1"],
1554
+ 3,
1555
+ start_pos=0,
1556
+ base_config=None,
1557
+ retry_count=1,
1558
+ backoff_base=3,
1559
+ )
1560
+ pd.testing.assert_frame_equal(
1561
+ result["EURUSD", 1],
1562
+ pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}),
1563
+ )
1564
+
1565
+ def test_rejects_forming_bar_only_frames(self, mocker: MockerFixture) -> None:
1566
+ """Test empty results after dropping the forming bar raise ValueError."""
1567
+ mocker.patch(
1568
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1569
+ return_value={("EURUSD", 1): pd.DataFrame({"time": [1], "close": [1.1]})},
1570
+ )
1571
+
1572
+ with pytest.raises(ValueError, match="Rate data is empty"):
1573
+ collect_latest_closed_rates_for_accounts(
1574
+ [AccountSpec(symbols=["EURUSD"])],
1575
+ ["M1"],
1576
+ count=1,
1577
+ )
1578
+
1579
+ def test_skips_extra_fetch_when_start_pos_nonzero(
1580
+ self,
1581
+ mocker: MockerFixture,
1582
+ ) -> None:
1583
+ """Test start_pos > 0 fetches count bars without dropping the last row."""
1584
+ df_rate = pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]})
1585
+ wrapped = mocker.patch(
1586
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1587
+ return_value={("EURUSD", 1): df_rate},
1588
+ )
1589
+
1590
+ result = collect_latest_closed_rates_for_accounts(
1591
+ [AccountSpec(symbols=["EURUSD"])],
1592
+ ["M1"],
1593
+ count=2,
1594
+ start_pos=1,
1595
+ )
1596
+
1597
+ wrapped.assert_called_once_with(
1598
+ [AccountSpec(symbols=["EURUSD"])],
1599
+ ["M1"],
1600
+ 2,
1601
+ start_pos=1,
1602
+ base_config=None,
1603
+ retry_count=0,
1604
+ backoff_base=2.0,
1605
+ )
1606
+ pd.testing.assert_frame_equal(result["EURUSD", 1], df_rate)
1607
+
1608
+ def test_rejects_zero_count_before_fetching(self, mocker: MockerFixture) -> None:
1609
+ """Test count=0 is rejected before any MT5 collection attempt."""
1610
+ wrapped = mocker.patch(
1611
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1612
+ )
1613
+
1614
+ with pytest.raises(ValueError, match="count must be positive"):
1615
+ collect_latest_closed_rates_for_accounts(
1616
+ [AccountSpec(symbols=["EURUSD"])],
1617
+ ["M1"],
1618
+ count=0,
1619
+ )
1620
+
1621
+ wrapped.assert_not_called()
1622
+
1623
+ def test_rejects_negative_start_pos(self, mocker: MockerFixture) -> None:
1624
+ """Test negative start_pos is rejected before any MT5 collection attempt."""
1625
+ wrapped = mocker.patch(
1626
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1627
+ )
1628
+
1629
+ with pytest.raises(ValueError, match="start_pos must be non-negative"):
1630
+ collect_latest_closed_rates_for_accounts(
1631
+ [AccountSpec(symbols=["EURUSD"])],
1632
+ ["M1"],
1633
+ count=1,
1634
+ start_pos=-1,
1635
+ )
1636
+
1637
+ wrapped.assert_not_called()
1638
+
1639
+ def test_rejects_empty_frames_with_start_pos_nonzero(
1640
+ self,
1641
+ mocker: MockerFixture,
1642
+ ) -> None:
1643
+ """Test empty upstream frames raise ValueError when start_pos > 0."""
1644
+ mocker.patch(
1645
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1646
+ return_value={("EURUSD", 1): pd.DataFrame(columns=["time", "close"])},
1647
+ )
1648
+
1649
+ with pytest.raises(ValueError, match="Rate data is empty"):
1650
+ collect_latest_closed_rates_for_accounts(
1651
+ [AccountSpec(symbols=["EURUSD"])],
1652
+ ["M1"],
1653
+ count=1,
1654
+ start_pos=1,
1655
+ )
1656
+
1657
+ def test_processes_multiple_symbol_timeframe_pairs(
1658
+ self,
1659
+ mocker: MockerFixture,
1660
+ ) -> None:
1661
+ """Test each returned series is trimmed and validated independently."""
1662
+ mocker.patch(
1663
+ "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries",
1664
+ return_value={
1665
+ ("EURUSD", 1): pd.DataFrame(
1666
+ {"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]},
1667
+ ),
1668
+ ("GBPUSD", 16385): pd.DataFrame(
1669
+ {"time": [4, 5, 6], "close": [2.1, 2.2, 2.3]},
1670
+ ),
1671
+ },
1672
+ )
1673
+
1674
+ result = collect_latest_closed_rates_for_accounts(
1675
+ [AccountSpec(symbols=["EURUSD", "GBPUSD"])],
1676
+ ["M1", "H1"],
1677
+ count=2,
1678
+ )
1679
+
1680
+ assert set(result) == {("EURUSD", 1), ("GBPUSD", 16385)}
1681
+ pd.testing.assert_frame_equal(
1682
+ result["EURUSD", 1],
1683
+ pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}),
1684
+ )
1685
+ pd.testing.assert_frame_equal(
1686
+ result["GBPUSD", 16385],
1687
+ pd.DataFrame({"time": [4, 5], "close": [2.1, 2.2]}),
1688
+ )
1689
+
1690
+
1691
+ class TestCollectLatestClosedRatesByGranularity:
1692
+ """Tests for collect_latest_closed_rates_by_granularity."""
1693
+
1694
+ def test_rekeys_by_granularity_name(self, mocker: MockerFixture) -> None:
1695
+ """Test closed rates are keyed by symbol and granularity name."""
1696
+ df_rate = pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]})
1697
+ wrapped = mocker.patch(
1698
+ "mt5cli.sdk.collect_latest_closed_rates_for_accounts",
1699
+ return_value={("EURUSD", 1): df_rate},
1700
+ )
1701
+
1702
+ result = collect_latest_closed_rates_by_granularity(
1703
+ [AccountSpec(symbols=["EURUSD"])],
1704
+ ["M1"],
1705
+ count=2,
1706
+ )
1707
+
1708
+ wrapped.assert_called_once_with(
1709
+ [AccountSpec(symbols=["EURUSD"])],
1710
+ ["M1"],
1711
+ 2,
1712
+ start_pos=0,
1713
+ base_config=None,
1714
+ retry_count=0,
1715
+ backoff_base=2.0,
1716
+ )
1717
+ assert ("EURUSD", "M1") in result
1718
+ pd.testing.assert_frame_equal(result["EURUSD", "M1"], df_rate)
1719
+
1720
+
1526
1721
  class TestSubstituteEnvPlaceholders:
1527
1722
  """Tests for ${ENV_VAR} substitution."""
1528
1723
 
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.5.3"
490
+ version = "0.6.0"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { name = "click" },
@@ -836,11 +836,11 @@ wheels = [
836
836
 
837
837
  [[package]]
838
838
  name = "pygments"
839
- version = "2.19.2"
839
+ version = "2.20.0"
840
840
  source = { registry = "https://pypi.org/simple" }
841
- sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
841
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
842
842
  wheels = [
843
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
843
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
844
844
  ]
845
845
 
846
846
  [[package]]
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