Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37eef16e99 | |||
| 96c75f7852 |
@@ -92,16 +92,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
|||||||
Trading applications can depend on `mt5cli` imports only; terminal path,
|
Trading applications can depend on `mt5cli` imports only; terminal path,
|
||||||
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
||||||
login strings are coerced to integers, and empty login strings are treated as
|
login strings are coerced to integers, and empty login strings are treated as
|
||||||
unset.
|
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
|
||||||
|
`$ENV_NAME` placeholders in connection string parameters before coercion.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
|
build_config,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
create_trading_client,
|
create_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Login from environment — numeric string is coerced to int automatically
|
||||||
|
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
with mt5_trading_session(
|
with mt5_trading_session(
|
||||||
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
||||||
login="12345",
|
login="12345",
|
||||||
@@ -248,7 +253,7 @@ rates = collect_latest_closed_rates_by_granularity(
|
|||||||
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
|
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
|
||||||
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
||||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
||||||
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
||||||
|
|||||||
+32
-29
@@ -28,23 +28,25 @@ These names are exported from `mt5cli` and covered by the contract in
|
|||||||
|
|
||||||
### Session lifecycle and configuration
|
### Session lifecycle and configuration
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
||||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
| `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
|
||||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
||||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
||||||
|
| `substitute_mapping_values` | Recursively traverse a dict/list/scalar structure and substitute `${ENV_VAR}` placeholders for caller-selected mapping keys only; optionally normalise blank strings to `None` for a separate caller-selected key set; does not hard-code any application-specific key names |
|
||||||
|
|
||||||
Credential resolution is generic: any environment variable name may appear inside
|
Credential resolution is generic: any environment variable name may appear inside
|
||||||
`${...}`. mt5cli does not hard-code application-specific keys such as
|
`${...}`. mt5cli does not hard-code application-specific keys such as
|
||||||
`mt5_login` or `mt5_exe`.
|
`mt5_login` or `mt5_exe`.
|
||||||
|
|
||||||
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
||||||
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
|
`substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
|
||||||
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
|
and `build_config()` to additionally expand strings whose entire value is a bare
|
||||||
|
`$ENV_NAME` identifier.
|
||||||
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
||||||
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
||||||
Default is `False` to preserve backward compatibility.
|
Default is `False` to preserve backward compatibility.
|
||||||
@@ -90,24 +92,25 @@ diagrams.
|
|||||||
These helpers implement broker-facing calculations only. They do not encode
|
These helpers implement broker-facing calculations only. They do not encode
|
||||||
strategy entries, exits, Kelly sizing, or signal logic.
|
strategy entries, exits, Kelly sizing, or signal logic.
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
| `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 |
|
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||||
| `detect_position_side` | Net long / short / flat from open positions |
|
| `detect_position_side` | Net long / short / flat from open positions |
|
||||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
| `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 |
|
| `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 |
|
| `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_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
| `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_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure |
|
||||||
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
|
||||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||||
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
||||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
| `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.
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ from .sdk import (
|
|||||||
resolve_account_spec,
|
resolve_account_spec,
|
||||||
resolve_account_specs,
|
resolve_account_specs,
|
||||||
substitute_env_placeholders,
|
substitute_env_placeholders,
|
||||||
|
substitute_mapping_values,
|
||||||
symbol_info,
|
symbol_info,
|
||||||
symbol_info_tick,
|
symbol_info_tick,
|
||||||
symbols,
|
symbols,
|
||||||
@@ -119,6 +120,7 @@ from .trading import (
|
|||||||
OrderSide,
|
OrderSide,
|
||||||
OrderTimeMode,
|
OrderTimeMode,
|
||||||
PositionSide,
|
PositionSide,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_new_position_margin_ratio,
|
calculate_new_position_margin_ratio,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
@@ -196,6 +198,7 @@ __all__ = [
|
|||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
"build_rate_view_name",
|
||||||
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
@@ -281,6 +284,7 @@ __all__ = [
|
|||||||
"resolve_rate_view_names",
|
"resolve_rate_view_names",
|
||||||
"schema_columns",
|
"schema_columns",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"substitute_mapping_values",
|
||||||
"symbol_info",
|
"symbol_info",
|
||||||
"symbol_info_tick",
|
"symbol_info_tick",
|
||||||
"symbols",
|
"symbols",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
"build_rate_view_name",
|
||||||
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
"calculate_projected_margin_ratio",
|
"calculate_projected_margin_ratio",
|
||||||
@@ -76,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"resolve_rate_view_name",
|
"resolve_rate_view_name",
|
||||||
"resolve_rate_view_names",
|
"resolve_rate_view_names",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"substitute_mapping_values",
|
||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
|
|||||||
+83
-6
@@ -40,7 +40,7 @@ from .utils import (
|
|||||||
from .utils import coerce_login as _coerce_login
|
from .utils import coerce_login as _coerce_login
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable, Iterator, Sequence
|
from collections.abc import Callable, Collection, Iterator, Sequence
|
||||||
|
|
||||||
UpdateHistoryBackend = Callable[..., None]
|
UpdateHistoryBackend = Callable[..., None]
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ __all__ = [
|
|||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"resolve_account_specs",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"substitute_mapping_values",
|
||||||
"symbol_info",
|
"symbol_info",
|
||||||
"symbol_info_tick",
|
"symbol_info_tick",
|
||||||
"symbols",
|
"symbols",
|
||||||
@@ -305,7 +306,7 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
|
|||||||
def build_config(
|
def build_config(
|
||||||
*,
|
*,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
login: int | None = None,
|
login: int | str | None = None,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
@@ -315,14 +316,19 @@ def build_config(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Optional terminal executable path.
|
path: Optional terminal executable path.
|
||||||
login: Optional trading account login.
|
login: Optional trading account login. Integers are preserved. String
|
||||||
|
values are coerced: empty or whitespace-only strings become
|
||||||
|
``None``; numeric strings such as ``"12345"`` are converted to
|
||||||
|
``int``; non-numeric strings raise ``ValueError``. When
|
||||||
|
``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
|
||||||
|
``${ENV_NAME}`` placeholders are expanded before coercion.
|
||||||
password: Optional trading account password.
|
password: Optional trading account password.
|
||||||
server: Optional trading server name.
|
server: Optional trading server name.
|
||||||
timeout: Optional connection timeout in milliseconds.
|
timeout: Optional connection timeout in milliseconds.
|
||||||
allow_whole_dollar_env: When ``True``, string parameters that are
|
allow_whole_dollar_env: When ``True``, string parameters that are
|
||||||
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
||||||
to ``path``, ``password``, and ``server``. Default ``False``
|
to ``path``, ``login``, ``password``, and ``server``. Default
|
||||||
preserves existing behavior.
|
``False`` preserves existing behavior.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured ``Mt5Config`` instance.
|
Configured ``Mt5Config`` instance.
|
||||||
@@ -330,6 +336,8 @@ def build_config(
|
|||||||
if allow_whole_dollar_env:
|
if allow_whole_dollar_env:
|
||||||
if path is not None:
|
if path is not None:
|
||||||
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
||||||
|
if isinstance(login, str):
|
||||||
|
login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
|
||||||
if password is not None:
|
if password is not None:
|
||||||
password = substitute_env_placeholders(
|
password = substitute_env_placeholders(
|
||||||
password, allow_whole_dollar_env=True
|
password, allow_whole_dollar_env=True
|
||||||
@@ -338,7 +346,7 @@ def build_config(
|
|||||||
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
||||||
return Mt5Config(
|
return Mt5Config(
|
||||||
path=path,
|
path=path,
|
||||||
login=login,
|
login=_coerce_login(login),
|
||||||
password=password,
|
password=password,
|
||||||
server=server,
|
server=server,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -1442,6 +1450,75 @@ def substitute_env_placeholders(
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def substitute_mapping_values(
|
||||||
|
data: object,
|
||||||
|
*,
|
||||||
|
keys: Collection[str],
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
|
blank_string_keys_as_none: Collection[str] = (),
|
||||||
|
) -> object:
|
||||||
|
"""Recursively substitute environment placeholders for selected mapping keys.
|
||||||
|
|
||||||
|
Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
|
||||||
|
``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
|
||||||
|
whose immediate parent dict key is in ``keys``. Fields whose key is
|
||||||
|
not in ``keys`` are preserved exactly, including literal dollar signs.
|
||||||
|
Strings that are direct elements of a list are never substituted;
|
||||||
|
substitution only applies to strings that are immediate dict values.
|
||||||
|
|
||||||
|
This is a generic downstream config utility. Key names such as
|
||||||
|
``mt5_login`` or ``mt5_password`` must be supplied by the caller;
|
||||||
|
mt5cli does not hard-code any application-specific key names.
|
||||||
|
Callers are responsible for ensuring ``data`` has bounded nesting depth;
|
||||||
|
deeply nested or self-referential structures will hit Python's recursion
|
||||||
|
limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Arbitrarily nested dict/list/scalar value to process.
|
||||||
|
keys: Mapping keys whose string values receive placeholder
|
||||||
|
substitution.
|
||||||
|
allow_whole_dollar_env: When ``True``, a string that is exactly
|
||||||
|
``$ENV_NAME`` (whole value) is also expanded from the
|
||||||
|
environment in addition to ``${ENV_NAME}`` placeholders.
|
||||||
|
Default ``False`` expands ``${ENV_NAME}`` only.
|
||||||
|
blank_string_keys_as_none: Mapping keys for which blank strings
|
||||||
|
(after any substitution) are normalised to ``None``. A key
|
||||||
|
may appear in ``blank_string_keys_as_none`` without also
|
||||||
|
appearing in ``keys``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The processed value. Dicts and lists are rebuilt into new
|
||||||
|
containers with selected string values substituted and
|
||||||
|
blank-normalised. Scalar inputs (non-dict, non-list) are
|
||||||
|
returned as-is.
|
||||||
|
"""
|
||||||
|
keys_set: frozenset[str] = frozenset(keys)
|
||||||
|
blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
|
||||||
|
|
||||||
|
def _visit(node: object, current_key: str | None) -> object:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
typed = cast("dict[object, object]", node)
|
||||||
|
return {
|
||||||
|
k: _visit(v, k if isinstance(k, str) else None)
|
||||||
|
for k, v in typed.items()
|
||||||
|
}
|
||||||
|
if isinstance(node, list):
|
||||||
|
typed_list = cast("list[object]", node)
|
||||||
|
return [_visit(item, None) for item in typed_list]
|
||||||
|
if not isinstance(node, str):
|
||||||
|
return node
|
||||||
|
text = node
|
||||||
|
if current_key in keys_set:
|
||||||
|
text = substitute_env_placeholders(
|
||||||
|
node, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
|
if current_key in blank_keys_set and not text.strip():
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
return _visit(data, None)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_field(
|
def _resolve_field(
|
||||||
override: str | None,
|
override: str | None,
|
||||||
account_value: str | None,
|
account_value: str | None,
|
||||||
|
|||||||
+54
-8
@@ -127,6 +127,7 @@ __all__ = [
|
|||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
"PositionSide",
|
"PositionSide",
|
||||||
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
@@ -834,15 +835,60 @@ def calculate_new_position_margin_ratio(
|
|||||||
|
|
||||||
def _account_equity(client: Mt5TradingClient) -> float:
|
def _account_equity(client: Mt5TradingClient) -> float:
|
||||||
account = get_account_snapshot(client)
|
account = get_account_snapshot(client)
|
||||||
try:
|
return _required_account_number(account, "equity", allow_zero=False)
|
||||||
equity = float(account.get("equity") or 0.0)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
msg = "Account equity must be positive to calculate margin ratio."
|
def _required_account_number(
|
||||||
raise Mt5TradingError(msg) from exc
|
account: Mapping[str, object],
|
||||||
if equity <= 0 or not isfinite(equity):
|
field: str,
|
||||||
msg = "Account equity must be positive to calculate margin ratio."
|
*,
|
||||||
|
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)
|
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(
|
def calculate_projected_margin_ratio(
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.3"
|
version = "0.9.5"
|
||||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
maintainers = [{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,
|
RateTarget,
|
||||||
build_config,
|
build_config,
|
||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
calculate_projected_margin_ratio,
|
calculate_projected_margin_ratio,
|
||||||
@@ -684,6 +685,7 @@ class TestStableSdkContract:
|
|||||||
assert price is not None
|
assert price is not None
|
||||||
assert abs(price - 1.2) < 1e-9
|
assert abs(price - 1.2) < 1e-9
|
||||||
assert callable(calculate_trailing_stop_updates)
|
assert callable(calculate_trailing_stop_updates)
|
||||||
|
assert callable(calculate_account_projected_margin_ratio)
|
||||||
assert callable(calculate_projected_margin_ratio)
|
assert callable(calculate_projected_margin_ratio)
|
||||||
assert callable(calculate_symbol_group_margin_ratio)
|
assert callable(calculate_symbol_group_margin_ratio)
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from mt5cli.sdk import (
|
|||||||
resolve_account_spec,
|
resolve_account_spec,
|
||||||
resolve_account_specs,
|
resolve_account_specs,
|
||||||
substitute_env_placeholders,
|
substitute_env_placeholders,
|
||||||
|
substitute_mapping_values,
|
||||||
symbol_info,
|
symbol_info,
|
||||||
symbol_info_tick,
|
symbol_info_tick,
|
||||||
symbols,
|
symbols,
|
||||||
@@ -2436,3 +2437,263 @@ class TestThrottledHistoryUpdater:
|
|||||||
updater.update(MagicMock(), ["EURUSD"])
|
updater.update(MagicMock(), ["EURUSD"])
|
||||||
|
|
||||||
assert updater.last_update_monotonic is None
|
assert updater.last_update_monotonic is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildConfigStringLogin:
|
||||||
|
"""Tests for build_config() string login coercion (issue #61)."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("login", "expected"),
|
||||||
|
[
|
||||||
|
(None, None),
|
||||||
|
(12345, 12345),
|
||||||
|
("12345", 12345),
|
||||||
|
(" 12345 ", 12345),
|
||||||
|
("", None),
|
||||||
|
(" ", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_coerces_login_from_string(
|
||||||
|
self,
|
||||||
|
login: int | str | None,
|
||||||
|
expected: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config coerces string login to int or None."""
|
||||||
|
config = build_config(login=login)
|
||||||
|
assert config.login == expected
|
||||||
|
|
||||||
|
def test_rejects_non_numeric_string_login(self) -> None:
|
||||||
|
"""Test build_config raises ValueError for non-numeric string login."""
|
||||||
|
with pytest.raises(ValueError, match="invalid literal"):
|
||||||
|
build_config(login="abc")
|
||||||
|
|
||||||
|
def test_expands_dollar_brace_login_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config expands ${MT5_LOGIN} and coerces with opt-in."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||||
|
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||||
|
assert config.login == 12345
|
||||||
|
|
||||||
|
def test_expands_whole_dollar_login_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config expands $MT5_LOGIN and coerces with opt-in."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "99999")
|
||||||
|
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||||
|
assert config.login == 99999
|
||||||
|
|
||||||
|
def test_missing_env_variable_raises(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config raises ValueError when referenced env var is not set."""
|
||||||
|
monkeypatch.delenv("MT5_LOGIN", raising=False)
|
||||||
|
with pytest.raises(ValueError, match="'MT5_LOGIN' is not set"):
|
||||||
|
build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
def test_env_expands_to_blank_becomes_none(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config coerces blank env-expanded login to None."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "")
|
||||||
|
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||||
|
assert config.login is None
|
||||||
|
|
||||||
|
def test_dollar_brace_login_not_expanded_without_opt_in(self) -> None:
|
||||||
|
"""Test ${MT5_LOGIN} is not expanded when allow_whole_dollar_env=False."""
|
||||||
|
with pytest.raises(ValueError, match="invalid literal"):
|
||||||
|
build_config(login="${MT5_LOGIN}")
|
||||||
|
|
||||||
|
def test_integer_login_preserved_backward_compat(self) -> None:
|
||||||
|
"""Test existing int login callers remain backward-compatible."""
|
||||||
|
config = build_config(login=54321)
|
||||||
|
assert config.login == 54321
|
||||||
|
|
||||||
|
def test_none_login_preserved_backward_compat(self) -> None:
|
||||||
|
"""Test existing None login callers remain backward-compatible."""
|
||||||
|
config = build_config(login=None)
|
||||||
|
assert config.login is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubstituteMappingValues:
|
||||||
|
"""Tests for substitute_mapping_values() (issue #62)."""
|
||||||
|
|
||||||
|
def test_substitutes_selected_keys_in_flat_dict(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test selected keys are substituted in a flat mapping."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_login": "${MT5_LOGIN}",
|
||||||
|
"strategy_name": "${MT5_LOGIN}",
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
assert result == {"mt5_login": "12345", "strategy_name": "${MT5_LOGIN}"}
|
||||||
|
|
||||||
|
def test_preserves_non_selected_literal_dollar_signs(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test literal dollar signs in non-selected fields are preserved exactly."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_password": "${MT5_PASSWORD}",
|
||||||
|
"notes": "$NOT_EXPANDED",
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_password"})
|
||||||
|
assert result == {"mt5_password": "secret", "notes": "$NOT_EXPANDED"}
|
||||||
|
|
||||||
|
def test_nested_dict_traversal_substitutes_selected_keys(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test selected keys inside nested dicts are substituted."""
|
||||||
|
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"outer": {
|
||||||
|
"mt5_server": "${MT5_SERVER}",
|
||||||
|
"other": "${MT5_SERVER}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_server"})
|
||||||
|
assert result == {
|
||||||
|
"outer": {"mt5_server": "Broker-Demo", "other": "${MT5_SERVER}"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_nested_list_traversal_substitutes_selected_keys(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test selected keys inside list elements are substituted."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "42")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"accounts": [
|
||||||
|
{"mt5_login": "${MT5_LOGIN}", "name": "${MT5_LOGIN}"},
|
||||||
|
{"mt5_login": "${MT5_LOGIN}", "name": "fixed"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
assert result == {
|
||||||
|
"accounts": [
|
||||||
|
{"mt5_login": "42", "name": "${MT5_LOGIN}"},
|
||||||
|
{"mt5_login": "42", "name": "fixed"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_whole_dollar_expanded_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data,
|
||||||
|
keys={"mt5_password"},
|
||||||
|
allow_whole_dollar_env=True,
|
||||||
|
)
|
||||||
|
assert result == {"mt5_password": "secret"}
|
||||||
|
|
||||||
|
def test_whole_dollar_not_expanded_by_default(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME in a selected key is preserved when opt-in is False."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_password"})
|
||||||
|
assert result == {"mt5_password": "$MT5_PASSWORD"}
|
||||||
|
|
||||||
|
def test_blank_string_becomes_none_for_blank_keys(self) -> None:
|
||||||
|
"""Test blank strings are normalised to None for blank_string_keys_as_none."""
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_login": "",
|
||||||
|
"mt5_password": " ",
|
||||||
|
"other": "",
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data,
|
||||||
|
keys=set(),
|
||||||
|
blank_string_keys_as_none={"mt5_login", "mt5_password"},
|
||||||
|
)
|
||||||
|
assert result == {"mt5_login": None, "mt5_password": None, "other": ""}
|
||||||
|
|
||||||
|
def test_env_expanded_blank_becomes_none(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test env-expanded blank string is normalised to None."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "")
|
||||||
|
data: dict[str, object] = {"mt5_login": "${MT5_LOGIN}"}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data,
|
||||||
|
keys={"mt5_login"},
|
||||||
|
blank_string_keys_as_none={"mt5_login"},
|
||||||
|
)
|
||||||
|
assert result == {"mt5_login": None}
|
||||||
|
|
||||||
|
def test_missing_env_variable_raises_for_selected_key(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test missing env var for a selected key raises ValueError."""
|
||||||
|
monkeypatch.delenv("MT5_MISSING", raising=False)
|
||||||
|
data: dict[str, object] = {"mt5_login": "${MT5_MISSING}"}
|
||||||
|
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||||
|
substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
|
||||||
|
def test_non_string_values_preserved(self) -> None:
|
||||||
|
"""Test non-string values under selected or non-selected keys are preserved."""
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_login": 12345,
|
||||||
|
"timeout": 5000,
|
||||||
|
"enabled": True,
|
||||||
|
"ratio": 1.5,
|
||||||
|
"nothing": None,
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data, keys={"mt5_login", "timeout", "enabled", "ratio", "nothing"}
|
||||||
|
)
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_caller_supplied_key_set_substitutes_correctly(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test helper works with any caller-supplied key set."""
|
||||||
|
monkeypatch.setenv("APP_LOGIN", "77777")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "p4ss")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"app_login": "${APP_LOGIN}",
|
||||||
|
"app_password": "${APP_PASSWORD}",
|
||||||
|
"unrelated": "${APP_LOGIN}",
|
||||||
|
}
|
||||||
|
credential_keys = {"app_login", "app_password"}
|
||||||
|
result = substitute_mapping_values(data, keys=credential_keys)
|
||||||
|
assert result == {
|
||||||
|
"app_login": "77777",
|
||||||
|
"app_password": "p4ss",
|
||||||
|
"unrelated": "${APP_LOGIN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_scalar_data_returned_unchanged(self) -> None:
|
||||||
|
"""Test a scalar (non-dict, non-list) value is returned as-is."""
|
||||||
|
assert substitute_mapping_values("hello", keys={"x"}) == "hello"
|
||||||
|
assert substitute_mapping_values(42, keys={"x"}) == 42
|
||||||
|
assert substitute_mapping_values(None, keys={"x"}) is None
|
||||||
|
|
||||||
|
def test_tuple_container_not_traversed(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test tuple containers are returned as-is without traversal."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "42")
|
||||||
|
data: dict[str, object] = {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
# tuple is returned as-is; inner dict is NOT visited
|
||||||
|
assert result == {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from mt5cli.trading import (
|
|||||||
MarginVolume,
|
MarginVolume,
|
||||||
OrderExecutionResult,
|
OrderExecutionResult,
|
||||||
OrderLimits,
|
OrderLimits,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_new_position_margin_ratio,
|
calculate_new_position_margin_ratio,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
@@ -1891,6 +1892,142 @@ class TestVolumeAndExecution:
|
|||||||
_assert_close(result, 0.024)
|
_assert_close(result, 0.024)
|
||||||
client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1)
|
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(
|
def test_symbol_group_margin_ratio_sums_group_exposure(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
Reference in New Issue
Block a user