mt5cli 1.1.5__tar.gz → 1.1.6__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-1.1.5 → mt5cli-1.1.6}/PKG-INFO +2 -2
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/converters.py +1 -1
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/history.py +1 -1
- {mt5cli-1.1.5 → mt5cli-1.1.6}/pyproject.toml +2 -2
- mt5cli-1.1.6/tests/test_client.py +44 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_contracts.py +23 -397
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_examples.py +7 -11
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_grafana.py +0 -13
- mt5cli-1.1.6/tests/test_packaging.py +36 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_telemetry.py +9 -27
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_utils.py +0 -7
- {mt5cli-1.1.5 → mt5cli-1.1.6}/uv.lock +6 -6
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.claude/agents/codex.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.claude/settings.json +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/FUNDING.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/dependabot.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/renovate.json +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/ci.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/claude.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/opencode.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/release.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/.gitignore +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/AGENTS.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/CLAUDE.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/LICENSE +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/README.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/cli.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/client.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/converters.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/exceptions.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/grafana.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/history.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/index.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/public-contract.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/schemas.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/sdk.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/telemetry.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/trading.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/utils.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/index.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/README.md +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/compose.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/dashboards/mt5cli-market.json +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mkdocs.yml +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/__init__.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/__main__.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/cli.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/client.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/contract.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/exceptions.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/grafana.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/retry.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/schemas.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/sdk.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/telemetry.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/trading.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/utils.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/__init__.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/conftest.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_cli.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_history.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_sdk.py +0 -0
- {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_trading.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.6
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -18,7 +18,7 @@ Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
|
18
18
|
Requires-Python: <3.14,>=3.11
|
|
19
19
|
Requires-Dist: click>=8.1.0
|
|
20
20
|
Requires-Dist: pandas>=2.2.2
|
|
21
|
-
Requires-Dist: pdmt5>=1.
|
|
21
|
+
Requires-Dist: pdmt5>=1.2.0
|
|
22
22
|
Requires-Dist: pydantic>=2.13.4
|
|
23
23
|
Requires-Dist: typer>=0.15.0
|
|
24
24
|
Provides-Extra: otel
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import UTC, datetime, timedelta
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
|
8
|
+
from pdmt5.constants import get_timeframe_name as _get_timeframe_name
|
|
9
9
|
|
|
10
10
|
from .utils import parse_datetime, parse_tick_flags, parse_timeframe
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Literal, SupportsInt, cast, overload
|
|
|
13
13
|
|
|
14
14
|
import pandas as pd
|
|
15
15
|
from pdmt5 import Mt5RuntimeError
|
|
16
|
-
from pdmt5 import get_timeframe_name as _get_timeframe_name
|
|
16
|
+
from pdmt5.constants import get_timeframe_name as _get_timeframe_name
|
|
17
17
|
|
|
18
18
|
from .schemas import DEDUP_KEYS, REQUIRED_COLUMNS, DataKind, ensure_utc_columns
|
|
19
19
|
from .utils import (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "1.1.
|
|
3
|
+
version = "1.1.6"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -9,7 +9,7 @@ license-files = ["LICENSE"]
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">= 3.11, < 3.14"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"pdmt5 >= 1.
|
|
12
|
+
"pdmt5 >= 1.2.0",
|
|
13
13
|
"pandas >= 2.2.2",
|
|
14
14
|
"pydantic >= 2.13.4",
|
|
15
15
|
"click >= 8.1.0",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Tests for mt5cli.client module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from mt5cli.client import MT5Client, mt5_session
|
|
8
|
+
from mt5cli.sdk import build_config
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
|
+
|
|
13
|
+
from pytest_mock import MockerFixture
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_order_check_and_order_send_route_through_connected_client(
|
|
17
|
+
mock_client: MagicMock,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""order_check/order_send fetch through the same connected client path."""
|
|
20
|
+
request = {"action": 1}
|
|
21
|
+
client = MT5Client()
|
|
22
|
+
client.order_check(request)
|
|
23
|
+
client.order_send(request)
|
|
24
|
+
mock_client.order_check_as_df.assert_called_once_with(request=request)
|
|
25
|
+
mock_client.order_send_as_df.assert_called_once_with(request=request)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_from_connected_client_binds_without_owning(mock_client: MagicMock) -> None:
|
|
29
|
+
"""from_connected_client wraps an injected client without shutting it down."""
|
|
30
|
+
client = MT5Client.from_connected_client(mock_client)
|
|
31
|
+
with client:
|
|
32
|
+
client.order_check({"action": 1})
|
|
33
|
+
mock_client.shutdown.assert_not_called()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_mt5_session_yields_connected_mt5_client(mocker: MockerFixture) -> None:
|
|
37
|
+
"""Public mt5_session yields an MT5Client bound to a connected session."""
|
|
38
|
+
connected = mocker.MagicMock()
|
|
39
|
+
context = mocker.MagicMock()
|
|
40
|
+
context.__enter__.return_value = connected
|
|
41
|
+
context.__exit__.return_value = False
|
|
42
|
+
mocker.patch("mt5cli.client.connected_client", return_value=context)
|
|
43
|
+
with mt5_session(build_config()) as client:
|
|
44
|
+
assert isinstance(client, MT5Client)
|
|
@@ -2,15 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import importlib
|
|
6
|
-
import sqlite3
|
|
7
5
|
from datetime import UTC, datetime
|
|
8
|
-
from importlib.metadata import requires
|
|
9
|
-
from typing import TYPE_CHECKING, get_type_hints
|
|
10
|
-
from unittest.mock import MagicMock
|
|
11
|
-
|
|
12
|
-
if TYPE_CHECKING:
|
|
13
|
-
from pathlib import Path
|
|
14
6
|
|
|
15
7
|
import pandas as pd
|
|
16
8
|
import pytest
|
|
@@ -20,37 +12,9 @@ from pytest_mock import MockerFixture # noqa: TC002
|
|
|
20
12
|
import mt5cli
|
|
21
13
|
from mt5cli import (
|
|
22
14
|
STABLE_SDK_EXPORTS,
|
|
23
|
-
AccountSpec,
|
|
24
|
-
ExecutionStatus,
|
|
25
|
-
MarginVolume,
|
|
26
|
-
MT5Client,
|
|
27
15
|
Mt5CliError,
|
|
28
16
|
Mt5ConnectionError,
|
|
29
17
|
Mt5SchemaError,
|
|
30
|
-
OrderExecutionResult,
|
|
31
|
-
OrderLimits,
|
|
32
|
-
RateTarget,
|
|
33
|
-
build_config,
|
|
34
|
-
build_rate_targets,
|
|
35
|
-
calculate_account_projected_margin_ratio,
|
|
36
|
-
calculate_margin_and_volume,
|
|
37
|
-
calculate_positions_margin,
|
|
38
|
-
calculate_projected_margin_ratio,
|
|
39
|
-
calculate_symbol_group_margin_ratio,
|
|
40
|
-
calculate_trailing_stop_updates,
|
|
41
|
-
drop_forming_rate_bar,
|
|
42
|
-
ensure_symbol_selected,
|
|
43
|
-
extract_tick_price,
|
|
44
|
-
fetch_latest_closed_rates,
|
|
45
|
-
fetch_latest_closed_rates_for_trading_client,
|
|
46
|
-
fetch_latest_closed_rates_indexed,
|
|
47
|
-
load_rate_series_from_sqlite,
|
|
48
|
-
mt5_session,
|
|
49
|
-
mt5_trading_session,
|
|
50
|
-
normalize_order_volume,
|
|
51
|
-
place_market_order,
|
|
52
|
-
resolve_account_spec,
|
|
53
|
-
resolve_account_specs,
|
|
54
18
|
)
|
|
55
19
|
from mt5cli.converters import (
|
|
56
20
|
ensure_utc,
|
|
@@ -65,11 +29,6 @@ from mt5cli.exceptions import (
|
|
|
65
29
|
is_recoverable_mt5_error,
|
|
66
30
|
normalize_mt5_exception,
|
|
67
31
|
)
|
|
68
|
-
from mt5cli.history import (
|
|
69
|
-
create_rate_compatibility_views,
|
|
70
|
-
load_rate_data,
|
|
71
|
-
resolve_rate_view_name,
|
|
72
|
-
)
|
|
73
32
|
from mt5cli.retry import retry_with_backoff
|
|
74
33
|
from mt5cli.schemas import (
|
|
75
34
|
DEDUP_KEYS,
|
|
@@ -82,12 +41,6 @@ from mt5cli.schemas import (
|
|
|
82
41
|
schema_columns,
|
|
83
42
|
validate_schema,
|
|
84
43
|
)
|
|
85
|
-
from mt5cli.utils import (
|
|
86
|
-
Dataset,
|
|
87
|
-
detect_format,
|
|
88
|
-
export_dataframe,
|
|
89
|
-
export_dataframe_to_sqlite,
|
|
90
|
-
)
|
|
91
44
|
|
|
92
45
|
_SAMPLE_FRAME_COLUMNS: dict[DataKind, dict[str, list[object]]] = {
|
|
93
46
|
DataKind.rates: {
|
|
@@ -198,13 +151,6 @@ def test_validate_schema_raises_for_missing_columns() -> None:
|
|
|
198
151
|
validate_schema(pd.DataFrame({"time": [1]}), DataKind.rates)
|
|
199
152
|
|
|
200
153
|
|
|
201
|
-
def test_history_dedup_keys_match_schema_contract() -> None:
|
|
202
|
-
"""SQLite history dedup keys stay aligned with schema contracts."""
|
|
203
|
-
assert DEDUP_KEYS[DataKind.rates][0] == ("symbol", "timeframe", "time")
|
|
204
|
-
assert DEDUP_KEYS[DataKind.ticks][0] == ("symbol", "time_msc")
|
|
205
|
-
assert Dataset.rates.table_name == "rates"
|
|
206
|
-
|
|
207
|
-
|
|
208
154
|
@pytest.mark.parametrize(
|
|
209
155
|
("raw", "expected"),
|
|
210
156
|
[
|
|
@@ -308,40 +254,6 @@ def test_retry_with_backoff_retries_recoverable_errors(
|
|
|
308
254
|
assert calls["count"] == 2
|
|
309
255
|
|
|
310
256
|
|
|
311
|
-
def test_public_api_exports_mt5_client() -> None:
|
|
312
|
-
"""MT5Client is the primary importable client abstraction."""
|
|
313
|
-
client = MT5Client(config=build_config())
|
|
314
|
-
assert isinstance(client, MT5Client)
|
|
315
|
-
assert isinstance(client, MT5Client.__mro__[1])
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def test_mt5_client_order_primitives_use_connected_client(
|
|
319
|
-
mock_client: object,
|
|
320
|
-
) -> None:
|
|
321
|
-
"""Order check/send route through the same client fetch path as exports."""
|
|
322
|
-
request = {"action": 1}
|
|
323
|
-
client = MT5Client()
|
|
324
|
-
client.order_check(request)
|
|
325
|
-
client.order_send(request)
|
|
326
|
-
assert mock_client.order_check_as_df.call_count == 1 # type: ignore[attr-defined]
|
|
327
|
-
assert mock_client.order_send_as_df.call_count == 1 # type: ignore[attr-defined]
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def test_storage_export_round_trip_csv(tmp_path: Path) -> None:
|
|
331
|
-
"""Storage helpers export normalized rate frames to CSV."""
|
|
332
|
-
frame = normalize_dataframe(
|
|
333
|
-
_sample_frame(DataKind.rates),
|
|
334
|
-
DataKind.rates,
|
|
335
|
-
symbol="EURUSD",
|
|
336
|
-
timeframe="M1",
|
|
337
|
-
)
|
|
338
|
-
output = tmp_path / "rates.csv"
|
|
339
|
-
export_dataframe(frame, output, detect_format(output))
|
|
340
|
-
loaded = pd.read_csv(output)
|
|
341
|
-
assert len(loaded) == 1
|
|
342
|
-
assert "close" in loaded.columns
|
|
343
|
-
|
|
344
|
-
|
|
345
257
|
def test_normalize_symbol_rejects_empty_value() -> None:
|
|
346
258
|
"""Empty symbols are rejected after trimming."""
|
|
347
259
|
with pytest.raises(ValueError, match="must not be empty"):
|
|
@@ -408,6 +320,18 @@ def test_schema_columns_and_extra_required_validation() -> None:
|
|
|
408
320
|
validate_schema(frame, DataKind.rates, extra_required=["storage_symbol"])
|
|
409
321
|
|
|
410
322
|
|
|
323
|
+
def test_dedup_keys_match_storage_deduplication_contract() -> None:
|
|
324
|
+
"""SQLite history dedup keys stay aligned with the documented schema contract."""
|
|
325
|
+
assert DEDUP_KEYS[DataKind.rates][0] == ("symbol", "timeframe", "time")
|
|
326
|
+
assert DEDUP_KEYS[DataKind.ticks][0] == ("symbol", "time_msc")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_time_columns_include_optional_order_fields() -> None:
|
|
330
|
+
"""Schema contracts document optional MT5 time columns per dataset kind."""
|
|
331
|
+
assert "time_done" in TIME_COLUMNS[DataKind.orders]
|
|
332
|
+
assert "time_setup_msc" in TIME_COLUMNS[DataKind.history_orders]
|
|
333
|
+
|
|
334
|
+
|
|
411
335
|
def test_normalize_dataframe_empty_and_tick_sort_paths() -> None:
|
|
412
336
|
"""Normalization handles empty frames and tick time_msc sorting."""
|
|
413
337
|
empty = pd.DataFrame()
|
|
@@ -492,12 +416,6 @@ def test_normalize_time_columns_handles_optional_order_times() -> None:
|
|
|
492
416
|
)
|
|
493
417
|
|
|
494
418
|
|
|
495
|
-
def test_time_columns_include_optional_order_fields() -> None:
|
|
496
|
-
"""Schema contracts document optional MT5 time columns per dataset kind."""
|
|
497
|
-
assert "time_done" in TIME_COLUMNS[DataKind.orders]
|
|
498
|
-
assert "time_setup_msc" in TIME_COLUMNS[DataKind.history_orders]
|
|
499
|
-
|
|
500
|
-
|
|
501
419
|
def test_normalize_dataframe_sorts_ticks_by_time_msc(
|
|
502
420
|
mocker: MockerFixture,
|
|
503
421
|
) -> None:
|
|
@@ -527,17 +445,6 @@ def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
|
|
|
527
445
|
assert result.loc[0, "created_at"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
|
528
446
|
|
|
529
447
|
|
|
530
|
-
def test_mt5_session_yields_connected_client(mocker: MockerFixture) -> None:
|
|
531
|
-
"""Public mt5_session yields an MT5Client bound to a connected session."""
|
|
532
|
-
connected = mocker.MagicMock()
|
|
533
|
-
context = mocker.MagicMock()
|
|
534
|
-
context.__enter__.return_value = connected
|
|
535
|
-
context.__exit__.return_value = False
|
|
536
|
-
mocker.patch("mt5cli.client.connected_client", return_value=context)
|
|
537
|
-
with mt5_session(build_config()) as client:
|
|
538
|
-
assert isinstance(client, MT5Client)
|
|
539
|
-
|
|
540
|
-
|
|
541
448
|
def test_retry_with_backoff_reraises_non_recoverable_errors() -> None:
|
|
542
449
|
"""Non-MT5 errors are not retried."""
|
|
543
450
|
|
|
@@ -549,40 +456,9 @@ def test_retry_with_backoff_reraises_non_recoverable_errors() -> None:
|
|
|
549
456
|
retry_with_backoff(_raise, retry_count=2)
|
|
550
457
|
|
|
551
458
|
|
|
552
|
-
def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None:
|
|
553
|
-
"""Storage helpers append deduplicated frames to SQLite."""
|
|
554
|
-
frame = normalize_dataframe(
|
|
555
|
-
_sample_frame(DataKind.rates),
|
|
556
|
-
DataKind.rates,
|
|
557
|
-
symbol="EURUSD",
|
|
558
|
-
timeframe="M1",
|
|
559
|
-
)
|
|
560
|
-
output = tmp_path / "rates.db"
|
|
561
|
-
export_dataframe_to_sqlite(
|
|
562
|
-
frame,
|
|
563
|
-
output,
|
|
564
|
-
"rates",
|
|
565
|
-
deduplicate_on=DEDUP_KEYS[DataKind.rates][0],
|
|
566
|
-
)
|
|
567
|
-
with __import__("sqlite3").connect(output) as conn:
|
|
568
|
-
count = conn.execute("SELECT COUNT(*) FROM rates").fetchone()[0]
|
|
569
|
-
assert count == 1
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def test_storage_module_does_not_exist() -> None:
|
|
573
|
-
"""mt5cli.storage re-export module has been removed."""
|
|
574
|
-
with pytest.raises(ModuleNotFoundError):
|
|
575
|
-
importlib.import_module("mt5cli.storage")
|
|
576
|
-
|
|
577
|
-
|
|
578
459
|
class TestStableSdkContract:
|
|
579
460
|
"""Tests for the documented stable downstream SDK contract."""
|
|
580
461
|
|
|
581
|
-
def test_stable_exports_are_subset_of_all(self) -> None:
|
|
582
|
-
"""Every stable export is also listed in the package __all__."""
|
|
583
|
-
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
|
584
|
-
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
|
585
|
-
|
|
586
462
|
def test_stable_exports_cover_root_api(self) -> None:
|
|
587
463
|
"""STABLE_SDK_EXPORTS classifies every package-root symbol."""
|
|
588
464
|
tier_metadata = {"STABLE_SDK_EXPORTS"}
|
|
@@ -603,277 +479,27 @@ class TestStableSdkContract:
|
|
|
603
479
|
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
|
604
480
|
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
|
605
481
|
|
|
606
|
-
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
|
607
|
-
"""Closed-bar trimming is available from the stable package surface."""
|
|
608
|
-
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
|
609
|
-
closed = drop_forming_rate_bar(frame)
|
|
610
|
-
assert list(closed["close"]) == [1.0, 1.1]
|
|
611
|
-
assert len(closed) == 2
|
|
612
|
-
|
|
613
|
-
def test_fetch_latest_closed_rates_from_package_root(self) -> None:
|
|
614
|
-
"""Single-client closed-bar helper drops the forming row."""
|
|
615
|
-
client = MagicMock()
|
|
616
|
-
client.latest_rates.return_value = pd.DataFrame(
|
|
617
|
-
{"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]},
|
|
618
|
-
)
|
|
619
|
-
|
|
620
|
-
result = fetch_latest_closed_rates(
|
|
621
|
-
client,
|
|
622
|
-
symbol="EURUSD",
|
|
623
|
-
granularity="M1",
|
|
624
|
-
count=2,
|
|
625
|
-
)
|
|
626
|
-
|
|
627
|
-
client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
|
|
628
|
-
assert list(result["close"]) == [1.0, 1.1]
|
|
629
|
-
|
|
630
|
-
def test_fetch_latest_closed_rates_for_trading_client_from_package_root(
|
|
631
|
-
self,
|
|
632
|
-
) -> None:
|
|
633
|
-
"""Trading-client closed-bar helper is importable from the stable surface."""
|
|
634
|
-
client = MagicMock()
|
|
635
|
-
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
|
636
|
-
{"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]},
|
|
637
|
-
)
|
|
638
|
-
|
|
639
|
-
result = fetch_latest_closed_rates_for_trading_client(
|
|
640
|
-
client,
|
|
641
|
-
symbol="EURUSD",
|
|
642
|
-
granularity="M1",
|
|
643
|
-
count=2,
|
|
644
|
-
)
|
|
645
|
-
|
|
646
|
-
assert list(result["close"]) == [1.0, 1.1]
|
|
647
|
-
|
|
648
|
-
def test_normalize_order_volume_from_package_root(self) -> None:
|
|
649
|
-
"""Volume normalization helper is importable from the stable surface."""
|
|
650
|
-
result = normalize_order_volume(
|
|
651
|
-
0.25,
|
|
652
|
-
volume_min=0.1,
|
|
653
|
-
volume_max=1.0,
|
|
654
|
-
volume_step=0.1,
|
|
655
|
-
)
|
|
656
|
-
assert abs(result - 0.2) < 1e-9
|
|
657
|
-
|
|
658
|
-
def test_calculate_positions_margin_from_package_root(self) -> None:
|
|
659
|
-
"""Position margin helper is importable from the stable surface."""
|
|
660
|
-
client = MagicMock()
|
|
661
|
-
client.mt5.POSITION_TYPE_BUY = 0
|
|
662
|
-
client.mt5.POSITION_TYPE_SELL = 1
|
|
663
|
-
client.mt5.ORDER_TYPE_BUY = 10
|
|
664
|
-
client.mt5.ORDER_TYPE_SELL = 11
|
|
665
|
-
client.positions_get_as_df.return_value = pd.DataFrame()
|
|
666
|
-
|
|
667
|
-
assert calculate_positions_margin(client) == 0
|
|
668
|
-
|
|
669
|
-
def test_generic_trading_helpers_from_package_root(self) -> None:
|
|
670
|
-
"""New generic trading helpers resolve through the stable surface."""
|
|
671
|
-
price = extract_tick_price({"bid": "1.2"}, "bid")
|
|
672
|
-
assert price is not None
|
|
673
|
-
assert abs(price - 1.2) < 1e-9
|
|
674
|
-
assert callable(calculate_trailing_stop_updates)
|
|
675
|
-
assert callable(calculate_account_projected_margin_ratio)
|
|
676
|
-
assert callable(calculate_projected_margin_ratio)
|
|
677
|
-
assert callable(calculate_symbol_group_margin_ratio)
|
|
678
|
-
|
|
679
|
-
def test_load_rate_series_from_sqlite_requires_managed_views(
|
|
680
|
-
self,
|
|
681
|
-
tmp_path: Path,
|
|
682
|
-
) -> None:
|
|
683
|
-
"""Multi-series loading fails clearly when managed views are absent."""
|
|
684
|
-
db_path = tmp_path / "empty-views.db"
|
|
685
|
-
with sqlite3.connect(db_path) as conn:
|
|
686
|
-
conn.execute(
|
|
687
|
-
"CREATE TABLE rates("
|
|
688
|
-
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
689
|
-
)
|
|
690
|
-
|
|
691
|
-
targets = build_rate_targets(["EURUSD"], ["M1"])
|
|
692
|
-
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
|
693
|
-
load_rate_series_from_sqlite(db_path, targets, count=10)
|
|
694
|
-
|
|
695
|
-
assert targets == [RateTarget(symbol="EURUSD", timeframe=1)]
|
|
696
|
-
|
|
697
|
-
def test_resolve_account_spec_from_package_root(
|
|
698
|
-
self,
|
|
699
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
700
|
-
) -> None:
|
|
701
|
-
"""Account credential resolution uses generic ${ENV_VAR} placeholders."""
|
|
702
|
-
monkeypatch.setenv("APP_MT5_LOGIN", "555")
|
|
703
|
-
monkeypatch.setenv("APP_MT5_PASSWORD", "secret")
|
|
704
|
-
account = AccountSpec(
|
|
705
|
-
symbols=["EURUSD"],
|
|
706
|
-
login="${APP_MT5_LOGIN}",
|
|
707
|
-
password="${APP_MT5_PASSWORD}",
|
|
708
|
-
server="Broker-Demo",
|
|
709
|
-
)
|
|
710
|
-
|
|
711
|
-
resolved = resolve_account_spec(account, timeout=3000)
|
|
712
|
-
assert resolved.login == "555"
|
|
713
|
-
assert resolved.password == "secret" # noqa: S105
|
|
714
|
-
assert resolved.timeout == 3000
|
|
715
|
-
|
|
716
|
-
batch = resolve_account_specs([account], server="Override")
|
|
717
|
-
assert batch[0].server == "Override"
|
|
718
|
-
|
|
719
|
-
def test_mt5_trading_session_lifecycle_from_package_root(
|
|
720
|
-
self,
|
|
721
|
-
mocker: MockerFixture,
|
|
722
|
-
) -> None:
|
|
723
|
-
"""Trading session helper initializes and always shuts down."""
|
|
724
|
-
mock_client = MagicMock()
|
|
725
|
-
mocker.patch(
|
|
726
|
-
"mt5cli.trading.Mt5DataClient",
|
|
727
|
-
return_value=mock_client,
|
|
728
|
-
)
|
|
729
|
-
|
|
730
|
-
with mt5_trading_session(login=12345, server="Broker-Demo") as client:
|
|
731
|
-
assert client is mock_client
|
|
732
|
-
mock_client.initialize_and_login_mt5.assert_called_once()
|
|
733
|
-
|
|
734
|
-
mock_client.shutdown.assert_called_once()
|
|
735
|
-
|
|
736
|
-
def test_trading_order_helpers_importable_from_package_root(self) -> None:
|
|
737
|
-
"""Order planning helpers resolve through the stable package surface."""
|
|
738
|
-
assert callable(calculate_margin_and_volume)
|
|
739
|
-
assert callable(ensure_symbol_selected)
|
|
740
|
-
assert callable(place_market_order)
|
|
741
|
-
margin_hints = get_type_hints(MarginVolume)
|
|
742
|
-
limits_hints = get_type_hints(OrderLimits)
|
|
743
|
-
execution_hints = get_type_hints(OrderExecutionResult)
|
|
744
|
-
assert margin_hints["buy_volume"] is float
|
|
745
|
-
assert limits_hints["stop_loss"] == float | None
|
|
746
|
-
assert execution_hints["status"] == ExecutionStatus
|
|
747
|
-
|
|
748
|
-
def test_mt5_trading_session_shuts_down_on_exception(
|
|
749
|
-
self,
|
|
750
|
-
mocker: MockerFixture,
|
|
751
|
-
) -> None:
|
|
752
|
-
"""Trading session helper shuts down even when the body raises."""
|
|
753
|
-
mock_client = MagicMock()
|
|
754
|
-
mocker.patch(
|
|
755
|
-
"mt5cli.trading.Mt5DataClient",
|
|
756
|
-
return_value=mock_client,
|
|
757
|
-
)
|
|
758
|
-
|
|
759
|
-
message = "strategy error"
|
|
760
|
-
with (
|
|
761
|
-
pytest.raises(RuntimeError, match=message),
|
|
762
|
-
mt5_trading_session(login=12345, server="Broker-Demo"),
|
|
763
|
-
):
|
|
764
|
-
raise RuntimeError(message)
|
|
765
|
-
|
|
766
|
-
mock_client.shutdown.assert_called_once()
|
|
767
|
-
|
|
768
|
-
def test_fetch_latest_closed_rates_indexed_from_package_root(
|
|
769
|
-
self,
|
|
770
|
-
mocker: MockerFixture,
|
|
771
|
-
) -> None:
|
|
772
|
-
"""Indexed closed-bar helper returns a UTC DatetimeIndex named 'time'."""
|
|
773
|
-
client = MagicMock()
|
|
774
|
-
mocker.patch(
|
|
775
|
-
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
|
776
|
-
return_value=pd.DataFrame(
|
|
777
|
-
{
|
|
778
|
-
"time": [1704067200, 1704153600, 1704240000],
|
|
779
|
-
"close": [1.0, 1.1, 1.2],
|
|
780
|
-
},
|
|
781
|
-
),
|
|
782
|
-
)
|
|
783
|
-
|
|
784
|
-
result = fetch_latest_closed_rates_indexed(
|
|
785
|
-
client,
|
|
786
|
-
symbol="EURUSD",
|
|
787
|
-
granularity="M1",
|
|
788
|
-
count=2,
|
|
789
|
-
)
|
|
790
|
-
|
|
791
|
-
assert isinstance(result.index, pd.DatetimeIndex)
|
|
792
|
-
assert result.index.name == "time"
|
|
793
|
-
assert result.index.tz is not None
|
|
794
|
-
assert "time" not in result.columns
|
|
795
|
-
assert "close" in result.columns
|
|
796
|
-
|
|
797
|
-
def test_rate_view_helpers_in_history_module(self, tmp_path: Path) -> None:
|
|
798
|
-
"""Rate view helpers are available from mt5cli.history."""
|
|
799
|
-
db_path = tmp_path / "rates.db"
|
|
800
|
-
with sqlite3.connect(db_path) as conn:
|
|
801
|
-
conn.execute(
|
|
802
|
-
"CREATE TABLE rates("
|
|
803
|
-
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
|
804
|
-
)
|
|
805
|
-
conn.execute(
|
|
806
|
-
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
|
807
|
-
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
|
808
|
-
)
|
|
809
|
-
create_rate_compatibility_views(conn)
|
|
810
|
-
|
|
811
|
-
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
|
812
|
-
missing = tmp_path / "missing.db"
|
|
813
|
-
with pytest.raises(ValueError, match="SQLite database not found"):
|
|
814
|
-
resolve_rate_view_name(missing, "EURUSD", "M1", require_existing=True)
|
|
815
|
-
|
|
816
|
-
def test_load_rate_data_in_history_module(self, tmp_path: Path) -> None:
|
|
817
|
-
"""SQLite rate loading normalizes timestamps through mt5cli.history."""
|
|
818
|
-
db_path = tmp_path / "view.db"
|
|
819
|
-
with sqlite3.connect(db_path) as conn:
|
|
820
|
-
conn.execute(
|
|
821
|
-
'CREATE VIEW "rate_EURUSD__1" AS'
|
|
822
|
-
" SELECT '2024-01-01T00:00:00+00:00' AS time, 1.1 AS close",
|
|
823
|
-
)
|
|
824
|
-
|
|
825
|
-
frame = load_rate_data(db_path, "rate_EURUSD__1")
|
|
826
|
-
assert frame.index.name == "time"
|
|
827
|
-
assert abs(float(frame.iloc[0]["close"]) - 1.1) < 1e-9
|
|
828
|
-
|
|
829
482
|
|
|
830
483
|
@pytest.mark.parametrize(
|
|
831
484
|
"name",
|
|
832
485
|
[
|
|
833
486
|
"Mt5Config",
|
|
834
487
|
"Mt5RuntimeError",
|
|
835
|
-
"
|
|
836
|
-
"Mt5TradingError",
|
|
837
|
-
"TICK_FLAG_MAP",
|
|
488
|
+
"Mt5DataClient",
|
|
838
489
|
"TIMEFRAME_MAP",
|
|
490
|
+
"COPY_TICKS_MAP",
|
|
491
|
+
"ORDER_TYPE_MAP",
|
|
839
492
|
],
|
|
840
493
|
)
|
|
841
|
-
def
|
|
842
|
-
"""
|
|
494
|
+
def test_pdmt5_pass_through_names_stay_out_of_public_contract(name: str) -> None:
|
|
495
|
+
"""Downstream code must import pdmt5 types/constants directly from pdmt5.
|
|
496
|
+
|
|
497
|
+
docs/api/public-contract.md documents that mt5cli is not a pass-through
|
|
498
|
+
compatibility namespace for pdmt5; these names must never reappear on the
|
|
499
|
+
mt5cli package root.
|
|
500
|
+
"""
|
|
843
501
|
assert name not in STABLE_SDK_EXPORTS, (
|
|
844
502
|
f"{name!r} should not be in STABLE_SDK_EXPORTS"
|
|
845
503
|
)
|
|
846
504
|
assert name not in mt5cli.__all__, f"{name!r} should not be in mt5cli.__all__"
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
def test_mt5cli_does_not_import_high_level_trading_symbols() -> None:
|
|
850
|
-
"""mt5cli doesn't import Mt5TradingClient or Mt5TradingError at module level."""
|
|
851
|
-
trading_module = importlib.import_module("mt5cli.trading")
|
|
852
|
-
module_dict = vars(trading_module)
|
|
853
|
-
assert "Mt5TradingClient" not in module_dict, (
|
|
854
|
-
"mt5cli.trading should not import Mt5TradingClient at module level"
|
|
855
|
-
)
|
|
856
|
-
assert "Mt5TradingError" not in module_dict, (
|
|
857
|
-
"mt5cli.trading should not import Mt5TradingError at module level"
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
# ---------------------------------------------------------------------------
|
|
862
|
-
# Packaging metadata
|
|
863
|
-
# ---------------------------------------------------------------------------
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
def test_parquet_extra_declares_pyarrow() -> None:
|
|
867
|
-
"""Package metadata lists pyarrow under the parquet optional extra."""
|
|
868
|
-
reqs = requires("mt5cli") or []
|
|
869
|
-
parquet_reqs = [r for r in reqs if "pyarrow" in r and "parquet" in r]
|
|
870
|
-
assert parquet_reqs, "pyarrow not found in parquet optional extra"
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
def test_pyarrow_not_in_core_dependencies() -> None:
|
|
874
|
-
"""Pyarrow is not a core dependency; it belongs only in the parquet extra."""
|
|
875
|
-
reqs = requires("mt5cli") or []
|
|
876
|
-
core_reqs = [r for r in reqs if "extra ==" not in r]
|
|
877
|
-
assert not any("pyarrow" in r for r in core_reqs), (
|
|
878
|
-
"pyarrow should not appear in core dependencies"
|
|
879
|
-
)
|
|
505
|
+
assert not hasattr(mt5cli, name), f"{name!r} should not be on the mt5cli root"
|
|
@@ -29,10 +29,6 @@ def dashboard_path(request: pytest.FixtureRequest) -> Iterator[Path]:
|
|
|
29
29
|
class TestGrafanaExamples:
|
|
30
30
|
"""Validate structure and content of bundled Grafana example files."""
|
|
31
31
|
|
|
32
|
-
def test_dashboard_json_files_are_present(self) -> None:
|
|
33
|
-
"""At least one dashboard JSON file is bundled."""
|
|
34
|
-
assert _dashboard_json_files(), "No dashboard JSON files found"
|
|
35
|
-
|
|
36
32
|
def test_dashboard_json_file_is_valid_json(self, dashboard_path: Path) -> None:
|
|
37
33
|
"""Each dashboard JSON file parses to a JSON object."""
|
|
38
34
|
content = dashboard_path.read_text(encoding="utf-8")
|
|
@@ -41,6 +37,13 @@ class TestGrafanaExamples:
|
|
|
41
37
|
f"{dashboard_path.name} root must be a JSON object"
|
|
42
38
|
)
|
|
43
39
|
|
|
40
|
+
def test_dashboard_json_uses_grafana_views(self, dashboard_path: Path) -> None:
|
|
41
|
+
"""Each dashboard JSON file queries grafana_* views."""
|
|
42
|
+
content = dashboard_path.read_text(encoding="utf-8")
|
|
43
|
+
assert "grafana_" in content, (
|
|
44
|
+
f"{dashboard_path.name} must contain queries against grafana_* views"
|
|
45
|
+
)
|
|
46
|
+
|
|
44
47
|
@pytest.mark.parametrize("private_pattern", ["password", "api_key", "apikey"])
|
|
45
48
|
def test_dashboard_json_has_no_private_placeholders(
|
|
46
49
|
self,
|
|
@@ -53,13 +56,6 @@ class TestGrafanaExamples:
|
|
|
53
56
|
f"{dashboard_path.name} contains {private_pattern!r}"
|
|
54
57
|
)
|
|
55
58
|
|
|
56
|
-
def test_dashboard_json_uses_grafana_views(self, dashboard_path: Path) -> None:
|
|
57
|
-
"""Each dashboard JSON file queries grafana_* views."""
|
|
58
|
-
content = dashboard_path.read_text(encoding="utf-8")
|
|
59
|
-
assert "grafana_" in content, (
|
|
60
|
-
f"{dashboard_path.name} must contain queries against grafana_* views"
|
|
61
|
-
)
|
|
62
|
-
|
|
63
59
|
@pytest.mark.parametrize("field", ["uid", "title"])
|
|
64
60
|
def test_dashboard_json_has_required_field(
|
|
65
61
|
self,
|
|
@@ -127,19 +127,6 @@ class TestSnapshotTables:
|
|
|
127
127
|
class TestCreateViewSafe:
|
|
128
128
|
"""Tests for _create_view_safe."""
|
|
129
129
|
|
|
130
|
-
def test_creates_view_successfully(self, conn: sqlite3.Connection) -> None:
|
|
131
|
-
"""A valid select SQL creates the named view."""
|
|
132
|
-
_create_view_safe(conn, "test_view", "SELECT 1 AS val")
|
|
133
|
-
views = _get_names(conn, "view")
|
|
134
|
-
assert "test_view" in views
|
|
135
|
-
|
|
136
|
-
def test_replaces_existing_view(self, conn: sqlite3.Connection) -> None:
|
|
137
|
-
"""Calling again with a new SQL replaces the existing view."""
|
|
138
|
-
_create_view_safe(conn, "test_view", "SELECT 1 AS val")
|
|
139
|
-
_create_view_safe(conn, "test_view", "SELECT 2 AS val")
|
|
140
|
-
result = conn.execute("SELECT val FROM test_view").fetchone()
|
|
141
|
-
assert result == (2,)
|
|
142
|
-
|
|
143
130
|
def test_logs_warning_on_sqlite_error(
|
|
144
131
|
self,
|
|
145
132
|
caplog: pytest.LogCaptureFixture,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Packaging metadata invariants for mt5cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import requires
|
|
6
|
+
|
|
7
|
+
from packaging.requirements import Requirement
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _mt5cli_requirements() -> list[Requirement]:
|
|
11
|
+
reqs = requires("mt5cli") or []
|
|
12
|
+
return [Requirement(r) for r in reqs]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_parquet_extra_declares_pyarrow() -> None:
|
|
16
|
+
"""Package metadata lists pyarrow under the parquet optional extra."""
|
|
17
|
+
parquet_reqs = [
|
|
18
|
+
req
|
|
19
|
+
for req in _mt5cli_requirements()
|
|
20
|
+
if req.name == "pyarrow"
|
|
21
|
+
and req.marker is not None
|
|
22
|
+
and 'extra == "parquet"' in str(req.marker)
|
|
23
|
+
]
|
|
24
|
+
assert parquet_reqs, "pyarrow not found in parquet optional extra"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_pyarrow_not_in_core_dependencies() -> None:
|
|
28
|
+
"""Pyarrow is not a core dependency; it belongs only in the parquet extra."""
|
|
29
|
+
core_reqs = [
|
|
30
|
+
req
|
|
31
|
+
for req in _mt5cli_requirements()
|
|
32
|
+
if req.marker is None or "extra ==" not in str(req.marker)
|
|
33
|
+
]
|
|
34
|
+
assert not any(req.name == "pyarrow" for req in core_reqs), (
|
|
35
|
+
"pyarrow should not appear in core dependencies"
|
|
36
|
+
)
|
|
@@ -6,29 +6,15 @@ from unittest.mock import MagicMock
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
|
9
|
+
from pytest_mock import MockerFixture # noqa: TC002
|
|
9
10
|
|
|
10
11
|
from mt5cli.telemetry import (
|
|
11
|
-
_OTEL_AVAILABLE, # type: ignore[reportPrivateUsage]
|
|
12
12
|
_Mt5Metrics, # type: ignore[reportPrivateUsage]
|
|
13
|
-
_NoOp, # type: ignore[reportPrivateUsage]
|
|
14
13
|
configure_metrics,
|
|
15
14
|
enable_otel_metrics,
|
|
16
|
-
get_metrics,
|
|
17
15
|
)
|
|
18
16
|
|
|
19
17
|
|
|
20
|
-
class TestNoOp:
|
|
21
|
-
"""Tests for _NoOp no-op instrument."""
|
|
22
|
-
|
|
23
|
-
@pytest.mark.parametrize("method", ["add", "set", "record"])
|
|
24
|
-
def test_method_is_noop(self, method: str) -> None:
|
|
25
|
-
"""_NoOp methods accept amount and optional attributes without error."""
|
|
26
|
-
noop = _NoOp()
|
|
27
|
-
bound = getattr(noop, method)
|
|
28
|
-
bound(1.0)
|
|
29
|
-
bound(1.0, {"key": "val"})
|
|
30
|
-
|
|
31
|
-
|
|
32
18
|
class TestMt5Metrics:
|
|
33
19
|
"""Tests for _Mt5Metrics."""
|
|
34
20
|
|
|
@@ -244,17 +230,17 @@ class TestMt5Metrics:
|
|
|
244
230
|
|
|
245
231
|
|
|
246
232
|
class TestConfigureMetrics:
|
|
247
|
-
"""Tests for configure_metrics
|
|
233
|
+
"""Tests for the module-level configure_metrics wiring function."""
|
|
248
234
|
|
|
249
|
-
def
|
|
250
|
-
|
|
235
|
+
def test_configure_metrics_delegates_to_global_registry(
|
|
236
|
+
self,
|
|
237
|
+
mocker: MockerFixture,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""configure_metrics(meter) delegates exactly once to the registry."""
|
|
251
240
|
meter = MagicMock()
|
|
241
|
+
configure_spy = mocker.patch("mt5cli.telemetry._metrics.configure")
|
|
252
242
|
configure_metrics(meter)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def test_get_metrics_returns_mt5metrics(self) -> None:
|
|
256
|
-
"""get_metrics returns the global _Mt5Metrics instance."""
|
|
257
|
-
assert isinstance(get_metrics(), _Mt5Metrics)
|
|
243
|
+
configure_spy.assert_called_once_with(meter)
|
|
258
244
|
|
|
259
245
|
|
|
260
246
|
class TestEnableOtelMetrics:
|
|
@@ -306,7 +292,3 @@ class TestEnableOtelMetrics:
|
|
|
306
292
|
monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", None)
|
|
307
293
|
with pytest.raises(ImportError, match="opentelemetry-exporter-otlp-proto-http"):
|
|
308
294
|
enable_otel_metrics()
|
|
309
|
-
|
|
310
|
-
def test_otel_available_flag_is_bool(self) -> None:
|
|
311
|
-
"""_OTEL_AVAILABLE is a boolean."""
|
|
312
|
-
assert isinstance(_OTEL_AVAILABLE, bool)
|
|
@@ -11,8 +11,6 @@ from typing import TYPE_CHECKING
|
|
|
11
11
|
import pandas as pd
|
|
12
12
|
import pytest
|
|
13
13
|
|
|
14
|
-
import mt5cli.utils
|
|
15
|
-
|
|
16
14
|
if TYPE_CHECKING:
|
|
17
15
|
from pathlib import Path
|
|
18
16
|
|
|
@@ -397,11 +395,6 @@ class TestParseRequest:
|
|
|
397
395
|
class TestConstants:
|
|
398
396
|
"""Tests for module constants."""
|
|
399
397
|
|
|
400
|
-
@pytest.mark.parametrize("name", ["TIMEFRAME_MAP", "TICK_FLAG_MAP"])
|
|
401
|
-
def test_private_maps_absent_from_utils(self, name: str) -> None:
|
|
402
|
-
"""Private pdmt5 maps are not exposed as public mt5cli.utils attributes."""
|
|
403
|
-
assert not hasattr(mt5cli.utils, name)
|
|
404
|
-
|
|
405
398
|
@pytest.mark.parametrize(
|
|
406
399
|
("dataset", "expected"),
|
|
407
400
|
[
|
|
@@ -370,7 +370,7 @@ name = "metatrader5"
|
|
|
370
370
|
version = "5.0.5735"
|
|
371
371
|
source = { registry = "https://pypi.org/simple" }
|
|
372
372
|
dependencies = [
|
|
373
|
-
{ name = "numpy" },
|
|
373
|
+
{ name = "numpy", marker = "sys_platform == 'win32'" },
|
|
374
374
|
]
|
|
375
375
|
wheels = [
|
|
376
376
|
{ url = "https://files.pythonhosted.org/packages/07/0b/3dd5143f14319dca393a3bcf11ddf24f36f16dfb8c6d1bf2b193ce0c5733/metatrader5-5.0.5735-cp311-cp311-win_amd64.whl", hash = "sha256:0d05b69cef5eb3f43ea47cb6d36dac0e7e15f82a4fb77974439c565daa54d1c9", size = 48091, upload-time = "2026-04-04T16:44:08.677Z" },
|
|
@@ -499,7 +499,7 @@ wheels = [
|
|
|
499
499
|
|
|
500
500
|
[[package]]
|
|
501
501
|
name = "mt5cli"
|
|
502
|
-
version = "1.1.
|
|
502
|
+
version = "1.1.6"
|
|
503
503
|
source = { editable = "." }
|
|
504
504
|
dependencies = [
|
|
505
505
|
{ name = "click" },
|
|
@@ -543,7 +543,7 @@ requires-dist = [
|
|
|
543
543
|
{ name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'" },
|
|
544
544
|
{ name = "opentelemetry-sdk", marker = "extra == 'otel'" },
|
|
545
545
|
{ name = "pandas", specifier = ">=2.2.2" },
|
|
546
|
-
{ name = "pdmt5", specifier = ">=1.
|
|
546
|
+
{ name = "pdmt5", specifier = ">=1.2.0" },
|
|
547
547
|
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
|
|
548
548
|
{ name = "pydantic", specifier = ">=2.13.4" },
|
|
549
549
|
{ name = "typer", specifier = ">=0.15.0" },
|
|
@@ -800,16 +800,16 @@ wheels = [
|
|
|
800
800
|
|
|
801
801
|
[[package]]
|
|
802
802
|
name = "pdmt5"
|
|
803
|
-
version = "1.
|
|
803
|
+
version = "1.2.0"
|
|
804
804
|
source = { registry = "https://pypi.org/simple" }
|
|
805
805
|
dependencies = [
|
|
806
806
|
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
|
807
807
|
{ name = "pandas" },
|
|
808
808
|
{ name = "pydantic" },
|
|
809
809
|
]
|
|
810
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
810
|
+
sdist = { url = "https://files.pythonhosted.org/packages/9b/44/410bdc4d567ddb49f5b8d0d366467c9cce5e1862ecfbbcd09734c8f34eac/pdmt5-1.2.0.tar.gz", hash = "sha256:972f500ee7e50032a14f83874baccbef69bf7377335011f72d0bbdd6e221edcd", size = 24017, upload-time = "2026-07-10T15:21:27.215Z" }
|
|
811
811
|
wheels = [
|
|
812
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
812
|
+
{ url = "https://files.pythonhosted.org/packages/12/ec/88297de8aad400c922715cbdc15804dcf1ac5fbc0a3a0abeafc988c3c25d/pdmt5-1.2.0-py3-none-any.whl", hash = "sha256:ce355f24f4ec08ba5cc0148b5c529f73c671ea2e83b1d65248de1150369daeb7", size = 22369, upload-time = "2026-07-10T15:21:25.833Z" },
|
|
813
813
|
]
|
|
814
814
|
|
|
815
815
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|