[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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"}]
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user