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.
Files changed (56) hide show
  1. {mt5cli-0.9.5 → mt5cli-0.9.6}/PKG-INFO +5 -2
  2. {mt5cli-0.9.5 → mt5cli-0.9.6}/README.md +4 -1
  3. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/public-contract.md +14 -1
  4. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/__init__.py +2 -0
  5. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/cli.py +93 -2
  6. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/contract.py +1 -0
  7. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/trading.py +34 -10
  8. {mt5cli-0.9.5 → mt5cli-0.9.6}/pyproject.toml +1 -1
  9. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_cli.py +300 -0
  10. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_contracts.py +30 -36
  11. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_history.py +19 -54
  12. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_sdk.py +31 -43
  13. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/test_trading.py +469 -589
  14. {mt5cli-0.9.5 → mt5cli-0.9.6}/uv.lock +1 -1
  15. {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/local-qa/SKILL.md +0 -0
  16. {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  17. {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/mt5cli/SKILL.md +0 -0
  18. {mt5cli-0.9.5 → mt5cli-0.9.6}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  19. {mt5cli-0.9.5 → mt5cli-0.9.6}/.claude/agents/codex.md +0 -0
  20. {mt5cli-0.9.5 → mt5cli-0.9.6}/.claude/settings.json +0 -0
  21. {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/FUNDING.yml +0 -0
  22. {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/dependabot.yml +0 -0
  23. {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/renovate.json +0 -0
  24. {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/workflows/ci.yml +0 -0
  25. {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/workflows/claude.yml +0 -0
  26. {mt5cli-0.9.5 → mt5cli-0.9.6}/.github/workflows/release.yml +0 -0
  27. {mt5cli-0.9.5 → mt5cli-0.9.6}/.gitignore +0 -0
  28. {mt5cli-0.9.5 → mt5cli-0.9.6}/AGENTS.md +0 -0
  29. {mt5cli-0.9.5 → mt5cli-0.9.6}/CLAUDE.md +0 -0
  30. {mt5cli-0.9.5 → mt5cli-0.9.6}/LICENSE +0 -0
  31. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/cli.md +0 -0
  32. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/client.md +0 -0
  33. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/converters.md +0 -0
  34. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/exceptions.md +0 -0
  35. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/history.md +0 -0
  36. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/index.md +0 -0
  37. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/schemas.md +0 -0
  38. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/sdk.md +0 -0
  39. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/storage.md +0 -0
  40. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/trading.md +0 -0
  41. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/api/utils.md +0 -0
  42. {mt5cli-0.9.5 → mt5cli-0.9.6}/docs/index.md +0 -0
  43. {mt5cli-0.9.5 → mt5cli-0.9.6}/mkdocs.yml +0 -0
  44. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/__main__.py +0 -0
  45. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/client.py +0 -0
  46. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/converters.py +0 -0
  47. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/exceptions.py +0 -0
  48. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/history.py +0 -0
  49. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/retry.py +0 -0
  50. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/schemas.py +0 -0
  51. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/sdk.py +0 -0
  52. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/storage.py +0 -0
  53. {mt5cli-0.9.5 → mt5cli-0.9.6}/mt5cli/utils.py +0 -0
  54. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/__init__.py +0 -0
  55. {mt5cli-0.9.5 → mt5cli-0.9.6}/tests/conftest.py +0 -0
  56. {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.5
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` before placing live trades.
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,
@@ -17,6 +17,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
17
17
  "OrderSide",
18
18
  "OrderTimeMode",
19
19
  "PositionSide",
20
+ "ProjectionMode",
20
21
  "ExecutionStatus",
21
22
  "MarginVolume",
22
23
  "OrderExecutionResult",
@@ -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, optional projected order margin is added for that
937
- symbol. Invalid equity always raises to fail closed.
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
- margin = sum(
951
- calculate_positions_margin_by_symbol(
952
- client,
953
- symbols=unique_symbols,
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
- margin += estimate_order_margin(
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.5"
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
  # ---------------------------------------------------------------------------