diff --git a/README.md b/README.md index 4762b54..eb480d8 100644 --- a/README.md +++ b/README.md @@ -50,21 +50,29 @@ python -m mt5cli -o account.csv account-info ## Commands -| Command | Description | -| ---------------- | ---------------------------------- | -| `rates-from` | Export rates from a start date | -| `rates-from-pos` | Export rates from a start position | -| `rates-range` | Export rates for a date range | -| `ticks-from` | Export ticks from a start date | -| `ticks-range` | Export ticks for a date range | -| `account-info` | Export account information | -| `terminal-info` | Export terminal information | -| `symbols` | Export symbol list | -| `symbol-info` | Export symbol details | -| `orders` | Export active orders | -| `positions` | Export open positions | -| `history-orders` | Export historical orders | -| `history-deals` | Export historical deals | +| Command | Description | +| ------------------ | ------------------------------------------- | +| `rates-from` | Export rates from a start date | +| `rates-from-pos` | Export rates from a start position | +| `rates-range` | Export rates for a date range | +| `ticks-from` | Export ticks from a start date | +| `ticks-range` | Export ticks for a date range | +| `account-info` | Export account information | +| `terminal-info` | Export terminal information | +| `version` | Export MetaTrader 5 version information | +| `last-error` | Export the last error information | +| `symbols` | Export symbol list | +| `symbol-info` | Export symbol details | +| `symbol-info-tick` | Export the last tick for a symbol | +| `market-book` | Export market depth (order book) | +| `orders` | Export active orders | +| `positions` | Export open positions | +| `history-orders` | Export historical orders | +| `history-deals` | Export historical deals | +| `order-check` | Check funds sufficiency for a trade request | +| `order-send` | Send a trade request to the trade server (`--yes` required) | + +Use `order-check` to validate a request payload before running `order-send --yes`. ## Requirements diff --git a/docs/index.md b/docs/index.md index 901c5c0..010396f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -61,21 +61,29 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \ ### Information -| Command | Description | -| --------------- | --------------------------- | -| `account-info` | Export account information | -| `terminal-info` | Export terminal information | -| `symbols` | Export symbol list | -| `symbol-info` | Export symbol details | +| Command | Description | +| ------------------ | --------------------------------------- | +| `account-info` | Export account information | +| `terminal-info` | Export terminal information | +| `version` | Export MetaTrader 5 version information | +| `last-error` | Export the last error information | +| `symbols` | Export symbol list | +| `symbol-info` | Export symbol details | +| `symbol-info-tick` | Export the last tick for a symbol | +| `market-book` | Export market depth (order book) | ### Trading -| Command | Description | -| ---------------- | ------------------------ | -| `orders` | Export active orders | -| `positions` | Export open positions | -| `history-orders` | Export historical orders | -| `history-deals` | Export historical deals | +| Command | Description | +| ---------------- | ------------------------------------------- | +| `orders` | Export active orders | +| `positions` | Export open positions | +| `history-orders` | Export historical orders | +| `history-deals` | Export historical deals | +| `order-check` | Check funds sufficiency for a trade request | +| `order-send` | Send a trade request to the trade server (`--yes` required) | + +Use `order-check` to validate a request payload before running `order-send --yes`. ## Global Options diff --git a/mt5cli/cli.py b/mt5cli/cli.py index c72e746..d76cf17 100644 --- a/mt5cli/cli.py +++ b/mt5cli/cli.py @@ -2,13 +2,14 @@ from __future__ import annotations +import json import logging import sqlite3 from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum -from pathlib import Path # noqa: TC003 -from typing import TYPE_CHECKING, Annotated, cast +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, TypeGuard, cast import click import typer @@ -180,9 +181,37 @@ class _TickFlagsType(click.ParamType): self.fail(str(exc), param, ctx) +class _RequestType(click.ParamType): + """Click parameter type for JSON order requests.""" + + name = "REQUEST" + + def convert( + self, + value: object, + param: click.Parameter | None, + ctx: click.Context | None, + ) -> dict[str, Any]: + """Convert a raw CLI value to an order request dictionary. + + Args: + value: Raw value from the command line. + param: Click parameter instance. + ctx: Click context. + + Returns: + Parsed request dictionary. + """ + try: + return parse_request(str(value)) + except ValueError as exc: + self.fail(str(exc), param, ctx) + + DATETIME_TYPE = _DateTimeType() TIMEFRAME_TYPE = _TimeframeType() TICK_FLAGS_TYPE = _TickFlagsType() +REQUEST_TYPE = _RequestType() # --------------------------------------------------------------------------- # Export context @@ -342,6 +371,43 @@ def parse_tick_flags(value: str) -> int: raise ValueError(msg) from None +def _is_request_dict(value: object) -> TypeGuard[dict[str, Any]]: + return isinstance(value, dict) + + +def parse_request(value: str) -> dict[str, Any]: + """Parse a JSON-formatted order request string or file reference. + + Args: + value: JSON object string, or '@path' to read JSON from a file. + + Returns: + Parsed request dictionary. + + Raises: + ValueError: If the request file cannot be read or the value is not a + JSON object. + """ + if value.startswith("@"): + path = Path(value[1:]) + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + msg = f"Failed to read JSON request file '{path}': {exc}" + raise ValueError(msg) from exc + else: + text = value + try: + parsed: object = json.loads(text) + except json.JSONDecodeError as exc: + msg = f"Invalid JSON request: {exc}" + raise ValueError(msg) from exc + if not _is_request_dict(parsed): + msg = "Order request must be a JSON object." + raise ValueError(msg) + return parsed + + # --------------------------------------------------------------------------- # Typer application # --------------------------------------------------------------------------- @@ -351,6 +417,10 @@ app = typer.Typer( help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.", ) +_REQUEST_OPTION_HELP = ( + "Order request as a JSON object string, or '@path' to load JSON from a file." +) + def _get_export_context(ctx: typer.Context) -> _ExportContext: return cast("_ExportContext", ctx.obj) @@ -747,6 +817,83 @@ def history_deals( ) +@app.command() +def version(ctx: typer.Context) -> None: + """Export MetaTrader5 version information.""" + _execute_export(ctx, lambda c: c.version_as_df()) + + +@app.command() +def last_error(ctx: typer.Context) -> None: + """Export the last error information.""" + _execute_export(ctx, lambda c: c.last_error_as_df()) + + +@app.command() +def symbol_info_tick( + ctx: typer.Context, + symbol: Annotated[str, typer.Option(help="Symbol name.")], +) -> None: + """Export the last tick for a symbol.""" + _execute_export( + ctx, + lambda c: c.symbol_info_tick_as_df(symbol=symbol), + ) + + +@app.command() +def market_book( + ctx: typer.Context, + symbol: Annotated[str, typer.Option(help="Symbol name.")], +) -> None: + """Export market depth (order book) for a symbol.""" + _execute_export( + ctx, + lambda c: c.market_book_get_as_df(symbol=symbol), + ) + + +@app.command() +def order_check( + ctx: typer.Context, + request: Annotated[ + dict[str, Any], + typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP), + ], +) -> None: + """Check funds sufficiency for a trading operation.""" + _execute_export( + ctx, + lambda c: c.order_check_as_df(request=request), + ) + + +@app.command() +def order_send( + ctx: typer.Context, + request: Annotated[ + dict[str, Any], + typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP), + ], + yes: Annotated[ + bool, + typer.Option("--yes", help="Confirm the live trade request."), + ] = False, +) -> None: + """Send a trading operation request to the trade server. + + Raises: + typer.BadParameter: If --yes is not provided. + """ + if not yes: + msg = "Pass --yes to send a live trade request." + raise typer.BadParameter(msg, param_hint="--yes") + _execute_export( + ctx, + lambda c: c.order_send_as_df(request=request), + ) + + def main() -> None: """Run the mt5cli CLI.""" app() diff --git a/pyproject.toml b/pyproject.toml index 321450e..15d8f6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mt5cli" -version = "0.1.0" +version = "0.2.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"}] diff --git a/tests/test_cli.py b/tests/test_cli.py index 29e8d82..bd28cd4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import sqlite3 from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -18,6 +19,7 @@ if TYPE_CHECKING: from mt5cli.cli import ( DATETIME_TYPE, + REQUEST_TYPE, TICK_FLAG_MAP, TICK_FLAGS_TYPE, TIMEFRAME_MAP, @@ -29,11 +31,18 @@ from mt5cli.cli import ( export_dataframe, main, parse_datetime, + parse_request, parse_tick_flags, parse_timeframe, ) runner = CliRunner() +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def normalize_cli_output(output: str) -> str: + """Normalize CLI output for cross-platform assertions.""" + return " ".join(_ANSI_ESCAPE_RE.sub("", output).split()) # --------------------------------------------------------------------------- @@ -203,6 +212,43 @@ class TestParseTickFlags: parse_tick_flags("INVALID") +# --------------------------------------------------------------------------- +# parse_request +# --------------------------------------------------------------------------- + + +class TestParseRequest: + """Tests for parse_request.""" + + def test_inline_json(self) -> None: + """Test parsing an inline JSON object string.""" + result = parse_request('{"action": 1, "symbol": "EURUSD"}') + assert result == {"action": 1, "symbol": "EURUSD"} + + def test_file_reference(self, tmp_path: Path) -> None: + """Test parsing JSON from a file via the @path syntax.""" + path = tmp_path / "req.json" + path.write_text('{"action": 2}', encoding="utf-8") + result = parse_request(f"@{path}") + assert result == {"action": 2} + + def test_invalid_json_raises(self) -> None: + """Test that invalid JSON raises ValueError.""" + with pytest.raises(ValueError, match="Invalid JSON request"): + parse_request("not json") + + def test_non_object_raises(self) -> None: + """Test that a non-object JSON raises ValueError.""" + with pytest.raises(ValueError, match="must be a JSON object"): + parse_request("[1, 2, 3]") + + def test_missing_file_raises(self, tmp_path: Path) -> None: + """Test that a missing request file raises ValueError.""" + path = tmp_path / "missing.json" + with pytest.raises(ValueError, match="Failed to read JSON request file"): + parse_request(f"@{path}") + + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -279,6 +325,19 @@ class TestTickFlagsType: TICK_FLAGS_TYPE.convert("bad", None, None) +class TestRequestType: + """Tests for _RequestType.""" + + def test_convert_string(self) -> None: + """Test converting a JSON string to a request dictionary.""" + assert REQUEST_TYPE.convert('{"action": 1}', None, None) == {"action": 1} + + def test_convert_invalid(self) -> None: + """Test that invalid values raise BadParameter.""" + with pytest.raises(Exception, match="Invalid JSON request"): + REQUEST_TYPE.convert("bad", None, None) + + # --------------------------------------------------------------------------- # _execute_export # --------------------------------------------------------------------------- @@ -331,6 +390,12 @@ def mock_client(mocker: MockerFixture) -> MagicMock: client.positions_get_as_df.return_value = sample_df client.history_orders_get_as_df.return_value = sample_df client.history_deals_get_as_df.return_value = sample_df + client.version_as_df.return_value = sample_df + client.last_error_as_df.return_value = sample_df + client.symbol_info_tick_as_df.return_value = sample_df + client.market_book_get_as_df.return_value = sample_df + client.order_check_as_df.return_value = sample_df + client.order_send_as_df.return_value = sample_df mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client) return client @@ -630,6 +695,213 @@ class TestCommands: assert result.exit_code == 0, result.output mock_client.history_deals_get_as_df.assert_called_once() + def test_version( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test version command.""" + output = tmp_path / "out.csv" + result = runner.invoke(app, ["-o", str(output), "version"]) + assert result.exit_code == 0, result.output + mock_client.version_as_df.assert_called_once() + + def test_last_error( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test last-error command.""" + output = tmp_path / "out.csv" + result = runner.invoke(app, ["-o", str(output), "last-error"]) + assert result.exit_code == 0, result.output + mock_client.last_error_as_df.assert_called_once() + + def test_symbol_info_tick( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test symbol-info-tick command.""" + output = tmp_path / "out.csv" + result = runner.invoke( + app, + ["-o", str(output), "symbol-info-tick", "--symbol", "EURUSD"], + ) + assert result.exit_code == 0, result.output + mock_client.symbol_info_tick_as_df.assert_called_once_with( + symbol="EURUSD", + ) + + def test_market_book( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test market-book command.""" + output = tmp_path / "out.csv" + result = runner.invoke( + app, + ["-o", str(output), "market-book", "--symbol", "EURUSD"], + ) + assert result.exit_code == 0, result.output + mock_client.market_book_get_as_df.assert_called_once_with( + symbol="EURUSD", + ) + + def test_order_check( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test order-check command with inline JSON.""" + output = tmp_path / "out.csv" + request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1}) + result = runner.invoke( + app, + ["-o", str(output), "order-check", "--request", request], + ) + assert result.exit_code == 0, result.output + mock_client.order_check_as_df.assert_called_once_with( + request={"action": 1, "symbol": "EURUSD", "volume": 0.1}, + ) + + def test_order_check_file_reference( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test order-check command with file-based JSON.""" + output = tmp_path / "out.csv" + req_path = tmp_path / "req.json" + req_path.write_text( + json.dumps({"action": 2, "symbol": "EURUSD"}), + encoding="utf-8", + ) + result = runner.invoke( + app, + ["-o", str(output), "order-check", "--request", f"@{req_path}"], + ) + assert result.exit_code == 0, result.output + mock_client.order_check_as_df.assert_called_once_with( + request={"action": 2, "symbol": "EURUSD"}, + ) + + def test_order_check_invalid_request( + self, + tmp_path: Path, + mock_client: MagicMock, # noqa: ARG002 + ) -> None: + """Test order-check rejects invalid JSON.""" + output = tmp_path / "out.csv" + result = runner.invoke( + app, + ["-o", str(output), "order-check", "--request", "not-json"], + ) + assert result.exit_code != 0 + assert "Invalid JSON request" in normalize_cli_output(result.output) + + def test_order_check_missing_request_file( + self, + tmp_path: Path, + mock_client: MagicMock, # noqa: ARG002 + ) -> None: + """Test order-check rejects a missing request file.""" + output = tmp_path / "out.csv" + missing = tmp_path / "missing.json" + result = runner.invoke( + app, + ["-o", str(output), "order-check", "--request", f"@{missing}"], + ) + assert result.exit_code != 0 + assert "Failed to read JSON request file" in normalize_cli_output( + result.output, + ) + + def test_order_send( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test order-send command with file-based JSON.""" + output = tmp_path / "out.csv" + req_path = tmp_path / "req.json" + req_path.write_text( + json.dumps({"action": 2, "symbol": "EURUSD"}), + encoding="utf-8", + ) + result = runner.invoke( + app, + [ + "-o", + str(output), + "order-send", + "--request", + f"@{req_path}", + "--yes", + ], + ) + assert result.exit_code == 0, result.output + mock_client.order_send_as_df.assert_called_once_with( + request={"action": 2, "symbol": "EURUSD"}, + ) + + def test_order_send_inline_json( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test order-send command with inline JSON.""" + output = tmp_path / "out.csv" + request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1}) + result = runner.invoke( + app, + [ + "-o", + str(output), + "order-send", + "--request", + request, + "--yes", + ], + ) + assert result.exit_code == 0, result.output + mock_client.order_send_as_df.assert_called_once_with( + request={"action": 1, "symbol": "EURUSD", "volume": 0.1}, + ) + + def test_order_send_requires_yes( + self, + tmp_path: Path, + mock_client: MagicMock, + ) -> None: + """Test order-send requires explicit confirmation.""" + output = tmp_path / "out.csv" + request = json.dumps({"action": 1, "symbol": "EURUSD"}) + result = runner.invoke( + app, + ["-o", str(output), "order-send", "--request", request], + ) + assert result.exit_code != 0 + assert "Pass --yes to send a live trade request" in normalize_cli_output( + result.output, + ) + mock_client.order_send_as_df.assert_not_called() + + def test_order_send_invalid_request( + self, + tmp_path: Path, + mock_client: MagicMock, # noqa: ARG002 + ) -> None: + """Test order-send rejects invalid JSON.""" + output = tmp_path / "out.csv" + result = runner.invoke( + app, + ["-o", str(output), "order-send", "--request", "[1,2]", "--yes"], + ) + assert result.exit_code != 0 + assert "must be a JSON object" in normalize_cli_output(result.output) + # --------------------------------------------------------------------------- # Callback / shared options @@ -647,7 +919,7 @@ class TestCallback: ["-o", str(output), "account-info"], ) assert result.exit_code != 0 - assert "Cannot detect format" in result.output + assert "Cannot detect format" in normalize_cli_output(result.output) def test_connection_args_forwarded( self, diff --git a/uv.lock b/uv.lock index 35bd7cc..85d133d 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "mt5cli" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" },