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