feat: stable SDK helpers for volume, margin, and closed bars (#39–#41) (#42)

* feat: add stable SDK helpers for volume, margin, and closed bars (#39, #40, #41)

Expose generic trading utilities in the stable downstream SDK so applications
like mteor can drop local MT5 adapter code:

- normalize_order_volume() for broker step/min/max sizing
- estimate_order_margin() and calculate_positions_margin() for margin totals
- fetch_latest_closed_rates_for_trading_client() for closed bars from Mt5TradingClient

Update STABLE_SDK_EXPORTS, package-root exports, docs, and unit tests.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* chore: bump version to 0.8.3

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: address PR review feedback on volume cap, rate time, and margin grouping

- Re-apply volume_max after step normalization in normalize_order_volume()
- Drop misleading non-time index reset branch in _ensure_rate_time_column()
- Group positions by (symbol, side) before margin estimation
- Add branch-coverage tests for tick price validation and volume cap edge case

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: address remaining PR review threads on docs and DatetimeIndex

- Rename unnamed DatetimeIndex column to time after reset_index()
- Guard estimate_order_margin example on positive normalized volume
- Document calculate_positions_margin skip vs error propagation behavior
- Add test for unnamed DatetimeIndex branch coverage

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: harden stable SDK margin, rate fetch, and volume normalization

- Wrap order_calc_margin conversion and reject None/non-numeric results
- Validate fetched rate objects are DataFrames before time normalization
- Return 0.0 for non-finite volume inputs and constraints in normalize_order_volume

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: reject non-finite volumes in margin estimation helpers

Use _is_positive_finite_number() in estimate_order_margin() and
calculate_positions_margin() so NaN/inf volumes never reach broker calls.
Add focused tests and document non-finite volume skipping in trading.md.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: guard symbol filter in calculate_positions_margin for empty frames

Return 0.0 before filtering when positions are empty or lack a symbol column.
Add regression tests for filtered calls on malformed position frames.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>
This commit is contained in:
Daichi Narushima
2026-06-19 01:02:18 +09:00
committed by GitHub
parent 9f2968cc98
commit c4a4253fbc
10 changed files with 996 additions and 6 deletions
+2
View File
@@ -60,6 +60,7 @@ timestamp normalization in downstream apps.
| ------------------------------------------------ | ------------------------------------------------------------ |
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session |
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
@@ -96,6 +97,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
| `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 |
| `determine_order_limits` | SL/TP price levels from ratios |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
+4 -3
View File
@@ -31,9 +31,10 @@ rates = collect_latest_rates_for_accounts_with_retries(
### Latest closed rate bars
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
row. `fetch_latest_closed_rates()` handles one connected client; multi-account
helpers fetch `count + 1` bars, drop that row with `drop_forming_rate_bar()`,
and validate each series is non-empty. Returned frames are ordered
row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use
`fetch_latest_closed_rates_for_trading_client()` from an active
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
fewer closed bars.
+29
View File
@@ -40,15 +40,19 @@ betting logic, or scheduling code in downstream applications.
```python
from mt5cli import (
calculate_positions_margin,
calculate_spread_ratio,
calculate_margin_and_volume,
close_open_positions,
detect_position_side,
determine_order_limits,
estimate_order_margin,
fetch_latest_closed_rates_for_trading_client,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
get_tick_snapshot,
normalize_order_volume,
place_market_order,
)
@@ -58,6 +62,22 @@ tick = get_tick_snapshot(client, "EURUSD")
positions = get_positions_frame(client, "EURUSD")
side = detect_position_side(client, "EURUSD")
spread_ratio = calculate_spread_ratio(client, "EURUSD")
volume = normalize_order_volume(
0.15,
volume_min=symbol["volume_min"],
volume_max=symbol["volume_max"],
volume_step=symbol["volume_step"],
)
buy_margin = (
estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0
)
open_margin = calculate_positions_margin(client, symbols=["EURUSD"])
closed_bars = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=100,
)
sizing = calculate_margin_and_volume(
client,
"EURUSD",
@@ -87,6 +107,12 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
sell-only exposure, and `None` for no positions or mixed long/short exposure.
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
`Mt5TradingError` when bid or ask is missing or non-positive.
`normalize_order_volume()` returns `0.0` for invalid constraints or
sub-minimum requests; check the result before calling `estimate_order_margin()`,
which requires a positive finite volume. `calculate_positions_margin()` silently
skips rows with missing symbols, non-positive volumes, non-finite volumes, or
unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row
encounters invalid tick data or margin results from the broker.
SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
omits that level. SL/TP prices are rounded with symbol `digits` metadata when
@@ -153,6 +179,9 @@ through the stable package root without embedding entry/exit policy.
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
| Local position-side detection | `detect_position_side()` |
| Local margin/volume sizing | `calculate_margin_and_volume()` |
| Local broker volume step normalization | `normalize_order_volume()` |
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()` |
| Local SL/TP price derivation | `determine_order_limits()` |
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
+8
View File
@@ -118,6 +118,7 @@ from .trading import (
PositionSide,
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
calculate_positions_margin,
calculate_spread_ratio,
calculate_volume_by_margin,
close_open_positions,
@@ -125,11 +126,14 @@ from .trading import (
detect_position_side,
determine_order_limits,
ensure_symbol_selected,
estimate_order_margin,
fetch_latest_closed_rates_for_trading_client,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
get_tick_snapshot,
mt5_trading_session,
normalize_order_volume,
place_market_order,
update_sltp_for_open_positions,
)
@@ -182,6 +186,7 @@ __all__ = [
"build_rate_view_name",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors",
@@ -204,9 +209,11 @@ __all__ = [
"drop_forming_rate_bar",
"ensure_symbol_selected",
"ensure_utc",
"estimate_order_margin",
"export_dataframe",
"export_dataframe_to_sqlite",
"fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
@@ -230,6 +237,7 @@ __all__ = [
"mt5_version",
"normalize_dataframe",
"normalize_mt5_exception",
"normalize_order_volume",
"normalize_symbol",
"normalize_symbols",
"normalize_time_columns",
+4
View File
@@ -30,6 +30,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"build_rate_view_name",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors",
@@ -50,9 +51,11 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"determine_order_limits",
"drop_forming_rate_bar",
"ensure_symbol_selected",
"estimate_order_margin",
"export_dataframe",
"export_dataframe_to_sqlite",
"fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
@@ -74,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"mt5_trading_session",
"mt5_version",
"normalize_mt5_exception",
"normalize_order_volume",
"orders",
"place_market_order",
"positions",
+206 -1
View File
@@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Literal, TypedDict, cast
import pandas as pd
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
from .history import drop_forming_rate_bar
from .sdk import build_config
from .utils import coerce_login as _coerce_login
from .utils import parse_timeframe
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterator, Sequence
PositionSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"]
@@ -125,6 +127,7 @@ __all__ = [
"PositionSide",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"close_open_positions",
@@ -132,11 +135,14 @@ __all__ = [
"detect_position_side",
"determine_order_limits",
"ensure_symbol_selected",
"estimate_order_margin",
"fetch_latest_closed_rates_for_trading_client",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"mt5_trading_session",
"normalize_order_volume",
"place_market_order",
"update_sltp_for_open_positions",
]
@@ -276,6 +282,48 @@ def _normalize_order_side(side: str) -> OrderSide:
raise ValueError(msg)
def _is_finite_number(value: object) -> bool:
return isinstance(value, int | float) and isfinite(value)
def _is_positive_finite_number(value: object) -> bool:
if not _is_finite_number(value):
return False
return float(cast("float | int", value)) > 0
def normalize_order_volume(
volume: float,
*,
volume_min: float,
volume_max: float,
volume_step: float,
) -> float:
"""Normalize a requested order volume to broker volume constraints.
Returns:
Volume floored to the nearest valid broker step from ``volume_min``,
capped at ``volume_max`` when finite and positive, and rounded
deterministically. Returns ``0.0`` when inputs or constraints are
invalid, non-finite, or the capped request is below ``volume_min``.
"""
if not _is_finite_number(volume):
return 0.0
if not _is_positive_finite_number(volume_min):
return 0.0
if not _is_positive_finite_number(volume_step):
return 0.0
has_volume_cap = _is_positive_finite_number(volume_max)
capped = min(volume, volume_max) if has_volume_cap else volume
if capped < volume_min:
return 0.0
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
normalized = volume_min + max(0, steps) * volume_step
if has_volume_cap:
normalized = min(normalized, volume_max)
return round(normalized, 10)
def _position_side_from_order_side(side: str) -> PositionSide:
normalized = side.lower()
if normalized in {"long", "buy"}:
@@ -531,6 +579,108 @@ def get_positions_frame(
return frame
def _order_side_from_position_type(
client: Mt5TradingClient,
position_type: object,
) -> OrderSide | None:
if position_type == client.mt5.POSITION_TYPE_BUY:
return "BUY"
if position_type == client.mt5.POSITION_TYPE_SELL:
return "SELL"
return None
def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame:
if frame.empty or "time" in frame.columns:
return frame
if frame.index.name == "time" or isinstance(frame.index, pd.DatetimeIndex):
normalized = frame.reset_index()
if "time" not in normalized.columns and not normalized.empty:
normalized = normalized.rename(columns={normalized.columns[0]: "time"})
return normalized
return frame
def estimate_order_margin(
client: Mt5TradingClient,
symbol: str,
order_side: OrderSide | str,
volume: float,
) -> float:
"""Estimate required margin for one order at the current market price.
Returns:
Positive finite margin required for the order at the current quote.
Raises:
Mt5TradingError: If volume, tick data, or margin estimation is invalid.
"""
if not _is_positive_finite_number(volume):
msg = "Volume must be a positive finite number to estimate order margin."
raise Mt5TradingError(msg)
side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
try:
margin = float(raw_margin)
except (TypeError, ValueError) as exc:
msg = f"Margin estimate is invalid for {symbol!r}."
raise Mt5TradingError(msg) from exc
if margin <= 0 or not isfinite(margin):
msg = f"Margin estimate is invalid for {symbol!r}."
raise Mt5TradingError(msg)
return margin
def calculate_positions_margin(
client: Mt5TradingClient,
*,
symbols: Sequence[str] | None = None,
) -> float:
"""Return the sum of estimated current margin for open positions.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbols: Optional symbol filter. When omitted, all open positions are
included.
Returns:
Total estimated margin, or ``0.0`` when no matching positions exist.
"""
frame = get_positions_frame(client)
if frame.empty or "symbol" not in frame.columns:
return 0.0
if symbols is not None:
frame = frame[frame["symbol"].isin(list(symbols))]
if frame.empty:
return 0.0
grouped_volumes: dict[tuple[str, OrderSide], float] = {}
for _, row in frame.iterrows():
symbol = row.get("symbol")
if not isinstance(symbol, str) or not symbol:
continue
volume = row.get("volume")
if not _is_positive_finite_number(volume):
continue
order_side = _order_side_from_position_type(client, row.get("type"))
if order_side is None:
continue
key = (symbol, order_side)
finite_volume = float(cast("float | int", volume))
grouped_volumes[key] = grouped_volumes.get(key, 0.0) + finite_volume
total = 0.0
for (symbol, order_side), volume in grouped_volumes.items():
total += estimate_order_margin(client, symbol, order_side, volume)
return total
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
@@ -997,6 +1147,61 @@ def update_sltp_for_open_positions(
return results
def fetch_latest_closed_rates_for_trading_client(
client: Mt5TradingClient,
*,
symbol: str,
granularity: str,
count: int,
) -> pd.DataFrame:
"""Fetch the latest closed bars from a connected trading client.
Returns:
Up to ``count`` closed bars ordered oldest to newest.
Raises:
ValueError: If ``count`` is not positive, rate data is empty or
malformed, or the ``time`` column is missing.
Mt5TradingError: If the trading client cannot fetch rate data.
"""
if count <= 0:
msg = "count must be positive."
raise ValueError(msg)
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
if callable(fetch_method):
fetched = fetch_method(symbol, granularity, count + 1)
else:
copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
if not callable(copy_method):
msg = "MT5 trading client cannot fetch rate data."
raise Mt5TradingError(msg)
fetched = copy_method(
symbol=symbol,
timeframe=parse_timeframe(granularity),
start_pos=0,
count=count + 1,
)
if not isinstance(fetched, pd.DataFrame):
msg = (
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
"expected a DataFrame."
)
raise ValueError(msg) # noqa: TRY004
frame = fetched
frame = _ensure_rate_time_column(frame)
if "time" not in frame.columns:
msg = f"Rate data is missing a time column for {symbol!r}."
raise ValueError(msg)
closed = drop_forming_rate_bar(frame)
if closed.empty:
msg = (
f"Rate data is empty for {symbol!r} at granularity {granularity!r} "
f"with count {count}."
)
raise ValueError(msg)
return closed.tail(count).reset_index(drop=True)
@contextmanager
def mt5_trading_session(
config: Mt5Config | None = None,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mt5cli"
version = "0.8.2"
version = "0.8.3"
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"}]
+42
View File
@@ -34,6 +34,7 @@ from mt5cli import (
build_config,
build_rate_targets,
calculate_margin_and_volume,
calculate_positions_margin,
call_with_normalized_errors,
detect_format,
drop_forming_rate_bar,
@@ -42,6 +43,7 @@ from mt5cli import (
export_dataframe,
export_dataframe_to_sqlite,
fetch_latest_closed_rates,
fetch_latest_closed_rates_for_trading_client,
granularity_name,
is_recoverable_mt5_error,
load_rate_data,
@@ -50,6 +52,7 @@ from mt5cli import (
mt5_trading_session,
normalize_dataframe,
normalize_mt5_exception,
normalize_order_volume,
normalize_symbol,
normalize_symbols,
parse_date_range,
@@ -572,6 +575,45 @@ class TestStableSdkContract:
client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
assert list(result["close"]) == [1.0, 1.1]
def test_fetch_latest_closed_rates_for_trading_client_from_package_root(
self,
) -> None:
"""Trading-client closed-bar helper is importable from the stable surface."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
{"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]},
)
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
assert list(result["close"]) == [1.0, 1.1]
def test_normalize_order_volume_from_package_root(self) -> None:
"""Volume normalization helper is importable from the stable surface."""
result = normalize_order_volume(
0.25,
volume_min=0.1,
volume_max=1.0,
volume_step=0.1,
)
assert abs(result - 0.2) < 1e-9
def test_calculate_positions_margin_from_package_root(self) -> None:
"""Position margin helper is importable from the stable surface."""
client = MagicMock()
client.mt5.POSITION_TYPE_BUY = 0
client.mt5.POSITION_TYPE_SELL = 1
client.mt5.ORDER_TYPE_BUY = 10
client.mt5.ORDER_TYPE_SELL = 11
client.positions_get_as_df.return_value = pd.DataFrame()
assert calculate_positions_margin(client) == 0
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
"""Rate view resolution is importable and honors require_existing."""
db_path = tmp_path / "rates.db"
+699
View File
@@ -19,6 +19,7 @@ from mt5cli.trading import (
OrderLimits,
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
calculate_positions_margin,
calculate_spread_ratio,
calculate_volume_by_margin,
close_open_positions,
@@ -26,11 +27,14 @@ from mt5cli.trading import (
detect_position_side,
determine_order_limits,
ensure_symbol_selected,
estimate_order_margin,
fetch_latest_closed_rates_for_trading_client,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
get_tick_snapshot,
mt5_trading_session,
normalize_order_volume,
place_market_order,
update_sltp_for_open_positions,
)
@@ -773,6 +777,474 @@ class TestSnapshotsAndState:
calculate_spread_ratio(client, "EURUSD")
class TestNormalizeOrderVolume:
"""Tests for normalize_order_volume."""
def test_returns_exact_minimum_volume(self) -> None:
"""Test exact volume_min is returned unchanged."""
_assert_close(
normalize_order_volume(
0.1,
volume_min=0.1,
volume_max=1.0,
volume_step=0.1,
),
0.1,
)
def test_floors_to_step_between_boundaries(self) -> None:
"""Test volume between steps floors down to the nearest valid step."""
_assert_close(
normalize_order_volume(
0.25,
volume_min=0.1,
volume_max=1.0,
volume_step=0.1,
),
0.2,
)
def test_clamps_to_volume_max(self) -> None:
"""Test positive volume_max caps the normalized result."""
_assert_close(
normalize_order_volume(
0.9,
volume_min=0.1,
volume_max=0.5,
volume_step=0.1,
),
0.5,
)
def test_returns_zero_below_volume_min(self) -> None:
"""Test sub-minimum requests return zero volume."""
_assert_close(
normalize_order_volume(
0.05,
volume_min=0.1,
volume_max=1.0,
volume_step=0.1,
),
0.0,
)
def test_returns_zero_for_invalid_volume_min(self) -> None:
"""Test non-positive volume_min returns zero volume."""
_assert_close(
normalize_order_volume(
1.0,
volume_min=0.0,
volume_max=1.0,
volume_step=0.1,
),
0.0,
)
def test_returns_zero_for_invalid_volume_step(self) -> None:
"""Test non-positive volume_step returns zero volume."""
_assert_close(
normalize_order_volume(
1.0,
volume_min=0.1,
volume_max=1.0,
volume_step=0.0,
),
0.0,
)
def test_treats_non_positive_volume_max_as_no_cap(self) -> None:
"""Test volume_max <= 0 disables the maximum cap."""
_assert_close(
normalize_order_volume(
2.5,
volume_min=0.1,
volume_max=0.0,
volume_step=0.1,
),
2.5,
)
def test_reapplies_volume_max_after_step_normalization(self) -> None:
"""Test post-step normalization cannot exceed volume_max."""
_assert_close(
normalize_order_volume(
0.5,
volume_min=0.1,
volume_max=0.34,
volume_step=0.12,
),
0.34,
)
def test_returns_zero_for_non_finite_volume(self) -> None:
"""Test NaN or infinite requested volume returns zero."""
_assert_close(
normalize_order_volume(
float("nan"),
volume_min=0.1,
volume_max=1.0,
volume_step=0.1,
),
0.0,
)
_assert_close(
normalize_order_volume(
float("inf"),
volume_min=0.1,
volume_max=1.0,
volume_step=0.1,
),
0.0,
)
def test_returns_zero_for_non_finite_constraints(self) -> None:
"""Test NaN or infinite volume_min/volume_step returns zero."""
_assert_close(
normalize_order_volume(
1.0,
volume_min=float("nan"),
volume_max=1.0,
volume_step=0.1,
),
0.0,
)
_assert_close(
normalize_order_volume(
1.0,
volume_min=0.1,
volume_max=1.0,
volume_step=float("inf"),
),
0.0,
)
def test_treats_non_finite_volume_max_as_no_cap(self) -> None:
"""Test non-finite volume_max disables the maximum cap."""
_assert_close(
normalize_order_volume(
2.5,
volume_min=0.1,
volume_max=float("nan"),
volume_step=0.1,
),
2.5,
)
class TestEstimateOrderMargin:
"""Tests for estimate_order_margin."""
def test_estimates_buy_margin_at_ask(self) -> None:
"""Test buy margin uses ask price and buy order type."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = 12.5
margin = estimate_order_margin(client, "EURUSD", "BUY", 0.1)
_assert_close(margin, 12.5)
client.order_calc_margin.assert_called_once_with(10, "EURUSD", 0.1, 1.1010)
def test_estimates_sell_margin_at_bid(self) -> None:
"""Test sell margin uses bid price and sell order type."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = 12.4
margin = estimate_order_margin(client, "EURUSD", "SELL", 0.1)
_assert_close(margin, 12.4)
client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1000)
def test_accepts_long_and_short_aliases(self) -> None:
"""Test long/short aliases normalize to buy/sell pricing."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.side_effect = [12.5, 12.4]
estimate_order_margin(client, "EURUSD", "long", 0.1)
estimate_order_margin(client, "EURUSD", "short", 0.1)
client.order_calc_margin.assert_any_call(10, "EURUSD", 0.1, 1.1010)
client.order_calc_margin.assert_any_call(11, "EURUSD", 0.1, 1.1000)
def test_rejects_invalid_side(self) -> None:
"""Test unsupported order side raises ValueError."""
client = _mock_trade_client()
with pytest.raises(ValueError, match="Unsupported order side"):
estimate_order_margin(client, "EURUSD", "HOLD", 0.1)
def test_rejects_non_positive_volume(self) -> None:
"""Test non-positive volume raises Mt5TradingError."""
client = _mock_trade_client()
with pytest.raises(Mt5TradingError, match="positive finite number"):
estimate_order_margin(client, "EURUSD", "BUY", 0.0)
def test_rejects_nan_volume(self) -> None:
"""Test NaN volume raises Mt5TradingError without broker calls."""
client = _mock_trade_client()
with pytest.raises(Mt5TradingError, match="positive finite number"):
estimate_order_margin(client, "EURUSD", "BUY", float("nan"))
client.symbol_info_tick_as_dict.assert_not_called()
client.order_calc_margin.assert_not_called()
def test_rejects_infinite_volume(self) -> None:
"""Test infinite volume raises Mt5TradingError without broker calls."""
client = _mock_trade_client()
with pytest.raises(Mt5TradingError, match="positive finite number"):
estimate_order_margin(client, "EURUSD", "BUY", float("inf"))
client.symbol_info_tick_as_dict.assert_not_called()
client.order_calc_margin.assert_not_called()
def test_rejects_missing_tick_prices(self) -> None:
"""Test missing tick prices raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_non_positive_tick_price(self) -> None:
"""Test non-positive tick prices raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_non_finite_tick_price(self) -> None:
"""Test non-finite tick prices raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {
"ask": float("inf"),
"bid": 1.1000,
}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_invalid_margin_result(self) -> None:
"""Test non-positive margin estimates raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = 0.0
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_non_finite_margin_result(self) -> None:
"""Test non-finite margin estimates raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = float("inf")
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_none_margin_result(self) -> None:
"""Test None margin results raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = None
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
def test_rejects_non_numeric_margin_result(self) -> None:
"""Test non-numeric margin results raise Mt5TradingError."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = "invalid"
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
class TestCalculatePositionsMargin:
"""Tests for calculate_positions_margin."""
def test_returns_zero_for_empty_positions(self) -> None:
"""Test empty positions return zero total margin."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame()
_assert_close(calculate_positions_margin(client), 0.0)
def test_filters_by_symbols(self) -> None:
"""Test optional symbol filter limits summed positions."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
{"symbol": "USDJPY", "type": 1, "volume": 0.2},
],
)
client.symbol_info_tick_as_dict.side_effect = [
{"ask": 1.1010, "bid": 1.1000},
{"ask": 110.0, "bid": 109.0},
]
client.order_calc_margin.side_effect = [12.5, 20.0]
margin = calculate_positions_margin(client, symbols=["EURUSD"])
_assert_close(margin, 12.5)
assert client.order_calc_margin.call_count == 1
def test_sums_mixed_buy_and_sell_positions(self) -> None:
"""Test mixed buy/sell exposure sums each side independently."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
{"symbol": "EURUSD", "type": 1, "volume": 0.2},
],
)
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.side_effect = [12.5, 24.8]
margin = calculate_positions_margin(client)
_assert_close(margin, 37.3)
def test_groups_positions_by_symbol_and_side(self) -> None:
"""Test repeated symbol/side pairs use one margin call with summed volume."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
{"symbol": "EURUSD", "type": 0, "volume": 0.2},
],
)
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = 37.5
margin = calculate_positions_margin(client)
_assert_close(margin, 37.5)
client.order_calc_margin.assert_called_once()
args = client.order_calc_margin.call_args[0]
assert args[0] == 10
assert args[1] == "EURUSD"
_assert_close(args[2], 0.3)
_assert_close(args[3], 1.1010)
def test_sums_multiple_symbols(self) -> None:
"""Test positions across symbols are all included."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
{"symbol": "GBPUSD", "type": 1, "volume": 0.3},
],
)
client.symbol_info_tick_as_dict.side_effect = [
{"ask": 1.1010, "bid": 1.1000},
{"ask": 1.3010, "bid": 1.3000},
]
client.order_calc_margin.side_effect = [12.5, 30.0]
margin = calculate_positions_margin(client)
_assert_close(margin, 42.5)
def test_propagates_invalid_tick_or_margin_errors(self) -> None:
"""Test invalid tick or margin data raises Mt5TradingError."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"symbol": "EURUSD", "type": 0, "volume": 0.1}],
)
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
calculate_positions_margin(client)
def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None:
"""Test malformed position rows are ignored when summing margin."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "", "type": 0, "volume": 0.1},
{"symbol": "EURUSD", "type": 0, "volume": 0.0},
{"symbol": "EURUSD", "type": 2, "volume": 0.1},
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
],
)
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = 12.5
margin = calculate_positions_margin(client)
_assert_close(margin, 12.5)
client.order_calc_margin.assert_called_once()
def test_skips_rows_with_non_finite_volume(self) -> None:
"""Test NaN and infinite position volumes are ignored."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "EURUSD", "type": 0, "volume": float("nan")},
{"symbol": "EURUSD", "type": 0, "volume": float("inf")},
{"symbol": "EURUSD", "type": 0, "volume": 0.1},
],
)
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
client.order_calc_margin.return_value = 12.5
margin = calculate_positions_margin(client)
_assert_close(margin, 12.5)
client.order_calc_margin.assert_called_once()
def test_returns_zero_when_all_volumes_are_non_finite(self) -> None:
"""Test all-invalid non-finite volumes return zero without broker calls."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"symbol": "EURUSD", "type": 0, "volume": float("nan")},
{"symbol": "EURUSD", "type": 1, "volume": float("inf")},
],
)
_assert_close(calculate_positions_margin(client), 0.0)
client.order_calc_margin.assert_not_called()
client.symbol_info_tick_as_dict.assert_not_called()
def test_returns_zero_when_symbol_filter_matches_nothing(self) -> None:
"""Test filtered symbol lists with no matches return zero."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"symbol": "EURUSD", "type": 0, "volume": 0.1}],
)
_assert_close(calculate_positions_margin(client, symbols=["GBPUSD"]), 0.0)
client.order_calc_margin.assert_not_called()
def test_returns_zero_for_empty_positions_with_symbol_filter(self) -> None:
"""Test empty positions with a symbol filter return zero."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame()
_assert_close(calculate_positions_margin(client, symbols=["EURUSD"]), 0.0)
def test_returns_zero_for_positions_without_symbol_column_with_symbol_filter(
self,
) -> None:
"""Test positions missing a symbol column return zero when filtered."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"type": 0, "volume": 0.1}],
)
_assert_close(calculate_positions_margin(client, symbols=["EURUSD"]), 0.0)
client.order_calc_margin.assert_not_called()
class TestVolumeAndExecution:
"""Tests for order planning and execution helpers."""
@@ -1928,3 +2400,230 @@ class TestVolumeAndExecution:
raise RuntimeError(body_error)
mock_client.shutdown.assert_called_once()
class TestFetchLatestClosedRatesForTradingClient:
"""Tests for fetch_latest_closed_rates_for_trading_client."""
def test_fetches_extra_bar_and_drops_forming_row(self) -> None:
"""Test trading-client helper hides the forming bar."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
{
"time": [1, 2, 3],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.fetch_latest_rates_as_df.assert_called_once_with("EURUSD", "M1", 3)
assert list(result["close"]) == [1.0, 1.1]
assert list(result["time"]) == [1, 2]
def test_falls_back_to_copy_rates_from_pos_as_df(self) -> None:
"""Test legacy trading clients without fetch helper still work."""
client = MagicMock(spec=["copy_rates_from_pos_as_df", "mt5"])
del client.fetch_latest_rates_as_df
client.copy_rates_from_pos_as_df.return_value = pd.DataFrame(
{
"time": [1, 2, 3],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.copy_rates_from_pos_as_df.assert_called_once_with(
symbol="EURUSD",
timeframe=1,
start_pos=0,
count=3,
)
assert list(result["close"]) == [1.0, 1.1]
def test_accepts_numeric_epoch_timestamps(self) -> None:
"""Test numeric epoch timestamps are preserved in output."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
{
"time": [1700000000, 1700000060, 1700000120],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
assert list(result["time"]) == [1700000000, 1700000060]
def test_accepts_timezone_aware_timestamps_from_index(self) -> None:
"""Test timezone-aware timestamps in the index are exposed as a column."""
client = MagicMock()
frame = pd.DataFrame(
{
"close": [1.0, 1.1, 1.2],
},
index=pd.to_datetime(
[
"2024-01-01T00:00:00Z",
"2024-01-01T00:01:00Z",
"2024-01-01T00:02:00Z",
],
utc=True,
),
)
frame.index.name = "time"
client.fetch_latest_rates_as_df.return_value = frame
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
assert "time" in result.columns
assert len(result) == 2
assert result["close"].tolist() == [1.0, 1.1]
def test_accepts_unnamed_datetime_index(self) -> None:
"""Test unnamed DatetimeIndex values are exposed as a time column."""
client = MagicMock()
frame = pd.DataFrame(
{"close": [1.0, 1.1, 1.2]},
index=pd.to_datetime(
[
"2024-01-01T00:00:00Z",
"2024-01-01T00:01:00Z",
"2024-01-01T00:02:00Z",
],
utc=True,
),
)
client.fetch_latest_rates_as_df.return_value = frame
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
assert "time" in result.columns
assert len(result) == 2
def test_accepts_named_non_time_index(self) -> None:
"""Test non-time named indexes are left unchanged before validation."""
client = MagicMock()
frame = pd.DataFrame(
{
"close": [1.0, 1.1, 1.2],
"bar_id": [1, 2, 3],
},
).set_index("bar_id")
client.fetch_latest_rates_as_df.return_value = frame
with pytest.raises(ValueError, match="missing a time column"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
def test_raises_when_trading_client_cannot_fetch_rates(self) -> None:
"""Test missing rate-fetch methods raise Mt5TradingError."""
client = MagicMock(spec=[])
with pytest.raises(Mt5TradingError, match="cannot fetch rate data"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=1,
)
def test_raises_when_time_column_is_missing(self) -> None:
"""Test malformed rate data without time raises ValueError."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
{"close": [1.0, 1.1, 1.2]},
)
with pytest.raises(ValueError, match="missing a time column"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
def test_raises_when_no_closed_bars_are_available(self) -> None:
"""Test empty closed-bar results raise an actionable ValueError."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
{"time": [1], "close": [1.0]},
)
with pytest.raises(ValueError, match="Rate data is empty"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=1,
)
def test_raises_when_fetch_returns_none(self) -> None:
"""Test None fetch results raise a malformed rate data error."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = None
with pytest.raises(ValueError, match="Malformed rate data"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
def test_raises_when_fetch_returns_non_dataframe(self) -> None:
"""Test non-DataFrame fetch results raise a malformed rate data error."""
client = MagicMock()
client.fetch_latest_rates_as_df.return_value = [{"time": 1, "close": 1.0}]
with pytest.raises(ValueError, match="Malformed rate data"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
def test_rejects_non_positive_count_before_fetching(self) -> None:
"""Test invalid count values fail before calling MT5."""
client = MagicMock()
with pytest.raises(ValueError, match="count must be positive"):
fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=0,
)
client.fetch_latest_rates_as_df.assert_not_called()
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.8.2"
version = "0.8.3"
source = { editable = "." }
dependencies = [
{ name = "click" },