[codex] fix mt5 adapter APIs (#36)

* fix mt5 adapter APIs

* address PR feedback

* fix zero ratio minimum volume sizing

* Bump version to v0.8.0
This commit is contained in:
Daichi Narushima
2026-06-15 02:47:05 +09:00
committed by GitHub
parent 307d6f5320
commit d156dd7176
14 changed files with 2002 additions and 95 deletions
+31
View File
@@ -85,6 +85,37 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly.
### Trading lifecycle and state helpers
Trading applications can depend on `mt5cli` imports only; terminal path,
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
login strings are coerced to integers, and empty login strings are treated as
unset.
```python
from mt5cli import (
calculate_spread_ratio,
create_trading_client,
get_account_snapshot,
mt5_trading_session,
)
with mt5_trading_session(
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
login="12345",
password="from-env-or-secret-store",
server="Broker-Demo",
) as client:
account = get_account_snapshot(client)
spread = calculate_spread_ratio(client, "EURUSD")
client = create_trading_client(login=12345, server="Broker-Demo")
try:
positions = client.positions_get_as_df(symbol="EURUSD")
finally:
client.shutdown()
```
## CLI usage
```bash
+29 -3
View File
@@ -167,19 +167,45 @@ Resolution rules:
### Rate data loading
Use `load_rate_data()` to load a table or view from a SQLite path, or
`load_rate_data_from_connection()` when you already have a connection:
The canonical normalized rate table is `rates`; compatibility views are named
with `rate_<symbol>__<timeframe>` for single-timeframe symbols or
`rate_<symbol>__<granularity>_<timeframe>` when a symbol has multiple stored
timeframes. `resolve_rate_table_name()` returns `rates`, while
`resolve_rate_view_name()` returns the per-symbol compatibility view name.
Use `load_rate_data()` or `load_rate_series_from_sqlite(..., table=...)` to load
a single table or view from a SQLite path. Use
`load_rate_series_by_granularity()` to load multiple instrument/granularity
targets without hard-coding view names:
```python
from pathlib import Path
from mt5cli import load_rate_data
from mt5cli import (
load_rate_data,
load_rate_series_by_granularity,
load_rate_series_from_sqlite,
resolve_rate_table_name,
)
from mt5cli.history import resolve_rate_view_name
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
rates = load_rate_data(Path("history.db"), view, count=1000)
same_rates = load_rate_series_from_sqlite(Path("history.db"), table=view, count=1000)
table = resolve_rate_table_name("EURUSD", "M1") # "rates"
series = load_rate_series_by_granularity(
Path("history.db"),
symbols=["EURUSD", "GBPUSD"],
granularities=["M1", "H1"],
count=500,
)
```
`count` returns the latest rows while preserving chronological order. Missing
tables/views and mismatched `explicit_tables` lengths raise `ValueError` with
the requested database target in the message.
The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
validates that `time` exists, parses timestamps with pandas, and returns a
DataFrame indexed by ascending `DatetimeIndex` named `time`.
+20 -5
View File
@@ -31,13 +31,25 @@ 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. `collect_latest_closed_rates_for_accounts()` fetches `count + 1` bars,
drops that row with `drop_forming_rate_bar()`, and validates each series is
non-empty. Use `collect_latest_closed_rates_by_granularity()` when callers
prefer keys such as `("EURUSD", "M1")` instead of integer timeframes.
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
oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
fewer closed bars.
```python
from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
from mt5cli import (
AccountSpec,
collect_latest_closed_rates_by_granularity,
fetch_latest_closed_rates,
)
closed = fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=500,
)
rates = collect_latest_closed_rates_by_granularity(
[AccountSpec(symbols=["EURUSD"], login=12345)],
@@ -48,6 +60,9 @@ rates = collect_latest_closed_rates_by_granularity(
closed_m1 = rates["EURUSD", "M1"]
```
Use `collect_latest_closed_rates_by_granularity()` when callers prefer keys such
as `("EURUSD", "M1")` instead of integer timeframes.
### Resolving credentials and `${ENV_VAR}` placeholders
`resolve_account_spec()` / `resolve_account_specs()` merge explicit override
+56 -12
View File
@@ -4,38 +4,60 @@
## Trading-capable MT5 sessions
`mt5_trading_session()` complements the read-only `mt5_session()` helper in
`sdk.py`. It yields a connected `pdmt5.Mt5TradingClient`, uses
`Mt5Config.path` to launch the terminal when configured, and always calls
`shutdown()` on exit.
`create_trading_client()` and `mt5_trading_session()` complement the read-only
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when
configured, and `mt5_trading_session()` always calls `shutdown()` on exit.
```python
from pdmt5 import Mt5Config
from mt5cli import mt5_trading_session
from mt5cli import create_trading_client, mt5_trading_session
with mt5_trading_session(
Mt5Config(path=r"C:\Program Files\MetaTrader 5\terminal64.exe", login=12345),
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
login="12345",
password="secret",
server="Broker-Demo",
retry_count=2,
) as client:
positions = client.positions_get_as_df(symbol="EURUSD")
client = create_trading_client(login=12345, server="Broker-Demo")
try:
account = client.account_info_as_dict()
finally:
client.shutdown()
```
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
## Operational trading helpers
## State and order helpers
These helpers are strategy-agnostic and do not depend on signal detection,
betting logic, or scheduling code in downstream applications.
```python
from mt5cli import (
calculate_spread_ratio,
calculate_margin_and_volume,
close_open_positions,
detect_position_side,
determine_order_limits,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
get_tick_snapshot,
place_market_order,
)
account = get_account_snapshot(client)
symbol = get_symbol_snapshot(client, "EURUSD")
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")
sizing = calculate_margin_and_volume(
client,
"EURUSD",
@@ -49,11 +71,33 @@ limits = determine_order_limits(
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.02,
)
preview = place_market_order(
client,
symbol="EURUSD",
volume=sizing["buy_volume"],
order_side="BUY",
sl=limits["stop_loss"],
tp=limits["take_profit"],
dry_run=True,
)
closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
```
Protective ratios must satisfy `0 <= ratio < 1`; `0` omits that level.
`calculate_margin_and_volume()` clamps negative `margin_free` to `0.0`
before sizing.
`detect_position_side()` returns `long` for buy-only exposure, `short` for
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.
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
available. `unit_margin_ratio` and `preserved_margin_ratio` for
`calculate_margin_and_volume()` accept `0 <= ratio <= 1`; `unit_margin_ratio=0`
requests one minimum valid unit when the post-reserve margin can afford it.
Negative `margin_free` is clamped to `0.0` before sizing. Execution helpers
return normalized dictionaries containing the request, response, status,
retcode, and `dry_run` flag; `dry_run=True` never sends an order. Market order
helpers mark known non-success MT5 retcodes as `status="failed"` while keeping
the normalized response for inspection.
## Migration from mteor-local helpers
+42
View File
@@ -2,6 +2,8 @@
from importlib.metadata import version
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .client import MT5Client, build_config, mt5_session
from .converters import (
ensure_utc,
@@ -32,6 +34,7 @@ from .history import (
resolve_history_datasets,
resolve_history_tick_flags,
resolve_history_timeframes,
resolve_rate_table_name,
resolve_rate_tables,
resolve_rate_view_name,
resolve_rate_view_names,
@@ -63,6 +66,7 @@ from .sdk import (
copy_rates_range,
copy_ticks_from,
copy_ticks_range,
fetch_latest_closed_rates,
history_deals,
history_orders,
last_error,
@@ -96,10 +100,26 @@ from .storage import (
export_dataframe_to_sqlite,
)
from .trading import (
POSITION_COLUMNS,
OrderFillingMode,
OrderSide,
OrderTimeMode,
PositionSide,
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
calculate_spread_ratio,
calculate_volume_by_margin,
close_open_positions,
create_trading_client,
detect_position_side,
determine_order_limits,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
get_tick_snapshot,
mt5_trading_session,
place_market_order,
update_sltp_for_open_positions,
)
from .utils import (
TICK_FLAG_MAP,
@@ -114,6 +134,7 @@ __version__ = version(__package__) if __package__ else None
__all__ = [
"DEDUP_KEYS",
"KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS",
"REQUIRED_COLUMNS",
"TICK_FLAG_MAP",
"TIMEFRAME_MAP",
@@ -125,9 +146,17 @@ __all__ = [
"MT5Client",
"Mt5CliClient",
"Mt5CliError",
"Mt5Config",
"Mt5ConnectionError",
"Mt5OperationError",
"Mt5RuntimeError",
"Mt5SchemaError",
"Mt5TradingClient",
"Mt5TradingError",
"OrderFillingMode",
"OrderSide",
"OrderTimeMode",
"PositionSide",
"RateTarget",
"ThrottledHistoryUpdater",
"account_info",
@@ -135,7 +164,11 @@ __all__ = [
"build_rate_targets",
"build_rate_view_name",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors",
"close_open_positions",
"collect_history",
"collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts",
@@ -147,6 +180,7 @@ __all__ = [
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"create_trading_client",
"detect_format",
"detect_position_side",
"determine_order_limits",
@@ -154,6 +188,11 @@ __all__ = [
"ensure_utc",
"export_dataframe",
"export_dataframe_to_sqlite",
"fetch_latest_closed_rates",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"granularity_name",
"history_deals",
"history_orders",
@@ -181,6 +220,7 @@ __all__ = [
"parse_datetime",
"parse_tick_flags",
"parse_timeframe",
"place_market_order",
"positions",
"recent_history_deals",
"recent_ticks",
@@ -190,6 +230,7 @@ __all__ = [
"resolve_history_datasets",
"resolve_history_tick_flags",
"resolve_history_timeframes",
"resolve_rate_table_name",
"resolve_rate_tables",
"resolve_rate_view_name",
"resolve_rate_view_names",
@@ -201,5 +242,6 @@ __all__ = [
"terminal_info",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"validate_schema",
]
+72 -10
View File
@@ -7,7 +7,7 @@ import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from typing import TYPE_CHECKING, Literal, cast, overload
import pandas as pd
from pdmt5 import get_timeframe_name as _get_timeframe_name
@@ -142,6 +142,26 @@ def build_rate_view_name(
return f"rate_{symbol}__{granularity}_{timeframe}"
def resolve_rate_table_name(symbol: str, granularity: str) -> str:
"""Return the canonical normalized SQLite rate table name.
The normalized history table stores all symbols and timeframes in
``rates``; use :func:`resolve_rate_view_name` for per-symbol compatibility
view names.
Returns:
Canonical normalized rates table name.
Raises:
ValueError: If ``symbol`` or ``granularity`` is invalid.
"""
parse_timeframe(granularity)
if not symbol.strip():
msg = "symbol must not be empty."
raise ValueError(msg)
return Dataset.rates.table_name
SqliteConnOrPath = sqlite3.Connection | Path | str
@@ -653,34 +673,76 @@ def resolve_rate_tables(
conn.close()
if TYPE_CHECKING:
@overload
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: None = None,
count: int | None = None,
explicit_tables: None = None,
*,
table: str,
) -> pd.DataFrame: ...
@overload
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: None = None,
count: int | None = None,
explicit_tables: Sequence[str] | None = None,
*,
table: None = None,
) -> dict[tuple[str | None, int], pd.DataFrame]: ...
@overload
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: Sequence[RateTarget],
count: int,
explicit_tables: Sequence[str] | None = None,
*,
table: None = None,
) -> dict[tuple[str | None, int], pd.DataFrame]: ...
def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath,
targets: Sequence[RateTarget],
count: int,
targets: Sequence[RateTarget] | None = None,
count: int | None = None,
explicit_tables: Sequence[str] | None = None,
) -> dict[tuple[str | None, int], pd.DataFrame]:
"""Load multiple rate series from a SQLite database.
*,
table: str | None = None,
) -> dict[tuple[str | None, int], pd.DataFrame] | pd.DataFrame:
"""Load one table/view or multiple rate series from a SQLite database.
Args:
conn_or_path: SQLite database path or open connection.
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair
must be unique.
count: Number of most recent rows to load per series.
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair must
be unique. Omit when loading a single explicit ``table``.
count: Optional number of most recent rows to load per series.
explicit_tables: Optional explicit table or view names matching targets.
When omitted, managed ``rate_*`` compatibility views must already
exist in the database.
table: Optional single table or view name to load directly.
Returns:
Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame.
A DataFrame when ``table`` is provided, otherwise a mapping keyed by
``(symbol, timeframe_int)`` to each rate DataFrame.
Raises:
ValueError: If ``count`` is not positive, targets are empty, duplicate
``(symbol, timeframe_int)`` pairs are present, or table resolution
fails.
"""
if count <= 0:
if table is not None:
return load_rate_data(conn_or_path, table, count=count)
if count is None or count <= 0:
msg = "count must be positive."
raise ValueError(msg)
if targets is None:
msg = "targets are required when table is not provided."
raise ValueError(msg)
target_list = list(targets)
if not target_list:
msg = "At least one rate target is required."
+29 -14
View File
@@ -37,6 +37,7 @@ from .utils import (
parse_tick_flags,
parse_timeframe,
)
from .utils import coerce_login as _coerce_login
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence
@@ -122,6 +123,7 @@ __all__ = [
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"fetch_latest_closed_rates",
"history_deals",
"history_orders",
"last_error",
@@ -1289,6 +1291,33 @@ def latest_rates(
)
def fetch_latest_closed_rates(
client: Mt5CliClient,
*,
symbol: str,
granularity: str,
count: int,
) -> pd.DataFrame:
"""Fetch up to ``count`` most recent closed bars, oldest to newest.
Returns:
Closed rate bars ordered oldest to newest.
Raises:
ValueError: If ``count`` is not positive or no closed bars are returned.
"""
_require_positive(count, "count")
frame = client.latest_rates(symbol, granularity, count + 1, start_pos=0)
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)
def collect_latest_rates(
symbols: Sequence[str],
timeframes: Sequence[int | str],
@@ -1470,20 +1499,6 @@ def resolve_account_specs(
]
def _coerce_login(login: int | str | None) -> int | None:
"""Coerce a login value to int, treating empty strings as unset.
Returns:
Integer login, or None when unset or an empty string.
"""
if login is None or isinstance(login, int):
return login
text = login.strip()
if not text:
return None
return int(text)
def _build_account_config(
account: AccountSpec,
base_config: Mt5Config | None,
+688 -37
View File
@@ -3,27 +3,98 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import TYPE_CHECKING, Literal
from math import floor, isfinite
from typing import TYPE_CHECKING, Literal, cast
from pdmt5 import Mt5Config, Mt5TradingClient
import pandas as pd
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
from .sdk import build_config
from .utils import coerce_login as _coerce_login
if TYPE_CHECKING:
from collections.abc import Iterator
import pandas as pd
PositionSide = Literal["long", "short"]
OrderSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"]
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
_ORDER_FILLING_MODES: frozenset[str] = frozenset({"IOC", "FOK", "RETURN"})
_ORDER_TIME_MODES: frozenset[str] = frozenset({
"GTC",
"DAY",
"SPECIFIED",
"SPECIFIED_DAY",
})
_SUCCESS_RETCODE_NAMES: tuple[str, ...] = (
"TRADE_RETCODE_DONE",
"TRADE_RETCODE_DONE_PARTIAL",
"TRADE_RETCODE_PLACED",
)
_SUCCESS_RETCODE_FALLBACKS: frozenset[int] = frozenset({10008, 10009, 10010})
_ACCOUNT_SNAPSHOT_FIELDS = (
"login",
"balance",
"equity",
"margin",
"margin_free",
"margin_level",
"leverage",
"currency",
)
_SYMBOL_SNAPSHOT_FIELDS = (
"symbol",
"visible",
"trade_mode",
"digits",
"point",
"volume_min",
"volume_max",
"volume_step",
"trade_contract_size",
"trade_tick_size",
"trade_tick_value",
"trade_stops_level",
"filling_mode",
)
_TICK_SNAPSHOT_FIELDS = ("symbol", "time", "bid", "ask", "last", "volume")
POSITION_COLUMNS = (
"ticket",
"time",
"symbol",
"type",
"volume",
"price_open",
"sl",
"tp",
"price_current",
"profit",
"swap",
"comment",
)
__all__ = [
"POSITION_COLUMNS",
"OrderFillingMode",
"OrderSide",
"OrderTimeMode",
"PositionSide",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"close_open_positions",
"create_trading_client",
"detect_position_side",
"determine_order_limits",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"mt5_trading_session",
"place_market_order",
"update_sltp_for_open_positions",
]
@@ -46,18 +117,184 @@ def _sum_position_volume(positions: pd.DataFrame, position_type: object) -> floa
return float(matched.to_numpy(dtype=float).sum())
def _resolve_config(
*,
config: Mt5Config | None,
login: int | str | None,
password: str | None,
server: str | None,
path: str | None,
timeout: int | None,
) -> Mt5Config:
if config is not None:
return config
return build_config(
path=path,
login=_coerce_login(login),
password=password,
server=server,
timeout=timeout,
)
def _normalize_order_side(side: str) -> OrderSide:
normalized = side.upper()
if normalized in {"BUY", "LONG"}:
return "BUY"
if normalized in {"SELL", "SHORT"}:
return "SELL"
msg = f"Unsupported order side: {side!r}. Expected 'BUY' or 'SELL'."
raise ValueError(msg)
def _position_side_from_order_side(side: str) -> PositionSide:
normalized = side.lower()
if normalized in {"long", "buy"}:
return "long"
if normalized in {"short", "sell"}:
return "short"
msg = (
f"Unsupported order side: {side!r}. Expected 'long', 'short', 'buy', or 'sell'."
)
msg = f"Unsupported position side: {side!r}. Expected 'long' or 'short'."
raise ValueError(msg)
def _snapshot_from_value(value: object, fields: tuple[str, ...]) -> dict[str, object]:
if isinstance(value, pd.DataFrame):
row: dict[str, object] = (
{} if value.empty else cast("dict[str, object]", value.iloc[0].to_dict())
)
else:
asdict = getattr(value, "_asdict", None)
if callable(asdict):
row = cast("dict[str, object]", asdict())
elif isinstance(value, dict):
typed_value = cast("dict[object, object]", value)
row = {str(key): item for key, item in typed_value.items()}
else:
row = {
field: getattr(value, field)
for field in fields
if hasattr(value, field)
}
if not fields:
return row
return {field: row.get(field) for field in fields}
def _call_snapshot_method(client: Mt5TradingClient, *names: str) -> object:
for name in names:
method = getattr(client, name, None)
if callable(method):
return method()
msg = f"MT5 client is missing required method: {' or '.join(names)}"
raise AttributeError(msg)
def _resolve_mt5_constant(
mt5: object,
prefix: str,
value: str,
allowed: frozenset[str],
) -> int:
normalized = value.upper()
if normalized not in allowed:
msg = f"Unsupported {prefix.lower()} mode: {value!r}."
raise ValueError(msg)
name = f"{prefix}_{normalized}"
try:
return cast("int", getattr(mt5, name))
except AttributeError as exc:
msg = f"MT5 module is missing required constant: {name}"
raise Mt5TradingError(msg) from exc
def _optional_price(value: object) -> float | None:
if value is None:
return None
if not isinstance(value, int | float):
return None
price = float(value)
if price <= 0 or not isfinite(price):
return None
return price
def _success_retcodes(mt5: object) -> frozenset[int]:
values = {
value
for name in _SUCCESS_RETCODE_NAMES
if isinstance(value := getattr(mt5, name, None), int)
}
return frozenset(values) or _SUCCESS_RETCODE_FALLBACKS
def _order_status_from_retcode(mt5: object, retcode: object) -> str:
if retcode is None:
return "executed"
if isinstance(retcode, int) and retcode not in _success_retcodes(mt5):
return "failed"
return "executed"
def _calculate_min_volume_if_affordable(
client: Mt5TradingClient,
symbol: str,
available_margin: float,
order_side: OrderSide,
) -> float:
if available_margin <= 0:
return 0.0
symbol_info = get_symbol_snapshot(client, symbol)
volume_min = float(symbol_info.get("volume_min") or 0.0)
volume_max = float(symbol_info.get("volume_max") or 0.0)
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
if (
volume_min <= 0
or volume_step <= 0
or (volume_max > 0 and volume_min > volume_max)
):
msg = f"Invalid volume constraints for {symbol!r}."
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:
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
)
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
return round(volume_min, 10) if 0 < min_margin <= available_margin else 0.0
def create_trading_client(
*,
config: Mt5Config | None = None,
login: int | str | None = None,
password: str | None = None,
server: str | None = None,
path: str | None = None,
timeout: int | None = None,
retry_count: int = 0,
) -> Mt5TradingClient:
"""Return an initialized and logged-in trading client."""
mt5_config = _resolve_config(
config=config,
login=login,
password=password,
server=server,
path=path,
timeout=timeout,
)
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
try:
client.initialize_and_login_mt5()
except Exception:
client.shutdown()
raise
return client
def detect_position_side(
client: Mt5TradingClient,
symbol: str,
@@ -69,11 +306,11 @@ def detect_position_side(
symbol: Symbol to inspect.
Returns:
``"long"`` when net buy volume exceeds sell volume, ``"short"`` when
net sell volume exceeds buy volume, or ``None`` when no positions exist
or buy/sell volumes are exactly balanced.
``"long"`` when there are buy positions and no sell positions,
``"short"`` when there are sell positions and no buy positions, or
``None`` when no positions or mixed exposure exists.
"""
positions = client.positions_get_as_df(symbol=symbol)
positions = get_positions_frame(client, symbol=symbol)
if positions.empty:
return None
@@ -81,14 +318,114 @@ def detect_position_side(
sell_type = client.mt5.POSITION_TYPE_SELL
buy_volume = _sum_position_volume(positions, buy_type)
sell_volume = _sum_position_volume(positions, sell_type)
net_volume = buy_volume - sell_volume
if net_volume > 0:
if buy_volume > 0 and sell_volume == 0:
return "long"
if net_volume < 0:
if sell_volume > 0 and buy_volume == 0:
return "short"
return None
def get_account_snapshot(
client: Mt5TradingClient,
) -> dict[str, float | int | str | None]:
"""Return normalized account state with stable keys."""
value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
return cast(
"dict[str, float | int | str | None]",
_snapshot_from_value(value, _ACCOUNT_SNAPSHOT_FIELDS),
)
def get_symbol_snapshot(
client: Mt5TradingClient,
symbol: str,
) -> dict[str, float | int | str | bool | None]:
"""Return normalized symbol metadata required for trading decisions."""
method = getattr(client, "symbol_info_as_dict", None)
value = method(symbol=symbol) if callable(method) else client.symbol_info(symbol)
snapshot = _snapshot_from_value(value, _SYMBOL_SNAPSHOT_FIELDS)
snapshot["symbol"] = snapshot.get("symbol") or symbol
return cast("dict[str, float | int | str | bool | None]", snapshot)
def get_tick_snapshot(
client: Mt5TradingClient,
symbol: str,
) -> dict[str, float | int | None]:
"""Return normalized latest tick data, including bid, ask, and timestamp."""
method = getattr(client, "symbol_info_tick_as_dict", None)
value = (
method(symbol=symbol) if callable(method) else client.symbol_info_tick(symbol)
)
snapshot = _snapshot_from_value(value, _TICK_SNAPSHOT_FIELDS)
snapshot["symbol"] = snapshot.get("symbol") or symbol
return cast("dict[str, float | int | None]", snapshot)
def get_positions_frame(
client: Mt5TradingClient,
symbol: str | None = None,
) -> pd.DataFrame:
"""Return open positions as a DataFrame with stable baseline columns."""
frame = client.positions_get_as_df(symbol=symbol)
for column in POSITION_COLUMNS:
if column not in frame.columns:
frame[column] = pd.Series(dtype="object")
return frame
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
Raises:
Mt5TradingError: If bid or ask is unavailable or non-positive.
"""
tick = get_tick_snapshot(client, symbol)
bid = tick.get("bid")
ask = tick.get("ask")
if not isinstance(bid, int | float) or not isinstance(ask, int | float):
msg = f"Tick bid/ask is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
if bid <= 0 or ask <= 0:
msg = f"Tick bid/ask must be positive for {symbol!r}."
raise Mt5TradingError(msg)
return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)
def calculate_new_position_margin_ratio(
client: Mt5TradingClient,
*,
symbol: str,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
) -> float:
"""Return total margin/equity ratio after an optional hypothetical position.
Raises:
Mt5TradingError: If equity or required tick data is invalid.
"""
account = get_account_snapshot(client)
equity = float(account.get("equity") or 0.0)
if equity <= 0:
msg = "Account equity must be positive to calculate margin ratio."
raise Mt5TradingError(msg)
margin = float(account.get("margin") or 0.0)
if new_position_side is not None and new_position_volume > 0:
side = _normalize_order_side(new_position_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:
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
)
margin += float(
client.order_calc_margin(order_type, symbol, new_position_volume, price),
)
return margin / equity
def calculate_margin_and_volume(
client: Mt5TradingClient,
symbol: str,
@@ -99,7 +436,9 @@ def calculate_margin_and_volume(
Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
then allocates ``unit_margin_ratio`` of the remainder as the margin budget
for volume sizing on both buy and sell sides.
for proportional volume sizing on both buy and sell sides. A
``unit_margin_ratio`` of ``0`` requests exactly one minimum valid unit per
side when the post-reserve margin can afford it.
Args:
client: Connected ``Mt5TradingClient`` instance.
@@ -119,23 +458,109 @@ def calculate_margin_and_volume(
margin_free = max(0.0, float(account.get("margin_free") or 0.0))
available_margin = margin_free * (1.0 - preserved_margin_ratio)
trade_margin = available_margin * unit_margin_ratio
buy_volume = client.calculate_volume_by_margin(symbol, trade_margin, "BUY")
sell_volume = client.calculate_volume_by_margin(symbol, trade_margin, "SELL")
if unit_margin_ratio == 0:
buy_volume = _calculate_min_volume_if_affordable(
client,
symbol,
available_margin,
"BUY",
)
sell_volume = _calculate_min_volume_if_affordable(
client,
symbol,
available_margin,
"SELL",
)
else:
native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
if callable(native_calculate_volume):
buy_volume = float(
cast(
"float | int | str",
native_calculate_volume(symbol, trade_margin, "BUY"),
),
)
sell_volume = float(
cast(
"float | int | str",
native_calculate_volume(symbol, trade_margin, "SELL"),
),
)
else:
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
sell_volume = calculate_volume_by_margin(
client,
symbol,
trade_margin,
"SELL",
)
try:
symbol_info = get_symbol_snapshot(client, symbol)
volume_min = float(symbol_info.get("volume_min") or 0.0)
volume_max = float(symbol_info.get("volume_max") or 0.0)
volume_step = float(symbol_info.get("volume_step") or 0.0)
except AttributeError:
volume_min = volume_max = volume_step = 0.0
return {
"margin_free": margin_free,
"available_margin": available_margin,
"trade_margin": trade_margin,
"buy_volume": buy_volume,
"sell_volume": sell_volume,
"buy_volume": float(buy_volume),
"sell_volume": float(sell_volume),
"volume_min": volume_min,
"volume_max": volume_max,
"volume_step": volume_step,
}
def calculate_volume_by_margin(
client: Mt5TradingClient,
symbol: str,
available_margin: float,
order_side: OrderSide,
) -> float:
"""Calculate max normalized volume affordable for one side.
Returns:
Affordable volume rounded down to symbol volume constraints.
Raises:
Mt5TradingError: If symbol volume constraints or tick data are invalid.
"""
if available_margin <= 0:
return 0.0
symbol_info = get_symbol_snapshot(client, symbol)
volume_min = float(symbol_info.get("volume_min") or 0.0)
volume_max = float(symbol_info.get("volume_max") or 0.0)
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
if volume_min <= 0 or volume_step <= 0:
msg = f"Invalid volume constraints for {symbol!r}."
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:
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
)
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
if min_margin <= 0 or min_margin > available_margin:
return 0.0
raw_volume = available_margin / min_margin * volume_min
capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
normalized = volume_min + max(0, steps) * volume_step
return round(normalized, 10) if normalized >= volume_min else 0.0
def determine_order_limits(
client: Mt5TradingClient,
symbol: str,
side: OrderSide | str,
stop_loss_limit_ratio: float,
take_profit_limit_ratio: float,
side: PositionSide | str,
stop_loss_limit_ratio: float | None = None,
take_profit_limit_ratio: float | None = None,
) -> dict[str, float | None]:
"""Derive entry and protective order prices from current market quotes.
@@ -152,26 +577,41 @@ def determine_order_limits(
Returns:
Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys.
Omitted protective levels are returned as ``None``.
Raises:
Mt5TradingError: If required tick data is invalid.
"""
_require_protective_ratio(stop_loss_limit_ratio, "stop_loss_limit_ratio")
_require_protective_ratio(take_profit_limit_ratio, "take_profit_limit_ratio")
normalized_side = _normalize_order_side(side)
tick = client.symbol_info_tick_as_dict(symbol=symbol)
entry = float(tick["ask"] if normalized_side == "long" else tick["bid"])
stop_loss_ratio = stop_loss_limit_ratio or 0.0
take_profit_ratio = take_profit_limit_ratio or 0.0
_require_protective_ratio(stop_loss_ratio, "stop_loss_limit_ratio")
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
normalized_side = _position_side_from_order_side(side)
tick = get_tick_snapshot(client, symbol)
entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
if not isinstance(entry_value, int | float):
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
entry = float(entry_value)
try:
digits = int(get_symbol_snapshot(client, symbol).get("digits") or 8)
except AttributeError:
digits = 8
stop_loss: float | None = None
if stop_loss_limit_ratio > 0:
if stop_loss_ratio > 0:
if normalized_side == "long":
stop_loss = entry * (1.0 - stop_loss_limit_ratio)
stop_loss = entry * (1.0 - stop_loss_ratio)
else:
stop_loss = entry * (1.0 + stop_loss_limit_ratio)
stop_loss = entry * (1.0 + stop_loss_ratio)
stop_loss = round(stop_loss, digits)
take_profit: float | None = None
if take_profit_limit_ratio > 0:
if take_profit_ratio > 0:
if normalized_side == "long":
take_profit = entry * (1.0 + take_profit_limit_ratio)
take_profit = entry * (1.0 + take_profit_ratio)
else:
take_profit = entry * (1.0 - take_profit_limit_ratio)
take_profit = entry * (1.0 - take_profit_ratio)
take_profit = round(take_profit, digits)
return {
"entry": entry,
@@ -180,9 +620,209 @@ def determine_order_limits(
}
def place_market_order(
client: Mt5TradingClient,
*,
symbol: str,
volume: float,
order_side: OrderSide,
order_filling_mode: OrderFillingMode = "IOC",
order_time_mode: OrderTimeMode = "GTC",
sl: float | None = None,
tp: float | None = None,
position: int | None = None,
dry_run: bool = False,
) -> dict[str, object]:
"""Place one normalized market order or return a dry-run result.
``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no
response. When MT5 returns a response with a known non-success retcode, this
helper returns ``status="failed"`` and keeps the normalized response
details for callers to inspect.
Returns:
Normalized execution result containing request and response details.
Raises:
Mt5TradingError: If volume or required tick data is invalid.
"""
if volume <= 0:
msg = "volume must be positive."
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:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
request = {
"action": client.mt5.TRADE_ACTION_DEAL,
"symbol": symbol,
"volume": volume,
"type": (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
),
"price": float(price),
"type_filling": _resolve_mt5_constant(
client.mt5,
"ORDER_FILLING",
order_filling_mode,
_ORDER_FILLING_MODES,
),
"type_time": _resolve_mt5_constant(
client.mt5,
"ORDER_TIME",
order_time_mode,
_ORDER_TIME_MODES,
),
}
if sl is not None:
request["sl"] = sl
if tp is not None:
request["tp"] = tp
if position is not None:
request["position"] = position
if dry_run:
return {
"status": "dry_run",
"symbol": symbol,
"order_side": side,
"volume": volume,
"retcode": None,
"comment": None,
"request": request,
"response": None,
"dry_run": True,
}
response = client.order_send(request)
response_dict = _snapshot_from_value(response, ())
retcode = response_dict.get("retcode")
return {
"status": _order_status_from_retcode(client.mt5, retcode),
"symbol": symbol,
"order_side": side,
"volume": volume,
"retcode": retcode,
"comment": response_dict.get("comment"),
"request": request,
"response": response_dict,
"dry_run": False,
}
def _filter_positions(
positions: pd.DataFrame,
*,
symbols: str | list[str] | None = None,
tickets: list[int] | None = None,
) -> pd.DataFrame:
frame = positions
if symbols is not None:
symbol_set = {symbols} if isinstance(symbols, str) else set(symbols)
frame = frame.loc[frame["symbol"].isin(symbol_set)]
if tickets is not None:
frame = frame.loc[frame["ticket"].isin(tickets)]
return frame
def close_open_positions(
client: Mt5TradingClient,
*,
symbols: str | list[str] | None = None,
tickets: list[int] | None = None,
dry_run: bool = False,
) -> list[dict[str, object]]:
"""Close matching open positions.
Returns:
Normalized execution results for matching positions.
"""
positions = _filter_positions(
get_positions_frame(client),
symbols=symbols,
tickets=tickets,
)
results: list[dict[str, object]] = []
for row in positions.to_dict("records"):
pos_type = row["type"]
side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY"
result = place_market_order(
client,
symbol=str(row["symbol"]),
volume=float(row["volume"]),
order_side=side,
position=int(row["ticket"]),
dry_run=dry_run,
)
results.append(result)
return results
def update_sltp_for_open_positions(
client: Mt5TradingClient,
*,
symbol: str | None = None,
tickets: list[int] | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
dry_run: bool = False,
) -> list[dict[str, object]]:
"""Update SL/TP for matching open positions.
Returns:
Normalized execution results for matching positions.
"""
positions = _filter_positions(
get_positions_frame(client),
symbols=symbol,
tickets=tickets,
)
results: list[dict[str, object]] = []
for row in positions.to_dict("records"):
request = {
"action": client.mt5.TRADE_ACTION_SLTP,
"symbol": row["symbol"],
"position": row["ticket"],
}
sl = _optional_price(row.get("sl") if stop_loss is None else stop_loss)
tp = _optional_price(row.get("tp") if take_profit is None else take_profit)
if sl is not None:
request["sl"] = sl
if tp is not None:
request["tp"] = tp
if dry_run:
response = None
status = "dry_run"
else:
response = _snapshot_from_value(client.order_send(request), ())
status = "executed"
results.append(
{
"status": status,
"symbol": row["symbol"],
"order_side": "BUY"
if row["type"] == client.mt5.POSITION_TYPE_BUY
else "SELL",
"volume": row["volume"],
"retcode": None if response is None else response.get("retcode"),
"comment": None if response is None else response.get("comment"),
"request": request,
"response": response,
"dry_run": dry_run,
},
)
return results
@contextmanager
def mt5_trading_session(
config: Mt5Config | None = None,
*,
login: int | str | None = None,
password: str | None = None,
server: str | None = None,
path: str | None = None,
timeout: int | None = None,
retry_count: int = 0,
) -> Iterator[Mt5TradingClient]:
"""Open a trading-capable MT5 session and always shut down safely.
@@ -195,16 +835,27 @@ def mt5_trading_session(
Args:
config: MT5 connection configuration. Defaults to an empty config that
attaches to a running terminal.
login: Optional trading account login.
password: Optional trading account password.
server: Optional trading server name.
path: Optional terminal executable path.
timeout: Optional connection timeout in milliseconds.
retry_count: Number of initialization retries passed to
``Mt5TradingClient``.
Yields:
Connected ``Mt5TradingClient`` bound to the session.
"""
mt5_config = config or build_config()
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
client = create_trading_client(
config=config,
login=login,
password=password,
server=server,
path=path,
timeout=timeout,
retry_count=retry_count,
)
try:
client.initialize_and_login_mt5()
yield client
finally:
client.shutdown()
+14
View File
@@ -241,6 +241,20 @@ def detect_format(
raise ValueError(msg)
def coerce_login(login: int | str | None) -> int | None:
"""Coerce a login value to int, treating empty strings as unset.
Returns:
Integer login, or None when unset or an empty string.
"""
if login is None or isinstance(login, int):
return login
text = login.strip()
if not text:
return None
return int(text)
def export_dataframe_to_sqlite(
df: pd.DataFrame,
output_path: Path,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mt5cli"
version = "0.7.2"
version = "0.8.0"
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"}]
+36
View File
@@ -48,6 +48,7 @@ from mt5cli.history import (
resolve_history_datasets,
resolve_history_tick_flags,
resolve_history_timeframes,
resolve_rate_table_name,
resolve_rate_tables,
resolve_rate_view_name,
resolve_rate_view_names,
@@ -63,6 +64,15 @@ from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists
class TestResolveRateViewName:
"""Tests for resolve_rate_view_name and resolve_rate_view_names."""
def test_resolve_rate_table_name_returns_normalized_table(self) -> None:
"""Test canonical normalized rates table name is stable."""
assert resolve_rate_table_name("EURUSD", "M1") == "rates"
def test_resolve_rate_table_name_rejects_empty_symbol(self) -> None:
"""Test canonical rate table resolution validates symbols."""
with pytest.raises(ValueError, match="symbol must not be empty"):
resolve_rate_table_name(" ", "M1")
def test_missing_database_path_does_not_create_file(self, tmp_path: Path) -> None:
"""Test resolving against a missing path does not create a database."""
db_path = tmp_path / "missing.db"
@@ -416,6 +426,32 @@ class TestLoadRateData:
frame = load_rate_data_from_connection(conn, "rate_view")
assert list(frame["close"]) == [1.0]
def test_load_rate_series_from_sqlite_table_style(
self,
tmp_path: Path,
) -> None:
"""Test public table-style loader returns one rate DataFrame."""
db_path = tmp_path / "table-style.db"
with sqlite3.connect(db_path) as conn:
conn.execute("CREATE TABLE rates(time TEXT, close REAL)")
conn.executemany(
"INSERT INTO rates(time, close) VALUES (?, ?)",
[
("2024-01-01T00:00:00+00:00", 1.0),
("2024-01-01T00:01:00+00:00", 1.1),
],
)
frame = load_rate_series_from_sqlite(db_path, table="rates", count=1)
assert isinstance(frame, pd.DataFrame)
assert list(frame["close"]) == [1.1]
def test_load_rate_series_from_sqlite_requires_targets_without_table(self) -> None:
"""Test multi-series loading requires targets when table is omitted."""
with pytest.raises(ValueError, match="targets are required"):
load_rate_series_from_sqlite("unused.db", count=1)
def test_loads_quoted_identifier(self, tmp_path: Path) -> None:
"""Test table names are quoted safely."""
db_path = tmp_path / "quoted.db"
+60 -3
View File
@@ -37,6 +37,7 @@ from mt5cli.sdk import (
copy_rates_range,
copy_ticks_from,
copy_ticks_range,
fetch_latest_closed_rates,
history_deals,
history_orders,
last_error,
@@ -61,7 +62,7 @@ from mt5cli.sdk import (
update_history_with_config,
version,
)
from mt5cli.utils import Dataset, IfExists
from mt5cli.utils import Dataset, IfExists, coerce_login
class _TerminalInfo(NamedTuple):
@@ -1301,12 +1302,12 @@ class TestAccountSpec:
expected: int | None,
) -> None:
"""Test login values are normalized for account configs."""
assert sdk._coerce_login(login) == expected # type: ignore[reportPrivateUsage]
assert coerce_login(login) == expected
def test_coerce_login_rejects_non_numeric_string(self) -> None:
"""Test non-numeric login strings raise ValueError."""
with pytest.raises(ValueError, match="invalid literal"):
sdk._coerce_login("abc") # type: ignore[reportPrivateUsage]
coerce_login("abc")
class TestCollectLatestRatesForAccounts:
@@ -1662,6 +1663,62 @@ class TestCollectLatestClosedRatesForAccounts:
)
class TestFetchLatestClosedRates:
"""Tests for fetch_latest_closed_rates."""
def test_fetches_extra_bar_and_drops_forming_row(self) -> None:
"""Test single-symbol closed-bar helper hides the forming bar."""
client = MagicMock()
client.latest_rates.return_value = pd.DataFrame(
{
"time": [1, 2, 3],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.latest_rates.assert_called_once_with(
"EURUSD",
"M1",
3,
start_pos=0,
)
assert list(result["close"]) == [1.0, 1.1]
def test_raises_when_no_closed_bars_are_available(self) -> None:
"""Test empty closed-bar results raise an actionable ValueError."""
client = MagicMock()
client.latest_rates.return_value = pd.DataFrame({"close": [1.0]})
with pytest.raises(ValueError, match="Rate data is empty"):
fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=1,
)
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(
client,
symbol="EURUSD",
granularity="M1",
count=0,
)
client.latest_rates.assert_not_called()
class TestCollectLatestClosedRatesByGranularity:
"""Tests for collect_latest_closed_rates_by_granularity."""
+923 -9
View File
File diff suppressed because it is too large Load Diff
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.7.2"
version = "0.8.0"
source = { editable = "." }
dependencies = [
{ name = "click" },