Files
mt5cli/mt5cli/sdk.py
T

1453 lines
42 KiB
Python

"""Programmatic SDK for MetaTrader 5 data collection."""
from __future__ import annotations
import json
import logging
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Self, TypeVar, cast
import pandas as pd
from pdmt5 import Mt5Config, Mt5DataClient
from .history import (
create_cash_events_view,
create_history_indexes,
create_positions_reconstructed_view,
resolve_history_datasets,
resolve_history_tick_flags,
resolve_history_timeframes,
write_collected_datasets,
write_incremental_datasets,
)
from .utils import (
Dataset,
IfExists,
parse_datetime,
parse_tick_flags,
parse_timeframe,
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence
T = TypeVar("T")
logger = logging.getLogger(__name__)
__all__ = [
"AccountSpec",
"Mt5CliClient",
"account_info",
"build_config",
"collect_history",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"history_deals",
"history_orders",
"last_error",
"latest_rates",
"market_book",
"minimum_margins",
"mt5_session",
"mt5_summary",
"mt5_summary_as_df",
"orders",
"positions",
"recent_history_deals",
"recent_ticks",
"symbol_info",
"symbol_info_tick",
"symbols",
"terminal_info",
"update_history",
"update_history_with_config",
"version",
]
def _coerce_timeframe(timeframe: int | str) -> int:
if isinstance(timeframe, int):
return timeframe
return parse_timeframe(timeframe)
def _coerce_tick_flags(flags: int | str) -> int:
if isinstance(flags, int):
return flags
return parse_tick_flags(flags)
def _plain_mt5_value(value: object) -> object:
asdict = getattr(value, "_asdict", None)
if callable(asdict):
return _plain_mt5_value(asdict())
if isinstance(value, dict):
typed_value = cast("dict[object, object]", value)
return {key: _plain_mt5_value(item) for key, item in typed_value.items()}
if isinstance(value, tuple):
typed_value = cast("tuple[object, ...]", value)
return [_plain_mt5_value(item) for item in typed_value]
if isinstance(value, list):
typed_value = cast("list[object]", value)
return [_plain_mt5_value(item) for item in typed_value]
return value
def _require_datetime(value: datetime | str) -> datetime:
if isinstance(value, datetime):
return value
return parse_datetime(value)
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
if value is None or isinstance(value, datetime):
return value
return parse_datetime(value)
def _require_positive(value: float, name: str) -> None:
if value <= 0:
msg = f"{name} must be positive."
raise ValueError(msg)
def _call_required_client_method(client: Mt5DataClient, name: str) -> object:
try:
method = getattr(client, name)
except AttributeError as exc:
msg = f"MT5 client is missing required method: {name}"
raise AttributeError(msg) from exc
if not callable(method):
msg = f"MT5 client attribute is not callable: {name}"
raise TypeError(msg)
return method()
def _mt5_summary_export_value(value: object) -> object:
plain_value = _plain_mt5_value(value)
if isinstance(plain_value, dict | list):
return json.dumps(plain_value, sort_keys=True, separators=(",", ":"))
return plain_value
def _coerce_tick_time(value: object) -> datetime:
if isinstance(value, datetime):
return value
if isinstance(value, str):
return parse_datetime(value)
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=UTC)
msg = f"Unsupported tick time value: {value!r}"
raise TypeError(msg)
def _filter_ticks_to_end(frame: pd.DataFrame, end: datetime) -> pd.DataFrame:
if frame.empty or "time" not in frame.columns:
return frame
times = pd.to_datetime(frame["time"], utc=True)
return frame.loc[times <= end].reset_index(drop=True)
def _fetch_recent_ticks(
client: Mt5DataClient,
symbol: str,
seconds: float,
date_to: datetime | None,
count: int,
flags: int,
) -> pd.DataFrame:
if date_to is not None:
end = date_to
else:
tick = client.symbol_info_tick(symbol)
end = _coerce_tick_time(tick.time)
start = end - timedelta(seconds=seconds)
if count > 0:
from_frame = _filter_ticks_to_end(
client.copy_ticks_from_as_df(
symbol=symbol,
date_from=start,
count=count,
flags=flags,
),
end,
)
if len(from_frame) < count:
return from_frame
frame = client.copy_ticks_range_as_df(
symbol=symbol,
date_from=start,
date_to=end,
flags=flags,
)
if count > 0 and len(frame) > count:
return frame.tail(count).reset_index(drop=True)
return frame
def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
sym = client.symbol_info(symbol)
account = client.account_info()
tick = client.symbol_info_tick(symbol)
volume_min = sym.volume_min
buy_margin = client.order_calc_margin(
client.mt5.ORDER_TYPE_BUY,
symbol,
volume_min,
tick.ask,
)
sell_margin = client.order_calc_margin(
client.mt5.ORDER_TYPE_SELL,
symbol,
volume_min,
tick.bid,
)
return pd.DataFrame([
{
"symbol": symbol,
"account_currency": account.currency,
"volume_min": volume_min,
"buy_margin": buy_margin,
"sell_margin": sell_margin,
}
])
def build_config(
*,
path: str | None = None,
login: int | None = None,
password: str | None = None,
server: str | None = None,
timeout: int | None = None,
) -> Mt5Config:
"""Build an ``Mt5Config`` from optional connection parameters.
Returns:
Configured ``Mt5Config`` instance.
"""
return Mt5Config(
path=path,
login=login,
password=password,
server=server,
timeout=timeout,
)
@contextmanager
def _connected_client(config: Mt5Config) -> Iterator[Mt5DataClient]:
"""Initialize MT5, yield a connected client, and always shut down.
Args:
config: MT5 connection configuration.
Yields:
Connected ``Mt5DataClient`` instance.
"""
client = Mt5DataClient(config=config)
try:
client.initialize_and_login_mt5()
yield client
finally:
client.shutdown()
def _run_with_client(
config: Mt5Config,
fetch_fn: Callable[[Mt5DataClient], T],
) -> T:
"""Connect, run ``fetch_fn``, and shut down safely.
Args:
config: MT5 connection configuration.
fetch_fn: Callable receiving a connected client.
Returns:
Value returned by ``fetch_fn``.
"""
with _connected_client(config) as client:
return fetch_fn(client)
@contextmanager
def mt5_session(config: Mt5Config | None = None) -> Iterator[Mt5CliClient]:
"""Open an MT5 terminal session and yield a connected client.
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` (when set),
logs in, yields a connected :class:`Mt5CliClient`, and always shuts the
terminal down on exit.
Args:
config: MT5 connection configuration. Defaults to an empty config that
attaches to a running terminal.
Yields:
Connected ``Mt5CliClient`` bound to the session.
"""
mt5_config = config or build_config()
with _connected_client(mt5_config) as client:
yield Mt5CliClient.from_connected_client(client)
class Mt5CliClient:
"""Programmatic client for read-only MetaTrader 5 data access."""
def __init__(
self,
*,
path: str | None = None,
login: int | None = None,
password: str | None = None,
server: str | None = None,
timeout: int | None = None,
config: Mt5Config | None = None,
client: Mt5DataClient | None = None,
) -> None:
"""Initialize the SDK client.
Args:
path: Path to MetaTrader5 terminal EXE file.
login: Trading account login.
password: Trading account password.
server: Trading server name.
timeout: Connection timeout in milliseconds.
config: Optional pre-built ``Mt5Config`` (overrides other args).
client: Optional already-connected ``Mt5DataClient``. Injected
clients are reused as-is and are not initialized or shut down.
"""
self._config = config or build_config(
path=path,
login=login,
password=password,
server=server,
timeout=timeout,
)
self._client = client
self._owns_client = client is None
@classmethod
def from_connected_client(cls, client: Mt5DataClient) -> Self:
"""Bind to an already-connected ``Mt5DataClient`` without owning it.
The returned ``Mt5CliClient`` never initializes or shuts down the
injected client, including when used as a context manager.
Returns:
Client wrapper bound to the injected connection.
"""
return cls(client=client)
@property
def config(self) -> Mt5Config:
"""Return the underlying MT5 configuration."""
return self._config
def __enter__(self) -> Self:
"""Open a persistent MT5 connection for multiple calls.
Returns:
This client instance.
"""
if self._client is not None:
return self
client = Mt5DataClient(config=self._config)
try:
client.initialize_and_login_mt5()
except Exception:
client.shutdown()
raise
self._client = client
self._owns_client = True # only set when this method created the client
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: object,
) -> None:
"""Shut down the persistent MT5 connection."""
if self._client is not None and self._owns_client:
self._client.shutdown()
self._client = None
def _fetch_value(self, fetch_fn: Callable[[Mt5DataClient], T]) -> T:
if self._client is not None:
return fetch_fn(self._client)
return _run_with_client(self._config, fetch_fn)
def _fetch(self, fetch_fn: Callable[[Mt5DataClient], pd.DataFrame]) -> pd.DataFrame:
return self._fetch_value(fetch_fn)
def copy_rates_from(
self,
symbol: str,
timeframe: int | str,
date_from: datetime | str,
count: int,
) -> pd.DataFrame:
"""Return rates starting from a date."""
tf = _coerce_timeframe(timeframe)
start = _require_datetime(date_from)
return self._fetch(
lambda c: c.copy_rates_from_as_df(
symbol=symbol,
timeframe=tf,
date_from=start,
count=count,
),
)
def copy_rates_from_pos(
self,
symbol: str,
timeframe: int | str,
start_pos: int,
count: int,
) -> pd.DataFrame:
"""Return rates starting from a bar position."""
tf = _coerce_timeframe(timeframe)
return self._fetch(
lambda c: c.copy_rates_from_pos_as_df(
symbol=symbol,
timeframe=tf,
start_pos=start_pos,
count=count,
),
)
def latest_rates(
self,
symbol: str,
timeframe: int | str,
count: int,
start_pos: int = 0,
) -> pd.DataFrame:
"""Return the latest rates from a bar position."""
_require_positive(count, "count")
return self.copy_rates_from_pos(symbol, timeframe, start_pos, count)
def collect_latest_rates(
self,
symbols: Sequence[str],
timeframes: Sequence[int | str],
*,
count: int,
start_pos: int = 0,
) -> dict[tuple[str, int], pd.DataFrame]:
"""Return latest rates for each symbol/timeframe pair.
Returns:
Mapping keyed by ``(symbol, timeframe_int)``.
Raises:
ValueError: If ``count`` is not positive or inputs are empty.
"""
_require_positive(count, "count")
if not symbols:
msg = "At least one symbol is required."
raise ValueError(msg)
if not timeframes:
msg = "At least one timeframe is required."
raise ValueError(msg)
resolved_timeframes = [_coerce_timeframe(timeframe) for timeframe in timeframes]
return self._fetch_value(
lambda c: {
(symbol, timeframe): c.copy_rates_from_pos_as_df(
symbol=symbol,
timeframe=timeframe,
start_pos=start_pos,
count=count,
)
for symbol in symbols
for timeframe in resolved_timeframes
},
)
def copy_rates_range(
self,
symbol: str,
timeframe: int | str,
date_from: datetime | str,
date_to: datetime | str,
) -> pd.DataFrame:
"""Return rates for a date range."""
tf = _coerce_timeframe(timeframe)
start = _require_datetime(date_from)
end = _require_datetime(date_to)
return self._fetch(
lambda c: c.copy_rates_range_as_df(
symbol=symbol,
timeframe=tf,
date_from=start,
date_to=end,
),
)
def copy_ticks_from(
self,
symbol: str,
date_from: datetime | str,
count: int,
flags: int | str,
) -> pd.DataFrame:
"""Return ticks starting from a date."""
start = _require_datetime(date_from)
tick_flags = _coerce_tick_flags(flags)
return self._fetch(
lambda c: c.copy_ticks_from_as_df(
symbol=symbol,
date_from=start,
count=count,
flags=tick_flags,
),
)
def copy_ticks_range(
self,
symbol: str,
date_from: datetime | str,
date_to: datetime | str,
flags: int | str,
) -> pd.DataFrame:
"""Return ticks for a date range."""
start = _require_datetime(date_from)
end = _require_datetime(date_to)
tick_flags = _coerce_tick_flags(flags)
return self._fetch(
lambda c: c.copy_ticks_range_as_df(
symbol=symbol,
date_from=start,
date_to=end,
flags=tick_flags,
),
)
def account_info(self) -> pd.DataFrame:
"""Return account information."""
return self._fetch(lambda c: c.account_info_as_df())
def terminal_info(self) -> pd.DataFrame:
"""Return terminal information."""
return self._fetch(lambda c: c.terminal_info_as_df())
def symbols(self, group: str | None = None) -> pd.DataFrame:
"""Return the symbol list."""
return self._fetch(lambda c: c.symbols_get_as_df(group=group))
def symbol_info(self, symbol: str) -> pd.DataFrame:
"""Return details for one symbol."""
return self._fetch(lambda c: c.symbol_info_as_df(symbol=symbol))
def orders(
self,
symbol: str | None = None,
group: str | None = None,
ticket: int | None = None,
) -> pd.DataFrame:
"""Return active orders."""
return self._fetch(
lambda c: c.orders_get_as_df(
symbol=symbol,
group=group,
ticket=ticket,
),
)
def positions(
self,
symbol: str | None = None,
group: str | None = None,
ticket: int | None = None,
) -> pd.DataFrame:
"""Return open positions."""
return self._fetch(
lambda c: c.positions_get_as_df(
symbol=symbol,
group=group,
ticket=ticket,
),
)
def history_orders(
self,
date_from: datetime | str | None = None,
date_to: datetime | str | None = None,
group: str | None = None,
symbol: str | None = None,
ticket: int | None = None,
position: int | None = None,
) -> pd.DataFrame:
"""Return historical orders."""
start = _coerce_datetime(date_from)
end = _coerce_datetime(date_to)
return self._fetch(
lambda c: c.history_orders_get_as_df(
date_from=start,
date_to=end,
group=group,
symbol=symbol,
ticket=ticket,
position=position,
),
)
def history_deals(
self,
date_from: datetime | str | None = None,
date_to: datetime | str | None = None,
group: str | None = None,
symbol: str | None = None,
ticket: int | None = None,
position: int | None = None,
) -> pd.DataFrame:
"""Return historical deals."""
start = _coerce_datetime(date_from)
end = _coerce_datetime(date_to)
return self._fetch(
lambda c: c.history_deals_get_as_df(
date_from=start,
date_to=end,
group=group,
symbol=symbol,
ticket=ticket,
position=position,
),
)
def recent_history_deals(
self,
hours: float,
date_to: datetime | str | None = None,
group: str | None = None,
symbol: str | None = None,
) -> pd.DataFrame:
"""Return historical deals from a recent trailing window."""
_require_positive(hours, "hours")
end = _require_datetime(date_to) if date_to is not None else datetime.now(UTC)
start = end - timedelta(hours=hours)
return self.history_deals(
date_from=start,
date_to=end,
group=group,
symbol=symbol,
)
def version(self) -> pd.DataFrame:
"""Return MetaTrader5 version information."""
return self._fetch(lambda c: c.version_as_df())
def last_error(self) -> pd.DataFrame:
"""Return the last error information."""
return self._fetch(lambda c: c.last_error_as_df())
def symbol_info_tick(self, symbol: str) -> pd.DataFrame:
"""Return the last tick for a symbol."""
return self._fetch(lambda c: c.symbol_info_tick_as_df(symbol=symbol))
def market_book(self, symbol: str) -> pd.DataFrame:
"""Return market depth for a symbol."""
return self._fetch(lambda c: c.market_book_get_as_df(symbol=symbol))
def recent_ticks(
self,
symbol: str,
seconds: float,
*,
date_to: datetime | str | None = None,
count: int = 10000,
flags: int | str = "ALL",
) -> pd.DataFrame:
"""Return ticks from a recent time window.
Args:
symbol: Symbol name.
seconds: Lookback window in seconds ending at ``date_to``.
date_to: Window end time. When ``None``, uses the latest
``symbol_info_tick().time`` rather than wall-clock now.
count: Maximum ticks to return. Values ``<= 0`` return the full
window without trimming. Positive values keep the most recent
ticks; when the window is sparse, ``copy_ticks_from`` avoids
fetching the entire range.
flags: Tick flags as ``ALL``, ``INFO``, ``TRADE``, or an integer.
Returns:
Tick DataFrame with MT5 tick columns such as ``time``, ``bid``,
``ask``, ``last``, and ``volume``.
"""
tick_flags = _coerce_tick_flags(flags)
end = _coerce_datetime(date_to)
return self._fetch(
lambda c: _fetch_recent_ticks(
c,
symbol,
seconds,
end,
count,
tick_flags,
),
)
def minimum_margins(self, symbol: str) -> pd.DataFrame:
"""Return minimum-volume buy and sell margin requirements.
Args:
symbol: Symbol name.
Returns:
One-row DataFrame with columns ``symbol``, ``account_currency``,
``volume_min``, ``buy_margin``, and ``sell_margin``.
"""
return self._fetch(lambda c: _fetch_minimum_margins(c, symbol))
def mt5_summary(self) -> dict[str, object]:
"""Return a compact terminal/account status summary."""
def _summary(client: Mt5DataClient) -> dict[str, object]:
return {
"version": _plain_mt5_value(
_call_required_client_method(client, "version"),
),
"terminal_info": _plain_mt5_value(
_call_required_client_method(client, "terminal_info"),
),
"account_info": _plain_mt5_value(
_call_required_client_method(client, "account_info"),
),
"symbols_total": _plain_mt5_value(
_call_required_client_method(client, "symbols_total"),
),
}
return self._fetch_value(_summary)
def mt5_summary_as_df(self) -> pd.DataFrame:
"""Return an export-safe one-row terminal/account summary DataFrame."""
summary = self.mt5_summary()
return pd.DataFrame(
[
{
key: _mt5_summary_export_value(value)
for key, value in summary.items()
},
],
)
def _resolve_incremental_settings(
selected_datasets: set[Dataset],
timeframes: Sequence[int | str] | None,
flags: int | str,
) -> tuple[list[int], int]:
"""Resolve dataset-specific incremental update settings.
Returns:
Tuple of resolved rate timeframes and tick copy flags.
Raises:
ValueError: If timeframe or tick flag values are invalid.
"""
resolved_timeframes: list[int] = []
if Dataset.rates in selected_datasets:
try:
resolved_timeframes = resolve_history_timeframes(timeframes)
except ValueError as exc:
msg = str(exc)
raise ValueError(msg) from exc
resolved_tick_flags = 0
if Dataset.ticks in selected_datasets:
try:
resolved_tick_flags = resolve_history_tick_flags(flags)
except ValueError as exc:
msg = str(exc)
raise ValueError(msg) from exc
return resolved_timeframes, resolved_tick_flags
@dataclass(frozen=True)
class _UpdateHistoryRequest:
selected: set[Dataset]
end: datetime
fallback_start: datetime
resolved_timeframes: list[int]
resolved_tick_flags: int
output_path: Path
def _resolve_update_history_request(
*,
output: Path | str,
symbols: Sequence[str],
datasets: set[Dataset] | None,
timeframes: Sequence[int | str] | None,
flags: int | str,
lookback_hours: float,
date_to: datetime | str | None,
) -> _UpdateHistoryRequest | None:
"""Validate and resolve incremental history update inputs.
Returns:
Resolved request parameters, or None when no datasets are selected.
Raises:
ValueError: If symbols are empty, lookback_hours is not positive, or
timeframe/flag values are invalid.
"""
if lookback_hours <= 0:
msg = "lookback_hours must be positive."
raise ValueError(msg)
selected = resolve_history_datasets(datasets)
if not selected:
logger.info("Skipping SQLite history update: no datasets selected.")
return None
if not symbols:
msg = "At least one symbol is required."
raise ValueError(msg)
if date_to is not None:
resolved_end = _coerce_datetime(date_to)
else:
resolved_end = datetime.now(UTC)
end = resolved_end if resolved_end is not None else datetime.now(UTC)
fallback_start = end - timedelta(hours=lookback_hours)
resolved_timeframes, resolved_tick_flags = _resolve_incremental_settings(
selected,
timeframes,
flags,
)
return _UpdateHistoryRequest(
selected=selected,
end=end,
fallback_start=fallback_start,
resolved_timeframes=resolved_timeframes,
resolved_tick_flags=resolved_tick_flags,
output_path=Path(output),
)
def update_history( # noqa: PLR0913
*,
client: Mt5DataClient,
output: Path | str,
symbols: Sequence[str],
datasets: set[Dataset] | None = None,
timeframes: Sequence[int | str] | None = None,
flags: int | str = "ALL",
lookback_hours: float = 24.0,
date_to: datetime | str | None = None,
deduplicate: bool = True,
create_rate_views: bool = True,
with_views: bool = False,
include_account_events: bool = True,
) -> None:
"""Incrementally append MT5 history into a SQLite database.
Uses an already-connected ``Mt5DataClient`` and does not create or close
the MT5 connection. For first-time tables, data is fetched from
``date_to - lookback_hours``. Subsequent runs resume from existing
``MAX(time)`` per symbol (and timeframe for rates); when
``include_account_events=True``, account-level deals use a separate cursor
over ``type NOT IN (0, 1)`` / empty-symbol rows.
Args:
client: Connected MT5 data client.
output: SQLite database path.
symbols: Symbols to update.
datasets: Datasets to include (defaults to all).
timeframes: Rate timeframes to update (defaults to all fixed MT5
timeframes when None).
flags: Tick copy flags as integer or name (e.g. ``ALL``).
lookback_hours: First-run lookback when a table has no prior rows.
date_to: Optional update end datetime. Defaults to now (UTC).
deduplicate: Remove duplicate rows after append, keeping latest ROWID.
create_rate_views: Create ``rate_<symbol>__<timeframe>`` views.
with_views: Create ``cash_events`` and ``positions_reconstructed`` views.
include_account_events: Include account-level cash events in
``history_deals`` when True.
"""
request = _resolve_update_history_request(
output=output,
symbols=symbols,
datasets=datasets,
timeframes=timeframes,
flags=flags,
lookback_hours=lookback_hours,
date_to=date_to,
)
if request is None:
return
logger.info(
"Updating history in SQLite: symbols=%s, datasets=%s, path=%s",
list(symbols),
sorted(dataset.value for dataset in request.selected),
request.output_path,
)
with sqlite3.connect(request.output_path) as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
write_incremental_datasets(
conn,
client,
symbols,
request.selected,
request.resolved_timeframes,
request.resolved_tick_flags,
request.fallback_start,
request.end,
deduplicate=deduplicate,
create_rate_views=create_rate_views,
with_views=with_views,
include_account_events=include_account_events,
)
def update_history_with_config( # noqa: PLR0913
*,
output: Path | str,
symbols: Sequence[str],
config: Mt5Config | None = None,
datasets: set[Dataset] | None = None,
timeframes: Sequence[int | str] | None = None,
flags: int | str = "ALL",
lookback_hours: float = 24.0,
date_to: datetime | str | None = None,
deduplicate: bool = True,
create_rate_views: bool = True,
with_views: bool = False,
include_account_events: bool = True,
) -> None:
"""Incrementally append MT5 history, opening and closing the MT5 connection.
Convenience wrapper around :func:`update_history` for standalone use.
"""
request = _resolve_update_history_request(
output=output,
symbols=symbols,
datasets=datasets,
timeframes=timeframes,
flags=flags,
lookback_hours=lookback_hours,
date_to=date_to,
)
if request is None:
return
mt5_config = config or build_config()
with _connected_client(mt5_config) as client:
update_history(
client=client,
output=output,
symbols=symbols,
datasets=datasets,
timeframes=timeframes,
flags=flags,
lookback_hours=lookback_hours,
date_to=date_to,
deduplicate=deduplicate,
create_rate_views=create_rate_views,
with_views=with_views,
include_account_events=include_account_events,
)
def collect_history(
output: Path,
symbols: list[str],
date_from: datetime | str,
date_to: datetime | str,
*,
datasets: set[Dataset] | None = None,
timeframe: int | str = 1,
flags: int | str = 1,
if_exists: IfExists = IfExists.FAIL,
with_views: bool = False,
config: Mt5Config | None = None,
) -> None:
"""Collect historical datasets into a single SQLite database.
Args:
output: SQLite database path.
symbols: Symbols to collect.
date_from: Start date.
date_to: End date.
datasets: Datasets to include (defaults to all).
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
flags: Tick copy flags as integer or name (e.g. ``ALL``).
if_exists: Behavior when a target table already exists.
with_views: Create ``cash_events`` and ``positions_reconstructed`` views.
config: MT5 connection configuration.
"""
start = _require_datetime(date_from)
end = _require_datetime(date_to)
selected = datasets if datasets is not None else set(Dataset)
tf = _coerce_timeframe(timeframe)
tick_flags = _coerce_tick_flags(flags)
mt5_config = config or build_config()
with _connected_client(mt5_config) as client, sqlite3.connect(output) as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
written_tables, written_columns = write_collected_datasets(
conn,
client,
symbols,
selected,
tf,
tick_flags,
start,
end,
if_exists,
)
create_history_indexes(conn, written_columns)
if with_views and Dataset.history_deals in written_tables:
create_cash_events_view(conn, written_columns[Dataset.history_deals])
create_positions_reconstructed_view(
conn,
written_columns[Dataset.history_deals],
)
elif with_views:
logger.warning(
"--with-views ignored: history_deals table was not written",
)
logger.info(
"Collected %s for %d symbol(s) into %s",
", ".join(sorted(ds.value for ds in selected)),
len(symbols),
output,
)
def _make_client(*, config: Mt5Config | None = None) -> Mt5CliClient:
return Mt5CliClient(config=config) if config is not None else Mt5CliClient()
def copy_rates_from(
symbol: str,
timeframe: int | str,
date_from: datetime | str,
count: int,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return rates starting from a date."""
return _make_client(config=config).copy_rates_from(
symbol,
timeframe,
date_from,
count,
)
def copy_rates_from_pos(
symbol: str,
timeframe: int | str,
start_pos: int,
count: int,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return rates starting from a bar position."""
return _make_client(config=config).copy_rates_from_pos(
symbol,
timeframe,
start_pos,
count,
)
def latest_rates(
symbol: str,
timeframe: int | str,
count: int,
start_pos: int = 0,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return the latest rates from a bar position."""
return _make_client(config=config).latest_rates(
symbol,
timeframe,
count,
start_pos=start_pos,
)
def collect_latest_rates(
symbols: Sequence[str],
timeframes: Sequence[int | str],
*,
count: int,
start_pos: int = 0,
config: Mt5Config | None = None,
) -> dict[tuple[str, int], pd.DataFrame]:
"""Return latest rates for each symbol/timeframe pair."""
return _make_client(config=config).collect_latest_rates(
symbols,
timeframes,
count=count,
start_pos=start_pos,
)
@dataclass(frozen=True)
class AccountSpec:
"""Connection parameters and symbols for one MT5 account group.
Attributes:
symbols: Symbols to load latest rates for under this account.
login: Trading account login. String values are coerced to int when
non-empty.
password: Trading account password.
server: Trading server name.
path: Path to the MetaTrader5 terminal EXE file.
timeout: Connection timeout in milliseconds.
"""
symbols: Sequence[str]
login: int | str | None = None
password: str | None = field(default=None, repr=False)
server: str | None = None
path: str | None = None
timeout: int | None = None
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,
) -> Mt5Config:
"""Build an ``Mt5Config`` for an account, falling back to ``base_config``.
Returns:
Merged MT5 configuration for the account.
"""
login = _coerce_login(account.login)
if login is None and base_config is not None:
login = base_config.login
return build_config(
path=account.path or (base_config.path if base_config else None),
login=login,
password=account.password or (base_config.password if base_config else None),
server=account.server or (base_config.server if base_config else None),
timeout=account.timeout
if account.timeout is not None
else (base_config.timeout if base_config else None),
)
def collect_latest_rates_for_accounts(
accounts: Sequence[AccountSpec],
timeframes: Sequence[int | str],
count: int,
*,
start_pos: int = 0,
base_config: Mt5Config | None = None,
) -> dict[tuple[str, int], pd.DataFrame]:
"""Collect latest rates across multiple MT5 account groups.
Each account is connected in turn, its symbols are read for every
timeframe, and the resulting frames are merged into a single mapping.
Args:
accounts: Account groups to read. Each must define at least one symbol.
timeframes: MT5 timeframes as integers or names (for example ``M1``).
count: Number of most recent bars to read per symbol/timeframe.
start_pos: Initial bar position offset.
base_config: Optional base configuration whose fields fill any value not
set on an individual account.
Returns:
Mapping keyed by ``(symbol, timeframe_int)``. When accounts share a
symbol/timeframe pair, the last account processed wins.
Raises:
ValueError: If ``accounts``, ``timeframes``, or any account's symbols are
empty, or ``count`` is not positive.
"""
account_list = list(accounts)
if not account_list:
msg = "At least one account is required."
raise ValueError(msg)
if not timeframes:
msg = "At least one timeframe is required."
raise ValueError(msg)
if any(not account.symbols for account in account_list):
msg = "Each account requires at least one symbol."
raise ValueError(msg)
_require_positive(count, "count")
result: dict[tuple[str, int], pd.DataFrame] = {}
for account in account_list:
config = _build_account_config(account, base_config)
with Mt5CliClient(config=config) as client:
result.update(
client.collect_latest_rates(
account.symbols,
timeframes,
count=count,
start_pos=start_pos,
),
)
return result
def copy_rates_range(
symbol: str,
timeframe: int | str,
date_from: datetime | str,
date_to: datetime | str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return rates for a date range."""
return _make_client(config=config).copy_rates_range(
symbol,
timeframe,
date_from,
date_to,
)
def copy_ticks_from(
symbol: str,
date_from: datetime | str,
count: int,
flags: int | str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return ticks starting from a date."""
return _make_client(config=config).copy_ticks_from(
symbol,
date_from,
count,
flags,
)
def copy_ticks_range(
symbol: str,
date_from: datetime | str,
date_to: datetime | str,
flags: int | str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return ticks for a date range."""
return _make_client(config=config).copy_ticks_range(
symbol,
date_from,
date_to,
flags,
)
def account_info(*, config: Mt5Config | None = None) -> pd.DataFrame:
"""Return account information."""
return _make_client(config=config).account_info()
def terminal_info(*, config: Mt5Config | None = None) -> pd.DataFrame:
"""Return terminal information."""
return _make_client(config=config).terminal_info()
def symbols(
group: str | None = None,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return the symbol list."""
return _make_client(config=config).symbols(group=group)
def symbol_info(
symbol: str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return details for one symbol."""
return _make_client(config=config).symbol_info(symbol)
def orders(
symbol: str | None = None,
group: str | None = None,
ticket: int | None = None,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return active orders."""
return _make_client(config=config).orders(
symbol=symbol,
group=group,
ticket=ticket,
)
def positions(
symbol: str | None = None,
group: str | None = None,
ticket: int | None = None,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return open positions."""
return _make_client(config=config).positions(
symbol=symbol,
group=group,
ticket=ticket,
)
def history_orders(
date_from: datetime | str | None = None,
date_to: datetime | str | None = None,
group: str | None = None,
symbol: str | None = None,
ticket: int | None = None,
position: int | None = None,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return historical orders."""
return _make_client(config=config).history_orders(
date_from=date_from,
date_to=date_to,
group=group,
symbol=symbol,
ticket=ticket,
position=position,
)
def history_deals(
date_from: datetime | str | None = None,
date_to: datetime | str | None = None,
group: str | None = None,
symbol: str | None = None,
ticket: int | None = None,
position: int | None = None,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return historical deals."""
return _make_client(config=config).history_deals(
date_from=date_from,
date_to=date_to,
group=group,
symbol=symbol,
ticket=ticket,
position=position,
)
def recent_history_deals(
hours: float,
date_to: datetime | str | None = None,
group: str | None = None,
symbol: str | None = None,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return historical deals from a recent trailing window."""
return _make_client(config=config).recent_history_deals(
hours,
date_to=date_to,
group=group,
symbol=symbol,
)
def version(*, config: Mt5Config | None = None) -> pd.DataFrame:
"""Return MetaTrader5 version information."""
return _make_client(config=config).version()
def last_error(*, config: Mt5Config | None = None) -> pd.DataFrame:
"""Return the last error information."""
return _make_client(config=config).last_error()
def symbol_info_tick(
symbol: str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return the last tick for a symbol."""
return _make_client(config=config).symbol_info_tick(symbol)
def market_book(
symbol: str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return market depth for a symbol."""
return _make_client(config=config).market_book(symbol)
def recent_ticks(
symbol: str,
seconds: float,
*,
date_to: datetime | str | None = None,
count: int = 10000,
flags: int | str = "ALL",
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return ticks from a recent time window ending at ``date_to`` or now.
See ``Mt5CliClient.recent_ticks`` for parameter and return details.
"""
return _make_client(config=config).recent_ticks(
symbol,
seconds,
date_to=date_to,
count=count,
flags=flags,
)
def minimum_margins(
symbol: str,
*,
config: Mt5Config | None = None,
) -> pd.DataFrame:
"""Return minimum-volume buy and sell margin requirements.
See ``Mt5CliClient.minimum_margins`` for return details.
"""
return _make_client(config=config).minimum_margins(symbol)
def mt5_summary(*, config: Mt5Config | None = None) -> dict[str, object]:
"""Return a compact terminal/account status summary."""
return _make_client(config=config).mt5_summary()
def mt5_summary_as_df(*, config: Mt5Config | None = None) -> pd.DataFrame:
"""Return an export-safe terminal/account status summary DataFrame."""
return _make_client(config=config).mt5_summary_as_df()