Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

This is a Python package for pandas-friendly MetaTrader 5 access. Core code
lives in `pdmt5/`: `mt5.py` wraps MT5 calls, `dataframe.py` adds DataFrame/dict
conversions, `trading.py` contains trading helpers, and `constants.py`/`utils.py`
hold shared parsing and utilities. Tests live in `tests/`, documentation in
`docs/`, and project settings in `pyproject.toml`. Keep `uv.lock` in sync.
conversions, and `constants.py`/`utils.py` hold shared parsing and utilities.
Tests live in `tests/`, documentation in `docs/`, and project settings in
`pyproject.toml`. Keep `uv.lock` in sync.

## Build, Test, and Development Commands

Expand Down Expand Up @@ -56,7 +56,6 @@ Windows/MT5 assumptions reviewers need to verify.

## Security & Configuration Tips

Do not commit MT5 account credentials, terminal paths containing secrets, or live
trading configuration. Use `Mt5Config` inputs or environment-specific setup in
local scripts. Preserve dry-run pathways and mock-based tests when modifying
trading behavior.
Do not commit MT5 account credentials or terminal paths containing secrets. Use
`Mt5Config` inputs or environment-specific setup in local scripts. Preserve
mock-based tests when modifying MT5 connection or data-access behavior.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ High-level trading orchestration (market-order construction, margin-budget sizin
and ORDER_TYPE values with official names, short aliases, or valid integers
- **Context Manager Support**: Clean initialization and cleanup with `with` statements; `Mt5DataClient` applies `Mt5Config` automatically
- **Time Series Ready**: OHLCV data with proper datetime indexing
- **Robust Error Handling**: Custom exceptions with detailed MT5 error information
- **Robust Error Handling**: Custom exceptions with detailed MT5 failure messages
- **Direct Order Primitives**: `order_check` / `order_send` wrappers with DataFrame/dict conversions

## Requirements
Expand Down Expand Up @@ -335,7 +335,7 @@ This project maintains high code quality standards:

- **Type Checking**: Strict mode with pyright
- **Linting**: Comprehensive ruff configuration with 40+ rule categories
- **Testing**: pytest with coverage tracking (minimum 90%)
- **Testing**: pytest with 100% branch coverage enforcement
- **Documentation**: Google-style docstrings

## Error Handling
Expand All @@ -352,8 +352,7 @@ try:
)
except Mt5RuntimeError as e:
print(f"MT5 Error: {e}")
print(f"Error code: {e.error_code}")
print(f"Description: {e.description}")
print("Inspect the exception message for the MT5 status details.")
```

## Limitations
Expand Down
28 changes: 21 additions & 7 deletions pdmt5/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Any, Self, cast

import pandas as pd
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator

from .mt5 import Mt5Client, Mt5RuntimeError
from .utils import (
Expand All @@ -27,12 +27,26 @@ class Mt5Config(BaseModel):
default=None, description="Path to MetaTrader5 terminal EXE file"
)
login: int | None = Field(default=None, description="Trading account login")
password: str | None = Field(default=None, description="Trading account password")
password: str | SecretStr | None = Field(
Comment thread
dceoy marked this conversation as resolved.
default=None, description="Trading account password"
)
server: str | None = Field(default=None, description="Trading server name")
timeout: int | None = Field(
default=None, description="Connection timeout in milliseconds"
)

@field_validator("password", mode="before")
@classmethod
def _coerce_password(cls, value: str | SecretStr | None) -> SecretStr | None:
"""Normalize password inputs to SecretStr for masked serialization.

Returns:
Password value stored as SecretStr when provided.
"""
if value is None or isinstance(value, SecretStr):
return value
return SecretStr(value)


class Mt5DataClient(Mt5Client):
"""MetaTrader5 data client with pandas DataFrame and dictionary conversion.
Expand Down Expand Up @@ -89,13 +103,11 @@ def initialize_and_login_mt5(
self,
path: str | None = None,
login: int | None = None,
password: str | None = None,
password: str | SecretStr | None = None,
server: str | None = None,
timeout: int | None = None,
) -> None:
"""Initialize MetaTrader5 connection with retry logic.

This method overrides the base class to add retry logic and use config values.
"""Initialize an MT5 connection with config-aware retry and login support.

Args:
path: Path to terminal EXE file (overrides config).
Expand All @@ -109,7 +121,9 @@ def initialize_and_login_mt5(
"""
path = path or self.config.path
login_value = login if login is not None else self.config.login
password_value = password if password is not None else self.config.password
password_value = self._unwrap_password(
password if password is not None else self.config.password
)
server_value = server if server is not None else self.config.server
timeout_value = timeout if timeout is not None else self.config.timeout
for i in range(1 + max(0, self.retry_count)):
Expand Down
17 changes: 12 additions & 5 deletions pdmt5/mt5.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from types import ModuleType # noqa: TC003
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, Self, TypeVar

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, SecretStr

if TYPE_CHECKING:
from collections.abc import Callable
Expand Down Expand Up @@ -105,12 +105,19 @@ def __exit__(
"""Context manager exit."""
self.shutdown()

@staticmethod
def _unwrap_password(password: str | SecretStr | None) -> str | None:
"""Return a plain password string only at the MT5 call boundary."""
if isinstance(password, SecretStr):
return password.get_secret_value()
return password

@_log_mt5_last_status_code
def initialize(
self,
path: str | None = None,
login: int | None = None,
password: str | None = None,
password: str | SecretStr | None = None,
server: str | None = None,
timeout: int | None = None,
) -> bool:
Expand All @@ -130,7 +137,7 @@ def initialize(
k: v
for k, v in {
"login": login,
"password": password,
"password": self._unwrap_password(password),
"server": server,
"timeout": timeout,
}.items()
Expand All @@ -157,7 +164,7 @@ def initialize(
def login(
self,
login: int,
password: str | None = None,
password: str | SecretStr | None = None,
server: str | None = None,
timeout: int | None = None,
) -> bool:
Expand All @@ -179,7 +186,7 @@ def login(
**{
k: v
for k, v in {
"password": password,
"password": self._unwrap_password(password),
"server": server,
"timeout": timeout,
}.items()
Expand Down
84 changes: 79 additions & 5 deletions tests/test_dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import numpy as np
import pandas as pd
import pytest
from pydantic import ValidationError
from pydantic import SecretStr, ValidationError
from pytest_mock import MockerFixture

from pdmt5.dataframe import Mt5Config, Mt5DataClient
Expand Down Expand Up @@ -55,6 +55,7 @@
"market_book_release",
"market_book_get",
)
_MASKED_SECRET = "*" * 10


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -623,16 +624,25 @@ def test_default_config(self) -> None:
assert config.server is None
assert config.timeout is None

def test_custom_config(self) -> None:
"""Test custom configuration."""
@pytest.mark.parametrize(
"password_input",
[
"secret",
SecretStr("secret"),
],
ids=["str-password", "secretstr-password"],
)
def test_config_password_coercion(self, password_input: str | SecretStr) -> None:
"""Test Mt5Config coerces password inputs to SecretStr."""
config = Mt5Config(
login=123456,
password="secret",
password=password_input,
server="Demo-Server",
timeout=30000,
)
assert config.login == 123456
assert config.password == "secret" # noqa: S105
assert isinstance(config.password, SecretStr)
assert config.password.get_secret_value() == "secret"
assert config.server == "Demo-Server"
assert config.timeout == 30000

Expand All @@ -642,6 +652,18 @@ def test_config_immutable(self) -> None:
with pytest.raises(ValidationError):
config.login = 123456

def test_config_masks_password_in_string_representations(self) -> None:
"""Test SecretStr masks the password in printable config output."""
config = Mt5Config(password="secret")
dumped_password = config.model_dump()["password"]

assert "secret" not in repr(config)
assert "secret" not in str(config)
assert "secret" not in repr(config.model_dump())
assert isinstance(dumped_password, SecretStr)
assert str(dumped_password) == _MASKED_SECRET
assert config.model_dump(mode="json")["password"] == _MASKED_SECRET


class TestMt5DataClient:
"""Test Mt5DataClient class."""
Expand All @@ -666,8 +688,31 @@ def test_init_custom_config(self, mock_mt5_import: ModuleType | None) -> None:
client = Mt5DataClient(mt5=mock_mt5_import, config=config)
assert client.config == config
assert client.config.login == 123456
assert isinstance(client.config.password, SecretStr)
assert client.config.password.get_secret_value() == "test"
assert client.config.timeout == 30000

def test_client_masks_nested_password_in_dumps(
self, mock_mt5_import: ModuleType | None
) -> None:
"""Test nested client config dumps do not expose a literal password."""
assert mock_mt5_import is not None
client = Mt5DataClient(
mt5=mock_mt5_import,
config=Mt5Config(password="secret"),
)
dumped_password = client.model_dump()["config"]["password"]

assert "secret" not in repr(client)
assert "secret" not in str(client)
assert "secret" not in repr(client.model_dump())
assert isinstance(dumped_password, SecretStr)
assert str(dumped_password) == _MASKED_SECRET
assert (
client.model_dump(mode="json", exclude={"mt5"})["config"]["password"]
== _MASKED_SECRET
)

def test_mt5_module_default_factory(self, mocker: MockerFixture) -> None:
"""Test initialization with default mt5 module factory."""
# Mock the importlib.import_module to verify it's called
Expand Down Expand Up @@ -860,6 +905,35 @@ def test_initialize_and_login_shuts_down_after_login_exception(

mock_mt5_import.shutdown.assert_called_once()

def test_initialize_and_login_unwraps_secret_password_override(
self, mock_mt5_import: ModuleType | None
) -> None:
"""Test SecretStr overrides are unwrapped before MT5 calls."""
assert mock_mt5_import is not None
mock_mt5_import.initialize.return_value = True
mock_mt5_import.login.return_value = True
client = Mt5DataClient(mt5=mock_mt5_import, retry_count=0)

client.initialize_and_login_mt5(
login=123456,
password=SecretStr("secret"),
server="Demo",
timeout=60000,
)

mock_mt5_import.initialize.assert_called_once_with(
login=123456,
password="secret",
server="Demo",
timeout=60000,
)
mock_mt5_import.login.assert_called_once_with(
123456,
password="secret",
server="Demo",
timeout=60000,
)

@pytest.mark.parametrize(
("client_method", "mt5_method"),
[
Expand Down
Loading