diff --git a/README.md b/README.md index 4f07fd0..8c6028b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Command-line tool for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQL Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. +## Architecture + +- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types). +- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5. +- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli. + ## Features - **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats diff --git a/docs/index.md b/docs/index.md index ce9622e..5763494 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,12 @@ Command-line tool for MetaTrader 5 data export. mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple file formats. It is built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. +## Architecture + +- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types). +- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5. +- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli. + ## Features - **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats diff --git a/mt5cli/cli.py b/mt5cli/cli.py index 5c1e1c1..a36ab4e 100644 --- a/mt5cli/cli.py +++ b/mt5cli/cli.py @@ -347,7 +347,7 @@ def ticks_recent( click_type=TICK_FLAGS_TYPE, help="Tick flags (ALL, INFO, TRADE, or integer).", ), - ] = 1, + ] = "ALL", # pyright: ignore[reportArgumentType] ) -> None: """Export ticks from a recent time window.""" client = _sdk_client(ctx) @@ -656,7 +656,7 @@ def collect_history( click_type=TICK_FLAGS_TYPE, help="Tick copy flags (ALL, INFO, TRADE, or integer).", ), - ] = 1, + ] = "ALL", # pyright: ignore[reportArgumentType] if_exists: Annotated[ IfExists, typer.Option( diff --git a/mt5cli/history.py b/mt5cli/history.py index 23621b5..b9842a7 100644 --- a/mt5cli/history.py +++ b/mt5cli/history.py @@ -10,9 +10,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal, cast import pandas as pd +from pdmt5 import get_timeframe_name as _get_timeframe_name from .utils import ( - TIMEFRAME_MAP, + TIMEFRAME_NAMES, Dataset, IfExists, parse_datetime, @@ -27,7 +28,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = tuple(TIMEFRAME_MAP) +DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES _HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = { Dataset.rates: (("symbol", "timeframe", "time"), ("symbol", "time")), @@ -80,7 +81,7 @@ def resolve_history_timeframes( seen: set[int] = set() resolved: list[int] = [] for value in raw: - tf = value if isinstance(value, int) else parse_timeframe(str(value)) + tf = parse_timeframe(value) if tf not in seen: seen.add(tf) resolved.append(tf) @@ -93,17 +94,16 @@ def resolve_history_tick_flags(flags: int | str) -> int: Returns: Integer tick flag value. """ - if isinstance(flags, int): - return flags return parse_tick_flags(flags) def resolve_granularity_name(timeframe: int) -> str: """Return a granularity name for a timeframe integer when known.""" - for name, value in TIMEFRAME_MAP.items(): - if value == timeframe: - return name - return str(timeframe) + try: + name = _get_timeframe_name(timeframe) + except ValueError: + return str(timeframe) + return name.removeprefix("TIMEFRAME_") def drop_forming_rate_bar(df_rate: pd.DataFrame) -> pd.DataFrame: diff --git a/mt5cli/sdk.py b/mt5cli/sdk.py index 1a06255..c49bf4c 100644 --- a/mt5cli/sdk.py +++ b/mt5cli/sdk.py @@ -144,14 +144,10 @@ __all__ = [ def _coerce_timeframe(timeframe: int | str) -> int: - if isinstance(timeframe, int): - return timeframe return parse_timeframe(timeframe) def _coerce_tick_flags(flags: int | str) -> int: - if isinstance(flags, int): - return flags return parse_tick_flags(flags) @@ -1171,7 +1167,7 @@ def collect_history( *, datasets: set[Dataset] | None = None, timeframe: int | str = 1, - flags: int | str = 1, + flags: int | str = "ALL", if_exists: IfExists = IfExists.FAIL, with_views: bool = False, config: Mt5Config | None = None, diff --git a/mt5cli/utils.py b/mt5cli/utils.py index fb8fba1..e2eb1e3 100644 --- a/mt5cli/utils.py +++ b/mt5cli/utils.py @@ -10,6 +10,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, TypeGuard import click +from pdmt5 import COPY_TICKS_MAP, TIMEFRAME_MAP +from pdmt5 import parse_copy_ticks as _parse_copy_ticks +from pdmt5 import parse_timeframe as _parse_timeframe if TYPE_CHECKING: from collections.abc import Sequence @@ -20,35 +23,15 @@ if TYPE_CHECKING: # Constants # --------------------------------------------------------------------------- -TIMEFRAME_MAP: dict[str, int] = { - "M1": 1, - "M2": 2, - "M3": 3, - "M4": 4, - "M5": 5, - "M6": 6, - "M10": 10, - "M12": 12, - "M15": 15, - "M20": 20, - "M30": 30, - "H1": 16385, - "H2": 16386, - "H3": 16387, - "H4": 16388, - "H6": 16390, - "H8": 16392, - "H12": 16396, - "D1": 16408, - "W1": 32769, - "MN1": 49153, -} +# Backward-compatible snapshot; prefer ``COPY_TICKS_MAP`` from pdmt5 directly. +TICK_FLAG_MAP: dict[str, int] = dict(COPY_TICKS_MAP) -TICK_FLAG_MAP: dict[str, int] = { - "ALL": 1, - "INFO": 2, - "TRADE": 4, -} +TIMEFRAME_NAMES: tuple[str, ...] = tuple( + name for name in TIMEFRAME_MAP if not name.startswith("TIMEFRAME_") +) +_TICK_FLAG_NAMES: tuple[str, ...] = tuple( + name for name in COPY_TICKS_MAP if not name.startswith("COPY_TICKS_") +) _FORMAT_EXTENSIONS: dict[str, str] = { ".csv": "csv", @@ -160,10 +143,8 @@ class _TimeframeType(click.ParamType): Returns: Integer timeframe value. """ - if isinstance(value, int): - return value try: - return parse_timeframe(str(value)) + return parse_timeframe(value) except ValueError as exc: self.fail(str(exc), param, ctx) @@ -189,10 +170,8 @@ class _TickFlagsType(click.ParamType): Returns: Integer tick flag value. """ - if isinstance(value, int): - return value try: - return parse_tick_flags(str(value)) + return parse_tick_flags(value) except ValueError as exc: self.fail(str(exc), param, ctx) @@ -370,7 +349,7 @@ def parse_datetime(value: str) -> datetime: return dt -def parse_timeframe(value: str) -> int: +def parse_timeframe(value: object) -> int: """Parse a timeframe string or integer value. Args: @@ -382,37 +361,39 @@ def parse_timeframe(value: str) -> int: Raises: ValueError: If the timeframe is invalid. """ - upper = value.upper() - if upper in TIMEFRAME_MAP: - return TIMEFRAME_MAP[upper] try: - return int(value) + return _parse_timeframe(value) except ValueError: - valid = ", ".join(TIMEFRAME_MAP) - msg = f"Invalid timeframe: '{value}'. Use one of: {valid}, or an integer." + display = value if isinstance(value, str) else repr(value) + valid = ", ".join(TIMEFRAME_NAMES) + msg = ( + f"Invalid timeframe: '{display}'. " + f"Use one of: {valid}, or a supported integer." + ) raise ValueError(msg) from None -def parse_tick_flags(value: str) -> int: +def parse_tick_flags(value: object) -> int: """Parse tick flags string or integer value. Args: - value: Tick flag name (ALL, INFO, TRADE) or integer value. + value: Tick flag name (ALL, INFO, TRADE, COPY_TICKS_*) or integer value. Returns: - Integer tick flag value. + Integer tick flag value compatible with MetaTrader 5 ``COPY_TICKS_*``. Raises: ValueError: If the flag is invalid. """ - upper = value.upper() - if upper in TICK_FLAG_MAP: - return TICK_FLAG_MAP[upper] try: - return int(value) + return _parse_copy_ticks(value) except ValueError: - valid = ", ".join(TICK_FLAG_MAP) - msg = f"Invalid tick flags: '{value}'. Use one of: {valid}, or an integer." + display = value if isinstance(value, str) else repr(value) + valid = ", ".join(_TICK_FLAG_NAMES) + msg = ( + f"Invalid tick flags: '{display}'. " + f"Use one of: {valid}, or a supported integer." + ) raise ValueError(msg) from None diff --git a/pyproject.toml b/pyproject.toml index 0c81b60..4a654f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.6.1" +version = "0.7.0" description = "Command-line tool for MetaTrader 5" authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] @@ -9,7 +9,7 @@ license-files = ["LICENSE"] readme = "README.md" requires-python = ">= 3.11, < 3.14" dependencies = [ - "pdmt5 >= 0.2.3", + "pdmt5>=0.3.0", "click >= 8.1.0", "pyarrow >= 19.0.0", "typer >= 0.15.0", diff --git a/tests/test_cli.py b/tests/test_cli.py index c1cb70a..997d930 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -317,7 +317,7 @@ class TestCommands: symbol="EURUSD", date_from=datetime(2024, 1, 1, tzinfo=UTC), count=100, - flags=1, + flags=-1, ) def test_ticks_range( @@ -348,7 +348,7 @@ class TestCommands: symbol="EURUSD", date_from=datetime(2024, 1, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC), - flags=2, + flags=1, ) def test_ticks_recent( @@ -381,7 +381,7 @@ class TestCommands: symbol="EURUSD", date_from=datetime(2024, 1, 2, tzinfo=UTC) - timedelta(seconds=120), count=500, - flags=1, + flags=-1, ) mock_client.copy_ticks_range_as_df.assert_not_called() @@ -1000,7 +1000,7 @@ class TestCollectHistory: symbol="EURUSD", date_from=datetime(2024, 1, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC), - flags=1, + flags=-1, ) with sqlite3.connect(output) as conn: tables = { @@ -1213,7 +1213,7 @@ class TestCollectHistory: symbol="EURUSD", date_from=datetime(2024, 1, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC), - flags=1, + flags=-1, ) def test_collect_history_with_views( diff --git a/tests/test_history.py b/tests/test_history.py index 1ab284e..d2e2340 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -517,6 +517,9 @@ class TestResolveHistorySettings: """Test default timeframes include all fixed MT5 values.""" resolved = resolve_history_timeframes(None) assert len(resolved) == len(DEFAULT_HISTORY_TIMEFRAMES) + assert not any( + name.startswith("TIMEFRAME_") for name in DEFAULT_HISTORY_TIMEFRAMES + ) assert 1 in resolved assert TIMEFRAME_MAP["H1"] in resolved @@ -526,7 +529,7 @@ class TestResolveHistorySettings: def test_resolve_history_tick_flags(self) -> None: """Test tick flag resolution.""" - assert resolve_history_tick_flags("ALL") == 1 + assert resolve_history_tick_flags("ALL") == -1 assert resolve_history_tick_flags(2) == 2 def test_resolve_granularity_name_falls_back_to_integer(self) -> None: @@ -534,6 +537,17 @@ class TestResolveHistorySettings: assert resolve_granularity_name(999) == "999" assert resolve_granularity_name(1) == "M1" + def test_resolve_granularity_name_strips_official_prefix( + self, + mocker: MockerFixture, + ) -> None: + """Test official pdmt5 timeframe names are normalized to short aliases.""" + mocker.patch( + "mt5cli.history._get_timeframe_name", + return_value="TIMEFRAME_H1", + ) + assert resolve_granularity_name(16385) == "H1" + class TestDropFormingRateBar: """Tests for drop_forming_rate_bar.""" @@ -1754,6 +1768,8 @@ class TestIncrementalIntegration: """Test invalid tick flags raise ValueError.""" with pytest.raises(ValueError, match="Invalid tick flags"): resolve_history_tick_flags("BAD") + with pytest.raises(ValueError, match="Invalid tick flags"): + resolve_history_tick_flags(7) def test_resolve_history_timeframes_invalid(self) -> None: """Test invalid timeframes raise ValueError.""" diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 16f85b9..2d93730 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -390,7 +390,7 @@ class TestMt5CliClient: symbol="EURUSD", date_from=datetime(2024, 1, 1, tzinfo=UTC), count=100, - flags=2, + flags=1, ) def test_history_orders_accepts_string_dates( @@ -951,7 +951,7 @@ class TestUpdateHistory: assert kwargs["symbol"] == "EURUSD" assert kwargs["date_from"] == expected_start assert kwargs["date_to"] == date_to - assert kwargs["flags"] == 1 + assert kwargs["flags"] == -1 return pd.DataFrame({ "time": ["2024-01-01T12:00:00+00:00"], "time_msc": [1_704_110_400_000], @@ -1119,7 +1119,7 @@ class TestRecentTicks: symbol="EURUSD", date_from=end - timedelta(seconds=60), count=100, - flags=2, + flags=1, ) client.copy_ticks_range_as_df.assert_not_called() @@ -1149,7 +1149,7 @@ class TestRecentTicks: assert kwargs["symbol"] == "EURUSD" assert kwargs["date_to"] == tick.time assert kwargs["date_from"] == tick.time - timedelta(seconds=30) - assert kwargs["flags"] == 1 + assert kwargs["flags"] == -1 def test_recent_ticks_rejects_unsupported_tick_time( self, @@ -1219,7 +1219,7 @@ class TestRecentTicks: symbol="EURUSD", date_from=end - timedelta(seconds=60), date_to=end, - flags=1, + flags=-1, ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 24da1b3..a59fb04 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -274,8 +274,14 @@ class TestParseTimeframe: assert parse_timeframe(value) == expected def test_integer_timeframe(self) -> None: - """Test parsing integer timeframe.""" - assert parse_timeframe("42") == 42 + """Test parsing supported integer timeframes.""" + assert parse_timeframe("1") == 1 + assert parse_timeframe(16385) == 16385 + + def test_unsupported_integer_timeframe_raises(self) -> None: + """Test that unsupported integer timeframes raise ValueError.""" + with pytest.raises(ValueError, match="Invalid timeframe"): + parse_timeframe("42") def test_invalid_timeframe_raises(self) -> None: """Test that invalid timeframe raises ValueError.""" @@ -288,15 +294,21 @@ class TestParseTickFlags: @pytest.mark.parametrize( ("value", "expected"), - [("ALL", 1), ("info", 2), ("TRADE", 4)], + [("ALL", -1), ("info", 1), ("TRADE", 2), ("COPY_TICKS_ALL", -1)], ) def test_named_flag(self, value: str, expected: int) -> None: """Test parsing named tick flags.""" assert parse_tick_flags(value) == expected def test_integer_flag(self) -> None: - """Test parsing integer tick flag.""" - assert parse_tick_flags("7") == 7 + """Test parsing supported integer tick flags.""" + assert parse_tick_flags("-1") == -1 + assert parse_tick_flags(2) == 2 + + def test_unsupported_integer_flag_raises(self) -> None: + """Test that unsupported integer tick flags raise ValueError.""" + with pytest.raises(ValueError, match="Invalid tick flags"): + parse_tick_flags("7") def test_invalid_flag_raises(self) -> None: """Test that invalid flag raises ValueError.""" @@ -355,8 +367,11 @@ class TestConstants: assert key in TIMEFRAME_MAP def test_tick_flag_map_has_expected_keys(self) -> None: - """Test that TICK_FLAG_MAP contains standard flags.""" - assert set(TICK_FLAG_MAP) == {"ALL", "INFO", "TRADE"} + """Test that TICK_FLAG_MAP contains standard flags with MT5 values.""" + assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP) + assert TICK_FLAG_MAP["ALL"] == -1 + assert TICK_FLAG_MAP["INFO"] == 1 + assert TICK_FLAG_MAP["TRADE"] == 2 @pytest.mark.parametrize( ("dataset", "expected"), @@ -403,26 +418,48 @@ class TestTimeframeType: """Test converting a string to timeframe integer.""" assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385 - def test_convert_int_passthrough(self) -> None: - """Test that integer values pass through unchanged.""" - assert TIMEFRAME_TYPE.convert(42, None, None) == 42 + def test_convert_int(self) -> None: + """Test converting supported integer timeframe values.""" + assert TIMEFRAME_TYPE.convert(16385, None, None) == 16385 + + def test_convert_unsupported_int(self) -> None: + """Test that unsupported integer values raise BadParameter.""" + with pytest.raises(Exception, match="Invalid timeframe"): + TIMEFRAME_TYPE.convert(42, None, None) def test_convert_invalid(self) -> None: """Test that invalid values raise BadParameter.""" with pytest.raises(Exception, match="Invalid timeframe"): TIMEFRAME_TYPE.convert("bad", None, None) + @pytest.mark.parametrize("value", [True, False, None, 1.5]) + def test_convert_invalid_types(self, value: object) -> None: + """Test that bool, float, and None values raise BadParameter.""" + with pytest.raises(Exception, match="Invalid timeframe"): + TIMEFRAME_TYPE.convert(value, None, None) + class TestTickFlagsType: """Tests for _TickFlagsType.""" def test_convert_string(self) -> None: """Test converting a string to tick flags integer.""" - assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1 + assert TICK_FLAGS_TYPE.convert("ALL", None, None) == -1 - def test_convert_int_passthrough(self) -> None: - """Test that integer values pass through unchanged.""" - assert TICK_FLAGS_TYPE.convert(7, None, None) == 7 + def test_convert_int(self) -> None: + """Test converting supported integer tick flag values.""" + assert TICK_FLAGS_TYPE.convert(2, None, None) == 2 + + def test_convert_unsupported_int(self) -> None: + """Test that unsupported integer values raise BadParameter.""" + with pytest.raises(Exception, match="Invalid tick flags"): + TICK_FLAGS_TYPE.convert(7, None, None) + + @pytest.mark.parametrize("value", [True, False, None, 1.5]) + def test_convert_invalid_types(self, value: object) -> None: + """Test that bool, float, and None values raise BadParameter.""" + with pytest.raises(Exception, match="Invalid tick flags"): + TICK_FLAGS_TYPE.convert(value, None, None) def test_convert_invalid(self) -> None: """Test that invalid values raise BadParameter.""" diff --git a/uv.lock b/uv.lock index 6571114..23667eb 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.6.1" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -513,7 +513,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "pdmt5", specifier = ">=0.2.3" }, + { name = "pdmt5", specifier = ">=0.3.0" }, { name = "pyarrow", specifier = ">=19.0.0" }, { name = "typer", specifier = ">=0.15.0" }, ] @@ -684,16 +684,16 @@ wheels = [ [[package]] name = "pdmt5" -version = "0.2.3" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "metatrader5", marker = "sys_platform == 'win32'" }, { name = "pandas" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/25/52d9d954504ccdd0fe91f715ab74c424d61234b237cc4160d3ebe20070f1/pdmt5-0.2.3.tar.gz", hash = "sha256:21384f5826fb0125fee3f93c90b108340f55ab53b1c819d229ceac162289d2ec", size = 226665, upload-time = "2026-02-05T13:28:21.071Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cc/c8fa3a01e0e34178fec8527992f7bb8eda5881477ce23aaacaa9b2ef7bec/pdmt5-0.3.0.tar.gz", hash = "sha256:bb612d5c2695eafac9b2a7b74756e13bd383d7e5517bd90c9a2efa92492c484c", size = 215100, upload-time = "2026-06-11T13:26:46.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/75/c5e52a9cf459b85b2dd52f83e70857571b1b45805c9fe610b3959a26ac15/pdmt5-0.2.3-py3-none-any.whl", hash = "sha256:f92246a05cfc3b7feb3ab0cc5b48768a4d84aad6b02e7a68060948f5828718a1", size = 22967, upload-time = "2026-02-05T13:28:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/f2/03/b12cc4c9db983d971c9172b3765161b6d91136d0624e6718a04dd815e7a1/pdmt5-0.3.0-py3-none-any.whl", hash = "sha256:5388b406cc583202600cfe22c9d781679b1d931b1ed5a2b5dcf37c566149b49f", size = 26250, upload-time = "2026-06-11T13:26:45.689Z" }, ] [[package]]