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.
Files changed (71) hide show
  1. {mt5cli-1.1.5 → mt5cli-1.1.6}/PKG-INFO +2 -2
  2. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/converters.py +1 -1
  3. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/history.py +1 -1
  4. {mt5cli-1.1.5 → mt5cli-1.1.6}/pyproject.toml +2 -2
  5. mt5cli-1.1.6/tests/test_client.py +44 -0
  6. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_contracts.py +23 -397
  7. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_examples.py +7 -11
  8. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_grafana.py +0 -13
  9. mt5cli-1.1.6/tests/test_packaging.py +36 -0
  10. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_telemetry.py +9 -27
  11. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_utils.py +0 -7
  12. {mt5cli-1.1.5 → mt5cli-1.1.6}/uv.lock +6 -6
  13. {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/local-qa/SKILL.md +0 -0
  14. {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  15. {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/mt5cli/SKILL.md +0 -0
  16. {mt5cli-1.1.5 → mt5cli-1.1.6}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  17. {mt5cli-1.1.5 → mt5cli-1.1.6}/.claude/agents/codex.md +0 -0
  18. {mt5cli-1.1.5 → mt5cli-1.1.6}/.claude/settings.json +0 -0
  19. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/FUNDING.yml +0 -0
  20. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/dependabot.yml +0 -0
  21. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/renovate.json +0 -0
  22. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/ci.yml +0 -0
  23. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/claude.yml +0 -0
  24. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/opencode.yml +0 -0
  25. {mt5cli-1.1.5 → mt5cli-1.1.6}/.github/workflows/release.yml +0 -0
  26. {mt5cli-1.1.5 → mt5cli-1.1.6}/.gitignore +0 -0
  27. {mt5cli-1.1.5 → mt5cli-1.1.6}/AGENTS.md +0 -0
  28. {mt5cli-1.1.5 → mt5cli-1.1.6}/CLAUDE.md +0 -0
  29. {mt5cli-1.1.5 → mt5cli-1.1.6}/LICENSE +0 -0
  30. {mt5cli-1.1.5 → mt5cli-1.1.6}/README.md +0 -0
  31. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/cli.md +0 -0
  32. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/client.md +0 -0
  33. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/converters.md +0 -0
  34. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/exceptions.md +0 -0
  35. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/grafana.md +0 -0
  36. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/history.md +0 -0
  37. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/index.md +0 -0
  38. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/public-contract.md +0 -0
  39. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/schemas.md +0 -0
  40. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/sdk.md +0 -0
  41. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/telemetry.md +0 -0
  42. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/trading.md +0 -0
  43. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/api/utils.md +0 -0
  44. {mt5cli-1.1.5 → mt5cli-1.1.6}/docs/index.md +0 -0
  45. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/README.md +0 -0
  46. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/compose.yml +0 -0
  47. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  48. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  49. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  50. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  51. {mt5cli-1.1.5 → mt5cli-1.1.6}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  52. {mt5cli-1.1.5 → mt5cli-1.1.6}/mkdocs.yml +0 -0
  53. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/__init__.py +0 -0
  54. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/__main__.py +0 -0
  55. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/cli.py +0 -0
  56. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/client.py +0 -0
  57. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/contract.py +0 -0
  58. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/exceptions.py +0 -0
  59. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/grafana.py +0 -0
  60. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/retry.py +0 -0
  61. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/schemas.py +0 -0
  62. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/sdk.py +0 -0
  63. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/telemetry.py +0 -0
  64. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/trading.py +0 -0
  65. {mt5cli-1.1.5 → mt5cli-1.1.6}/mt5cli/utils.py +0 -0
  66. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/__init__.py +0 -0
  67. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/conftest.py +0 -0
  68. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_cli.py +0 -0
  69. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_history.py +0 -0
  70. {mt5cli-1.1.5 → mt5cli-1.1.6}/tests/test_sdk.py +0 -0
  71. {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.5
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.1.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.5"
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.1.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
- "Mt5TradingClient",
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 test_pdmt5_pass_through_names_removed_from_public_contract(name: str) -> None:
842
- """Removed pdmt5 pass-through names are not part of the public contract."""
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 and get_metrics."""
233
+ """Tests for the module-level configure_metrics wiring function."""
248
234
 
249
- def test_configure_metrics_updates_global(self) -> None:
250
- """configure_metrics wires up the global singleton."""
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
- assert get_metrics() is get_metrics()
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.5"
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.1.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.1.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/4d/9c/4c51e9b109440629762a5ca719c367798469bb590b8ce99aa0652c467549/pdmt5-1.1.1.tar.gz", hash = "sha256:2d8e4e9b87d237793f8534813b373545eadb5c0e1ced13ee8be8dc473c77190d", size = 23879, upload-time = "2026-07-07T15:50:19.604Z" }
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/8f/18/4221deef65dc320da9b6299e1914c36c1e81b29a5557f0c1083718b36545/pdmt5-1.1.1-py3-none-any.whl", hash = "sha256:9df16148ebec754c15ce3d1b02af82aaeec3c5cb1603a47258fa68c74f45b453", size = 22274, upload-time = "2026-07-07T15:50:18.489Z" },
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