Add CLI commands for remaining pdmt5.dataframe methods (#9)

* Add CLI commands for remaining pdmt5.dataframe methods

Add the following subcommands so the CLI fully covers pdmt5.dataframe's
public *_as_df methods:

- version
- last-error
- symbol-info-tick
- market-book
- order-check (request via inline JSON or @path/to/file.json)
- order-send  (request via inline JSON or @path/to/file.json)

Also export a new parse_request helper for parsing JSON order requests,
add tests for every new command, and update the README and docs command
tables.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump version to 0.2.0

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Address PR review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden CLI error assertions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Daichi Narushima
2026-04-26 21:50:19 +09:00
committed by GitHub
parent 5750625e14
commit b8ce76c9d6
6 changed files with 467 additions and 32 deletions
+23 -15
View File
@@ -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
+20 -12
View File
@@ -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
+149 -2
View File
@@ -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()
+1 -1
View File
@@ -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"}]
+273 -1
View File
@@ -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,
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.1.0"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "click" },