mt5cli 0.9.5__tar.gz → 0.9.6__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.9.5 → mt5cli-0.9.6}/PKG-INFO +5 -2
- {mt5cli-0.9.5 → mt5cli-0.9.6}/README.md +4 -1
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/public-contract.md +14 -1
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/__init__.py +2 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/cli.py +93 -2
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/contract.py +1 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/trading.py +34 -10
- {mt5cli-0.9.5 → mt5cli-0.9.6}/pyproject.toml +1 -1
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_cli.py +300 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_contracts.py +30 -36
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_history.py +19 -54
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_sdk.py +31 -43
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_trading.py +469 -589
- {mt5cli-0.9.5 → mt5cli-0.9.6}/uv.lock +1 -1
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.claude/agents/codex.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.claude/settings.json +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/FUNDING.yml +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/dependabot.yml +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/renovate.json +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/workflows/release.yml +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/.gitignore +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/AGENTS.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/CLAUDE.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/LICENSE +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/cli.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/client.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/converters.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/exceptions.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/history.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/index.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/schemas.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/sdk.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/storage.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/trading.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/utils.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/index.md +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mkdocs.yml +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/__main__.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/client.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/converters.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/exceptions.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/history.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/retry.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/schemas.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/sdk.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/storage.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/utils.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/__init__.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/conftest.py +0 -0
- {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.6
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -202,10 +202,13 @@ python -m mt5cli -o account.csv account-info
|
|
|
202
202
|
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
|
203
203
|
| `mt5-summary` | Export terminal/account status summary |
|
|
204
204
|
| `order-check` | Check funds sufficiency for a trade request |
|
|
205
|
-
| `order-send` | Send a trade request to the trade server (`--yes` required)
|
|
205
|
+
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
|
206
|
+
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
|
206
207
|
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
|
207
208
|
|
|
208
209
|
Use `order-check` to validate a request payload before running `order-send --yes`.
|
|
210
|
+
`close-positions` is the safer high-level alternative that builds correct close
|
|
211
|
+
requests automatically. At least one `--symbol` or `--ticket` must be provided.
|
|
209
212
|
|
|
210
213
|
### `collect-history`
|
|
211
214
|
|
|
@@ -178,10 +178,13 @@ python -m mt5cli -o account.csv account-info
|
|
|
178
178
|
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
|
179
179
|
| `mt5-summary` | Export terminal/account status summary |
|
|
180
180
|
| `order-check` | Check funds sufficiency for a trade request |
|
|
181
|
-
| `order-send` | Send a trade request to the trade server (`--yes` required)
|
|
181
|
+
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
|
182
|
+
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
|
182
183
|
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
|
183
184
|
|
|
184
185
|
Use `order-check` to validate a request payload before running `order-send --yes`.
|
|
186
|
+
`close-positions` is the safer high-level alternative that builds correct close
|
|
187
|
+
requests automatically. At least one `--symbol` or `--ticket` must be provided.
|
|
185
188
|
|
|
186
189
|
### `collect-history`
|
|
187
190
|
|
|
@@ -111,6 +111,14 @@ strategy entries, exits, Kelly sizing, or signal logic.
|
|
|
111
111
|
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
|
112
112
|
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
|
113
113
|
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
|
114
|
+
| `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection |
|
|
115
|
+
|
|
116
|
+
`calculate_symbol_group_margin_ratio` accepts an optional `projection_mode`
|
|
117
|
+
parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to
|
|
118
|
+
subtract current exposure for `new_symbol` before adding the candidate margin —
|
|
119
|
+
useful for reversal-style projections. mt5cli only calculates broker-facing
|
|
120
|
+
exposure; downstream applications own thresholds, risk guard actions, and
|
|
121
|
+
strategy policy.
|
|
114
122
|
|
|
115
123
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
|
116
124
|
|
|
@@ -182,7 +190,12 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
|
|
|
182
190
|
- Delegate to the same Python APIs described here; they are not duplicated
|
|
183
191
|
business logic.
|
|
184
192
|
|
|
185
|
-
`order-send` requires `--yes`
|
|
193
|
+
`order-send` is the expert raw-request path; it requires `--yes` and a fully
|
|
194
|
+
constructed request payload. `close-positions` is the safer high-level helper
|
|
195
|
+
that closes open positions by `--symbol` or `--ticket` using
|
|
196
|
+
`close_open_positions()`. Both `order-send --yes` and `close-positions --yes`
|
|
197
|
+
are live execution paths. `close-positions --dry-run` previews close orders
|
|
198
|
+
without placing them and does not require `--yes`.
|
|
186
199
|
|
|
187
200
|
## Internal helpers (not stable)
|
|
188
201
|
|
|
@@ -120,6 +120,7 @@ from .trading import (
|
|
|
120
120
|
OrderSide,
|
|
121
121
|
OrderTimeMode,
|
|
122
122
|
PositionSide,
|
|
123
|
+
ProjectionMode,
|
|
123
124
|
calculate_account_projected_margin_ratio,
|
|
124
125
|
calculate_margin_and_volume,
|
|
125
126
|
calculate_new_position_margin_ratio,
|
|
@@ -192,6 +193,7 @@ __all__ = [
|
|
|
192
193
|
"OrderSide",
|
|
193
194
|
"OrderTimeMode",
|
|
194
195
|
"PositionSide",
|
|
196
|
+
"ProjectionMode",
|
|
195
197
|
"RateTarget",
|
|
196
198
|
"ThrottledHistoryUpdater",
|
|
197
199
|
"account_info",
|
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import logging
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from datetime import datetime # noqa: TC003
|
|
8
9
|
from pathlib import Path # noqa: TC003
|
|
9
10
|
from typing import TYPE_CHECKING, Annotated, Any, cast
|
|
10
11
|
|
|
12
|
+
import pandas as pd
|
|
11
13
|
import typer
|
|
12
14
|
from pdmt5 import Mt5Config
|
|
13
15
|
|
|
14
16
|
from . import sdk
|
|
15
17
|
from .client import MT5Client
|
|
18
|
+
from .trading import OrderExecutionResult, close_open_positions, create_trading_client
|
|
16
19
|
from .utils import (
|
|
17
20
|
DATETIME_TYPE,
|
|
18
21
|
REQUEST_TYPE,
|
|
@@ -29,8 +32,6 @@ from .utils import (
|
|
|
29
32
|
if TYPE_CHECKING:
|
|
30
33
|
from collections.abc import Callable
|
|
31
34
|
|
|
32
|
-
import pandas as pd
|
|
33
|
-
|
|
34
35
|
logger = logging.getLogger(__name__)
|
|
35
36
|
|
|
36
37
|
# ---------------------------------------------------------------------------
|
|
@@ -600,6 +601,96 @@ def order_send(
|
|
|
600
601
|
_export_command(ctx, lambda client: client.order_send(request))
|
|
601
602
|
|
|
602
603
|
|
|
604
|
+
_EXECUTION_RESULT_COLUMNS: list[str] = [
|
|
605
|
+
"status",
|
|
606
|
+
"symbol",
|
|
607
|
+
"order_side",
|
|
608
|
+
"volume",
|
|
609
|
+
"retcode",
|
|
610
|
+
"comment",
|
|
611
|
+
"request",
|
|
612
|
+
"response",
|
|
613
|
+
"dry_run",
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame:
|
|
618
|
+
if not results:
|
|
619
|
+
return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS)
|
|
620
|
+
rows = [
|
|
621
|
+
{
|
|
622
|
+
**r,
|
|
623
|
+
"request": json.dumps(r["request"]),
|
|
624
|
+
"response": json.dumps(r["response"]),
|
|
625
|
+
}
|
|
626
|
+
for r in results
|
|
627
|
+
]
|
|
628
|
+
return pd.DataFrame(rows)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@app.command()
|
|
632
|
+
def close_positions(
|
|
633
|
+
ctx: typer.Context,
|
|
634
|
+
symbol: Annotated[
|
|
635
|
+
list[str] | None,
|
|
636
|
+
typer.Option(
|
|
637
|
+
"--symbol",
|
|
638
|
+
"-s",
|
|
639
|
+
help="Symbol to close (repeat for multiple symbols).",
|
|
640
|
+
),
|
|
641
|
+
] = None,
|
|
642
|
+
ticket: Annotated[
|
|
643
|
+
list[int] | None,
|
|
644
|
+
typer.Option(
|
|
645
|
+
"--ticket",
|
|
646
|
+
"-t",
|
|
647
|
+
help="Position ticket to close (repeat for multiple tickets).",
|
|
648
|
+
),
|
|
649
|
+
] = None,
|
|
650
|
+
dry_run: Annotated[
|
|
651
|
+
bool,
|
|
652
|
+
typer.Option("--dry-run", help="Preview close orders without executing them."),
|
|
653
|
+
] = False,
|
|
654
|
+
yes: Annotated[
|
|
655
|
+
bool,
|
|
656
|
+
typer.Option("--yes", help="Confirm live position closing."),
|
|
657
|
+
] = False,
|
|
658
|
+
) -> None:
|
|
659
|
+
"""Close open positions by symbol or ticket.
|
|
660
|
+
|
|
661
|
+
Delegates to :func:`mt5cli.trading.close_open_positions`. At least one
|
|
662
|
+
``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing
|
|
663
|
+
all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is
|
|
664
|
+
required for live execution.
|
|
665
|
+
|
|
666
|
+
``order-send`` is the expert raw-request path. ``close-positions`` is the
|
|
667
|
+
safer high-level helper that builds correct close requests automatically.
|
|
668
|
+
|
|
669
|
+
Raises:
|
|
670
|
+
typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given,
|
|
671
|
+
or if ``--yes`` is missing for a live (non-dry-run) run.
|
|
672
|
+
"""
|
|
673
|
+
if not symbol and not ticket:
|
|
674
|
+
msg = "Provide at least one --symbol or --ticket to close positions."
|
|
675
|
+
raise typer.BadParameter(msg)
|
|
676
|
+
if not dry_run and not yes:
|
|
677
|
+
msg = "Pass --yes to close live positions."
|
|
678
|
+
raise typer.BadParameter(msg, param_hint="--yes")
|
|
679
|
+
export_ctx = _get_export_context(ctx)
|
|
680
|
+
client = create_trading_client(config=export_ctx.config)
|
|
681
|
+
try:
|
|
682
|
+
results = close_open_positions(
|
|
683
|
+
client,
|
|
684
|
+
symbols=list(symbol) if symbol else None,
|
|
685
|
+
tickets=list(ticket) if ticket else None,
|
|
686
|
+
dry_run=dry_run,
|
|
687
|
+
)
|
|
688
|
+
finally:
|
|
689
|
+
client.shutdown()
|
|
690
|
+
df = _execution_results_to_df(results)
|
|
691
|
+
_execute_export(ctx, lambda: df)
|
|
692
|
+
|
|
693
|
+
|
|
603
694
|
@app.command()
|
|
604
695
|
def collect_history(
|
|
605
696
|
ctx: typer.Context,
|
|
@@ -25,6 +25,7 @@ OrderSide = Literal["BUY", "SELL"]
|
|
|
25
25
|
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
|
26
26
|
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
|
|
27
27
|
ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
|
|
28
|
+
ProjectionMode = Literal["add", "replace_symbol"]
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
class MarginVolume(TypedDict):
|
|
@@ -127,6 +128,7 @@ __all__ = [
|
|
|
127
128
|
"OrderSide",
|
|
128
129
|
"OrderTimeMode",
|
|
129
130
|
"PositionSide",
|
|
131
|
+
"ProjectionMode",
|
|
130
132
|
"calculate_account_projected_margin_ratio",
|
|
131
133
|
"calculate_margin_and_volume",
|
|
132
134
|
"calculate_new_position_margin_ratio",
|
|
@@ -920,6 +922,16 @@ def calculate_projected_margin_ratio(
|
|
|
920
922
|
return margin / equity
|
|
921
923
|
|
|
922
924
|
|
|
925
|
+
def _validate_projection_mode(projection_mode: str) -> ProjectionMode:
|
|
926
|
+
if projection_mode not in {"add", "replace_symbol"}:
|
|
927
|
+
msg = (
|
|
928
|
+
f"Unsupported projection mode: {projection_mode!r}. "
|
|
929
|
+
"Expected 'add' or 'replace_symbol'."
|
|
930
|
+
)
|
|
931
|
+
raise ValueError(msg)
|
|
932
|
+
return cast("ProjectionMode", projection_mode)
|
|
933
|
+
|
|
934
|
+
|
|
923
935
|
def calculate_symbol_group_margin_ratio(
|
|
924
936
|
client: Mt5TradingClient,
|
|
925
937
|
*,
|
|
@@ -928,13 +940,22 @@ def calculate_symbol_group_margin_ratio(
|
|
|
928
940
|
new_position_side: OrderSide | None = None,
|
|
929
941
|
new_position_volume: float = 0.0,
|
|
930
942
|
suppress_errors: bool = True,
|
|
943
|
+
projection_mode: ProjectionMode = "add",
|
|
931
944
|
) -> float:
|
|
932
945
|
"""Return estimated symbol-group margin over account equity.
|
|
933
946
|
|
|
934
947
|
Per-symbol current exposure is summed with
|
|
935
948
|
:func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
|
|
936
|
-
the input symbol group
|
|
937
|
-
|
|
949
|
+
the input symbol group and candidate side/volume are provided, projected order
|
|
950
|
+
margin is applied according to ``projection_mode``:
|
|
951
|
+
|
|
952
|
+
- ``"add"`` (default): adds candidate margin to the group total.
|
|
953
|
+
- ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then
|
|
954
|
+
adds candidate margin. Useful for reversal-style projections where the new
|
|
955
|
+
order is intended to replace existing exposure for that symbol.
|
|
956
|
+
|
|
957
|
+
If the candidate margin estimation fails, the subtraction is also skipped so
|
|
958
|
+
the operation is atomic. Invalid equity always raises to fail closed.
|
|
938
959
|
|
|
939
960
|
Raises:
|
|
940
961
|
AttributeError: When symbol margin lookup or projected margin lookup
|
|
@@ -945,23 +966,22 @@ def calculate_symbol_group_margin_ratio(
|
|
|
945
966
|
lookup or projected margin lookup fails and ``suppress_errors`` is
|
|
946
967
|
``False``.
|
|
947
968
|
"""
|
|
969
|
+
projection_mode = _validate_projection_mode(projection_mode)
|
|
948
970
|
equity = _account_equity(client)
|
|
949
971
|
unique_symbols = list(dict.fromkeys(symbols))
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
suppress_errors=suppress_errors,
|
|
955
|
-
).values(),
|
|
956
|
-
0.0,
|
|
972
|
+
per_symbol = calculate_positions_margin_by_symbol(
|
|
973
|
+
client,
|
|
974
|
+
symbols=unique_symbols,
|
|
975
|
+
suppress_errors=suppress_errors,
|
|
957
976
|
)
|
|
977
|
+
margin = sum(per_symbol.values(), 0.0)
|
|
958
978
|
if (
|
|
959
979
|
new_symbol in unique_symbols
|
|
960
980
|
and new_position_side is not None
|
|
961
981
|
and new_position_volume > 0
|
|
962
982
|
):
|
|
963
983
|
try:
|
|
964
|
-
|
|
984
|
+
candidate_margin = estimate_order_margin(
|
|
965
985
|
client,
|
|
966
986
|
new_symbol,
|
|
967
987
|
new_position_side,
|
|
@@ -971,6 +991,10 @@ def calculate_symbol_group_margin_ratio(
|
|
|
971
991
|
if not suppress_errors:
|
|
972
992
|
raise
|
|
973
993
|
_logger.warning("Skipping projected margin for %r.", new_symbol)
|
|
994
|
+
else:
|
|
995
|
+
if projection_mode == "replace_symbol":
|
|
996
|
+
margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0))
|
|
997
|
+
margin += candidate_margin
|
|
974
998
|
return margin / equity
|
|
975
999
|
|
|
976
1000
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.6"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -740,6 +740,306 @@ class TestCommands:
|
|
|
740
740
|
assert "must be a JSON object" in normalize_cli_output(result.output)
|
|
741
741
|
|
|
742
742
|
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
# close-positions command
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _build_mock_trading_client() -> MagicMock:
|
|
749
|
+
"""Return a MagicMock Mt5TradingClient with trading constants set."""
|
|
750
|
+
client = MagicMock()
|
|
751
|
+
client.mt5.POSITION_TYPE_BUY = 0
|
|
752
|
+
client.mt5.POSITION_TYPE_SELL = 1
|
|
753
|
+
client.mt5.ORDER_TYPE_BUY = 10
|
|
754
|
+
client.mt5.ORDER_TYPE_SELL = 11
|
|
755
|
+
client.mt5.TRADE_ACTION_DEAL = 20
|
|
756
|
+
client.mt5.ORDER_FILLING_IOC = 30
|
|
757
|
+
client.mt5.ORDER_TIME_GTC = 40
|
|
758
|
+
client.mt5.TRADE_RETCODE_DONE = 10009
|
|
759
|
+
client.mt5.TRADE_RETCODE_PLACED = 10008
|
|
760
|
+
client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010
|
|
761
|
+
return client
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class TestClosePositions:
|
|
765
|
+
"""Tests for the close-positions command."""
|
|
766
|
+
|
|
767
|
+
@pytest.fixture
|
|
768
|
+
def trading_client(self, mocker: MockerFixture) -> MagicMock:
|
|
769
|
+
"""Patch create_trading_client and return a mock trading client."""
|
|
770
|
+
client = _build_mock_trading_client()
|
|
771
|
+
client.positions_get_as_df.return_value = pd.DataFrame([
|
|
772
|
+
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
|
773
|
+
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5},
|
|
774
|
+
])
|
|
775
|
+
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
|
|
776
|
+
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
|
777
|
+
return client
|
|
778
|
+
|
|
779
|
+
def test_dry_run_does_not_require_yes(
|
|
780
|
+
self,
|
|
781
|
+
tmp_path: Path,
|
|
782
|
+
trading_client: MagicMock,
|
|
783
|
+
) -> None:
|
|
784
|
+
"""Test --dry-run mode succeeds without --yes."""
|
|
785
|
+
output = tmp_path / "close.json"
|
|
786
|
+
result = runner.invoke(
|
|
787
|
+
app,
|
|
788
|
+
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
|
789
|
+
)
|
|
790
|
+
assert result.exit_code == 0, result.output
|
|
791
|
+
assert output.exists()
|
|
792
|
+
trading_client.order_send.assert_not_called()
|
|
793
|
+
trading_client.shutdown.assert_called_once()
|
|
794
|
+
|
|
795
|
+
def test_live_requires_yes(
|
|
796
|
+
self,
|
|
797
|
+
tmp_path: Path,
|
|
798
|
+
trading_client: MagicMock,
|
|
799
|
+
) -> None:
|
|
800
|
+
"""Test live close-positions fails without --yes."""
|
|
801
|
+
output = tmp_path / "close.json"
|
|
802
|
+
result = runner.invoke(
|
|
803
|
+
app,
|
|
804
|
+
["-o", str(output), "close-positions", "--symbol", "JP225"],
|
|
805
|
+
)
|
|
806
|
+
assert result.exit_code != 0
|
|
807
|
+
assert "Pass --yes" in normalize_cli_output(result.output)
|
|
808
|
+
trading_client.order_send.assert_not_called()
|
|
809
|
+
|
|
810
|
+
def test_live_with_yes_calls_order_send(
|
|
811
|
+
self,
|
|
812
|
+
tmp_path: Path,
|
|
813
|
+
trading_client: MagicMock,
|
|
814
|
+
) -> None:
|
|
815
|
+
"""Test --yes triggers live execution for matching positions."""
|
|
816
|
+
trading_client.order_send.return_value = {"retcode": 10009, "comment": "ok"}
|
|
817
|
+
output = tmp_path / "close.json"
|
|
818
|
+
result = runner.invoke(
|
|
819
|
+
app,
|
|
820
|
+
["-o", str(output), "close-positions", "--symbol", "JP225", "--yes"],
|
|
821
|
+
)
|
|
822
|
+
assert result.exit_code == 0, result.output
|
|
823
|
+
trading_client.order_send.assert_called_once()
|
|
824
|
+
trading_client.shutdown.assert_called_once()
|
|
825
|
+
|
|
826
|
+
def test_symbol_filter_passed_through(
|
|
827
|
+
self,
|
|
828
|
+
tmp_path: Path,
|
|
829
|
+
trading_client: MagicMock,
|
|
830
|
+
) -> None:
|
|
831
|
+
"""Test --symbol values are used to filter positions."""
|
|
832
|
+
output = tmp_path / "close.json"
|
|
833
|
+
result = runner.invoke(
|
|
834
|
+
app,
|
|
835
|
+
[
|
|
836
|
+
"-o",
|
|
837
|
+
str(output),
|
|
838
|
+
"close-positions",
|
|
839
|
+
"--symbol",
|
|
840
|
+
"JP225",
|
|
841
|
+
"--dry-run",
|
|
842
|
+
],
|
|
843
|
+
)
|
|
844
|
+
assert result.exit_code == 0, result.output
|
|
845
|
+
data = json.loads(output.read_text())
|
|
846
|
+
assert len(data) == 1
|
|
847
|
+
assert data[0]["symbol"] == "JP225"
|
|
848
|
+
trading_client.shutdown.assert_called_once()
|
|
849
|
+
|
|
850
|
+
def test_multiple_symbols_filter(
|
|
851
|
+
self,
|
|
852
|
+
tmp_path: Path,
|
|
853
|
+
trading_client: MagicMock,
|
|
854
|
+
) -> None:
|
|
855
|
+
"""Test multiple --symbol options are combined."""
|
|
856
|
+
output = tmp_path / "close.json"
|
|
857
|
+
result = runner.invoke(
|
|
858
|
+
app,
|
|
859
|
+
[
|
|
860
|
+
"-o",
|
|
861
|
+
str(output),
|
|
862
|
+
"close-positions",
|
|
863
|
+
"--symbol",
|
|
864
|
+
"JP225",
|
|
865
|
+
"--symbol",
|
|
866
|
+
"EURUSD",
|
|
867
|
+
"--dry-run",
|
|
868
|
+
],
|
|
869
|
+
)
|
|
870
|
+
assert result.exit_code == 0, result.output
|
|
871
|
+
data = json.loads(output.read_text())
|
|
872
|
+
assert len(data) == 2
|
|
873
|
+
symbols = {row["symbol"] for row in data}
|
|
874
|
+
assert symbols == {"JP225", "EURUSD"}
|
|
875
|
+
trading_client.shutdown.assert_called_once()
|
|
876
|
+
|
|
877
|
+
def test_ticket_filter_passed_through(
|
|
878
|
+
self,
|
|
879
|
+
tmp_path: Path,
|
|
880
|
+
trading_client: MagicMock,
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Test --ticket values are used to filter positions."""
|
|
883
|
+
output = tmp_path / "close.json"
|
|
884
|
+
result = runner.invoke(
|
|
885
|
+
app,
|
|
886
|
+
[
|
|
887
|
+
"-o",
|
|
888
|
+
str(output),
|
|
889
|
+
"close-positions",
|
|
890
|
+
"--ticket",
|
|
891
|
+
"2",
|
|
892
|
+
"--dry-run",
|
|
893
|
+
],
|
|
894
|
+
)
|
|
895
|
+
assert result.exit_code == 0, result.output
|
|
896
|
+
data = json.loads(output.read_text())
|
|
897
|
+
assert len(data) == 1
|
|
898
|
+
assert data[0]["symbol"] == "EURUSD"
|
|
899
|
+
trading_client.shutdown.assert_called_once()
|
|
900
|
+
|
|
901
|
+
def test_symbol_and_ticket_combined(
|
|
902
|
+
self,
|
|
903
|
+
tmp_path: Path,
|
|
904
|
+
trading_client: MagicMock,
|
|
905
|
+
) -> None:
|
|
906
|
+
"""Test --symbol and --ticket apply AND semantics when combined."""
|
|
907
|
+
output = tmp_path / "close.json"
|
|
908
|
+
result = runner.invoke(
|
|
909
|
+
app,
|
|
910
|
+
[
|
|
911
|
+
"-o",
|
|
912
|
+
str(output),
|
|
913
|
+
"close-positions",
|
|
914
|
+
"--symbol",
|
|
915
|
+
"JP225",
|
|
916
|
+
"--ticket",
|
|
917
|
+
"1",
|
|
918
|
+
"--dry-run",
|
|
919
|
+
],
|
|
920
|
+
)
|
|
921
|
+
assert result.exit_code == 0, result.output
|
|
922
|
+
data = json.loads(output.read_text())
|
|
923
|
+
# symbol=JP225 AND ticket=1 → exactly one match
|
|
924
|
+
assert len(data) == 1
|
|
925
|
+
assert data[0]["symbol"] == "JP225"
|
|
926
|
+
trading_client.shutdown.assert_called_once()
|
|
927
|
+
|
|
928
|
+
def test_missing_symbol_and_ticket_fails(
|
|
929
|
+
self,
|
|
930
|
+
tmp_path: Path,
|
|
931
|
+
mocker: MockerFixture,
|
|
932
|
+
) -> None:
|
|
933
|
+
"""Test that omitting both --symbol and --ticket fails closed."""
|
|
934
|
+
mocker.patch("mt5cli.cli.create_trading_client")
|
|
935
|
+
output = tmp_path / "close.json"
|
|
936
|
+
result = runner.invoke(
|
|
937
|
+
app,
|
|
938
|
+
["-o", str(output), "close-positions", "--dry-run"],
|
|
939
|
+
)
|
|
940
|
+
assert result.exit_code != 0
|
|
941
|
+
assert "symbol" in normalize_cli_output(result.output).lower()
|
|
942
|
+
|
|
943
|
+
def test_output_export_dry_run(
|
|
944
|
+
self,
|
|
945
|
+
tmp_path: Path,
|
|
946
|
+
trading_client: MagicMock,
|
|
947
|
+
) -> None:
|
|
948
|
+
"""Test dry-run results export with status=dry_run."""
|
|
949
|
+
output = tmp_path / "close.json"
|
|
950
|
+
result = runner.invoke(
|
|
951
|
+
app,
|
|
952
|
+
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
|
953
|
+
)
|
|
954
|
+
assert result.exit_code == 0, result.output
|
|
955
|
+
trading_client.shutdown.assert_called_once()
|
|
956
|
+
data = json.loads(output.read_text())
|
|
957
|
+
assert data[0]["status"] == "dry_run"
|
|
958
|
+
assert data[0]["dry_run"] is True
|
|
959
|
+
assert data[0]["order_side"] == "SELL"
|
|
960
|
+
|
|
961
|
+
def test_order_send_unchanged(
|
|
962
|
+
self,
|
|
963
|
+
tmp_path: Path,
|
|
964
|
+
mock_client: MagicMock,
|
|
965
|
+
) -> None:
|
|
966
|
+
"""Test that order-send behavior is unchanged by close-positions addition."""
|
|
967
|
+
output = tmp_path / "out.csv"
|
|
968
|
+
request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
|
|
969
|
+
result = runner.invoke(
|
|
970
|
+
app,
|
|
971
|
+
["-o", str(output), "order-send", "--request", request, "--yes"],
|
|
972
|
+
)
|
|
973
|
+
assert result.exit_code == 0, result.output
|
|
974
|
+
mock_client.order_send_as_df.assert_called_once()
|
|
975
|
+
|
|
976
|
+
def test_shutdown_called_on_close_error(
|
|
977
|
+
self,
|
|
978
|
+
tmp_path: Path,
|
|
979
|
+
mocker: MockerFixture,
|
|
980
|
+
) -> None:
|
|
981
|
+
"""Test that shutdown is called even when close_open_positions raises."""
|
|
982
|
+
client = _build_mock_trading_client()
|
|
983
|
+
client.positions_get_as_df.side_effect = RuntimeError("connection lost")
|
|
984
|
+
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
|
985
|
+
output = tmp_path / "close.json"
|
|
986
|
+
result = runner.invoke(
|
|
987
|
+
app,
|
|
988
|
+
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
|
989
|
+
)
|
|
990
|
+
assert result.exit_code != 0
|
|
991
|
+
client.shutdown.assert_called_once()
|
|
992
|
+
|
|
993
|
+
def test_dry_run_wins_over_yes(
|
|
994
|
+
self,
|
|
995
|
+
tmp_path: Path,
|
|
996
|
+
trading_client: MagicMock,
|
|
997
|
+
) -> None:
|
|
998
|
+
"""Test that --dry-run takes precedence when combined with --yes."""
|
|
999
|
+
output = tmp_path / "close.json"
|
|
1000
|
+
result = runner.invoke(
|
|
1001
|
+
app,
|
|
1002
|
+
[
|
|
1003
|
+
"-o",
|
|
1004
|
+
str(output),
|
|
1005
|
+
"close-positions",
|
|
1006
|
+
"--symbol",
|
|
1007
|
+
"JP225",
|
|
1008
|
+
"--dry-run",
|
|
1009
|
+
"--yes",
|
|
1010
|
+
],
|
|
1011
|
+
)
|
|
1012
|
+
assert result.exit_code == 0, result.output
|
|
1013
|
+
trading_client.order_send.assert_not_called()
|
|
1014
|
+
trading_client.shutdown.assert_called_once()
|
|
1015
|
+
|
|
1016
|
+
def test_no_matching_positions_exports_empty_result(
|
|
1017
|
+
self,
|
|
1018
|
+
tmp_path: Path,
|
|
1019
|
+
trading_client: MagicMock,
|
|
1020
|
+
) -> None:
|
|
1021
|
+
"""Test that zero filter matches produces an empty JSON array."""
|
|
1022
|
+
trading_client.positions_get_as_df.return_value = pd.DataFrame([
|
|
1023
|
+
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
|
1024
|
+
])
|
|
1025
|
+
output = tmp_path / "close.json"
|
|
1026
|
+
result = runner.invoke(
|
|
1027
|
+
app,
|
|
1028
|
+
[
|
|
1029
|
+
"-o",
|
|
1030
|
+
str(output),
|
|
1031
|
+
"close-positions",
|
|
1032
|
+
"--symbol",
|
|
1033
|
+
"NONEXISTENT",
|
|
1034
|
+
"--dry-run",
|
|
1035
|
+
],
|
|
1036
|
+
)
|
|
1037
|
+
assert result.exit_code == 0, result.output
|
|
1038
|
+
trading_client.shutdown.assert_called_once()
|
|
1039
|
+
assert output.exists()
|
|
1040
|
+
assert json.loads(output.read_text()) == []
|
|
1041
|
+
|
|
1042
|
+
|
|
743
1043
|
# ---------------------------------------------------------------------------
|
|
744
1044
|
# Callback / shared options
|
|
745
1045
|
# ---------------------------------------------------------------------------
|