mt5cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/workflows/ci.yml +0 -13
  2. mt5cli-0.2.0/.github/workflows/release.yml +44 -0
  3. {mt5cli-0.1.0 → mt5cli-0.2.0}/PKG-INFO +24 -16
  4. {mt5cli-0.1.0 → mt5cli-0.2.0}/README.md +23 -15
  5. {mt5cli-0.1.0 → mt5cli-0.2.0}/docs/index.md +20 -12
  6. {mt5cli-0.1.0 → mt5cli-0.2.0}/mt5cli/cli.py +149 -2
  7. {mt5cli-0.1.0 → mt5cli-0.2.0}/pyproject.toml +1 -1
  8. {mt5cli-0.1.0 → mt5cli-0.2.0}/tests/test_cli.py +273 -1
  9. {mt5cli-0.1.0 → mt5cli-0.2.0}/uv.lock +1 -1
  10. mt5cli-0.1.0/.github/workflows/release.yml +0 -94
  11. {mt5cli-0.1.0 → mt5cli-0.2.0}/.agents/skills/local-qa/SKILL.md +0 -0
  12. {mt5cli-0.1.0 → mt5cli-0.2.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  13. {mt5cli-0.1.0 → mt5cli-0.2.0}/.agents/skills/mt5cli/SKILL.md +0 -0
  14. {mt5cli-0.1.0 → mt5cli-0.2.0}/.claude/agents/codex.md +0 -0
  15. {mt5cli-0.1.0 → mt5cli-0.2.0}/.claude/settings.json +0 -0
  16. {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/FUNDING.yml +0 -0
  17. {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/dependabot.yml +0 -0
  18. {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/renovate.json +0 -0
  19. {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/workflows/claude.yml +0 -0
  20. {mt5cli-0.1.0 → mt5cli-0.2.0}/.gitignore +0 -0
  21. {mt5cli-0.1.0 → mt5cli-0.2.0}/AGENTS.md +0 -0
  22. {mt5cli-0.1.0 → mt5cli-0.2.0}/CLAUDE.md +0 -0
  23. {mt5cli-0.1.0 → mt5cli-0.2.0}/LICENSE +0 -0
  24. {mt5cli-0.1.0 → mt5cli-0.2.0}/docs/api/cli.md +0 -0
  25. {mt5cli-0.1.0 → mt5cli-0.2.0}/docs/api/index.md +0 -0
  26. {mt5cli-0.1.0 → mt5cli-0.2.0}/mkdocs.yml +0 -0
  27. {mt5cli-0.1.0 → mt5cli-0.2.0}/mt5cli/__init__.py +0 -0
  28. {mt5cli-0.1.0 → mt5cli-0.2.0}/mt5cli/__main__.py +0 -0
  29. {mt5cli-0.1.0 → mt5cli-0.2.0}/tests/__init__.py +0 -0
@@ -63,19 +63,6 @@ jobs:
63
63
  runs-on: ubuntu-slim
64
64
  secrets:
65
65
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66
- python-package-release:
67
- if: >
68
- github.event_name == 'push'
69
- || (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
70
- permissions:
71
- contents: write
72
- id-token: write
73
- uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
74
- with:
75
- package-path: .
76
- create-releases: false
77
- secrets:
78
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79
66
  dependabot-auto-merge:
80
67
  if: >
81
68
  github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: Release
3
+ on:
4
+ workflow_dispatch:
5
+ permissions:
6
+ contents: read
7
+ defaults:
8
+ run:
9
+ shell: bash -euo pipefail {0}
10
+ working-directory: .
11
+ jobs:
12
+ build-and-release:
13
+ permissions:
14
+ contents: write
15
+ id-token: write
16
+ uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
17
+ with:
18
+ package-path: .
19
+ create-github-release: true
20
+ publish-to-pypi: false
21
+ secrets:
22
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23
+ publish-to-pypi:
24
+ name: Publish the Python 🐍 distribution 📦 to PyPI
25
+ if: >
26
+ startsWith(github.ref, 'refs/tags/')
27
+ needs:
28
+ - build-and-release
29
+ runs-on: ubuntu-latest
30
+ environment:
31
+ name: pypi
32
+ url: https://pypi.org/p/${{ needs.build-and-release.outputs.project-name }}
33
+ permissions:
34
+ id-token: write # IMPORTANT: mandatory for trusted publishing
35
+ steps:
36
+ - name: Download all the dists
37
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
38
+ with:
39
+ name: ${{ needs.build-and-release.outputs.distribution-artifact-name }}
40
+ path: dist/
41
+ - name: Publish distribution 📦 to PyPI
42
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
43
+ with:
44
+ verbose: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Command-line tool for MetaTrader 5
5
5
  Project-URL: Repository, https://github.com/dceoy/mt5cli.git
6
6
  Author-email: dceoy <dceoy@users.noreply.github.com>
@@ -74,21 +74,29 @@ python -m mt5cli -o account.csv account-info
74
74
 
75
75
  ## Commands
76
76
 
77
- | Command | Description |
78
- | ---------------- | ---------------------------------- |
79
- | `rates-from` | Export rates from a start date |
80
- | `rates-from-pos` | Export rates from a start position |
81
- | `rates-range` | Export rates for a date range |
82
- | `ticks-from` | Export ticks from a start date |
83
- | `ticks-range` | Export ticks for a date range |
84
- | `account-info` | Export account information |
85
- | `terminal-info` | Export terminal information |
86
- | `symbols` | Export symbol list |
87
- | `symbol-info` | Export symbol details |
88
- | `orders` | Export active orders |
89
- | `positions` | Export open positions |
90
- | `history-orders` | Export historical orders |
91
- | `history-deals` | Export historical deals |
77
+ | Command | Description |
78
+ | ------------------ | ------------------------------------------- |
79
+ | `rates-from` | Export rates from a start date |
80
+ | `rates-from-pos` | Export rates from a start position |
81
+ | `rates-range` | Export rates for a date range |
82
+ | `ticks-from` | Export ticks from a start date |
83
+ | `ticks-range` | Export ticks for a date range |
84
+ | `account-info` | Export account information |
85
+ | `terminal-info` | Export terminal information |
86
+ | `version` | Export MetaTrader 5 version information |
87
+ | `last-error` | Export the last error information |
88
+ | `symbols` | Export symbol list |
89
+ | `symbol-info` | Export symbol details |
90
+ | `symbol-info-tick` | Export the last tick for a symbol |
91
+ | `market-book` | Export market depth (order book) |
92
+ | `orders` | Export active orders |
93
+ | `positions` | Export open positions |
94
+ | `history-orders` | Export historical orders |
95
+ | `history-deals` | Export historical deals |
96
+ | `order-check` | Check funds sufficiency for a trade request |
97
+ | `order-send` | Send a trade request to the trade server (`--yes` required) |
98
+
99
+ Use `order-check` to validate a request payload before running `order-send --yes`.
92
100
 
93
101
  ## Requirements
94
102
 
@@ -50,21 +50,29 @@ python -m mt5cli -o account.csv account-info
50
50
 
51
51
  ## Commands
52
52
 
53
- | Command | Description |
54
- | ---------------- | ---------------------------------- |
55
- | `rates-from` | Export rates from a start date |
56
- | `rates-from-pos` | Export rates from a start position |
57
- | `rates-range` | Export rates for a date range |
58
- | `ticks-from` | Export ticks from a start date |
59
- | `ticks-range` | Export ticks for a date range |
60
- | `account-info` | Export account information |
61
- | `terminal-info` | Export terminal information |
62
- | `symbols` | Export symbol list |
63
- | `symbol-info` | Export symbol details |
64
- | `orders` | Export active orders |
65
- | `positions` | Export open positions |
66
- | `history-orders` | Export historical orders |
67
- | `history-deals` | Export historical deals |
53
+ | Command | Description |
54
+ | ------------------ | ------------------------------------------- |
55
+ | `rates-from` | Export rates from a start date |
56
+ | `rates-from-pos` | Export rates from a start position |
57
+ | `rates-range` | Export rates for a date range |
58
+ | `ticks-from` | Export ticks from a start date |
59
+ | `ticks-range` | Export ticks for a date range |
60
+ | `account-info` | Export account information |
61
+ | `terminal-info` | Export terminal information |
62
+ | `version` | Export MetaTrader 5 version information |
63
+ | `last-error` | Export the last error information |
64
+ | `symbols` | Export symbol list |
65
+ | `symbol-info` | Export symbol details |
66
+ | `symbol-info-tick` | Export the last tick for a symbol |
67
+ | `market-book` | Export market depth (order book) |
68
+ | `orders` | Export active orders |
69
+ | `positions` | Export open positions |
70
+ | `history-orders` | Export historical orders |
71
+ | `history-deals` | Export historical deals |
72
+ | `order-check` | Check funds sufficiency for a trade request |
73
+ | `order-send` | Send a trade request to the trade server (`--yes` required) |
74
+
75
+ Use `order-check` to validate a request payload before running `order-send --yes`.
68
76
 
69
77
  ## Requirements
70
78
 
@@ -61,21 +61,29 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
61
61
 
62
62
  ### Information
63
63
 
64
- | Command | Description |
65
- | --------------- | --------------------------- |
66
- | `account-info` | Export account information |
67
- | `terminal-info` | Export terminal information |
68
- | `symbols` | Export symbol list |
69
- | `symbol-info` | Export symbol details |
64
+ | Command | Description |
65
+ | ------------------ | --------------------------------------- |
66
+ | `account-info` | Export account information |
67
+ | `terminal-info` | Export terminal information |
68
+ | `version` | Export MetaTrader 5 version information |
69
+ | `last-error` | Export the last error information |
70
+ | `symbols` | Export symbol list |
71
+ | `symbol-info` | Export symbol details |
72
+ | `symbol-info-tick` | Export the last tick for a symbol |
73
+ | `market-book` | Export market depth (order book) |
70
74
 
71
75
  ### Trading
72
76
 
73
- | Command | Description |
74
- | ---------------- | ------------------------ |
75
- | `orders` | Export active orders |
76
- | `positions` | Export open positions |
77
- | `history-orders` | Export historical orders |
78
- | `history-deals` | Export historical deals |
77
+ | Command | Description |
78
+ | ---------------- | ------------------------------------------- |
79
+ | `orders` | Export active orders |
80
+ | `positions` | Export open positions |
81
+ | `history-orders` | Export historical orders |
82
+ | `history-deals` | Export historical deals |
83
+ | `order-check` | Check funds sufficiency for a trade request |
84
+ | `order-send` | Send a trade request to the trade server (`--yes` required) |
85
+
86
+ Use `order-check` to validate a request payload before running `order-send --yes`.
79
87
 
80
88
  ## Global Options
81
89
 
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import logging
6
7
  import sqlite3
7
8
  from dataclasses import dataclass
8
9
  from datetime import UTC, datetime
9
10
  from enum import StrEnum
10
- from pathlib import Path # noqa: TC003
11
- from typing import TYPE_CHECKING, Annotated, cast
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Annotated, Any, TypeGuard, cast
12
13
 
13
14
  import click
14
15
  import typer
@@ -180,9 +181,37 @@ class _TickFlagsType(click.ParamType):
180
181
  self.fail(str(exc), param, ctx)
181
182
 
182
183
 
184
+ class _RequestType(click.ParamType):
185
+ """Click parameter type for JSON order requests."""
186
+
187
+ name = "REQUEST"
188
+
189
+ def convert(
190
+ self,
191
+ value: object,
192
+ param: click.Parameter | None,
193
+ ctx: click.Context | None,
194
+ ) -> dict[str, Any]:
195
+ """Convert a raw CLI value to an order request dictionary.
196
+
197
+ Args:
198
+ value: Raw value from the command line.
199
+ param: Click parameter instance.
200
+ ctx: Click context.
201
+
202
+ Returns:
203
+ Parsed request dictionary.
204
+ """
205
+ try:
206
+ return parse_request(str(value))
207
+ except ValueError as exc:
208
+ self.fail(str(exc), param, ctx)
209
+
210
+
183
211
  DATETIME_TYPE = _DateTimeType()
184
212
  TIMEFRAME_TYPE = _TimeframeType()
185
213
  TICK_FLAGS_TYPE = _TickFlagsType()
214
+ REQUEST_TYPE = _RequestType()
186
215
 
187
216
  # ---------------------------------------------------------------------------
188
217
  # Export context
@@ -342,6 +371,43 @@ def parse_tick_flags(value: str) -> int:
342
371
  raise ValueError(msg) from None
343
372
 
344
373
 
374
+ def _is_request_dict(value: object) -> TypeGuard[dict[str, Any]]:
375
+ return isinstance(value, dict)
376
+
377
+
378
+ def parse_request(value: str) -> dict[str, Any]:
379
+ """Parse a JSON-formatted order request string or file reference.
380
+
381
+ Args:
382
+ value: JSON object string, or '@path' to read JSON from a file.
383
+
384
+ Returns:
385
+ Parsed request dictionary.
386
+
387
+ Raises:
388
+ ValueError: If the request file cannot be read or the value is not a
389
+ JSON object.
390
+ """
391
+ if value.startswith("@"):
392
+ path = Path(value[1:])
393
+ try:
394
+ text = path.read_text(encoding="utf-8")
395
+ except (OSError, UnicodeDecodeError) as exc:
396
+ msg = f"Failed to read JSON request file '{path}': {exc}"
397
+ raise ValueError(msg) from exc
398
+ else:
399
+ text = value
400
+ try:
401
+ parsed: object = json.loads(text)
402
+ except json.JSONDecodeError as exc:
403
+ msg = f"Invalid JSON request: {exc}"
404
+ raise ValueError(msg) from exc
405
+ if not _is_request_dict(parsed):
406
+ msg = "Order request must be a JSON object."
407
+ raise ValueError(msg)
408
+ return parsed
409
+
410
+
345
411
  # ---------------------------------------------------------------------------
346
412
  # Typer application
347
413
  # ---------------------------------------------------------------------------
@@ -351,6 +417,10 @@ app = typer.Typer(
351
417
  help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.",
352
418
  )
353
419
 
420
+ _REQUEST_OPTION_HELP = (
421
+ "Order request as a JSON object string, or '@path' to load JSON from a file."
422
+ )
423
+
354
424
 
355
425
  def _get_export_context(ctx: typer.Context) -> _ExportContext:
356
426
  return cast("_ExportContext", ctx.obj)
@@ -747,6 +817,83 @@ def history_deals(
747
817
  )
748
818
 
749
819
 
820
+ @app.command()
821
+ def version(ctx: typer.Context) -> None:
822
+ """Export MetaTrader5 version information."""
823
+ _execute_export(ctx, lambda c: c.version_as_df())
824
+
825
+
826
+ @app.command()
827
+ def last_error(ctx: typer.Context) -> None:
828
+ """Export the last error information."""
829
+ _execute_export(ctx, lambda c: c.last_error_as_df())
830
+
831
+
832
+ @app.command()
833
+ def symbol_info_tick(
834
+ ctx: typer.Context,
835
+ symbol: Annotated[str, typer.Option(help="Symbol name.")],
836
+ ) -> None:
837
+ """Export the last tick for a symbol."""
838
+ _execute_export(
839
+ ctx,
840
+ lambda c: c.symbol_info_tick_as_df(symbol=symbol),
841
+ )
842
+
843
+
844
+ @app.command()
845
+ def market_book(
846
+ ctx: typer.Context,
847
+ symbol: Annotated[str, typer.Option(help="Symbol name.")],
848
+ ) -> None:
849
+ """Export market depth (order book) for a symbol."""
850
+ _execute_export(
851
+ ctx,
852
+ lambda c: c.market_book_get_as_df(symbol=symbol),
853
+ )
854
+
855
+
856
+ @app.command()
857
+ def order_check(
858
+ ctx: typer.Context,
859
+ request: Annotated[
860
+ dict[str, Any],
861
+ typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
862
+ ],
863
+ ) -> None:
864
+ """Check funds sufficiency for a trading operation."""
865
+ _execute_export(
866
+ ctx,
867
+ lambda c: c.order_check_as_df(request=request),
868
+ )
869
+
870
+
871
+ @app.command()
872
+ def order_send(
873
+ ctx: typer.Context,
874
+ request: Annotated[
875
+ dict[str, Any],
876
+ typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
877
+ ],
878
+ yes: Annotated[
879
+ bool,
880
+ typer.Option("--yes", help="Confirm the live trade request."),
881
+ ] = False,
882
+ ) -> None:
883
+ """Send a trading operation request to the trade server.
884
+
885
+ Raises:
886
+ typer.BadParameter: If --yes is not provided.
887
+ """
888
+ if not yes:
889
+ msg = "Pass --yes to send a live trade request."
890
+ raise typer.BadParameter(msg, param_hint="--yes")
891
+ _execute_export(
892
+ ctx,
893
+ lambda c: c.order_send_as_df(request=request),
894
+ )
895
+
896
+
750
897
  def main() -> None:
751
898
  """Run the mt5cli CLI."""
752
899
  app()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Command-line tool for MetaTrader 5"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  import sqlite3
7
8
  from datetime import UTC, datetime
8
9
  from typing import TYPE_CHECKING
@@ -18,6 +19,7 @@ if TYPE_CHECKING:
18
19
 
19
20
  from mt5cli.cli import (
20
21
  DATETIME_TYPE,
22
+ REQUEST_TYPE,
21
23
  TICK_FLAG_MAP,
22
24
  TICK_FLAGS_TYPE,
23
25
  TIMEFRAME_MAP,
@@ -29,11 +31,18 @@ from mt5cli.cli import (
29
31
  export_dataframe,
30
32
  main,
31
33
  parse_datetime,
34
+ parse_request,
32
35
  parse_tick_flags,
33
36
  parse_timeframe,
34
37
  )
35
38
 
36
39
  runner = CliRunner()
40
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
41
+
42
+
43
+ def normalize_cli_output(output: str) -> str:
44
+ """Normalize CLI output for cross-platform assertions."""
45
+ return " ".join(_ANSI_ESCAPE_RE.sub("", output).split())
37
46
 
38
47
 
39
48
  # ---------------------------------------------------------------------------
@@ -203,6 +212,43 @@ class TestParseTickFlags:
203
212
  parse_tick_flags("INVALID")
204
213
 
205
214
 
215
+ # ---------------------------------------------------------------------------
216
+ # parse_request
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ class TestParseRequest:
221
+ """Tests for parse_request."""
222
+
223
+ def test_inline_json(self) -> None:
224
+ """Test parsing an inline JSON object string."""
225
+ result = parse_request('{"action": 1, "symbol": "EURUSD"}')
226
+ assert result == {"action": 1, "symbol": "EURUSD"}
227
+
228
+ def test_file_reference(self, tmp_path: Path) -> None:
229
+ """Test parsing JSON from a file via the @path syntax."""
230
+ path = tmp_path / "req.json"
231
+ path.write_text('{"action": 2}', encoding="utf-8")
232
+ result = parse_request(f"@{path}")
233
+ assert result == {"action": 2}
234
+
235
+ def test_invalid_json_raises(self) -> None:
236
+ """Test that invalid JSON raises ValueError."""
237
+ with pytest.raises(ValueError, match="Invalid JSON request"):
238
+ parse_request("not json")
239
+
240
+ def test_non_object_raises(self) -> None:
241
+ """Test that a non-object JSON raises ValueError."""
242
+ with pytest.raises(ValueError, match="must be a JSON object"):
243
+ parse_request("[1, 2, 3]")
244
+
245
+ def test_missing_file_raises(self, tmp_path: Path) -> None:
246
+ """Test that a missing request file raises ValueError."""
247
+ path = tmp_path / "missing.json"
248
+ with pytest.raises(ValueError, match="Failed to read JSON request file"):
249
+ parse_request(f"@{path}")
250
+
251
+
206
252
  # ---------------------------------------------------------------------------
207
253
  # Constants
208
254
  # ---------------------------------------------------------------------------
@@ -279,6 +325,19 @@ class TestTickFlagsType:
279
325
  TICK_FLAGS_TYPE.convert("bad", None, None)
280
326
 
281
327
 
328
+ class TestRequestType:
329
+ """Tests for _RequestType."""
330
+
331
+ def test_convert_string(self) -> None:
332
+ """Test converting a JSON string to a request dictionary."""
333
+ assert REQUEST_TYPE.convert('{"action": 1}', None, None) == {"action": 1}
334
+
335
+ def test_convert_invalid(self) -> None:
336
+ """Test that invalid values raise BadParameter."""
337
+ with pytest.raises(Exception, match="Invalid JSON request"):
338
+ REQUEST_TYPE.convert("bad", None, None)
339
+
340
+
282
341
  # ---------------------------------------------------------------------------
283
342
  # _execute_export
284
343
  # ---------------------------------------------------------------------------
@@ -331,6 +390,12 @@ def mock_client(mocker: MockerFixture) -> MagicMock:
331
390
  client.positions_get_as_df.return_value = sample_df
332
391
  client.history_orders_get_as_df.return_value = sample_df
333
392
  client.history_deals_get_as_df.return_value = sample_df
393
+ client.version_as_df.return_value = sample_df
394
+ client.last_error_as_df.return_value = sample_df
395
+ client.symbol_info_tick_as_df.return_value = sample_df
396
+ client.market_book_get_as_df.return_value = sample_df
397
+ client.order_check_as_df.return_value = sample_df
398
+ client.order_send_as_df.return_value = sample_df
334
399
  mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
335
400
  return client
336
401
 
@@ -630,6 +695,213 @@ class TestCommands:
630
695
  assert result.exit_code == 0, result.output
631
696
  mock_client.history_deals_get_as_df.assert_called_once()
632
697
 
698
+ def test_version(
699
+ self,
700
+ tmp_path: Path,
701
+ mock_client: MagicMock,
702
+ ) -> None:
703
+ """Test version command."""
704
+ output = tmp_path / "out.csv"
705
+ result = runner.invoke(app, ["-o", str(output), "version"])
706
+ assert result.exit_code == 0, result.output
707
+ mock_client.version_as_df.assert_called_once()
708
+
709
+ def test_last_error(
710
+ self,
711
+ tmp_path: Path,
712
+ mock_client: MagicMock,
713
+ ) -> None:
714
+ """Test last-error command."""
715
+ output = tmp_path / "out.csv"
716
+ result = runner.invoke(app, ["-o", str(output), "last-error"])
717
+ assert result.exit_code == 0, result.output
718
+ mock_client.last_error_as_df.assert_called_once()
719
+
720
+ def test_symbol_info_tick(
721
+ self,
722
+ tmp_path: Path,
723
+ mock_client: MagicMock,
724
+ ) -> None:
725
+ """Test symbol-info-tick command."""
726
+ output = tmp_path / "out.csv"
727
+ result = runner.invoke(
728
+ app,
729
+ ["-o", str(output), "symbol-info-tick", "--symbol", "EURUSD"],
730
+ )
731
+ assert result.exit_code == 0, result.output
732
+ mock_client.symbol_info_tick_as_df.assert_called_once_with(
733
+ symbol="EURUSD",
734
+ )
735
+
736
+ def test_market_book(
737
+ self,
738
+ tmp_path: Path,
739
+ mock_client: MagicMock,
740
+ ) -> None:
741
+ """Test market-book command."""
742
+ output = tmp_path / "out.csv"
743
+ result = runner.invoke(
744
+ app,
745
+ ["-o", str(output), "market-book", "--symbol", "EURUSD"],
746
+ )
747
+ assert result.exit_code == 0, result.output
748
+ mock_client.market_book_get_as_df.assert_called_once_with(
749
+ symbol="EURUSD",
750
+ )
751
+
752
+ def test_order_check(
753
+ self,
754
+ tmp_path: Path,
755
+ mock_client: MagicMock,
756
+ ) -> None:
757
+ """Test order-check command with inline JSON."""
758
+ output = tmp_path / "out.csv"
759
+ request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
760
+ result = runner.invoke(
761
+ app,
762
+ ["-o", str(output), "order-check", "--request", request],
763
+ )
764
+ assert result.exit_code == 0, result.output
765
+ mock_client.order_check_as_df.assert_called_once_with(
766
+ request={"action": 1, "symbol": "EURUSD", "volume": 0.1},
767
+ )
768
+
769
+ def test_order_check_file_reference(
770
+ self,
771
+ tmp_path: Path,
772
+ mock_client: MagicMock,
773
+ ) -> None:
774
+ """Test order-check command with file-based JSON."""
775
+ output = tmp_path / "out.csv"
776
+ req_path = tmp_path / "req.json"
777
+ req_path.write_text(
778
+ json.dumps({"action": 2, "symbol": "EURUSD"}),
779
+ encoding="utf-8",
780
+ )
781
+ result = runner.invoke(
782
+ app,
783
+ ["-o", str(output), "order-check", "--request", f"@{req_path}"],
784
+ )
785
+ assert result.exit_code == 0, result.output
786
+ mock_client.order_check_as_df.assert_called_once_with(
787
+ request={"action": 2, "symbol": "EURUSD"},
788
+ )
789
+
790
+ def test_order_check_invalid_request(
791
+ self,
792
+ tmp_path: Path,
793
+ mock_client: MagicMock, # noqa: ARG002
794
+ ) -> None:
795
+ """Test order-check rejects invalid JSON."""
796
+ output = tmp_path / "out.csv"
797
+ result = runner.invoke(
798
+ app,
799
+ ["-o", str(output), "order-check", "--request", "not-json"],
800
+ )
801
+ assert result.exit_code != 0
802
+ assert "Invalid JSON request" in normalize_cli_output(result.output)
803
+
804
+ def test_order_check_missing_request_file(
805
+ self,
806
+ tmp_path: Path,
807
+ mock_client: MagicMock, # noqa: ARG002
808
+ ) -> None:
809
+ """Test order-check rejects a missing request file."""
810
+ output = tmp_path / "out.csv"
811
+ missing = tmp_path / "missing.json"
812
+ result = runner.invoke(
813
+ app,
814
+ ["-o", str(output), "order-check", "--request", f"@{missing}"],
815
+ )
816
+ assert result.exit_code != 0
817
+ assert "Failed to read JSON request file" in normalize_cli_output(
818
+ result.output,
819
+ )
820
+
821
+ def test_order_send(
822
+ self,
823
+ tmp_path: Path,
824
+ mock_client: MagicMock,
825
+ ) -> None:
826
+ """Test order-send command with file-based JSON."""
827
+ output = tmp_path / "out.csv"
828
+ req_path = tmp_path / "req.json"
829
+ req_path.write_text(
830
+ json.dumps({"action": 2, "symbol": "EURUSD"}),
831
+ encoding="utf-8",
832
+ )
833
+ result = runner.invoke(
834
+ app,
835
+ [
836
+ "-o",
837
+ str(output),
838
+ "order-send",
839
+ "--request",
840
+ f"@{req_path}",
841
+ "--yes",
842
+ ],
843
+ )
844
+ assert result.exit_code == 0, result.output
845
+ mock_client.order_send_as_df.assert_called_once_with(
846
+ request={"action": 2, "symbol": "EURUSD"},
847
+ )
848
+
849
+ def test_order_send_inline_json(
850
+ self,
851
+ tmp_path: Path,
852
+ mock_client: MagicMock,
853
+ ) -> None:
854
+ """Test order-send command with inline JSON."""
855
+ output = tmp_path / "out.csv"
856
+ request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
857
+ result = runner.invoke(
858
+ app,
859
+ [
860
+ "-o",
861
+ str(output),
862
+ "order-send",
863
+ "--request",
864
+ request,
865
+ "--yes",
866
+ ],
867
+ )
868
+ assert result.exit_code == 0, result.output
869
+ mock_client.order_send_as_df.assert_called_once_with(
870
+ request={"action": 1, "symbol": "EURUSD", "volume": 0.1},
871
+ )
872
+
873
+ def test_order_send_requires_yes(
874
+ self,
875
+ tmp_path: Path,
876
+ mock_client: MagicMock,
877
+ ) -> None:
878
+ """Test order-send requires explicit confirmation."""
879
+ output = tmp_path / "out.csv"
880
+ request = json.dumps({"action": 1, "symbol": "EURUSD"})
881
+ result = runner.invoke(
882
+ app,
883
+ ["-o", str(output), "order-send", "--request", request],
884
+ )
885
+ assert result.exit_code != 0
886
+ assert "Pass --yes to send a live trade request" in normalize_cli_output(
887
+ result.output,
888
+ )
889
+ mock_client.order_send_as_df.assert_not_called()
890
+
891
+ def test_order_send_invalid_request(
892
+ self,
893
+ tmp_path: Path,
894
+ mock_client: MagicMock, # noqa: ARG002
895
+ ) -> None:
896
+ """Test order-send rejects invalid JSON."""
897
+ output = tmp_path / "out.csv"
898
+ result = runner.invoke(
899
+ app,
900
+ ["-o", str(output), "order-send", "--request", "[1,2]", "--yes"],
901
+ )
902
+ assert result.exit_code != 0
903
+ assert "must be a JSON object" in normalize_cli_output(result.output)
904
+
633
905
 
634
906
  # ---------------------------------------------------------------------------
635
907
  # Callback / shared options
@@ -647,7 +919,7 @@ class TestCallback:
647
919
  ["-o", str(output), "account-info"],
648
920
  )
649
921
  assert result.exit_code != 0
650
- assert "Cannot detect format" in result.output
922
+ assert "Cannot detect format" in normalize_cli_output(result.output)
651
923
 
652
924
  def test_connection_args_forwarded(
653
925
  self,
@@ -487,7 +487,7 @@ wheels = [
487
487
 
488
488
  [[package]]
489
489
  name = "mt5cli"
490
- version = "0.1.0"
490
+ version = "0.2.0"
491
491
  source = { editable = "." }
492
492
  dependencies = [
493
493
  { name = "click" },
@@ -1,94 +0,0 @@
1
- ---
2
- name: Release
3
- on:
4
- workflow_dispatch:
5
- permissions:
6
- contents: read
7
- defaults:
8
- run:
9
- shell: bash -euo pipefail {0}
10
- working-directory: .
11
- jobs:
12
- build:
13
- permissions:
14
- contents: write
15
- id-token: write
16
- uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
17
- with:
18
- package-path: .
19
- create-releases: false
20
- secrets:
21
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22
- github-release:
23
- name: Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release
24
- if: >
25
- startsWith(github.ref, 'refs/tags/')
26
- needs:
27
- - build
28
- runs-on: ubuntu-latest
29
- permissions:
30
- contents: write # IMPORTANT: mandatory for making GitHub Releases
31
- id-token: write # IMPORTANT: mandatory for sigstore
32
- steps:
33
- - name: Download all the dists
34
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
35
- with:
36
- name: ${{ needs.build.outputs.distribution-artifact-name }}
37
- path: dist/
38
- - name: Validate the version-tag consistency
39
- env:
40
- TAG_NAME: ${{ github.ref_name }}
41
- NEEDS_BUILD_OUTPUTS_PROJECT_NAME: ${{ needs.build.outputs.project-name }}
42
- run: |
43
- v="$( \
44
- find dist -type f -name "${NEEDS_BUILD_OUTPUTS_PROJECT_NAME}-*" -exec basename {} \; \
45
- | head -n 1 \
46
- | cut -d '-' -f 2 \
47
- )"
48
- v="${v%.tar.gz}"
49
- if [[ "${TAG_NAME}" != "${v}" ]] && [[ "${TAG_NAME}" != "v${v}" ]]; then
50
- echo "The tag (${TAG_NAME}) is inconsistent with the version (${v})." && exit 1
51
- fi
52
- - name: Sign the dists with Sigstore
53
- uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
54
- with:
55
- inputs: >-
56
- ./dist/*.whl
57
- - name: Create GitHub Release
58
- env:
59
- REPOSITORY: ${{ github.repository }}
60
- GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
61
- run: |
62
- gh release create "${GITHUB_REF_NAME}" --repo "${REPOSITORY}" --generate-notes --verify-tag
63
- - name: Upload artifact signatures to GitHub Release
64
- env:
65
- REPOSITORY: ${{ github.repository }}
66
- GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
67
- # Upload to GitHub Release using the `gh` CLI.
68
- # `dist/` contains the built packages, and the
69
- # sigstore-produced signatures and certificates.
70
- run: |
71
- gh release upload "${GITHUB_REF_NAME}" dist/** --repo "${REPOSITORY}"
72
- publish-to-pypi:
73
- name: Publish the Python 🐍 distribution 📦 to PyPI
74
- if: >
75
- startsWith(github.ref, 'refs/tags/')
76
- needs:
77
- - build
78
- - github-release
79
- runs-on: ubuntu-latest
80
- environment:
81
- name: pypi
82
- url: https://pypi.org/p/${{ needs.build.outputs.project-name }}
83
- permissions:
84
- id-token: write # IMPORTANT: mandatory for trusted publishing
85
- steps:
86
- - name: Download all the dists
87
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
88
- with:
89
- name: ${{ needs.build.outputs.distribution-artifact-name }}
90
- path: dist/
91
- - name: Publish distribution 📦 to PyPI
92
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
93
- with:
94
- verbose: true
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes