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:
co-authored by
Daichi Narushima
Cursor Agent
parent
9f2968cc98
commit
c4a4253fbc
@@ -60,6 +60,7 @@ timestamp normalization in downstream apps.
|
|||||||
| ------------------------------------------------ | ------------------------------------------------------------ |
|
| ------------------------------------------------ | ------------------------------------------------------------ |
|
||||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
| `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` | 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_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_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` |
|
| `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 |
|
| `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 |
|
||||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
| `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) |
|
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||||
|
|||||||
+4
-3
@@ -31,9 +31,10 @@ rates = collect_latest_rates_for_accounts_with_retries(
|
|||||||
### Latest closed rate bars
|
### Latest closed rate bars
|
||||||
|
|
||||||
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
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
|
row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use
|
||||||
helpers fetch `count + 1` bars, drop that row with `drop_forming_rate_bar()`,
|
`fetch_latest_closed_rates_for_trading_client()` from an active
|
||||||
and validate each series is non-empty. Returned frames are ordered
|
`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
|
oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
|
||||||
fewer closed bars.
|
fewer closed bars.
|
||||||
|
|
||||||
|
|||||||
@@ -40,15 +40,19 @@ betting logic, or scheduling code in downstream applications.
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
|
calculate_positions_margin,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
close_open_positions,
|
close_open_positions,
|
||||||
detect_position_side,
|
detect_position_side,
|
||||||
determine_order_limits,
|
determine_order_limits,
|
||||||
|
estimate_order_margin,
|
||||||
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
get_positions_frame,
|
get_positions_frame,
|
||||||
get_symbol_snapshot,
|
get_symbol_snapshot,
|
||||||
get_tick_snapshot,
|
get_tick_snapshot,
|
||||||
|
normalize_order_volume,
|
||||||
place_market_order,
|
place_market_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +62,22 @@ tick = get_tick_snapshot(client, "EURUSD")
|
|||||||
positions = get_positions_frame(client, "EURUSD")
|
positions = get_positions_frame(client, "EURUSD")
|
||||||
side = detect_position_side(client, "EURUSD")
|
side = detect_position_side(client, "EURUSD")
|
||||||
spread_ratio = calculate_spread_ratio(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(
|
sizing = calculate_margin_and_volume(
|
||||||
client,
|
client,
|
||||||
"EURUSD",
|
"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.
|
sell-only exposure, and `None` for no positions or mixed long/short exposure.
|
||||||
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
||||||
`Mt5TradingError` when bid or ask is missing or non-positive.
|
`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`
|
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
|
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()` |
|
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
||||||
| Local position-side detection | `detect_position_side()` |
|
| Local position-side detection | `detect_position_side()` |
|
||||||
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
| 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()` |
|
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ from .trading import (
|
|||||||
PositionSide,
|
PositionSide,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_new_position_margin_ratio,
|
calculate_new_position_margin_ratio,
|
||||||
|
calculate_positions_margin,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
calculate_volume_by_margin,
|
calculate_volume_by_margin,
|
||||||
close_open_positions,
|
close_open_positions,
|
||||||
@@ -125,11 +126,14 @@ from .trading import (
|
|||||||
detect_position_side,
|
detect_position_side,
|
||||||
determine_order_limits,
|
determine_order_limits,
|
||||||
ensure_symbol_selected,
|
ensure_symbol_selected,
|
||||||
|
estimate_order_margin,
|
||||||
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
get_positions_frame,
|
get_positions_frame,
|
||||||
get_symbol_snapshot,
|
get_symbol_snapshot,
|
||||||
get_tick_snapshot,
|
get_tick_snapshot,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
|
normalize_order_volume,
|
||||||
place_market_order,
|
place_market_order,
|
||||||
update_sltp_for_open_positions,
|
update_sltp_for_open_positions,
|
||||||
)
|
)
|
||||||
@@ -182,6 +186,7 @@ __all__ = [
|
|||||||
"build_rate_view_name",
|
"build_rate_view_name",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
|
"calculate_positions_margin",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
"call_with_normalized_errors",
|
||||||
@@ -204,9 +209,11 @@ __all__ = [
|
|||||||
"drop_forming_rate_bar",
|
"drop_forming_rate_bar",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
"ensure_utc",
|
"ensure_utc",
|
||||||
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
"export_dataframe",
|
||||||
"export_dataframe_to_sqlite",
|
"export_dataframe_to_sqlite",
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
@@ -230,6 +237,7 @@ __all__ = [
|
|||||||
"mt5_version",
|
"mt5_version",
|
||||||
"normalize_dataframe",
|
"normalize_dataframe",
|
||||||
"normalize_mt5_exception",
|
"normalize_mt5_exception",
|
||||||
|
"normalize_order_volume",
|
||||||
"normalize_symbol",
|
"normalize_symbol",
|
||||||
"normalize_symbols",
|
"normalize_symbols",
|
||||||
"normalize_time_columns",
|
"normalize_time_columns",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"build_rate_view_name",
|
"build_rate_view_name",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
|
"calculate_positions_margin",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
"call_with_normalized_errors",
|
||||||
@@ -50,9 +51,11 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"drop_forming_rate_bar",
|
"drop_forming_rate_bar",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
"export_dataframe",
|
||||||
"export_dataframe_to_sqlite",
|
"export_dataframe_to_sqlite",
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
@@ -74,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
"mt5_version",
|
"mt5_version",
|
||||||
"normalize_mt5_exception",
|
"normalize_mt5_exception",
|
||||||
|
"normalize_order_volume",
|
||||||
"orders",
|
"orders",
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"positions",
|
"positions",
|
||||||
|
|||||||
+206
-1
@@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
||||||
|
|
||||||
|
from .history import drop_forming_rate_bar
|
||||||
from .sdk import build_config
|
from .sdk import build_config
|
||||||
from .utils import coerce_login as _coerce_login
|
from .utils import coerce_login as _coerce_login
|
||||||
|
from .utils import parse_timeframe
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator, Sequence
|
||||||
|
|
||||||
PositionSide = Literal["long", "short"]
|
PositionSide = Literal["long", "short"]
|
||||||
OrderSide = Literal["BUY", "SELL"]
|
OrderSide = Literal["BUY", "SELL"]
|
||||||
@@ -125,6 +127,7 @@ __all__ = [
|
|||||||
"PositionSide",
|
"PositionSide",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
|
"calculate_positions_margin",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
@@ -132,11 +135,14 @@ __all__ = [
|
|||||||
"detect_position_side",
|
"detect_position_side",
|
||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
|
"estimate_order_margin",
|
||||||
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
"get_tick_snapshot",
|
"get_tick_snapshot",
|
||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
|
"normalize_order_volume",
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
]
|
]
|
||||||
@@ -276,6 +282,48 @@ def _normalize_order_side(side: str) -> OrderSide:
|
|||||||
raise ValueError(msg)
|
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:
|
def _position_side_from_order_side(side: str) -> PositionSide:
|
||||||
normalized = side.lower()
|
normalized = side.lower()
|
||||||
if normalized in {"long", "buy"}:
|
if normalized in {"long", "buy"}:
|
||||||
@@ -531,6 +579,108 @@ def get_positions_frame(
|
|||||||
return 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:
|
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
||||||
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
||||||
|
|
||||||
@@ -997,6 +1147,61 @@ def update_sltp_for_open_positions(
|
|||||||
return results
|
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
|
@contextmanager
|
||||||
def mt5_trading_session(
|
def mt5_trading_session(
|
||||||
config: Mt5Config | None = None,
|
config: Mt5Config | None = None,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
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"}]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from mt5cli import (
|
|||||||
build_config,
|
build_config,
|
||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
|
calculate_positions_margin,
|
||||||
call_with_normalized_errors,
|
call_with_normalized_errors,
|
||||||
detect_format,
|
detect_format,
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
@@ -42,6 +43,7 @@ from mt5cli import (
|
|||||||
export_dataframe,
|
export_dataframe,
|
||||||
export_dataframe_to_sqlite,
|
export_dataframe_to_sqlite,
|
||||||
fetch_latest_closed_rates,
|
fetch_latest_closed_rates,
|
||||||
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
granularity_name,
|
granularity_name,
|
||||||
is_recoverable_mt5_error,
|
is_recoverable_mt5_error,
|
||||||
load_rate_data,
|
load_rate_data,
|
||||||
@@ -50,6 +52,7 @@ from mt5cli import (
|
|||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
normalize_dataframe,
|
normalize_dataframe,
|
||||||
normalize_mt5_exception,
|
normalize_mt5_exception,
|
||||||
|
normalize_order_volume,
|
||||||
normalize_symbol,
|
normalize_symbol,
|
||||||
normalize_symbols,
|
normalize_symbols,
|
||||||
parse_date_range,
|
parse_date_range,
|
||||||
@@ -572,6 +575,45 @@ class TestStableSdkContract:
|
|||||||
client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
|
client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
|
||||||
assert list(result["close"]) == [1.0, 1.1]
|
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:
|
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
|
||||||
"""Rate view resolution is importable and honors require_existing."""
|
"""Rate view resolution is importable and honors require_existing."""
|
||||||
db_path = tmp_path / "rates.db"
|
db_path = tmp_path / "rates.db"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from mt5cli.trading import (
|
|||||||
OrderLimits,
|
OrderLimits,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_new_position_margin_ratio,
|
calculate_new_position_margin_ratio,
|
||||||
|
calculate_positions_margin,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
calculate_volume_by_margin,
|
calculate_volume_by_margin,
|
||||||
close_open_positions,
|
close_open_positions,
|
||||||
@@ -26,11 +27,14 @@ from mt5cli.trading import (
|
|||||||
detect_position_side,
|
detect_position_side,
|
||||||
determine_order_limits,
|
determine_order_limits,
|
||||||
ensure_symbol_selected,
|
ensure_symbol_selected,
|
||||||
|
estimate_order_margin,
|
||||||
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
get_positions_frame,
|
get_positions_frame,
|
||||||
get_symbol_snapshot,
|
get_symbol_snapshot,
|
||||||
get_tick_snapshot,
|
get_tick_snapshot,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
|
normalize_order_volume,
|
||||||
place_market_order,
|
place_market_order,
|
||||||
update_sltp_for_open_positions,
|
update_sltp_for_open_positions,
|
||||||
)
|
)
|
||||||
@@ -773,6 +777,474 @@ class TestSnapshotsAndState:
|
|||||||
calculate_spread_ratio(client, "EURUSD")
|
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:
|
class TestVolumeAndExecution:
|
||||||
"""Tests for order planning and execution helpers."""
|
"""Tests for order planning and execution helpers."""
|
||||||
|
|
||||||
@@ -1928,3 +2400,230 @@ class TestVolumeAndExecution:
|
|||||||
raise RuntimeError(body_error)
|
raise RuntimeError(body_error)
|
||||||
|
|
||||||
mock_client.shutdown.assert_called_once()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user