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