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:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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"}]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user