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.
- {mt5cli-0.5.3 → mt5cli-0.6.0}/PKG-INFO +15 -1
- {mt5cli-0.5.3 → mt5cli-0.6.0}/README.md +14 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/sdk.md +20 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/__init__.py +6 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/history.py +17 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/sdk.py +119 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/pyproject.toml +1 -1
- {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_history.py +41 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_sdk.py +195 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/uv.lock +4 -4
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.claude/agents/codex.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.claude/settings.json +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/FUNDING.yml +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/dependabot.yml +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/renovate.json +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.github/workflows/release.yml +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/.gitignore +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/AGENTS.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/CLAUDE.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/LICENSE +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/cli.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/history.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/index.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/api/utils.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/docs/index.md +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mkdocs.yml +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/__main__.py +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/cli.py +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/mt5cli/utils.py +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/__init__.py +0 -0
- {mt5cli-0.5.3 → mt5cli-0.6.0}/tests/test_cli.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
839
|
+
version = "2.20.0"
|
|
840
840
|
source = { registry = "https://pypi.org/simple" }
|
|
841
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
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/
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|