Add account-wide projected margin ratio helper (#60)
* feat: add account projected margin ratio helper * Bump version to v0.9.4 * fix: address account margin ratio review feedback * fix: simplify account margin ratio errors
This commit is contained in:
+19
-18
@@ -90,24 +90,25 @@ diagrams.
|
||||
These helpers implement broker-facing calculations only. They do not encode
|
||||
strategy entries, exits, Kelly sizing, or signal logic.
|
||||
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||
| `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure |
|
||||
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||
| `calculate_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure |
|
||||
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
|
||||
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
|
||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ from .trading import (
|
||||
OrderSide,
|
||||
OrderTimeMode,
|
||||
PositionSide,
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
@@ -196,6 +197,7 @@ __all__ = [
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
|
||||
@@ -26,6 +26,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_projected_margin_ratio",
|
||||
|
||||
+54
-8
@@ -127,6 +127,7 @@ __all__ = [
|
||||
"OrderSide",
|
||||
"OrderTimeMode",
|
||||
"PositionSide",
|
||||
"calculate_account_projected_margin_ratio",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
@@ -834,15 +835,60 @@ def calculate_new_position_margin_ratio(
|
||||
|
||||
def _account_equity(client: Mt5TradingClient) -> float:
|
||||
account = get_account_snapshot(client)
|
||||
try:
|
||||
equity = float(account.get("equity") or 0.0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = "Account equity must be positive to calculate margin ratio."
|
||||
raise Mt5TradingError(msg) from exc
|
||||
if equity <= 0 or not isfinite(equity):
|
||||
msg = "Account equity must be positive to calculate margin ratio."
|
||||
return _required_account_number(account, "equity", allow_zero=False)
|
||||
|
||||
|
||||
def _required_account_number(
|
||||
account: Mapping[str, object],
|
||||
field: str,
|
||||
*,
|
||||
allow_zero: bool,
|
||||
) -> float:
|
||||
raw_value = account.get(field)
|
||||
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
|
||||
msg = f"Account {field} must be a finite number to calculate margin ratio."
|
||||
raise Mt5TradingError(msg)
|
||||
return equity
|
||||
value = float(raw_value)
|
||||
if (
|
||||
not isfinite(value)
|
||||
or (not allow_zero and value <= 0)
|
||||
or (allow_zero and value < 0)
|
||||
):
|
||||
msg = (
|
||||
f"Account {field} must be a non-negative finite number."
|
||||
if allow_zero
|
||||
else f"Account {field} must be a positive finite number."
|
||||
)
|
||||
raise Mt5TradingError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def calculate_account_projected_margin_ratio(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
new_position_side: OrderSide | None = None,
|
||||
new_position_volume: float = 0.0,
|
||||
) -> float:
|
||||
"""Return account-wide current plus optional new-position margin over equity.
|
||||
|
||||
Current exposure comes from the broker account snapshot ``margin`` field so
|
||||
unrelated open positions remain in the baseline. Optional projected
|
||||
exposure is added via :func:`estimate_order_margin` only when a symbol, side,
|
||||
and positive volume are all supplied.
|
||||
|
||||
"""
|
||||
account = get_account_snapshot(client)
|
||||
equity = _required_account_number(account, "equity", allow_zero=False)
|
||||
margin = _required_account_number(account, "margin", allow_zero=True)
|
||||
if symbol is not None and new_position_side is not None and new_position_volume > 0:
|
||||
margin += estimate_order_margin(
|
||||
client,
|
||||
symbol,
|
||||
new_position_side,
|
||||
new_position_volume,
|
||||
)
|
||||
return margin / equity
|
||||
|
||||
|
||||
def calculate_projected_margin_ratio(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
|
||||
@@ -37,6 +37,7 @@ from mt5cli import (
|
||||
RateTarget,
|
||||
build_config,
|
||||
build_rate_targets,
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
calculate_positions_margin,
|
||||
calculate_projected_margin_ratio,
|
||||
@@ -684,6 +685,7 @@ class TestStableSdkContract:
|
||||
assert price is not None
|
||||
assert abs(price - 1.2) < 1e-9
|
||||
assert callable(calculate_trailing_stop_updates)
|
||||
assert callable(calculate_account_projected_margin_ratio)
|
||||
assert callable(calculate_projected_margin_ratio)
|
||||
assert callable(calculate_symbol_group_margin_ratio)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from mt5cli.trading import (
|
||||
MarginVolume,
|
||||
OrderExecutionResult,
|
||||
OrderLimits,
|
||||
calculate_account_projected_margin_ratio,
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
@@ -1891,6 +1892,142 @@ class TestVolumeAndExecution:
|
||||
_assert_close(result, 0.024)
|
||||
client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("account", "kwargs", "candidate_margin", "expected_ratio"),
|
||||
[
|
||||
({"equity": 10_000.0, "margin": 4500.0}, {}, None, 0.45),
|
||||
(
|
||||
{"equity": 10_000.0, "margin": 4500.0},
|
||||
{
|
||||
"symbol": "EURUSD",
|
||||
"new_position_side": "BUY",
|
||||
"new_position_volume": 0.1,
|
||||
},
|
||||
1000.0,
|
||||
0.55,
|
||||
),
|
||||
({"equity": 10_000.0, "margin": 55.0}, {}, None, 0.0055),
|
||||
],
|
||||
)
|
||||
def test_account_projected_margin_ratio_uses_account_margin_baseline(
|
||||
self,
|
||||
account: dict[str, object],
|
||||
kwargs: dict[str, object],
|
||||
candidate_margin: float | None,
|
||||
expected_ratio: float,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test account-wide exposure uses snapshot margin plus optional candidate."""
|
||||
client = _mock_trade_client()
|
||||
client.account_info_as_dict.return_value = account
|
||||
client.positions_get_as_df.return_value = pd.DataFrame(
|
||||
[{"symbol": "GBPUSD", "type": 0, "volume": 2.0}],
|
||||
)
|
||||
mock_margin = mocker.patch(
|
||||
"mt5cli.trading.estimate_order_margin",
|
||||
return_value=candidate_margin,
|
||||
)
|
||||
|
||||
result = calculate_account_projected_margin_ratio(client, **cast("Any", kwargs))
|
||||
|
||||
_assert_close(result, expected_ratio)
|
||||
if candidate_margin is None:
|
||||
mock_margin.assert_not_called()
|
||||
else:
|
||||
mock_margin.assert_called_once_with(client, "EURUSD", "BUY", 0.1)
|
||||
client.positions_get_as_df.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "expected_ratio"),
|
||||
[
|
||||
({"new_position_side": "BUY", "new_position_volume": 0.1}, 0.45),
|
||||
({"symbol": "EURUSD", "new_position_volume": 0.1}, 0.45),
|
||||
({"symbol": "EURUSD", "new_position_side": "BUY"}, 0.45),
|
||||
(
|
||||
{
|
||||
"symbol": "EURUSD",
|
||||
"new_position_side": "BUY",
|
||||
"new_position_volume": -0.1,
|
||||
},
|
||||
0.45,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_account_projected_margin_ratio_skips_incomplete_candidate(
|
||||
self,
|
||||
kwargs: dict[str, object],
|
||||
expected_ratio: float,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test candidate margin is added only when symbol, side, and volume exist."""
|
||||
client = _mock_trade_client()
|
||||
client.account_info_as_dict.return_value = {
|
||||
"equity": 10_000.0,
|
||||
"margin": 4500.0,
|
||||
}
|
||||
mock_margin = mocker.patch("mt5cli.trading.estimate_order_margin")
|
||||
|
||||
result = calculate_account_projected_margin_ratio(client, **cast("Any", kwargs))
|
||||
|
||||
_assert_close(result, expected_ratio)
|
||||
mock_margin.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("account", "match"),
|
||||
[
|
||||
({"margin": 4500.0}, "Account equity"),
|
||||
({"equity": None, "margin": 4500.0}, "Account equity"),
|
||||
({"equity": "10000", "margin": 4500.0}, "Account equity"),
|
||||
({"equity": True, "margin": 4500.0}, "Account equity"),
|
||||
({"equity": float("nan"), "margin": 4500.0}, "Account equity"),
|
||||
({"equity": float("inf"), "margin": 4500.0}, "Account equity"),
|
||||
({"equity": 0.0, "margin": 4500.0}, "Account equity"),
|
||||
({"equity": -1.0, "margin": 4500.0}, "Account equity"),
|
||||
({"equity": 10_000.0}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": None}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": "4500"}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": True}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": False}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": float("nan")}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": float("inf")}, "Account margin"),
|
||||
({"equity": 10_000.0, "margin": -1.0}, "Account margin"),
|
||||
],
|
||||
)
|
||||
def test_account_projected_margin_ratio_rejects_invalid_snapshot_fields(
|
||||
self,
|
||||
account: dict[str, object],
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test invalid account equity and margin fields fail closed."""
|
||||
client = _mock_trade_client()
|
||||
client.account_info_as_dict.return_value = account
|
||||
|
||||
with pytest.raises(Mt5TradingError, match=match):
|
||||
calculate_account_projected_margin_ratio(client)
|
||||
|
||||
def test_account_projected_margin_ratio_propagates_candidate_margin_error(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test candidate margin errors are not suppressed."""
|
||||
client = _mock_trade_client()
|
||||
client.account_info_as_dict.return_value = {
|
||||
"equity": 10_000.0,
|
||||
"margin": 4500.0,
|
||||
}
|
||||
mocker.patch(
|
||||
"mt5cli.trading.estimate_order_margin",
|
||||
side_effect=Mt5TradingError("bad tick"),
|
||||
)
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="bad tick"):
|
||||
calculate_account_projected_margin_ratio(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
new_position_side="BUY",
|
||||
new_position_volume=0.1,
|
||||
)
|
||||
|
||||
def test_symbol_group_margin_ratio_sums_group_exposure(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
|
||||
Reference in New Issue
Block a user