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.
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/workflows/ci.yml +0 -13
- mt5cli-0.2.0/.github/workflows/release.yml +44 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/PKG-INFO +24 -16
- {mt5cli-0.1.0 → mt5cli-0.2.0}/README.md +23 -15
- {mt5cli-0.1.0 → mt5cli-0.2.0}/docs/index.md +20 -12
- {mt5cli-0.1.0 → mt5cli-0.2.0}/mt5cli/cli.py +149 -2
- {mt5cli-0.1.0 → mt5cli-0.2.0}/pyproject.toml +1 -1
- {mt5cli-0.1.0 → mt5cli-0.2.0}/tests/test_cli.py +273 -1
- {mt5cli-0.1.0 → mt5cli-0.2.0}/uv.lock +1 -1
- mt5cli-0.1.0/.github/workflows/release.yml +0 -94
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.claude/agents/codex.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.claude/settings.json +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/FUNDING.yml +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/dependabot.yml +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/renovate.json +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/.gitignore +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/AGENTS.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/CLAUDE.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/LICENSE +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/docs/api/cli.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/docs/api/index.md +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/mkdocs.yml +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/mt5cli/__init__.py +0 -0
- {mt5cli-0.1.0 → mt5cli-0.2.0}/mt5cli/__main__.py +0 -0
- {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.
|
|
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
|
|
78
|
-
|
|
|
79
|
-
| `rates-from`
|
|
80
|
-
| `rates-from-pos`
|
|
81
|
-
| `rates-range`
|
|
82
|
-
| `ticks-from`
|
|
83
|
-
| `ticks-range`
|
|
84
|
-
| `account-info`
|
|
85
|
-
| `terminal-info`
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
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
|
|
54
|
-
|
|
|
55
|
-
| `rates-from`
|
|
56
|
-
| `rates-from-pos`
|
|
57
|
-
| `rates-range`
|
|
58
|
-
| `ticks-from`
|
|
59
|
-
| `ticks-range`
|
|
60
|
-
| `account-info`
|
|
61
|
-
| `terminal-info`
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
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
|
|
65
|
-
|
|
|
66
|
-
| `account-info`
|
|
67
|
-
| `terminal-info`
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
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
|
|
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.
|
|
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,
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|