mt5cli 0.5.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-0.5.0 → mt5cli-0.5.1}/PKG-INFO +5 -2
- {mt5cli-0.5.0 → mt5cli-0.5.1}/README.md +4 -1
- {mt5cli-0.5.0 → mt5cli-0.5.1}/docs/api/history.md +32 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mt5cli/__init__.py +40 -1
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mt5cli/history.py +231 -7
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mt5cli/sdk.py +138 -1
- {mt5cli-0.5.0 → mt5cli-0.5.1}/pyproject.toml +1 -1
- {mt5cli-0.5.0 → mt5cli-0.5.1}/tests/test_history.py +323 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/tests/test_sdk.py +178 -1
- {mt5cli-0.5.0 → mt5cli-0.5.1}/uv.lock +1 -1
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.claude/agents/codex.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.claude/settings.json +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.github/FUNDING.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.github/dependabot.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.github/renovate.json +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.github/workflows/release.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/.gitignore +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/AGENTS.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/CLAUDE.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/LICENSE +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/docs/api/cli.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/docs/api/index.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/docs/api/sdk.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/docs/api/utils.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/docs/index.md +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mkdocs.yml +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mt5cli/__main__.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mt5cli/cli.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/mt5cli/utils.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/tests/__init__.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/tests/test_cli.py +0 -0
- {mt5cli-0.5.0 → mt5cli-0.5.1}/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
|
+
Version: 0.5.1
|
|
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>
|
|
@@ -157,8 +157,11 @@ update_history_with_config(
|
|
|
157
157
|
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
|
|
158
158
|
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
|
|
159
159
|
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
|
|
160
|
-
- **Rate view resolution**: use `
|
|
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
|
+
- **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.
|
|
164
|
+
- **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.
|
|
162
165
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
|
163
166
|
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
|
|
164
167
|
|
|
@@ -133,8 +133,11 @@ update_history_with_config(
|
|
|
133
133
|
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
|
|
134
134
|
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
|
|
135
135
|
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
|
|
136
|
-
- **Rate view resolution**: use `
|
|
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
|
+
- **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.
|
|
140
|
+
- **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.
|
|
138
141
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
|
139
142
|
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
|
|
140
143
|
|
|
@@ -183,3 +183,35 @@ rates = load_rate_data(Path("history.db"), view, count=1000)
|
|
|
183
183
|
The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
|
|
184
184
|
validates that `time` exists, parses timestamps with pandas, and returns a
|
|
185
185
|
DataFrame indexed by ascending `DatetimeIndex` named `time`.
|
|
186
|
+
|
|
187
|
+
### Multi-series rate loading
|
|
188
|
+
|
|
189
|
+
For loading many rate series at once, build neutral `RateTarget` pairs and load
|
|
190
|
+
them from SQLite in one call. View names are resolved via the same
|
|
191
|
+
compatibility-view rules, or you can pass `explicit_tables` to bypass resolution:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from pathlib import Path
|
|
195
|
+
|
|
196
|
+
from mt5cli import build_rate_targets, load_rate_series_from_sqlite
|
|
197
|
+
|
|
198
|
+
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
|
199
|
+
series = load_rate_series_from_sqlite(Path("history.db"), targets, count=1000)
|
|
200
|
+
frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `build_rate_targets()` returns `RateTarget(symbol, timeframe)` pairs in
|
|
204
|
+
row-major order, normalizing timeframe names such as `"M1"` to their integer
|
|
205
|
+
values; set `allow_missing_symbol=True` to address series solely by
|
|
206
|
+
`explicit_tables` (targets carry `symbol=None`).
|
|
207
|
+
- `resolve_rate_tables()` maps targets to table or view names and validates that
|
|
208
|
+
any `explicit_tables` count matches the target count. Pass
|
|
209
|
+
`require_existing=True` to raise `ValueError` instead of returning a
|
|
210
|
+
best-guess name when the database or managed view is missing. When
|
|
211
|
+
`explicit_tables` is provided, names are returned as-is and
|
|
212
|
+
`require_existing` is ignored.
|
|
213
|
+
- `load_rate_series_from_sqlite()` returns a mapping keyed by
|
|
214
|
+
`(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
|
|
215
|
+
requires existing managed `rate_*` compatibility views and raises
|
|
216
|
+
`ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
|
|
217
|
+
are rejected.
|
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
from importlib.metadata import version
|
|
4
4
|
|
|
5
|
-
from .history import
|
|
5
|
+
from .history import (
|
|
6
|
+
RateTarget,
|
|
7
|
+
build_rate_targets,
|
|
8
|
+
build_rate_view_name,
|
|
9
|
+
load_rate_data,
|
|
10
|
+
load_rate_data_from_connection,
|
|
11
|
+
load_rate_series_from_sqlite,
|
|
12
|
+
resolve_history_datasets,
|
|
13
|
+
resolve_history_tick_flags,
|
|
14
|
+
resolve_history_timeframes,
|
|
15
|
+
resolve_rate_tables,
|
|
16
|
+
resolve_rate_view_name,
|
|
17
|
+
resolve_rate_view_names,
|
|
18
|
+
)
|
|
6
19
|
from .sdk import (
|
|
20
|
+
AccountSpec,
|
|
7
21
|
Mt5CliClient,
|
|
8
22
|
account_info,
|
|
9
23
|
build_config,
|
|
10
24
|
collect_history,
|
|
11
25
|
collect_latest_rates,
|
|
26
|
+
collect_latest_rates_for_accounts,
|
|
12
27
|
copy_rates_from,
|
|
13
28
|
copy_rates_from_pos,
|
|
14
29
|
copy_rates_range,
|
|
@@ -20,6 +35,7 @@ from .sdk import (
|
|
|
20
35
|
latest_rates,
|
|
21
36
|
market_book,
|
|
22
37
|
minimum_margins,
|
|
38
|
+
mt5_session,
|
|
23
39
|
mt5_summary,
|
|
24
40
|
mt5_summary_as_df,
|
|
25
41
|
orders,
|
|
@@ -37,23 +53,35 @@ from .sdk import (
|
|
|
37
53
|
version as mt5_version,
|
|
38
54
|
)
|
|
39
55
|
from .utils import (
|
|
56
|
+
TICK_FLAG_MAP,
|
|
57
|
+
TIMEFRAME_MAP,
|
|
40
58
|
Dataset,
|
|
41
59
|
IfExists,
|
|
42
60
|
detect_format,
|
|
43
61
|
export_dataframe,
|
|
44
62
|
export_dataframe_to_sqlite,
|
|
63
|
+
parse_datetime,
|
|
64
|
+
parse_tick_flags,
|
|
65
|
+
parse_timeframe,
|
|
45
66
|
)
|
|
46
67
|
|
|
47
68
|
__version__ = version(__package__) if __package__ else None
|
|
48
69
|
|
|
49
70
|
__all__ = [
|
|
71
|
+
"TICK_FLAG_MAP",
|
|
72
|
+
"TIMEFRAME_MAP",
|
|
73
|
+
"AccountSpec",
|
|
50
74
|
"Dataset",
|
|
51
75
|
"IfExists",
|
|
52
76
|
"Mt5CliClient",
|
|
77
|
+
"RateTarget",
|
|
53
78
|
"account_info",
|
|
54
79
|
"build_config",
|
|
80
|
+
"build_rate_targets",
|
|
81
|
+
"build_rate_view_name",
|
|
55
82
|
"collect_history",
|
|
56
83
|
"collect_latest_rates",
|
|
84
|
+
"collect_latest_rates_for_accounts",
|
|
57
85
|
"copy_rates_from",
|
|
58
86
|
"copy_rates_from_pos",
|
|
59
87
|
"copy_rates_range",
|
|
@@ -68,15 +96,26 @@ __all__ = [
|
|
|
68
96
|
"latest_rates",
|
|
69
97
|
"load_rate_data",
|
|
70
98
|
"load_rate_data_from_connection",
|
|
99
|
+
"load_rate_series_from_sqlite",
|
|
71
100
|
"market_book",
|
|
72
101
|
"minimum_margins",
|
|
102
|
+
"mt5_session",
|
|
73
103
|
"mt5_summary",
|
|
74
104
|
"mt5_summary_as_df",
|
|
75
105
|
"mt5_version",
|
|
76
106
|
"orders",
|
|
107
|
+
"parse_datetime",
|
|
108
|
+
"parse_tick_flags",
|
|
109
|
+
"parse_timeframe",
|
|
77
110
|
"positions",
|
|
78
111
|
"recent_history_deals",
|
|
79
112
|
"recent_ticks",
|
|
113
|
+
"resolve_history_datasets",
|
|
114
|
+
"resolve_history_tick_flags",
|
|
115
|
+
"resolve_history_timeframes",
|
|
116
|
+
"resolve_rate_tables",
|
|
117
|
+
"resolve_rate_view_name",
|
|
118
|
+
"resolve_rate_view_names",
|
|
80
119
|
"symbol_info",
|
|
81
120
|
"symbol_info_tick",
|
|
82
121
|
"symbols",
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import sqlite3
|
|
7
|
+
from dataclasses import dataclass
|
|
7
8
|
from datetime import UTC, datetime
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import TYPE_CHECKING, Literal, cast
|
|
@@ -135,14 +136,17 @@ def _require_non_empty_identifier(identifier: str, kind: str) -> str:
|
|
|
135
136
|
|
|
136
137
|
|
|
137
138
|
def _open_history_connection(
|
|
138
|
-
conn_or_path: SqliteConnOrPath,
|
|
139
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
139
140
|
) -> tuple[sqlite3.Connection | None, bool]:
|
|
140
141
|
"""Open a read-only SQLite connection when given a path.
|
|
141
142
|
|
|
142
143
|
Returns:
|
|
143
|
-
A connection and whether the caller should close it. When
|
|
144
|
-
not exist, returns ``(None, False)`` without
|
|
144
|
+
A connection and whether the caller should close it. When ``conn_or_path``
|
|
145
|
+
is None or the path does not exist, returns ``(None, False)`` without
|
|
146
|
+
creating a database file.
|
|
145
147
|
"""
|
|
148
|
+
if conn_or_path is None:
|
|
149
|
+
return None, False
|
|
146
150
|
if isinstance(conn_or_path, sqlite3.Connection):
|
|
147
151
|
return conn_or_path, False
|
|
148
152
|
path = Path(conn_or_path)
|
|
@@ -376,7 +380,7 @@ def _resolve_rate_view_name_from_context(
|
|
|
376
380
|
|
|
377
381
|
|
|
378
382
|
def resolve_rate_view_name(
|
|
379
|
-
conn_or_path: SqliteConnOrPath,
|
|
383
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
380
384
|
symbol: str,
|
|
381
385
|
granularity: str,
|
|
382
386
|
*,
|
|
@@ -385,7 +389,9 @@ def resolve_rate_view_name(
|
|
|
385
389
|
"""Resolve the mt5cli-managed rate compatibility view name.
|
|
386
390
|
|
|
387
391
|
Args:
|
|
388
|
-
conn_or_path: SQLite database path or open connection.
|
|
392
|
+
conn_or_path: SQLite database path or open connection. When None or a
|
|
393
|
+
non-existing path and ``require_existing`` is False, the deterministic
|
|
394
|
+
default view name is returned without creating a database file.
|
|
389
395
|
symbol: Symbol stored in the normalized ``rates`` table.
|
|
390
396
|
granularity: Timeframe name (for example ``M1``) or integer string.
|
|
391
397
|
require_existing: When True, require the database and a managed view to exist.
|
|
@@ -429,7 +435,7 @@ def resolve_rate_view_name(
|
|
|
429
435
|
|
|
430
436
|
|
|
431
437
|
def resolve_rate_view_names(
|
|
432
|
-
conn_or_path: SqliteConnOrPath,
|
|
438
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
433
439
|
symbols: Sequence[str],
|
|
434
440
|
granularities: Sequence[str],
|
|
435
441
|
*,
|
|
@@ -438,7 +444,9 @@ def resolve_rate_view_names(
|
|
|
438
444
|
"""Resolve rate compatibility view names for symbol and granularity pairs.
|
|
439
445
|
|
|
440
446
|
Args:
|
|
441
|
-
conn_or_path: SQLite database path or open connection.
|
|
447
|
+
conn_or_path: SQLite database path or open connection. When None or a
|
|
448
|
+
non-existing path and ``require_existing`` is False, deterministic
|
|
449
|
+
default view names are returned without creating a database file.
|
|
442
450
|
symbols: Symbols stored in the normalized ``rates`` table.
|
|
443
451
|
granularities: Timeframe names (for example ``M1``) or integer strings.
|
|
444
452
|
require_existing: When True, require the database and managed views to exist.
|
|
@@ -482,6 +490,222 @@ def resolve_rate_view_names(
|
|
|
482
490
|
conn.close()
|
|
483
491
|
|
|
484
492
|
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class RateTarget:
|
|
495
|
+
"""A single rate series identified by symbol and timeframe.
|
|
496
|
+
|
|
497
|
+
Attributes:
|
|
498
|
+
symbol: MT5 symbol name, or None when the rate series is addressed only
|
|
499
|
+
by an explicit table (for example a custom SQLite view).
|
|
500
|
+
timeframe: MT5 timeframe as an integer or name (for example ``M1``).
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
symbol: str | None
|
|
504
|
+
timeframe: int | str
|
|
505
|
+
|
|
506
|
+
def __post_init__(self) -> None:
|
|
507
|
+
"""Normalize accepted timeframe aliases to the stored integer value."""
|
|
508
|
+
if not isinstance(self.timeframe, int):
|
|
509
|
+
object.__setattr__(self, "timeframe", parse_timeframe(self.timeframe))
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def timeframe_int(self) -> int:
|
|
513
|
+
"""Return the timeframe as its integer MT5 value."""
|
|
514
|
+
return cast("int", self.timeframe)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_rate_targets(
|
|
518
|
+
symbols: Sequence[str],
|
|
519
|
+
timeframes: Sequence[int | str],
|
|
520
|
+
*,
|
|
521
|
+
allow_missing_symbol: bool = False,
|
|
522
|
+
) -> list[RateTarget]:
|
|
523
|
+
"""Build rate targets for every symbol and timeframe combination.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
|
|
527
|
+
timeframes: MT5 timeframes as integers or names (for example ``M1``).
|
|
528
|
+
allow_missing_symbol: When True and ``symbols`` is empty, build targets
|
|
529
|
+
with ``symbol=None`` for each timeframe instead of raising.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Targets in row-major order: every timeframe for the first symbol, then
|
|
533
|
+
every timeframe for the next symbol, and so on.
|
|
534
|
+
|
|
535
|
+
Raises:
|
|
536
|
+
ValueError: If ``timeframes`` is empty, or ``symbols`` is empty and
|
|
537
|
+
``allow_missing_symbol`` is False.
|
|
538
|
+
"""
|
|
539
|
+
if not timeframes:
|
|
540
|
+
msg = "At least one timeframe is required."
|
|
541
|
+
raise ValueError(msg)
|
|
542
|
+
if not symbols:
|
|
543
|
+
if not allow_missing_symbol:
|
|
544
|
+
msg = "At least one symbol is required."
|
|
545
|
+
raise ValueError(msg)
|
|
546
|
+
return [RateTarget(symbol=None, timeframe=tf) for tf in timeframes]
|
|
547
|
+
return [
|
|
548
|
+
RateTarget(symbol=symbol, timeframe=tf)
|
|
549
|
+
for symbol in symbols
|
|
550
|
+
for tf in timeframes
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def resolve_rate_tables(
|
|
555
|
+
conn_or_path: SqliteConnOrPath | None,
|
|
556
|
+
targets: Sequence[RateTarget],
|
|
557
|
+
explicit_tables: Sequence[str] | None = None,
|
|
558
|
+
*,
|
|
559
|
+
require_existing: bool = False,
|
|
560
|
+
) -> list[str]:
|
|
561
|
+
"""Resolve SQLite table or view names for rate targets.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
conn_or_path: SQLite database path or open connection. May be None when
|
|
565
|
+
``explicit_tables`` is provided, or when ``require_existing`` is
|
|
566
|
+
False and deterministic default view names are sufficient.
|
|
567
|
+
targets: Rate targets to resolve.
|
|
568
|
+
explicit_tables: Optional explicit table or view names. When provided,
|
|
569
|
+
they are used as-is and must match the number of targets.
|
|
570
|
+
require_existing: When True, require the database and managed views to
|
|
571
|
+
exist for each symbol target. Ignored when ``explicit_tables`` is
|
|
572
|
+
provided.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Table or view names aligned with ``targets``.
|
|
576
|
+
|
|
577
|
+
Raises:
|
|
578
|
+
ValueError: If ``targets`` is empty, ``explicit_tables`` length does not
|
|
579
|
+
match the target count, a target without a symbol is resolved
|
|
580
|
+
without an explicit table, or ``require_existing`` is True and the
|
|
581
|
+
database or a managed view is missing.
|
|
582
|
+
"""
|
|
583
|
+
target_list = list(targets)
|
|
584
|
+
if not target_list:
|
|
585
|
+
msg = "At least one rate target is required."
|
|
586
|
+
raise ValueError(msg)
|
|
587
|
+
if explicit_tables is not None:
|
|
588
|
+
tables = list(explicit_tables)
|
|
589
|
+
if len(tables) != len(target_list):
|
|
590
|
+
msg = (
|
|
591
|
+
f"Expected {len(target_list)} explicit table(s) "
|
|
592
|
+
f"to match the targets, got {len(tables)}."
|
|
593
|
+
)
|
|
594
|
+
raise ValueError(msg)
|
|
595
|
+
return tables
|
|
596
|
+
if any(target.symbol is None for target in target_list):
|
|
597
|
+
msg = (
|
|
598
|
+
"Cannot resolve a rate table for a target without a symbol; "
|
|
599
|
+
"provide explicit_tables."
|
|
600
|
+
)
|
|
601
|
+
raise ValueError(msg)
|
|
602
|
+
conn, should_close = _open_history_connection(conn_or_path)
|
|
603
|
+
try:
|
|
604
|
+
if conn is None:
|
|
605
|
+
if require_existing:
|
|
606
|
+
path = (
|
|
607
|
+
conn_or_path
|
|
608
|
+
if isinstance(conn_or_path, (Path, str))
|
|
609
|
+
else "database"
|
|
610
|
+
)
|
|
611
|
+
msg = f"SQLite database not found: {path}"
|
|
612
|
+
raise ValueError(msg)
|
|
613
|
+
timeframe_counts = None
|
|
614
|
+
existing_views: set[str] = set()
|
|
615
|
+
else:
|
|
616
|
+
timeframe_counts = _load_rates_timeframe_counts(conn)
|
|
617
|
+
existing_views = _load_existing_rate_views(conn)
|
|
618
|
+
resolved: list[str] = []
|
|
619
|
+
for target in target_list:
|
|
620
|
+
symbol = cast("str", target.symbol)
|
|
621
|
+
timeframe = target.timeframe_int
|
|
622
|
+
resolved.append(
|
|
623
|
+
_resolve_rate_view_name_from_context(
|
|
624
|
+
symbol=symbol,
|
|
625
|
+
timeframe=timeframe,
|
|
626
|
+
granularity_name=resolve_granularity_name(timeframe),
|
|
627
|
+
timeframe_counts=timeframe_counts,
|
|
628
|
+
existing_views=existing_views,
|
|
629
|
+
require_existing=require_existing,
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
return resolved
|
|
633
|
+
finally:
|
|
634
|
+
if should_close and conn is not None:
|
|
635
|
+
conn.close()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def load_rate_series_from_sqlite(
|
|
639
|
+
conn_or_path: SqliteConnOrPath,
|
|
640
|
+
targets: Sequence[RateTarget],
|
|
641
|
+
count: int,
|
|
642
|
+
explicit_tables: Sequence[str] | None = None,
|
|
643
|
+
) -> dict[tuple[str | None, int], pd.DataFrame]:
|
|
644
|
+
"""Load multiple rate series from a SQLite database.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
conn_or_path: SQLite database path or open connection.
|
|
648
|
+
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair
|
|
649
|
+
must be unique.
|
|
650
|
+
count: Number of most recent rows to load per series.
|
|
651
|
+
explicit_tables: Optional explicit table or view names matching targets.
|
|
652
|
+
When omitted, managed ``rate_*`` compatibility views must already
|
|
653
|
+
exist in the database.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame.
|
|
657
|
+
|
|
658
|
+
Raises:
|
|
659
|
+
ValueError: If ``count`` is not positive, targets are empty, duplicate
|
|
660
|
+
``(symbol, timeframe_int)`` pairs are present, or table resolution
|
|
661
|
+
fails.
|
|
662
|
+
"""
|
|
663
|
+
if count <= 0:
|
|
664
|
+
msg = "count must be positive."
|
|
665
|
+
raise ValueError(msg)
|
|
666
|
+
target_list = list(targets)
|
|
667
|
+
if not target_list:
|
|
668
|
+
msg = "At least one rate target is required."
|
|
669
|
+
raise ValueError(msg)
|
|
670
|
+
if explicit_tables is None and any(target.symbol is None for target in target_list):
|
|
671
|
+
msg = (
|
|
672
|
+
"Cannot resolve a rate table for a target without a symbol; "
|
|
673
|
+
"provide explicit_tables."
|
|
674
|
+
)
|
|
675
|
+
raise ValueError(msg)
|
|
676
|
+
seen_keys: set[tuple[str | None, int]] = set()
|
|
677
|
+
for target in target_list:
|
|
678
|
+
key = (target.symbol, target.timeframe_int)
|
|
679
|
+
if key in seen_keys:
|
|
680
|
+
symbol_repr = repr(target.symbol)
|
|
681
|
+
msg = f"Duplicate rate target: ({symbol_repr}, {target.timeframe_int})"
|
|
682
|
+
raise ValueError(msg)
|
|
683
|
+
seen_keys.add(key)
|
|
684
|
+
tables = (
|
|
685
|
+
resolve_rate_tables(None, target_list, explicit_tables)
|
|
686
|
+
if explicit_tables is not None
|
|
687
|
+
else None
|
|
688
|
+
)
|
|
689
|
+
conn, should_close = _open_existing_sqlite_database(conn_or_path)
|
|
690
|
+
try:
|
|
691
|
+
resolved_tables = tables or resolve_rate_tables(
|
|
692
|
+
conn,
|
|
693
|
+
target_list,
|
|
694
|
+
require_existing=True,
|
|
695
|
+
)
|
|
696
|
+
return {
|
|
697
|
+
(target.symbol, target.timeframe_int): load_rate_data_from_connection(
|
|
698
|
+
conn,
|
|
699
|
+
table,
|
|
700
|
+
count=count,
|
|
701
|
+
)
|
|
702
|
+
for target, table in zip(target_list, resolved_tables, strict=True)
|
|
703
|
+
}
|
|
704
|
+
finally:
|
|
705
|
+
if should_close:
|
|
706
|
+
conn.close()
|
|
707
|
+
|
|
708
|
+
|
|
485
709
|
def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
486
710
|
"""Return existing SQLite columns for a table."""
|
|
487
711
|
quoted_table = quote_sqlite_identifier(table)
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import sqlite3
|
|
8
8
|
from contextlib import contextmanager
|
|
9
|
-
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
10
|
from datetime import UTC, datetime, timedelta
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import TYPE_CHECKING, Self, TypeVar, cast
|
|
@@ -40,11 +40,13 @@ T = TypeVar("T")
|
|
|
40
40
|
logger = logging.getLogger(__name__)
|
|
41
41
|
|
|
42
42
|
__all__ = [
|
|
43
|
+
"AccountSpec",
|
|
43
44
|
"Mt5CliClient",
|
|
44
45
|
"account_info",
|
|
45
46
|
"build_config",
|
|
46
47
|
"collect_history",
|
|
47
48
|
"collect_latest_rates",
|
|
49
|
+
"collect_latest_rates_for_accounts",
|
|
48
50
|
"copy_rates_from",
|
|
49
51
|
"copy_rates_from_pos",
|
|
50
52
|
"copy_rates_range",
|
|
@@ -56,6 +58,7 @@ __all__ = [
|
|
|
56
58
|
"latest_rates",
|
|
57
59
|
"market_book",
|
|
58
60
|
"minimum_margins",
|
|
61
|
+
"mt5_session",
|
|
59
62
|
"mt5_summary",
|
|
60
63
|
"mt5_summary_as_df",
|
|
61
64
|
"orders",
|
|
@@ -277,6 +280,26 @@ def _run_with_client(
|
|
|
277
280
|
return fetch_fn(client)
|
|
278
281
|
|
|
279
282
|
|
|
283
|
+
@contextmanager
|
|
284
|
+
def mt5_session(config: Mt5Config | None = None) -> Iterator[Mt5CliClient]:
|
|
285
|
+
"""Open an MT5 terminal session and yield a connected client.
|
|
286
|
+
|
|
287
|
+
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` (when set),
|
|
288
|
+
logs in, yields a connected :class:`Mt5CliClient`, and always shuts the
|
|
289
|
+
terminal down on exit.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
config: MT5 connection configuration. Defaults to an empty config that
|
|
293
|
+
attaches to a running terminal.
|
|
294
|
+
|
|
295
|
+
Yields:
|
|
296
|
+
Connected ``Mt5CliClient`` bound to the session.
|
|
297
|
+
"""
|
|
298
|
+
mt5_config = config or build_config()
|
|
299
|
+
with _connected_client(mt5_config) as client:
|
|
300
|
+
yield Mt5CliClient.from_connected_client(client)
|
|
301
|
+
|
|
302
|
+
|
|
280
303
|
class Mt5CliClient:
|
|
281
304
|
"""Programmatic client for read-only MetaTrader 5 data access."""
|
|
282
305
|
|
|
@@ -1075,6 +1098,120 @@ def collect_latest_rates(
|
|
|
1075
1098
|
)
|
|
1076
1099
|
|
|
1077
1100
|
|
|
1101
|
+
@dataclass(frozen=True)
|
|
1102
|
+
class AccountSpec:
|
|
1103
|
+
"""Connection parameters and symbols for one MT5 account group.
|
|
1104
|
+
|
|
1105
|
+
Attributes:
|
|
1106
|
+
symbols: Symbols to load latest rates for under this account.
|
|
1107
|
+
login: Trading account login. String values are coerced to int when
|
|
1108
|
+
non-empty.
|
|
1109
|
+
password: Trading account password.
|
|
1110
|
+
server: Trading server name.
|
|
1111
|
+
path: Path to the MetaTrader5 terminal EXE file.
|
|
1112
|
+
timeout: Connection timeout in milliseconds.
|
|
1113
|
+
"""
|
|
1114
|
+
|
|
1115
|
+
symbols: Sequence[str]
|
|
1116
|
+
login: int | str | None = None
|
|
1117
|
+
password: str | None = field(default=None, repr=False)
|
|
1118
|
+
server: str | None = None
|
|
1119
|
+
path: str | None = None
|
|
1120
|
+
timeout: int | None = None
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _coerce_login(login: int | str | None) -> int | None:
|
|
1124
|
+
"""Coerce a login value to int, treating empty strings as unset.
|
|
1125
|
+
|
|
1126
|
+
Returns:
|
|
1127
|
+
Integer login, or None when unset or an empty string.
|
|
1128
|
+
"""
|
|
1129
|
+
if login is None or isinstance(login, int):
|
|
1130
|
+
return login
|
|
1131
|
+
text = login.strip()
|
|
1132
|
+
if not text:
|
|
1133
|
+
return None
|
|
1134
|
+
return int(text)
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _build_account_config(
|
|
1138
|
+
account: AccountSpec,
|
|
1139
|
+
base_config: Mt5Config | None,
|
|
1140
|
+
) -> Mt5Config:
|
|
1141
|
+
"""Build an ``Mt5Config`` for an account, falling back to ``base_config``.
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
Merged MT5 configuration for the account.
|
|
1145
|
+
"""
|
|
1146
|
+
login = _coerce_login(account.login)
|
|
1147
|
+
if login is None and base_config is not None:
|
|
1148
|
+
login = base_config.login
|
|
1149
|
+
return build_config(
|
|
1150
|
+
path=account.path or (base_config.path if base_config else None),
|
|
1151
|
+
login=login,
|
|
1152
|
+
password=account.password or (base_config.password if base_config else None),
|
|
1153
|
+
server=account.server or (base_config.server if base_config else None),
|
|
1154
|
+
timeout=account.timeout
|
|
1155
|
+
if account.timeout is not None
|
|
1156
|
+
else (base_config.timeout if base_config else None),
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def collect_latest_rates_for_accounts(
|
|
1161
|
+
accounts: Sequence[AccountSpec],
|
|
1162
|
+
timeframes: Sequence[int | str],
|
|
1163
|
+
count: int,
|
|
1164
|
+
*,
|
|
1165
|
+
start_pos: int = 0,
|
|
1166
|
+
base_config: Mt5Config | None = None,
|
|
1167
|
+
) -> dict[tuple[str, int], pd.DataFrame]:
|
|
1168
|
+
"""Collect latest rates across multiple MT5 account groups.
|
|
1169
|
+
|
|
1170
|
+
Each account is connected in turn, its symbols are read for every
|
|
1171
|
+
timeframe, and the resulting frames are merged into a single mapping.
|
|
1172
|
+
|
|
1173
|
+
Args:
|
|
1174
|
+
accounts: Account groups to read. Each must define at least one symbol.
|
|
1175
|
+
timeframes: MT5 timeframes as integers or names (for example ``M1``).
|
|
1176
|
+
count: Number of most recent bars to read per symbol/timeframe.
|
|
1177
|
+
start_pos: Initial bar position offset.
|
|
1178
|
+
base_config: Optional base configuration whose fields fill any value not
|
|
1179
|
+
set on an individual account.
|
|
1180
|
+
|
|
1181
|
+
Returns:
|
|
1182
|
+
Mapping keyed by ``(symbol, timeframe_int)``. When accounts share a
|
|
1183
|
+
symbol/timeframe pair, the last account processed wins.
|
|
1184
|
+
|
|
1185
|
+
Raises:
|
|
1186
|
+
ValueError: If ``accounts``, ``timeframes``, or any account's symbols are
|
|
1187
|
+
empty, or ``count`` is not positive.
|
|
1188
|
+
"""
|
|
1189
|
+
account_list = list(accounts)
|
|
1190
|
+
if not account_list:
|
|
1191
|
+
msg = "At least one account is required."
|
|
1192
|
+
raise ValueError(msg)
|
|
1193
|
+
if not timeframes:
|
|
1194
|
+
msg = "At least one timeframe is required."
|
|
1195
|
+
raise ValueError(msg)
|
|
1196
|
+
if any(not account.symbols for account in account_list):
|
|
1197
|
+
msg = "Each account requires at least one symbol."
|
|
1198
|
+
raise ValueError(msg)
|
|
1199
|
+
_require_positive(count, "count")
|
|
1200
|
+
result: dict[tuple[str, int], pd.DataFrame] = {}
|
|
1201
|
+
for account in account_list:
|
|
1202
|
+
config = _build_account_config(account, base_config)
|
|
1203
|
+
with Mt5CliClient(config=config) as client:
|
|
1204
|
+
result.update(
|
|
1205
|
+
client.collect_latest_rates(
|
|
1206
|
+
account.symbols,
|
|
1207
|
+
timeframes,
|
|
1208
|
+
count=count,
|
|
1209
|
+
start_pos=start_pos,
|
|
1210
|
+
),
|
|
1211
|
+
)
|
|
1212
|
+
return result
|
|
1213
|
+
|
|
1214
|
+
|
|
1078
1215
|
def copy_rates_range(
|
|
1079
1216
|
symbol: str,
|
|
1080
1217
|
timeframe: int | str,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.1"
|
|
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"}]
|
|
@@ -10,14 +10,18 @@ from unittest.mock import MagicMock
|
|
|
10
10
|
|
|
11
11
|
import pandas as pd
|
|
12
12
|
import pytest
|
|
13
|
+
from pytest_mock import MockerFixture # noqa: TC002
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
18
|
+
from mt5cli import history
|
|
17
19
|
from mt5cli.history import (
|
|
18
20
|
DEFAULT_HISTORY_TIMEFRAMES,
|
|
21
|
+
RateTarget,
|
|
19
22
|
append_dataframe,
|
|
20
23
|
augment_written_columns_from_sqlite,
|
|
24
|
+
build_rate_targets,
|
|
21
25
|
build_rate_view_name,
|
|
22
26
|
create_cash_events_view,
|
|
23
27
|
create_history_indexes,
|
|
@@ -33,6 +37,7 @@ from mt5cli.history import (
|
|
|
33
37
|
load_incremental_start_datetimes,
|
|
34
38
|
load_rate_data,
|
|
35
39
|
load_rate_data_from_connection,
|
|
40
|
+
load_rate_series_from_sqlite,
|
|
36
41
|
parse_sqlite_timestamp,
|
|
37
42
|
quote_sqlite_identifier,
|
|
38
43
|
record_written_columns,
|
|
@@ -40,6 +45,7 @@ from mt5cli.history import (
|
|
|
40
45
|
resolve_history_datasets,
|
|
41
46
|
resolve_history_tick_flags,
|
|
42
47
|
resolve_history_timeframes,
|
|
48
|
+
resolve_rate_tables,
|
|
43
49
|
resolve_rate_view_name,
|
|
44
50
|
resolve_rate_view_names,
|
|
45
51
|
write_collected_datasets,
|
|
@@ -60,6 +66,21 @@ class TestResolveRateViewName:
|
|
|
60
66
|
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
|
61
67
|
assert not db_path.exists()
|
|
62
68
|
|
|
69
|
+
def test_none_path_returns_default_name(self) -> None:
|
|
70
|
+
"""Test a None connection or path returns the deterministic default."""
|
|
71
|
+
assert resolve_rate_view_name(None, "EURUSD", "M1") == "rate_EURUSD__1"
|
|
72
|
+
assert resolve_rate_view_names(None, ["EURUSD"], ["M1", "H1"]) == [
|
|
73
|
+
"rate_EURUSD__1",
|
|
74
|
+
"rate_EURUSD__16385",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def test_none_path_with_require_existing_raises(self) -> None:
|
|
78
|
+
"""Test a None path under strict mode raises a clear error."""
|
|
79
|
+
with pytest.raises(ValueError, match="SQLite database not found"):
|
|
80
|
+
resolve_rate_view_name(None, "EURUSD", "M1", require_existing=True)
|
|
81
|
+
with pytest.raises(ValueError, match="SQLite database not found"):
|
|
82
|
+
resolve_rate_view_names(None, ["EURUSD"], ["M1"], require_existing=True)
|
|
83
|
+
|
|
63
84
|
def test_no_rates_table_falls_back_to_single_timeframe_name(
|
|
64
85
|
self,
|
|
65
86
|
tmp_path: Path,
|
|
@@ -1915,3 +1936,305 @@ class TestWriteHelpers:
|
|
|
1915
1936
|
)
|
|
1916
1937
|
assert get_table_columns(conn, "rates") == {"time", "open"}
|
|
1917
1938
|
create_history_indexes(conn, written_columns)
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
class TestRateSourceHelpers:
|
|
1942
|
+
"""Tests for generic rate-source SDK helpers."""
|
|
1943
|
+
|
|
1944
|
+
def test_rate_target_timeframe_int(self) -> None:
|
|
1945
|
+
"""Test RateTarget resolves named and integer timeframes."""
|
|
1946
|
+
target = RateTarget(symbol="EURUSD", timeframe="M1")
|
|
1947
|
+
assert target.timeframe == 1
|
|
1948
|
+
assert target.timeframe_int == 1
|
|
1949
|
+
assert RateTarget(symbol="EURUSD", timeframe=16385).timeframe_int == 16385
|
|
1950
|
+
|
|
1951
|
+
def test_build_rate_targets_row_major(self) -> None:
|
|
1952
|
+
"""Test targets are built in row-major symbol/timeframe order."""
|
|
1953
|
+
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
|
1954
|
+
assert [(t.symbol, t.timeframe) for t in targets] == [
|
|
1955
|
+
("EURUSD", 1),
|
|
1956
|
+
("EURUSD", 16385),
|
|
1957
|
+
("GBPUSD", 1),
|
|
1958
|
+
("GBPUSD", 16385),
|
|
1959
|
+
]
|
|
1960
|
+
|
|
1961
|
+
def test_build_rate_targets_allows_missing_symbol(self) -> None:
|
|
1962
|
+
"""Test missing symbols produce None-symbol targets when allowed."""
|
|
1963
|
+
targets = build_rate_targets([], ["M1", "H1"], allow_missing_symbol=True)
|
|
1964
|
+
assert [(t.symbol, t.timeframe) for t in targets] == [
|
|
1965
|
+
(None, 1),
|
|
1966
|
+
(None, 16385),
|
|
1967
|
+
]
|
|
1968
|
+
|
|
1969
|
+
@pytest.mark.parametrize(
|
|
1970
|
+
("symbols", "timeframes", "match"),
|
|
1971
|
+
[
|
|
1972
|
+
(["EURUSD"], [], "At least one timeframe"),
|
|
1973
|
+
([], ["M1"], "At least one symbol"),
|
|
1974
|
+
],
|
|
1975
|
+
)
|
|
1976
|
+
def test_build_rate_targets_rejects_empty(
|
|
1977
|
+
self,
|
|
1978
|
+
symbols: list[str],
|
|
1979
|
+
timeframes: list[str],
|
|
1980
|
+
match: str,
|
|
1981
|
+
) -> None:
|
|
1982
|
+
"""Test target building input validation."""
|
|
1983
|
+
with pytest.raises(ValueError, match=match):
|
|
1984
|
+
build_rate_targets(symbols, timeframes)
|
|
1985
|
+
|
|
1986
|
+
def test_resolve_rate_tables_uses_explicit_tables(self) -> None:
|
|
1987
|
+
"""Test explicit tables bypass view resolution when counts match."""
|
|
1988
|
+
targets = build_rate_targets([], ["M1", "H1"], allow_missing_symbol=True)
|
|
1989
|
+
assert resolve_rate_tables(None, targets, ["t1", "t2"]) == ["t1", "t2"]
|
|
1990
|
+
|
|
1991
|
+
def test_resolve_rate_tables_rejects_mismatched_explicit_count(self) -> None:
|
|
1992
|
+
"""Test explicit table count must match the number of targets."""
|
|
1993
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
1994
|
+
with pytest.raises(ValueError, match="Expected 1 explicit table"):
|
|
1995
|
+
resolve_rate_tables(None, targets, ["t1", "t2"])
|
|
1996
|
+
|
|
1997
|
+
def test_resolve_rate_tables_rejects_empty_targets(self) -> None:
|
|
1998
|
+
"""Test resolving requires at least one target."""
|
|
1999
|
+
with pytest.raises(ValueError, match="At least one rate target"):
|
|
2000
|
+
resolve_rate_tables(None, [])
|
|
2001
|
+
|
|
2002
|
+
def test_resolve_rate_tables_requires_symbol_without_explicit(self) -> None:
|
|
2003
|
+
"""Test None-symbol targets require explicit tables."""
|
|
2004
|
+
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
|
|
2005
|
+
with pytest.raises(ValueError, match="without a symbol"):
|
|
2006
|
+
resolve_rate_tables(None, targets)
|
|
2007
|
+
|
|
2008
|
+
def test_resolve_rate_tables_resolves_view_names(self) -> None:
|
|
2009
|
+
"""Test symbol targets resolve to default view names without a database."""
|
|
2010
|
+
targets = build_rate_targets(["EURUSD"], ["M1", "H1"])
|
|
2011
|
+
assert resolve_rate_tables(None, targets) == [
|
|
2012
|
+
"rate_EURUSD__1",
|
|
2013
|
+
"rate_EURUSD__16385",
|
|
2014
|
+
]
|
|
2015
|
+
|
|
2016
|
+
def test_resolve_rate_tables_none_path_with_require_existing_raises(self) -> None:
|
|
2017
|
+
"""Test strict mode rejects a missing database path."""
|
|
2018
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2019
|
+
with pytest.raises(ValueError, match="SQLite database not found"):
|
|
2020
|
+
resolve_rate_tables(None, targets, require_existing=True)
|
|
2021
|
+
|
|
2022
|
+
def test_resolve_rate_tables_missing_db_with_require_existing_raises(
|
|
2023
|
+
self,
|
|
2024
|
+
tmp_path: Path,
|
|
2025
|
+
) -> None:
|
|
2026
|
+
"""Test strict mode rejects a non-existing database path."""
|
|
2027
|
+
db_path = tmp_path / "missing.db"
|
|
2028
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2029
|
+
with pytest.raises(ValueError, match="SQLite database not found"):
|
|
2030
|
+
resolve_rate_tables(db_path, targets, require_existing=True)
|
|
2031
|
+
|
|
2032
|
+
def test_resolve_rate_tables_missing_view_with_require_existing_raises(
|
|
2033
|
+
self,
|
|
2034
|
+
tmp_path: Path,
|
|
2035
|
+
) -> None:
|
|
2036
|
+
"""Test strict mode rejects databases without managed rate views."""
|
|
2037
|
+
db_path = tmp_path / "no-views.db"
|
|
2038
|
+
with sqlite3.connect(db_path) as conn:
|
|
2039
|
+
conn.execute(
|
|
2040
|
+
"CREATE TABLE rates("
|
|
2041
|
+
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
2042
|
+
)
|
|
2043
|
+
conn.execute(
|
|
2044
|
+
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
2045
|
+
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
2046
|
+
)
|
|
2047
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2048
|
+
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
|
2049
|
+
resolve_rate_tables(db_path, targets, require_existing=True)
|
|
2050
|
+
|
|
2051
|
+
def test_resolve_rate_tables_with_require_existing_resolves_views(
|
|
2052
|
+
self,
|
|
2053
|
+
tmp_path: Path,
|
|
2054
|
+
) -> None:
|
|
2055
|
+
"""Test strict mode resolves existing managed rate views."""
|
|
2056
|
+
db_path = tmp_path / "strict-views.db"
|
|
2057
|
+
with sqlite3.connect(db_path) as conn:
|
|
2058
|
+
conn.execute(
|
|
2059
|
+
"CREATE TABLE rates("
|
|
2060
|
+
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
2061
|
+
)
|
|
2062
|
+
conn.execute(
|
|
2063
|
+
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
2064
|
+
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
2065
|
+
)
|
|
2066
|
+
create_rate_compatibility_views(conn)
|
|
2067
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2068
|
+
assert resolve_rate_tables(db_path, targets, require_existing=True) == [
|
|
2069
|
+
"rate_EURUSD__1",
|
|
2070
|
+
]
|
|
2071
|
+
|
|
2072
|
+
def test_resolve_rate_tables_batches_sqlite_metadata(
|
|
2073
|
+
self,
|
|
2074
|
+
tmp_path: Path,
|
|
2075
|
+
mocker: MockerFixture,
|
|
2076
|
+
) -> None:
|
|
2077
|
+
"""Test resolving multiple targets loads SQLite metadata once."""
|
|
2078
|
+
db_path = tmp_path / "batch-rate-tables.db"
|
|
2079
|
+
with sqlite3.connect(db_path) as conn:
|
|
2080
|
+
conn.execute(
|
|
2081
|
+
"CREATE TABLE rates("
|
|
2082
|
+
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
2083
|
+
)
|
|
2084
|
+
conn.executemany(
|
|
2085
|
+
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
2086
|
+
[
|
|
2087
|
+
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
2088
|
+
("EURUSD", 16385, "2024-01-01T01:00:00+00:00", 1.1),
|
|
2089
|
+
("GBPUSD", 1, "2024-01-01T00:00:00+00:00", 1.2),
|
|
2090
|
+
],
|
|
2091
|
+
)
|
|
2092
|
+
create_rate_compatibility_views(conn)
|
|
2093
|
+
counts_spy = mocker.spy(history, "_load_rates_timeframe_counts")
|
|
2094
|
+
views_spy = mocker.spy(history, "_load_existing_rate_views")
|
|
2095
|
+
|
|
2096
|
+
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
|
2097
|
+
assert resolve_rate_tables(db_path, targets) == [
|
|
2098
|
+
"rate_EURUSD__M1_1",
|
|
2099
|
+
"rate_EURUSD__H1_16385",
|
|
2100
|
+
"rate_GBPUSD__1",
|
|
2101
|
+
"rate_GBPUSD__16385",
|
|
2102
|
+
]
|
|
2103
|
+
assert counts_spy.call_count == 1
|
|
2104
|
+
assert views_spy.call_count == 1
|
|
2105
|
+
|
|
2106
|
+
def test_load_rate_series_from_sqlite(self, tmp_path: Path) -> None:
|
|
2107
|
+
"""Test loading multiple rate series keyed by symbol and timeframe."""
|
|
2108
|
+
db_path = tmp_path / "series.db"
|
|
2109
|
+
with sqlite3.connect(db_path) as conn:
|
|
2110
|
+
conn.execute(
|
|
2111
|
+
"CREATE TABLE rates("
|
|
2112
|
+
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
2113
|
+
)
|
|
2114
|
+
conn.executemany(
|
|
2115
|
+
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
2116
|
+
[
|
|
2117
|
+
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
2118
|
+
("EURUSD", 1, "2024-01-01T00:01:00+00:00", 1.1),
|
|
2119
|
+
],
|
|
2120
|
+
)
|
|
2121
|
+
create_rate_compatibility_views(conn)
|
|
2122
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2123
|
+
result = load_rate_series_from_sqlite(db_path, targets, count=2)
|
|
2124
|
+
assert set(result) == {("EURUSD", 1)}
|
|
2125
|
+
assert len(result["EURUSD", 1]) == 2
|
|
2126
|
+
|
|
2127
|
+
def test_load_rate_series_reuses_path_connection(
|
|
2128
|
+
self,
|
|
2129
|
+
tmp_path: Path,
|
|
2130
|
+
mocker: MockerFixture,
|
|
2131
|
+
) -> None:
|
|
2132
|
+
"""Test loading from a path opens SQLite once for resolve and reads."""
|
|
2133
|
+
db_path = tmp_path / "single-open-series.db"
|
|
2134
|
+
with sqlite3.connect(db_path) as conn:
|
|
2135
|
+
conn.execute(
|
|
2136
|
+
"CREATE TABLE rates("
|
|
2137
|
+
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
2138
|
+
)
|
|
2139
|
+
conn.execute(
|
|
2140
|
+
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
2141
|
+
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
2142
|
+
)
|
|
2143
|
+
create_rate_compatibility_views(conn)
|
|
2144
|
+
connect_spy = mocker.spy(history.sqlite3, "connect")
|
|
2145
|
+
|
|
2146
|
+
result = load_rate_series_from_sqlite(
|
|
2147
|
+
db_path,
|
|
2148
|
+
build_rate_targets(["EURUSD"], ["M1"]),
|
|
2149
|
+
count=1,
|
|
2150
|
+
)
|
|
2151
|
+
|
|
2152
|
+
assert set(result) == {("EURUSD", 1)}
|
|
2153
|
+
assert connect_spy.call_count == 1
|
|
2154
|
+
|
|
2155
|
+
def test_load_rate_series_with_explicit_tables(self, tmp_path: Path) -> None:
|
|
2156
|
+
"""Test explicit tables and None-symbol targets load series."""
|
|
2157
|
+
db_path = tmp_path / "explicit.db"
|
|
2158
|
+
with sqlite3.connect(db_path) as conn:
|
|
2159
|
+
conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
|
|
2160
|
+
conn.execute(
|
|
2161
|
+
"INSERT INTO custom_view(time, close) VALUES (?, ?)",
|
|
2162
|
+
("2024-01-01T00:00:00+00:00", 1.0),
|
|
2163
|
+
)
|
|
2164
|
+
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
|
|
2165
|
+
result = load_rate_series_from_sqlite(
|
|
2166
|
+
db_path,
|
|
2167
|
+
targets,
|
|
2168
|
+
count=1,
|
|
2169
|
+
explicit_tables=["custom_view"],
|
|
2170
|
+
)
|
|
2171
|
+
assert set(result) == {(None, 1)}
|
|
2172
|
+
|
|
2173
|
+
def test_load_rate_series_rejects_non_positive_count(self) -> None:
|
|
2174
|
+
"""Test loading requires a positive count."""
|
|
2175
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2176
|
+
with pytest.raises(ValueError, match="count must be positive"):
|
|
2177
|
+
load_rate_series_from_sqlite("unused.db", targets, count=0)
|
|
2178
|
+
|
|
2179
|
+
def test_load_rate_series_rejects_empty_targets(self) -> None:
|
|
2180
|
+
"""Test loading requires at least one target before opening SQLite."""
|
|
2181
|
+
with pytest.raises(ValueError, match="At least one rate target"):
|
|
2182
|
+
load_rate_series_from_sqlite("unused.db", [], count=1)
|
|
2183
|
+
|
|
2184
|
+
def test_load_rate_series_requires_symbol_without_explicit_tables(self) -> None:
|
|
2185
|
+
"""Test None-symbol targets require explicit tables before opening SQLite."""
|
|
2186
|
+
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
|
|
2187
|
+
with pytest.raises(ValueError, match="without a symbol"):
|
|
2188
|
+
load_rate_series_from_sqlite("unused.db", targets, count=1)
|
|
2189
|
+
|
|
2190
|
+
def test_load_rate_series_requires_existing_managed_views(
|
|
2191
|
+
self,
|
|
2192
|
+
tmp_path: Path,
|
|
2193
|
+
) -> None:
|
|
2194
|
+
"""Test loading without explicit tables requires managed rate views."""
|
|
2195
|
+
db_path = tmp_path / "no-managed-views.db"
|
|
2196
|
+
with sqlite3.connect(db_path) as conn:
|
|
2197
|
+
conn.execute(
|
|
2198
|
+
"CREATE TABLE rates("
|
|
2199
|
+
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
2200
|
+
)
|
|
2201
|
+
conn.execute(
|
|
2202
|
+
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
2203
|
+
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
2204
|
+
)
|
|
2205
|
+
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
2206
|
+
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
|
2207
|
+
load_rate_series_from_sqlite(db_path, targets, count=1)
|
|
2208
|
+
|
|
2209
|
+
def test_load_rate_series_rejects_duplicate_targets(self) -> None:
|
|
2210
|
+
"""Test duplicate (symbol, timeframe) targets are rejected."""
|
|
2211
|
+
targets = [
|
|
2212
|
+
RateTarget("EURUSD", 1),
|
|
2213
|
+
RateTarget("EURUSD", "M1"),
|
|
2214
|
+
]
|
|
2215
|
+
with pytest.raises(ValueError, match=r"Duplicate rate target: \('EURUSD', 1\)"):
|
|
2216
|
+
load_rate_series_from_sqlite("unused.db", targets, count=1)
|
|
2217
|
+
|
|
2218
|
+
def test_load_rate_series_rejects_duplicate_targets_with_explicit_tables(
|
|
2219
|
+
self,
|
|
2220
|
+
tmp_path: Path,
|
|
2221
|
+
) -> None:
|
|
2222
|
+
"""Test duplicate targets are rejected even with explicit tables."""
|
|
2223
|
+
db_path = tmp_path / "duplicate-explicit.db"
|
|
2224
|
+
with sqlite3.connect(db_path) as conn:
|
|
2225
|
+
conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
|
|
2226
|
+
conn.execute(
|
|
2227
|
+
"INSERT INTO custom_view(time, close) VALUES (?, ?)",
|
|
2228
|
+
("2024-01-01T00:00:00+00:00", 1.0),
|
|
2229
|
+
)
|
|
2230
|
+
targets = [
|
|
2231
|
+
RateTarget("EURUSD", 1),
|
|
2232
|
+
RateTarget("EURUSD", 1),
|
|
2233
|
+
]
|
|
2234
|
+
with pytest.raises(ValueError, match=r"Duplicate rate target: \('EURUSD', 1\)"):
|
|
2235
|
+
load_rate_series_from_sqlite(
|
|
2236
|
+
db_path,
|
|
2237
|
+
targets,
|
|
2238
|
+
count=1,
|
|
2239
|
+
explicit_tables=["custom_view", "custom_view"],
|
|
2240
|
+
)
|
|
@@ -15,16 +15,18 @@ from pytest_mock import MockerFixture # noqa: TC002
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
from pdmt5 import Mt5DataClient
|
|
18
|
+
from pdmt5 import Mt5Config, Mt5DataClient
|
|
19
19
|
|
|
20
20
|
from mt5cli import sdk
|
|
21
21
|
from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES
|
|
22
22
|
from mt5cli.sdk import (
|
|
23
|
+
AccountSpec,
|
|
23
24
|
Mt5CliClient,
|
|
24
25
|
account_info,
|
|
25
26
|
build_config,
|
|
26
27
|
collect_history,
|
|
27
28
|
collect_latest_rates,
|
|
29
|
+
collect_latest_rates_for_accounts,
|
|
28
30
|
copy_rates_from,
|
|
29
31
|
copy_rates_from_pos,
|
|
30
32
|
copy_rates_range,
|
|
@@ -36,6 +38,7 @@ from mt5cli.sdk import (
|
|
|
36
38
|
latest_rates,
|
|
37
39
|
market_book,
|
|
38
40
|
minimum_margins,
|
|
41
|
+
mt5_session,
|
|
39
42
|
mt5_summary,
|
|
40
43
|
mt5_summary_as_df,
|
|
41
44
|
orders,
|
|
@@ -1248,3 +1251,177 @@ class TestMinimumMargins:
|
|
|
1248
1251
|
)
|
|
1249
1252
|
client.order_calc_margin.assert_any_call(0, "EURUSD", 0.01, 1.1010)
|
|
1250
1253
|
client.order_calc_margin.assert_any_call(1, "EURUSD", 0.01, 1.1000)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
class TestMt5Session:
|
|
1257
|
+
"""Tests for the mt5_session context manager."""
|
|
1258
|
+
|
|
1259
|
+
def test_yields_connected_client_and_shuts_down(
|
|
1260
|
+
self,
|
|
1261
|
+
mocker: MockerFixture,
|
|
1262
|
+
) -> None:
|
|
1263
|
+
"""Test mt5_session connects, yields a client wrapper, and shuts down."""
|
|
1264
|
+
mock_client = MagicMock()
|
|
1265
|
+
mt5_data_client = mocker.patch(
|
|
1266
|
+
"mt5cli.sdk.Mt5DataClient",
|
|
1267
|
+
return_value=mock_client,
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
with mt5_session(build_config(path="/opt/mt5/terminal64.exe")) as client:
|
|
1271
|
+
mock_client.initialize_and_login_mt5.assert_called_once()
|
|
1272
|
+
assert isinstance(client, Mt5CliClient)
|
|
1273
|
+
|
|
1274
|
+
config = mt5_data_client.call_args.kwargs["config"]
|
|
1275
|
+
assert config.path == "/opt/mt5/terminal64.exe"
|
|
1276
|
+
mock_client.shutdown.assert_called_once()
|
|
1277
|
+
|
|
1278
|
+
def test_default_config_attaches_to_running_terminal(
|
|
1279
|
+
self,
|
|
1280
|
+
mocker: MockerFixture,
|
|
1281
|
+
) -> None:
|
|
1282
|
+
"""Test mt5_session builds a default config when none is supplied."""
|
|
1283
|
+
mock_client = MagicMock()
|
|
1284
|
+
mt5_data_client = mocker.patch(
|
|
1285
|
+
"mt5cli.sdk.Mt5DataClient",
|
|
1286
|
+
return_value=mock_client,
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
with mt5_session():
|
|
1290
|
+
pass
|
|
1291
|
+
|
|
1292
|
+
mt5_data_client.assert_called_once()
|
|
1293
|
+
mock_client.shutdown.assert_called_once()
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
class TestAccountSpec:
|
|
1297
|
+
"""Tests for account configuration helpers."""
|
|
1298
|
+
|
|
1299
|
+
def test_repr_omits_password(self) -> None:
|
|
1300
|
+
"""Test AccountSpec repr does not expose plaintext passwords."""
|
|
1301
|
+
spec = AccountSpec(symbols=["EURUSD"], login=123, password="secret")
|
|
1302
|
+
|
|
1303
|
+
assert "secret" not in repr(spec)
|
|
1304
|
+
assert "password" not in repr(spec)
|
|
1305
|
+
|
|
1306
|
+
@pytest.mark.parametrize(
|
|
1307
|
+
("login", "expected"),
|
|
1308
|
+
[
|
|
1309
|
+
(None, None),
|
|
1310
|
+
(123, 123),
|
|
1311
|
+
("", None),
|
|
1312
|
+
(" ", None),
|
|
1313
|
+
("456", 456),
|
|
1314
|
+
],
|
|
1315
|
+
)
|
|
1316
|
+
def test_coerce_login(
|
|
1317
|
+
self,
|
|
1318
|
+
login: int | str | None,
|
|
1319
|
+
expected: int | None,
|
|
1320
|
+
) -> None:
|
|
1321
|
+
"""Test login values are normalized for account configs."""
|
|
1322
|
+
assert sdk._coerce_login(login) == expected # type: ignore[reportPrivateUsage]
|
|
1323
|
+
|
|
1324
|
+
def test_coerce_login_rejects_non_numeric_string(self) -> None:
|
|
1325
|
+
"""Test non-numeric login strings raise ValueError."""
|
|
1326
|
+
with pytest.raises(ValueError, match="invalid literal"):
|
|
1327
|
+
sdk._coerce_login("abc") # type: ignore[reportPrivateUsage]
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
class TestCollectLatestRatesForAccounts:
|
|
1331
|
+
"""Tests for collect_latest_rates_for_accounts."""
|
|
1332
|
+
|
|
1333
|
+
def test_merges_results_across_accounts(
|
|
1334
|
+
self,
|
|
1335
|
+
mock_client: MagicMock,
|
|
1336
|
+
mocker: MockerFixture,
|
|
1337
|
+
) -> None:
|
|
1338
|
+
"""Test rates are collected and merged for each account group."""
|
|
1339
|
+
mt5_data_client = mocker.patch(
|
|
1340
|
+
"mt5cli.sdk.Mt5DataClient",
|
|
1341
|
+
return_value=mock_client,
|
|
1342
|
+
)
|
|
1343
|
+
accounts = [
|
|
1344
|
+
AccountSpec(symbols=["EURUSD"], login="123"),
|
|
1345
|
+
AccountSpec(symbols=["GBPUSD"], login=456),
|
|
1346
|
+
]
|
|
1347
|
+
|
|
1348
|
+
result = collect_latest_rates_for_accounts(accounts, ["M1"], count=2)
|
|
1349
|
+
|
|
1350
|
+
assert set(result) == {("EURUSD", 1), ("GBPUSD", 1)}
|
|
1351
|
+
assert mt5_data_client.call_count == 2
|
|
1352
|
+
assert mock_client.initialize_and_login_mt5.call_count == 2
|
|
1353
|
+
assert mock_client.shutdown.call_count == 2
|
|
1354
|
+
|
|
1355
|
+
def test_builds_config_from_account_and_base(
|
|
1356
|
+
self,
|
|
1357
|
+
mock_client: MagicMock,
|
|
1358
|
+
mocker: MockerFixture,
|
|
1359
|
+
) -> None:
|
|
1360
|
+
"""Test account fields override base_config, empty login falls back."""
|
|
1361
|
+
configs: list[object] = []
|
|
1362
|
+
|
|
1363
|
+
def _record_config(*, config: object) -> MagicMock:
|
|
1364
|
+
configs.append(config)
|
|
1365
|
+
return mock_client
|
|
1366
|
+
|
|
1367
|
+
mocker.patch("mt5cli.sdk.Mt5DataClient", side_effect=_record_config)
|
|
1368
|
+
base = build_config(login=999, server="Base-Server", timeout=5000)
|
|
1369
|
+
accounts = [
|
|
1370
|
+
AccountSpec(symbols=["EURUSD"], login="", server="Acct-Server"),
|
|
1371
|
+
]
|
|
1372
|
+
|
|
1373
|
+
collect_latest_rates_for_accounts(accounts, ["M1"], count=1, base_config=base)
|
|
1374
|
+
|
|
1375
|
+
assert len(configs) == 1
|
|
1376
|
+
config = cast("Mt5Config", configs[0])
|
|
1377
|
+
assert config.login == 999
|
|
1378
|
+
assert config.server == "Acct-Server"
|
|
1379
|
+
assert config.timeout == 5000
|
|
1380
|
+
|
|
1381
|
+
@pytest.mark.parametrize(
|
|
1382
|
+
("accounts", "timeframes", "count", "match"),
|
|
1383
|
+
[
|
|
1384
|
+
([], ["M1"], 1, "At least one account"),
|
|
1385
|
+
([AccountSpec(symbols=["EURUSD"])], [], 1, "At least one timeframe"),
|
|
1386
|
+
(
|
|
1387
|
+
[AccountSpec(symbols=[])],
|
|
1388
|
+
["M1"],
|
|
1389
|
+
1,
|
|
1390
|
+
"Each account requires at least one symbol",
|
|
1391
|
+
),
|
|
1392
|
+
(
|
|
1393
|
+
[AccountSpec(symbols=["EURUSD"])],
|
|
1394
|
+
["M1"],
|
|
1395
|
+
0,
|
|
1396
|
+
"count must be positive",
|
|
1397
|
+
),
|
|
1398
|
+
],
|
|
1399
|
+
)
|
|
1400
|
+
def test_rejects_invalid_inputs(
|
|
1401
|
+
self,
|
|
1402
|
+
accounts: list[AccountSpec],
|
|
1403
|
+
timeframes: list[str],
|
|
1404
|
+
count: int,
|
|
1405
|
+
match: str,
|
|
1406
|
+
) -> None:
|
|
1407
|
+
"""Test input validation for account-level rate collection."""
|
|
1408
|
+
with pytest.raises(ValueError, match=match):
|
|
1409
|
+
collect_latest_rates_for_accounts(accounts, timeframes, count)
|
|
1410
|
+
|
|
1411
|
+
def test_rejects_empty_symbols_before_connecting(
|
|
1412
|
+
self,
|
|
1413
|
+
mocker: MockerFixture,
|
|
1414
|
+
) -> None:
|
|
1415
|
+
"""Test all account symbols are validated before any MT5 connection."""
|
|
1416
|
+
mt5_data_client = mocker.patch("mt5cli.sdk.Mt5DataClient")
|
|
1417
|
+
accounts = [
|
|
1418
|
+
AccountSpec(symbols=["EURUSD"], login=123),
|
|
1419
|
+
AccountSpec(symbols=[], login=456),
|
|
1420
|
+
]
|
|
1421
|
+
|
|
1422
|
+
with pytest.raises(
|
|
1423
|
+
ValueError, match="Each account requires at least one symbol"
|
|
1424
|
+
):
|
|
1425
|
+
collect_latest_rates_for_accounts(accounts, ["M1"], count=1)
|
|
1426
|
+
|
|
1427
|
+
mt5_data_client.assert_not_called()
|
|
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
|