[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. `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 ## CLI usage
```bash ```bash
+29 -3
View File
@@ -167,19 +167,45 @@ Resolution rules:
### Rate data loading ### Rate data loading
Use `load_rate_data()` to load a table or view from a SQLite path, or The canonical normalized rate table is `rates`; compatibility views are named
`load_rate_data_from_connection()` when you already have a connection: 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 ```python
from pathlib import Path 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 from mt5cli.history import resolve_rate_view_name
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True) view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
rates = load_rate_data(Path("history.db"), view, count=1000) 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 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 validates that `time` exists, parses timestamps with pandas, and returns a
DataFrame indexed by ascending `DatetimeIndex` named `time`. 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 ### Latest closed rate bars
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
row. `collect_latest_closed_rates_for_accounts()` fetches `count + 1` bars, row. `fetch_latest_closed_rates()` handles one connected client; multi-account
drops that row with `drop_forming_rate_bar()`, and validates each series is helpers fetch `count + 1` bars, drop that row with `drop_forming_rate_bar()`,
non-empty. Use `collect_latest_closed_rates_by_granularity()` when callers and validate each series is non-empty. Returned frames are ordered
prefer keys such as `("EURUSD", "M1")` instead of integer timeframes. oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
fewer closed bars.
```python ```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( rates = collect_latest_closed_rates_by_granularity(
[AccountSpec(symbols=["EURUSD"], login=12345)], [AccountSpec(symbols=["EURUSD"], login=12345)],
@@ -48,6 +60,9 @@ rates = collect_latest_closed_rates_by_granularity(
closed_m1 = rates["EURUSD", "M1"] 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 ### Resolving credentials and `${ENV_VAR}` placeholders
`resolve_account_spec()` / `resolve_account_specs()` merge explicit override `resolve_account_spec()` / `resolve_account_specs()` merge explicit override
+56 -12
View File
@@ -4,38 +4,60 @@
## Trading-capable MT5 sessions ## Trading-capable MT5 sessions
`mt5_trading_session()` complements the read-only `mt5_session()` helper in `create_trading_client()` and `mt5_trading_session()` complement the read-only
`sdk.py`. It yields a connected `pdmt5.Mt5TradingClient`, uses `mt5_session()` helper in `sdk.py`. They return or yield an initialized
`Mt5Config.path` to launch the terminal when configured, and always calls `pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when
`shutdown()` on exit. configured, and `mt5_trading_session()` always calls `shutdown()` on exit.
```python ```python
from pdmt5 import Mt5Config from mt5cli import create_trading_client, mt5_trading_session
from mt5cli import mt5_trading_session
with 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, retry_count=2,
) as client: ) as client:
positions = client.positions_get_as_df(symbol="EURUSD") 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. 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, These helpers are strategy-agnostic and do not depend on signal detection,
betting logic, or scheduling code in downstream applications. betting logic, or scheduling code in downstream applications.
```python ```python
from mt5cli import ( from mt5cli import (
calculate_spread_ratio,
calculate_margin_and_volume, calculate_margin_and_volume,
close_open_positions,
detect_position_side, detect_position_side,
determine_order_limits, 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") side = detect_position_side(client, "EURUSD")
spread_ratio = calculate_spread_ratio(client, "EURUSD")
sizing = calculate_margin_and_volume( sizing = calculate_margin_and_volume(
client, client,
"EURUSD", "EURUSD",
@@ -49,11 +71,33 @@ limits = determine_order_limits(
stop_loss_limit_ratio=0.01, stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.02, 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. `detect_position_side()` returns `long` for buy-only exposure, `short` for
`calculate_margin_and_volume()` clamps negative `margin_free` to `0.0` sell-only exposure, and `None` for no positions or mixed long/short exposure.
before sizing. `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 ## Migration from mteor-local helpers
+42
View File
@@ -2,6 +2,8 @@
from importlib.metadata import version from importlib.metadata import version
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .client import MT5Client, build_config, mt5_session from .client import MT5Client, build_config, mt5_session
from .converters import ( from .converters import (
ensure_utc, ensure_utc,
@@ -32,6 +34,7 @@ from .history import (
resolve_history_datasets, resolve_history_datasets,
resolve_history_tick_flags, resolve_history_tick_flags,
resolve_history_timeframes, resolve_history_timeframes,
resolve_rate_table_name,
resolve_rate_tables, resolve_rate_tables,
resolve_rate_view_name, resolve_rate_view_name,
resolve_rate_view_names, resolve_rate_view_names,
@@ -63,6 +66,7 @@ from .sdk import (
copy_rates_range, copy_rates_range,
copy_ticks_from, copy_ticks_from,
copy_ticks_range, copy_ticks_range,
fetch_latest_closed_rates,
history_deals, history_deals,
history_orders, history_orders,
last_error, last_error,
@@ -96,10 +100,26 @@ from .storage import (
export_dataframe_to_sqlite, export_dataframe_to_sqlite,
) )
from .trading import ( from .trading import (
POSITION_COLUMNS,
OrderFillingMode,
OrderSide,
OrderTimeMode,
PositionSide,
calculate_margin_and_volume, 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, detect_position_side,
determine_order_limits, determine_order_limits,
get_account_snapshot,
get_positions_frame,
get_symbol_snapshot,
get_tick_snapshot,
mt5_trading_session, mt5_trading_session,
place_market_order,
update_sltp_for_open_positions,
) )
from .utils import ( from .utils import (
TICK_FLAG_MAP, TICK_FLAG_MAP,
@@ -114,6 +134,7 @@ __version__ = version(__package__) if __package__ else None
__all__ = [ __all__ = [
"DEDUP_KEYS", "DEDUP_KEYS",
"KNOWN_MT5_TIME_COLUMNS", "KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS",
"REQUIRED_COLUMNS", "REQUIRED_COLUMNS",
"TICK_FLAG_MAP", "TICK_FLAG_MAP",
"TIMEFRAME_MAP", "TIMEFRAME_MAP",
@@ -125,9 +146,17 @@ __all__ = [
"MT5Client", "MT5Client",
"Mt5CliClient", "Mt5CliClient",
"Mt5CliError", "Mt5CliError",
"Mt5Config",
"Mt5ConnectionError", "Mt5ConnectionError",
"Mt5OperationError", "Mt5OperationError",
"Mt5RuntimeError",
"Mt5SchemaError", "Mt5SchemaError",
"Mt5TradingClient",
"Mt5TradingError",
"OrderFillingMode",
"OrderSide",
"OrderTimeMode",
"PositionSide",
"RateTarget", "RateTarget",
"ThrottledHistoryUpdater", "ThrottledHistoryUpdater",
"account_info", "account_info",
@@ -135,7 +164,11 @@ __all__ = [
"build_rate_targets", "build_rate_targets",
"build_rate_view_name", "build_rate_view_name",
"calculate_margin_and_volume", "calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors", "call_with_normalized_errors",
"close_open_positions",
"collect_history", "collect_history",
"collect_latest_closed_rates_by_granularity", "collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts", "collect_latest_closed_rates_for_accounts",
@@ -147,6 +180,7 @@ __all__ = [
"copy_rates_range", "copy_rates_range",
"copy_ticks_from", "copy_ticks_from",
"copy_ticks_range", "copy_ticks_range",
"create_trading_client",
"detect_format", "detect_format",
"detect_position_side", "detect_position_side",
"determine_order_limits", "determine_order_limits",
@@ -154,6 +188,11 @@ __all__ = [
"ensure_utc", "ensure_utc",
"export_dataframe", "export_dataframe",
"export_dataframe_to_sqlite", "export_dataframe_to_sqlite",
"fetch_latest_closed_rates",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"granularity_name", "granularity_name",
"history_deals", "history_deals",
"history_orders", "history_orders",
@@ -181,6 +220,7 @@ __all__ = [
"parse_datetime", "parse_datetime",
"parse_tick_flags", "parse_tick_flags",
"parse_timeframe", "parse_timeframe",
"place_market_order",
"positions", "positions",
"recent_history_deals", "recent_history_deals",
"recent_ticks", "recent_ticks",
@@ -190,6 +230,7 @@ __all__ = [
"resolve_history_datasets", "resolve_history_datasets",
"resolve_history_tick_flags", "resolve_history_tick_flags",
"resolve_history_timeframes", "resolve_history_timeframes",
"resolve_rate_table_name",
"resolve_rate_tables", "resolve_rate_tables",
"resolve_rate_view_name", "resolve_rate_view_name",
"resolve_rate_view_names", "resolve_rate_view_names",
@@ -201,5 +242,6 @@ __all__ = [
"terminal_info", "terminal_info",
"update_history", "update_history",
"update_history_with_config", "update_history_with_config",
"update_sltp_for_open_positions",
"validate_schema", "validate_schema",
] ]
+72 -10
View File
@@ -7,7 +7,7 @@ import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast from typing import TYPE_CHECKING, Literal, cast, overload
import pandas as pd import pandas as pd
from pdmt5 import get_timeframe_name as _get_timeframe_name 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}" 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 SqliteConnOrPath = sqlite3.Connection | Path | str
@@ -653,34 +673,76 @@ def resolve_rate_tables(
conn.close() 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( def load_rate_series_from_sqlite(
conn_or_path: SqliteConnOrPath, conn_or_path: SqliteConnOrPath,
targets: Sequence[RateTarget], targets: Sequence[RateTarget] | None = None,
count: int, count: int | None = None,
explicit_tables: Sequence[str] | 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: Args:
conn_or_path: SQLite database path or open connection. conn_or_path: SQLite database path or open connection.
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair must
must be unique. be unique. Omit when loading a single explicit ``table``.
count: Number of most recent rows to load per series. count: Optional number of most recent rows to load per series.
explicit_tables: Optional explicit table or view names matching targets. explicit_tables: Optional explicit table or view names matching targets.
When omitted, managed ``rate_*`` compatibility views must already When omitted, managed ``rate_*`` compatibility views must already
exist in the database. exist in the database.
table: Optional single table or view name to load directly.
Returns: 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: Raises:
ValueError: If ``count`` is not positive, targets are empty, duplicate ValueError: If ``count`` is not positive, targets are empty, duplicate
``(symbol, timeframe_int)`` pairs are present, or table resolution ``(symbol, timeframe_int)`` pairs are present, or table resolution
fails. 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." msg = "count must be positive."
raise ValueError(msg) raise ValueError(msg)
if targets is None:
msg = "targets are required when table is not provided."
raise ValueError(msg)
target_list = list(targets) target_list = list(targets)
if not target_list: if not target_list:
msg = "At least one rate target is required." msg = "At least one rate target is required."
+29 -14
View File
@@ -37,6 +37,7 @@ from .utils import (
parse_tick_flags, parse_tick_flags,
parse_timeframe, parse_timeframe,
) )
from .utils import coerce_login as _coerce_login
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence from collections.abc import Callable, Iterator, Sequence
@@ -122,6 +123,7 @@ __all__ = [
"copy_rates_range", "copy_rates_range",
"copy_ticks_from", "copy_ticks_from",
"copy_ticks_range", "copy_ticks_range",
"fetch_latest_closed_rates",
"history_deals", "history_deals",
"history_orders", "history_orders",
"last_error", "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( def collect_latest_rates(
symbols: Sequence[str], symbols: Sequence[str],
timeframes: Sequence[int | 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( def _build_account_config(
account: AccountSpec, account: AccountSpec,
base_config: Mt5Config | None, base_config: Mt5Config | None,
+688 -37
View File
@@ -3,27 +3,98 @@
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager 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 .sdk import build_config
from .utils import coerce_login as _coerce_login
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
import pandas as pd
PositionSide = Literal["long", "short"] 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__ = [ __all__ = [
"POSITION_COLUMNS",
"OrderFillingMode",
"OrderSide", "OrderSide",
"OrderTimeMode",
"PositionSide", "PositionSide",
"calculate_margin_and_volume", "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", "detect_position_side",
"determine_order_limits", "determine_order_limits",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"mt5_trading_session", "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()) 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: 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() normalized = side.lower()
if normalized in {"long", "buy"}: if normalized in {"long", "buy"}:
return "long" return "long"
if normalized in {"short", "sell"}: if normalized in {"short", "sell"}:
return "short" return "short"
msg = ( msg = f"Unsupported position side: {side!r}. Expected 'long' or 'short'."
f"Unsupported order side: {side!r}. Expected 'long', 'short', 'buy', or 'sell'."
)
raise ValueError(msg) 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( def detect_position_side(
client: Mt5TradingClient, client: Mt5TradingClient,
symbol: str, symbol: str,
@@ -69,11 +306,11 @@ def detect_position_side(
symbol: Symbol to inspect. symbol: Symbol to inspect.
Returns: Returns:
``"long"`` when net buy volume exceeds sell volume, ``"short"`` when ``"long"`` when there are buy positions and no sell positions,
net sell volume exceeds buy volume, or ``None`` when no positions exist ``"short"`` when there are sell positions and no buy positions, or
or buy/sell volumes are exactly balanced. ``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: if positions.empty:
return None return None
@@ -81,14 +318,114 @@ def detect_position_side(
sell_type = client.mt5.POSITION_TYPE_SELL sell_type = client.mt5.POSITION_TYPE_SELL
buy_volume = _sum_position_volume(positions, buy_type) buy_volume = _sum_position_volume(positions, buy_type)
sell_volume = _sum_position_volume(positions, sell_type) sell_volume = _sum_position_volume(positions, sell_type)
net_volume = buy_volume - sell_volume if buy_volume > 0 and sell_volume == 0:
if net_volume > 0:
return "long" return "long"
if net_volume < 0: if sell_volume > 0 and buy_volume == 0:
return "short" return "short"
return None 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( def calculate_margin_and_volume(
client: Mt5TradingClient, client: Mt5TradingClient,
symbol: str, symbol: str,
@@ -99,7 +436,9 @@ def calculate_margin_and_volume(
Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``, Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
then allocates ``unit_margin_ratio`` of the remainder as the margin budget 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: Args:
client: Connected ``Mt5TradingClient`` instance. 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)) margin_free = max(0.0, float(account.get("margin_free") or 0.0))
available_margin = margin_free * (1.0 - preserved_margin_ratio) available_margin = margin_free * (1.0 - preserved_margin_ratio)
trade_margin = available_margin * unit_margin_ratio trade_margin = available_margin * unit_margin_ratio
buy_volume = client.calculate_volume_by_margin(symbol, trade_margin, "BUY") if unit_margin_ratio == 0:
sell_volume = client.calculate_volume_by_margin(symbol, trade_margin, "SELL") 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 { return {
"margin_free": margin_free, "margin_free": margin_free,
"available_margin": available_margin, "available_margin": available_margin,
"trade_margin": trade_margin, "trade_margin": trade_margin,
"buy_volume": buy_volume, "buy_volume": float(buy_volume),
"sell_volume": sell_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( def determine_order_limits(
client: Mt5TradingClient, client: Mt5TradingClient,
symbol: str, symbol: str,
side: OrderSide | str, side: PositionSide | str,
stop_loss_limit_ratio: float, stop_loss_limit_ratio: float | None = None,
take_profit_limit_ratio: float, take_profit_limit_ratio: float | None = None,
) -> dict[str, float | None]: ) -> dict[str, float | None]:
"""Derive entry and protective order prices from current market quotes. """Derive entry and protective order prices from current market quotes.
@@ -152,26 +577,41 @@ def determine_order_limits(
Returns: Returns:
Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys. Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys.
Omitted protective levels are returned as ``None``. 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") stop_loss_ratio = stop_loss_limit_ratio or 0.0
_require_protective_ratio(take_profit_limit_ratio, "take_profit_limit_ratio") take_profit_ratio = take_profit_limit_ratio or 0.0
normalized_side = _normalize_order_side(side) _require_protective_ratio(stop_loss_ratio, "stop_loss_limit_ratio")
tick = client.symbol_info_tick_as_dict(symbol=symbol) _require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
entry = float(tick["ask"] if normalized_side == "long" else tick["bid"]) 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 stop_loss: float | None = None
if stop_loss_limit_ratio > 0: if stop_loss_ratio > 0:
if normalized_side == "long": if normalized_side == "long":
stop_loss = entry * (1.0 - stop_loss_limit_ratio) stop_loss = entry * (1.0 - stop_loss_ratio)
else: 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 take_profit: float | None = None
if take_profit_limit_ratio > 0: if take_profit_ratio > 0:
if normalized_side == "long": if normalized_side == "long":
take_profit = entry * (1.0 + take_profit_limit_ratio) take_profit = entry * (1.0 + take_profit_ratio)
else: 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 { return {
"entry": entry, "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 @contextmanager
def mt5_trading_session( def mt5_trading_session(
config: Mt5Config | None = None, 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, retry_count: int = 0,
) -> Iterator[Mt5TradingClient]: ) -> Iterator[Mt5TradingClient]:
"""Open a trading-capable MT5 session and always shut down safely. """Open a trading-capable MT5 session and always shut down safely.
@@ -195,16 +835,27 @@ def mt5_trading_session(
Args: Args:
config: MT5 connection configuration. Defaults to an empty config that config: MT5 connection configuration. Defaults to an empty config that
attaches to a running terminal. 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 retry_count: Number of initialization retries passed to
``Mt5TradingClient``. ``Mt5TradingClient``.
Yields: Yields:
Connected ``Mt5TradingClient`` bound to the session. Connected ``Mt5TradingClient`` bound to the session.
""" """
mt5_config = config or build_config() client = create_trading_client(
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count) config=config,
login=login,
password=password,
server=server,
path=path,
timeout=timeout,
retry_count=retry_count,
)
try: try:
client.initialize_and_login_mt5()
yield client yield client
finally: finally:
client.shutdown() client.shutdown()
+14
View File
@@ -241,6 +241,20 @@ def detect_format(
raise ValueError(msg) 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( def export_dataframe_to_sqlite(
df: pd.DataFrame, df: pd.DataFrame,
output_path: Path, output_path: Path,
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "0.7.2" version = "0.8.0"
description = "Generic MT5 data and execution infrastructure for Python applications" description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
+36
View File
@@ -48,6 +48,7 @@ from mt5cli.history import (
resolve_history_datasets, resolve_history_datasets,
resolve_history_tick_flags, resolve_history_tick_flags,
resolve_history_timeframes, resolve_history_timeframes,
resolve_rate_table_name,
resolve_rate_tables, resolve_rate_tables,
resolve_rate_view_name, resolve_rate_view_name,
resolve_rate_view_names, resolve_rate_view_names,
@@ -63,6 +64,15 @@ from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists
class TestResolveRateViewName: class TestResolveRateViewName:
"""Tests for resolve_rate_view_name and resolve_rate_view_names.""" """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: 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.""" """Test resolving against a missing path does not create a database."""
db_path = tmp_path / "missing.db" db_path = tmp_path / "missing.db"
@@ -416,6 +426,32 @@ class TestLoadRateData:
frame = load_rate_data_from_connection(conn, "rate_view") frame = load_rate_data_from_connection(conn, "rate_view")
assert list(frame["close"]) == [1.0] 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: def test_loads_quoted_identifier(self, tmp_path: Path) -> None:
"""Test table names are quoted safely.""" """Test table names are quoted safely."""
db_path = tmp_path / "quoted.db" db_path = tmp_path / "quoted.db"
+60 -3
View File
@@ -37,6 +37,7 @@ from mt5cli.sdk import (
copy_rates_range, copy_rates_range,
copy_ticks_from, copy_ticks_from,
copy_ticks_range, copy_ticks_range,
fetch_latest_closed_rates,
history_deals, history_deals,
history_orders, history_orders,
last_error, last_error,
@@ -61,7 +62,7 @@ from mt5cli.sdk import (
update_history_with_config, update_history_with_config,
version, version,
) )
from mt5cli.utils import Dataset, IfExists from mt5cli.utils import Dataset, IfExists, coerce_login
class _TerminalInfo(NamedTuple): class _TerminalInfo(NamedTuple):
@@ -1301,12 +1302,12 @@ class TestAccountSpec:
expected: int | None, expected: int | None,
) -> None: ) -> None:
"""Test login values are normalized for account configs.""" """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: def test_coerce_login_rejects_non_numeric_string(self) -> None:
"""Test non-numeric login strings raise ValueError.""" """Test non-numeric login strings raise ValueError."""
with pytest.raises(ValueError, match="invalid literal"): with pytest.raises(ValueError, match="invalid literal"):
sdk._coerce_login("abc") # type: ignore[reportPrivateUsage] coerce_login("abc")
class TestCollectLatestRatesForAccounts: 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: class TestCollectLatestClosedRatesByGranularity:
"""Tests for collect_latest_closed_rates_by_granularity.""" """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]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "0.7.2" version = "0.8.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },