Compare commits

...

3 Commits

Author SHA1 Message Date
Daichi Narushima b8ce76c9d6 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>
2026-04-26 21:50:19 +09:00
dceoy 5750625e14 Remove python-package-release job 2026-04-26 06:21:18 +09:00
Daichi Narushima 2cf4d1cf6b Update GitHub workflows to use new reusable workflow parameters (#8) 2026-04-23 06:01:54 +09:00
8 changed files with 473 additions and 101 deletions
-13
View File
@@ -63,19 +63,6 @@ jobs:
runs-on: ubuntu-slim runs-on: ubuntu-slim
secrets: secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
python-package-release:
if: >
github.event_name == 'push'
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
permissions:
contents: write
id-token: write
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
with:
package-path: .
create-releases: false
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dependabot-auto-merge: dependabot-auto-merge:
if: > if: >
github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
+6 -56
View File
@@ -9,84 +9,34 @@ defaults:
shell: bash -euo pipefail {0} shell: bash -euo pipefail {0}
working-directory: . working-directory: .
jobs: jobs:
build: build-and-release:
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses] uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
with: with:
package-path: . package-path: .
create-releases: false create-github-release: true
publish-to-pypi: false
secrets: secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
github-release:
name: Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release
if: >
startsWith(github.ref, 'refs/tags/')
needs:
- build
runs-on: ubuntu-latest
permissions:
contents: write # IMPORTANT: mandatory for making GitHub Releases
id-token: write # IMPORTANT: mandatory for sigstore
steps:
- name: Download all the dists
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.build.outputs.distribution-artifact-name }}
path: dist/
- name: Validate the version-tag consistency
env:
TAG_NAME: ${{ github.ref_name }}
NEEDS_BUILD_OUTPUTS_PROJECT_NAME: ${{ needs.build.outputs.project-name }}
run: |
v="$( \
find dist -type f -name "${NEEDS_BUILD_OUTPUTS_PROJECT_NAME}-*" -exec basename {} \; \
| head -n 1 \
| cut -d '-' -f 2 \
)"
v="${v%.tar.gz}"
if [[ "${TAG_NAME}" != "${v}" ]] && [[ "${TAG_NAME}" != "v${v}" ]]; then
echo "The tag (${TAG_NAME}) is inconsistent with the version (${v})." && exit 1
fi
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.whl
- name: Create GitHub Release
env:
REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
run: |
gh release create "${GITHUB_REF_NAME}" --repo "${REPOSITORY}" --generate-notes --verify-tag
- name: Upload artifact signatures to GitHub Release
env:
REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
# Upload to GitHub Release using the `gh` CLI.
# `dist/` contains the built packages, and the
# sigstore-produced signatures and certificates.
run: |
gh release upload "${GITHUB_REF_NAME}" dist/** --repo "${REPOSITORY}"
publish-to-pypi: publish-to-pypi:
name: Publish the Python 🐍 distribution 📦 to PyPI name: Publish the Python 🐍 distribution 📦 to PyPI
if: > if: >
startsWith(github.ref, 'refs/tags/') startsWith(github.ref, 'refs/tags/')
needs: needs:
- build - build-and-release
- github-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: environment:
name: pypi name: pypi
url: https://pypi.org/p/${{ needs.build.outputs.project-name }} url: https://pypi.org/p/${{ needs.build-and-release.outputs.project-name }}
permissions: permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing id-token: write # IMPORTANT: mandatory for trusted publishing
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: ${{ needs.build.outputs.distribution-artifact-name }} name: ${{ needs.build-and-release.outputs.distribution-artifact-name }}
path: dist/ path: dist/
- name: Publish distribution 📦 to PyPI - name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+23 -15
View File
@@ -50,21 +50,29 @@ python -m mt5cli -o account.csv account-info
## Commands ## Commands
| Command | Description | | Command | Description |
| ---------------- | ---------------------------------- | | ------------------ | ------------------------------------------- |
| `rates-from` | Export rates from a start date | | `rates-from` | Export rates from a start date |
| `rates-from-pos` | Export rates from a start position | | `rates-from-pos` | Export rates from a start position |
| `rates-range` | Export rates for a date range | | `rates-range` | Export rates for a date range |
| `ticks-from` | Export ticks from a start date | | `ticks-from` | Export ticks from a start date |
| `ticks-range` | Export ticks for a date range | | `ticks-range` | Export ticks for a date range |
| `account-info` | Export account information | | `account-info` | Export account information |
| `terminal-info` | Export terminal information | | `terminal-info` | Export terminal information |
| `symbols` | Export symbol list | | `version` | Export MetaTrader 5 version information |
| `symbol-info` | Export symbol details | | `last-error` | Export the last error information |
| `orders` | Export active orders | | `symbols` | Export symbol list |
| `positions` | Export open positions | | `symbol-info` | Export symbol details |
| `history-orders` | Export historical orders | | `symbol-info-tick` | Export the last tick for a symbol |
| `history-deals` | Export historical deals | | `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 ## Requirements
+20 -12
View File
@@ -61,21 +61,29 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
### Information ### Information
| Command | Description | | Command | Description |
| --------------- | --------------------------- | | ------------------ | --------------------------------------- |
| `account-info` | Export account information | | `account-info` | Export account information |
| `terminal-info` | Export terminal information | | `terminal-info` | Export terminal information |
| `symbols` | Export symbol list | | `version` | Export MetaTrader 5 version information |
| `symbol-info` | Export symbol details | | `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 ### Trading
| Command | Description | | Command | Description |
| ---------------- | ------------------------ | | ---------------- | ------------------------------------------- |
| `orders` | Export active orders | | `orders` | Export active orders |
| `positions` | Export open positions | | `positions` | Export open positions |
| `history-orders` | Export historical orders | | `history-orders` | Export historical orders |
| `history-deals` | Export historical deals | | `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 ## Global Options
+149 -2
View File
@@ -2,13 +2,14 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
from pathlib import Path # noqa: TC003 from pathlib import Path
from typing import TYPE_CHECKING, Annotated, cast from typing import TYPE_CHECKING, Annotated, Any, TypeGuard, cast
import click import click
import typer import typer
@@ -180,9 +181,37 @@ class _TickFlagsType(click.ParamType):
self.fail(str(exc), param, ctx) 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() DATETIME_TYPE = _DateTimeType()
TIMEFRAME_TYPE = _TimeframeType() TIMEFRAME_TYPE = _TimeframeType()
TICK_FLAGS_TYPE = _TickFlagsType() TICK_FLAGS_TYPE = _TickFlagsType()
REQUEST_TYPE = _RequestType()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Export context # Export context
@@ -342,6 +371,43 @@ def parse_tick_flags(value: str) -> int:
raise ValueError(msg) from None 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 # Typer application
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -351,6 +417,10 @@ app = typer.Typer(
help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.", 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: def _get_export_context(ctx: typer.Context) -> _ExportContext:
return cast("_ExportContext", ctx.obj) 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: def main() -> None:
"""Run the mt5cli CLI.""" """Run the mt5cli CLI."""
app() app()
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "0.1.0" version = "0.2.0"
description = "Command-line tool for MetaTrader 5" description = "Command-line tool for MetaTrader 5"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{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 from __future__ import annotations
import json import json
import re
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -18,6 +19,7 @@ if TYPE_CHECKING:
from mt5cli.cli import ( from mt5cli.cli import (
DATETIME_TYPE, DATETIME_TYPE,
REQUEST_TYPE,
TICK_FLAG_MAP, TICK_FLAG_MAP,
TICK_FLAGS_TYPE, TICK_FLAGS_TYPE,
TIMEFRAME_MAP, TIMEFRAME_MAP,
@@ -29,11 +31,18 @@ from mt5cli.cli import (
export_dataframe, export_dataframe,
main, main,
parse_datetime, parse_datetime,
parse_request,
parse_tick_flags, parse_tick_flags,
parse_timeframe, parse_timeframe,
) )
runner = CliRunner() 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_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 # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -279,6 +325,19 @@ class TestTickFlagsType:
TICK_FLAGS_TYPE.convert("bad", None, None) 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 # _execute_export
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -331,6 +390,12 @@ def mock_client(mocker: MockerFixture) -> MagicMock:
client.positions_get_as_df.return_value = sample_df client.positions_get_as_df.return_value = sample_df
client.history_orders_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.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) mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
return client return client
@@ -630,6 +695,213 @@ class TestCommands:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
mock_client.history_deals_get_as_df.assert_called_once() 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 # Callback / shared options
@@ -647,7 +919,7 @@ class TestCallback:
["-o", str(output), "account-info"], ["-o", str(output), "account-info"],
) )
assert result.exit_code != 0 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( def test_connection_args_forwarded(
self, self,
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "0.1.0" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },