feat: add close-positions CLI command and replace_symbol projection mode (#65 #66)

Part 1 — close-positions CLI (#65):
- Add `close-positions` subcommand delegating to `close_open_positions()`.
- Accepts repeated `--symbol` and `--ticket` filters (AND semantics).
- Supports `--dry-run` (no `--yes` required); live execution requires `--yes`.
- Fails closed with `BadParameter` when neither `--symbol` nor `--ticket` is given.
- Exports normalized `OrderExecutionResult` list as a DataFrame (request/response
  serialized as JSON strings for clean CSV/JSON/Parquet/SQLite output).
- `order-send` remains the raw expert path; `close-positions` is the safer
  high-level helper that builds correct close requests automatically.

Part 2 — ProjectionMode and replace_symbol (#66):
- Add `ProjectionMode = Literal["add", "replace_symbol"]` type alias.
- Add optional `projection_mode` parameter to `calculate_symbol_group_margin_ratio`.
  Default `"add"` preserves existing additive behavior.
  `"replace_symbol"` subtracts current margin for `new_symbol`, then adds
  candidate margin — the subtraction and addition are atomic (suppressed together).
- Export `ProjectionMode` from `mt5cli` and add to `STABLE_SDK_EXPORTS`.
- No mteor-specific strategy, risk-threshold, or policy logic added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-24 17:07:58 +00:00
co-authored by Claude Sonnet 4.6
parent 15bfd17db3
commit 71b99ada26
9 changed files with 541 additions and 15 deletions
+165 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import Any, cast
from typing import Any, cast, get_args
from unittest.mock import MagicMock
import pandas as pd
@@ -19,6 +19,7 @@ from mt5cli.trading import (
MarginVolume,
OrderExecutionResult,
OrderLimits,
ProjectionMode,
calculate_account_projected_margin_ratio,
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
@@ -2065,6 +2066,169 @@ class TestVolumeAndExecution:
suppress_errors=False,
)
def test_symbol_group_margin_ratio_replace_symbol_subtracts_and_adds(
self,
mocker: MockerFixture,
) -> None:
"""Test replace_symbol mode subtracts current exposure and adds candidate."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1}
client.order_calc_margin.return_value = 15.0
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 25.0},
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
projection_mode="replace_symbol",
)
# margin = 25.0 - 25.0 (replaced) + 15.0 (candidate) = 15.0
_assert_close(result, 0.015)
def test_symbol_group_margin_ratio_replace_no_existing_adds_candidate(
self,
mocker: MockerFixture,
) -> None:
"""Test replace_symbol with no existing current margin still adds candidate."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1}
client.order_calc_margin.return_value = 20.0
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 0.0},
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.2,
projection_mode="replace_symbol",
)
# margin = 0.0 - 0.0 + 20.0 = 20.0
_assert_close(result, 0.02)
def test_symbol_group_margin_ratio_replace_symbol_outside_group_unchanged(
self,
mocker: MockerFixture,
) -> None:
"""Test candidate symbol outside the group does not affect margin."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 30.0},
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="GBPUSD",
new_position_side="BUY",
new_position_volume=0.1,
projection_mode="replace_symbol",
)
# GBPUSD is not in the group; no candidate margin applied
_assert_close(result, 0.03)
def test_symbol_group_margin_ratio_replace_no_candidate_returns_current(
self,
mocker: MockerFixture,
) -> None:
"""Test replace_symbol with no candidate side/volume returns current margin."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 40.0},
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
projection_mode="replace_symbol",
)
_assert_close(result, 0.04)
def test_symbol_group_margin_ratio_replace_suppresses_candidate_failure(
self,
mocker: MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test replace mode skips both subtract and add when estimation fails."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 25.0},
)
mocker.patch(
"mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"),
)
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
projection_mode="replace_symbol",
suppress_errors=True,
)
# When candidate fails, neither subtraction nor addition is applied
_assert_close(result, 0.025)
assert "Skipping projected margin" in caplog.text
def test_symbol_group_margin_ratio_replace_reraises_candidate_failure(
self,
mocker: MockerFixture,
) -> None:
"""Test replace mode re-raises candidate failure when suppress_errors=False."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 25.0},
)
mocker.patch(
"mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"),
)
with pytest.raises(Mt5TradingError, match="bad tick"):
calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
projection_mode="replace_symbol",
suppress_errors=False,
)
def test_projection_mode_type_alias_is_importable(self) -> None:
"""Test ProjectionMode type alias is importable and has expected values."""
assert ProjectionMode is not None
args = get_args(ProjectionMode)
assert "add" in args
assert "replace_symbol" in args
def test_place_market_order_dry_run_does_not_send(self) -> None:
"""Test dry-run market orders return a request without sending."""
client = _mock_trade_client()