Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ce76c9d6 | |||
| 5750625e14 | |||
| 2cf4d1cf6b |
@@ -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]'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user