Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 334f01b647 | |||
| 1b69e8f08e | |||
| 9957b0a1de |
@@ -13,6 +13,7 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
|
||||
- **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history
|
||||
- **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values
|
||||
- **Connection management**: Optional credentials, server, and timeout configuration
|
||||
- **SQLite rate loading**: Load mt5cli-managed rate tables/views for offline workflows
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -50,30 +51,33 @@ python -m mt5cli -o account.csv account-info
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `version` | Export MetaTrader 5 version information |
|
||||
| `last-error` | Export the last error information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `latest-rates` | Export latest rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `version` | Export MetaTrader 5 version information |
|
||||
| `last-error` | Export the last error information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
||||
|
||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||
|
||||
@@ -129,7 +133,11 @@ update_history_with_config(
|
||||
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
|
||||
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
|
||||
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
|
||||
- **Rate view resolution**: use `mt5cli.history.resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases.
|
||||
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
|
||||
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
|
||||
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
|
||||
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping.
|
||||
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
|
||||
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
||||
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
|
||||
|
||||
|
||||
@@ -164,3 +164,54 @@ Resolution rules:
|
||||
- Pass `require_existing=True` to raise `ValueError` instead of returning a
|
||||
best-guess name when the database or view is missing.
|
||||
- Accepts either a SQLite path or an open `sqlite3.Connection`.
|
||||
|
||||
### Rate data loading
|
||||
|
||||
Use `load_rate_data()` to load a table or view from a SQLite path, or
|
||||
`load_rate_data_from_connection()` when you already have a connection:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import load_rate_data
|
||||
from mt5cli.history import resolve_rate_view_name
|
||||
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
||||
rates = load_rate_data(Path("history.db"), view, count=1000)
|
||||
```
|
||||
|
||||
The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
|
||||
validates that `time` exists, parses timestamps with pandas, and returns a
|
||||
DataFrame indexed by ascending `DatetimeIndex` named `time`.
|
||||
|
||||
### Multi-series rate loading
|
||||
|
||||
For loading many rate series at once, build neutral `RateTarget` pairs and load
|
||||
them from SQLite in one call. View names are resolved via the same
|
||||
compatibility-view rules, or you can pass `explicit_tables` to bypass resolution:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import build_rate_targets, load_rate_series_from_sqlite
|
||||
|
||||
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
||||
series = load_rate_series_from_sqlite(Path("history.db"), targets, count=1000)
|
||||
frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
|
||||
```
|
||||
|
||||
- `build_rate_targets()` returns `RateTarget(symbol, timeframe)` pairs in
|
||||
row-major order, normalizing timeframe names such as `"M1"` to their integer
|
||||
values; set `allow_missing_symbol=True` to address series solely by
|
||||
`explicit_tables` (targets carry `symbol=None`).
|
||||
- `resolve_rate_tables()` maps targets to table or view names and validates that
|
||||
any `explicit_tables` count matches the target count. Pass
|
||||
`require_existing=True` to raise `ValueError` instead of returning a
|
||||
best-guess name when the database or managed view is missing. When
|
||||
`explicit_tables` is provided, names are returned as-is and
|
||||
`require_existing` is ignored.
|
||||
- `load_rate_series_from_sqlite()` returns a mapping keyed by
|
||||
`(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
|
||||
requires existing managed `rate_*` compatibility views and raises
|
||||
`ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
|
||||
are rejected.
|
||||
|
||||
+20
-9
@@ -13,6 +13,7 @@ mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple f
|
||||
- **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history
|
||||
- **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values
|
||||
- **Connection management**: Optional credentials, server, and timeout configuration
|
||||
- **SQLite rate loading**: Load mt5cli-managed rate tables/views for offline workflows
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -34,6 +35,7 @@ from mt5cli import (
|
||||
copy_rates_range,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
load_rate_data,
|
||||
minimum_margins,
|
||||
recent_ticks,
|
||||
)
|
||||
@@ -49,7 +51,8 @@ rates = copy_rates_range(
|
||||
export_dataframe(rates, Path("rates.csv"), "csv")
|
||||
|
||||
# Resolve SQLite rate compatibility views for downstream tools
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1")
|
||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
||||
offline_rates = load_rate_data(Path("history.db"), view, count=1000)
|
||||
|
||||
# Recent tick window and minimum margin summary
|
||||
ticks = recent_ticks("EURUSD", seconds=300)
|
||||
@@ -59,6 +62,9 @@ margins = minimum_margins("EURUSD")
|
||||
with Mt5CliClient(login=12345, password="secret", server="Broker-Demo") as client:
|
||||
account = client.account_info()
|
||||
positions = client.positions()
|
||||
latest = client.latest_rates("EURUSD", "M1", count=100)
|
||||
summary = client.mt5_summary()
|
||||
summary_table = client.mt5_summary_as_df()
|
||||
|
||||
# Bulk SQLite collection (same behavior as the collect-history CLI command)
|
||||
collect_history(
|
||||
@@ -74,6 +80,8 @@ collect_history(
|
||||
|
||||
Timeframes, tick flags, and ISO 8601 date strings are accepted wherever noted in the SDK API.
|
||||
|
||||
`Mt5CliClient.mt5_summary()` returns the SDK structured form as plain nested Python values. Use `Mt5CliClient.mt5_summary_as_df()` when you need a one-row DataFrame for export. The `mt5-summary` CLI command uses this tabular form, so nested terminal/account fields are JSON-encoded strings that are safe for CSV, JSON, Parquet, and SQLite output.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -104,6 +112,7 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
| ---------------- | ---------------------------------- |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `latest-rates` | Export latest rates |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
|
||||
### Ticks
|
||||
@@ -130,14 +139,16 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
|
||||
### Trading
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | ----------------------------------------------------------- |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
| Command | Description |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `recent-history-deals` | Export historical deals from a trailing window |
|
||||
| `mt5-summary` | Export terminal/account status summary |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
|
||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||
|
||||
|
||||
@@ -2,11 +2,28 @@
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from .history import (
|
||||
RateTarget,
|
||||
build_rate_targets,
|
||||
build_rate_view_name,
|
||||
load_rate_data,
|
||||
load_rate_data_from_connection,
|
||||
load_rate_series_from_sqlite,
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
resolve_rate_tables,
|
||||
resolve_rate_view_name,
|
||||
resolve_rate_view_names,
|
||||
)
|
||||
from .sdk import (
|
||||
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,
|
||||
@@ -15,10 +32,15 @@ from .sdk import (
|
||||
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,
|
||||
@@ -31,22 +53,35 @@ from .sdk import (
|
||||
version as mt5_version,
|
||||
)
|
||||
from .utils import (
|
||||
TICK_FLAG_MAP,
|
||||
TIMEFRAME_MAP,
|
||||
Dataset,
|
||||
IfExists,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
parse_datetime,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
__version__ = version(__package__) if __package__ else None
|
||||
|
||||
__all__ = [
|
||||
"TICK_FLAG_MAP",
|
||||
"TIMEFRAME_MAP",
|
||||
"AccountSpec",
|
||||
"Dataset",
|
||||
"IfExists",
|
||||
"Mt5CliClient",
|
||||
"RateTarget",
|
||||
"account_info",
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"collect_history",
|
||||
"collect_latest_rates",
|
||||
"collect_latest_rates_for_accounts",
|
||||
"copy_rates_from",
|
||||
"copy_rates_from_pos",
|
||||
"copy_rates_range",
|
||||
@@ -58,12 +93,29 @@ __all__ = [
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"last_error",
|
||||
"latest_rates",
|
||||
"load_rate_data",
|
||||
"load_rate_data_from_connection",
|
||||
"load_rate_series_from_sqlite",
|
||||
"market_book",
|
||||
"minimum_margins",
|
||||
"mt5_session",
|
||||
"mt5_summary",
|
||||
"mt5_summary_as_df",
|
||||
"mt5_version",
|
||||
"orders",
|
||||
"parse_datetime",
|
||||
"parse_tick_flags",
|
||||
"parse_timeframe",
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
"resolve_history_datasets",
|
||||
"resolve_history_tick_flags",
|
||||
"resolve_history_timeframes",
|
||||
"resolve_rate_tables",
|
||||
"resolve_rate_view_name",
|
||||
"resolve_rate_view_names",
|
||||
"symbol_info",
|
||||
"symbol_info_tick",
|
||||
"symbols",
|
||||
|
||||
@@ -222,6 +222,31 @@ def rates_from_pos(
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def latest_rates(
|
||||
ctx: typer.Context,
|
||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||
timeframe: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
click_type=TIMEFRAME_TYPE,
|
||||
help="Timeframe.",
|
||||
),
|
||||
],
|
||||
count: Annotated[int, typer.Option(help="Number of records.")],
|
||||
start_pos: Annotated[
|
||||
int,
|
||||
typer.Option(help="Start position (0 = current bar)."),
|
||||
] = 0,
|
||||
) -> None:
|
||||
"""Export latest rates from a start position."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda: client.latest_rates(symbol, timeframe, count, start_pos=start_pos),
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def rates_range(
|
||||
ctx: typer.Context,
|
||||
@@ -475,6 +500,37 @@ def history_deals(
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def recent_history_deals(
|
||||
ctx: typer.Context,
|
||||
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
||||
date_to: Annotated[
|
||||
datetime | None,
|
||||
typer.Option(click_type=DATETIME_TYPE, help="Window end date."),
|
||||
] = None,
|
||||
group: Annotated[str | None, typer.Option(help="Group filter.")] = None,
|
||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||
) -> None:
|
||||
"""Export historical deals from a recent trailing window."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(
|
||||
ctx,
|
||||
lambda: client.recent_history_deals(
|
||||
hours,
|
||||
date_to=date_to,
|
||||
group=group,
|
||||
symbol=symbol,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def mt5_summary(ctx: typer.Context) -> None:
|
||||
"""Export a compact terminal/account status summary."""
|
||||
client = _sdk_client(ctx)
|
||||
_execute_export(ctx, client.mt5_summary_as_df)
|
||||
|
||||
|
||||
@app.command()
|
||||
def version(ctx: typer.Context) -> None:
|
||||
"""Export MetaTrader5 version information."""
|
||||
|
||||
+369
-9
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
import pandas as pd
|
||||
|
||||
@@ -126,15 +127,26 @@ def build_rate_view_name(
|
||||
SqliteConnOrPath = sqlite3.Connection | Path | str
|
||||
|
||||
|
||||
def _require_non_empty_identifier(identifier: str, kind: str) -> str:
|
||||
value = identifier.strip()
|
||||
if not value:
|
||||
msg = f"SQLite {kind} name must not be empty."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _open_history_connection(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
conn_or_path: SqliteConnOrPath | None,
|
||||
) -> tuple[sqlite3.Connection | None, bool]:
|
||||
"""Open a read-only SQLite connection when given a path.
|
||||
|
||||
Returns:
|
||||
A connection and whether the caller should close it. When the path does
|
||||
not exist, returns ``(None, False)`` without creating a database file.
|
||||
A connection and whether the caller should close it. When ``conn_or_path``
|
||||
is None or the path does not exist, returns ``(None, False)`` without
|
||||
creating a database file.
|
||||
"""
|
||||
if conn_or_path is None:
|
||||
return None, False
|
||||
if isinstance(conn_or_path, sqlite3.Connection):
|
||||
return conn_or_path, False
|
||||
path = Path(conn_or_path)
|
||||
@@ -144,6 +156,133 @@ def _open_history_connection(
|
||||
return conn, True
|
||||
|
||||
|
||||
def _open_existing_sqlite_database(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
) -> tuple[sqlite3.Connection, bool]:
|
||||
"""Open a read-only SQLite database or reuse an existing connection.
|
||||
|
||||
Returns:
|
||||
Tuple of connection and whether the caller should close it.
|
||||
|
||||
Raises:
|
||||
ValueError: If the database path does not exist or is not a file.
|
||||
"""
|
||||
if isinstance(conn_or_path, sqlite3.Connection):
|
||||
return conn_or_path, False
|
||||
path = Path(conn_or_path)
|
||||
if not path.exists():
|
||||
msg = f"SQLite database not found: {path}"
|
||||
raise ValueError(msg)
|
||||
if not path.is_file():
|
||||
msg = f"SQLite database path is not a file: {path}"
|
||||
raise ValueError(msg)
|
||||
conn = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
|
||||
return conn, True
|
||||
|
||||
|
||||
def _validate_rate_load_request(table: str, count: int | None) -> str:
|
||||
table_name = _require_non_empty_identifier(table, "table or view")
|
||||
if count is not None and count <= 0:
|
||||
msg = "count must be positive when provided."
|
||||
raise ValueError(msg)
|
||||
return table_name
|
||||
|
||||
|
||||
def _ensure_rate_columns(columns: set[str], table: str) -> None:
|
||||
if not columns:
|
||||
msg = f"SQLite table or view not found: {table}"
|
||||
raise ValueError(msg)
|
||||
if "time" not in columns:
|
||||
msg = f"SQLite table or view {table!r} must include a time column."
|
||||
raise ValueError(msg)
|
||||
if "close" not in columns and not {"ask", "bid"}.issubset(columns):
|
||||
msg = (
|
||||
f"SQLite table or view {table!r} must include close, "
|
||||
"or both ask and bid columns."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _parse_rate_time_index(frame: pd.DataFrame, table: str) -> pd.DataFrame:
|
||||
parsed = frame["time"].map(parse_sqlite_timestamp)
|
||||
if parsed.isna().any():
|
||||
msg = f"SQLite table or view {table!r} contains unparsable time values."
|
||||
raise ValueError(msg)
|
||||
result = frame.drop(columns=["time"])
|
||||
result.index = pd.DatetimeIndex(parsed, name="time")
|
||||
return result.sort_index(kind="stable")
|
||||
|
||||
|
||||
def load_rate_data_from_connection(
|
||||
connection: sqlite3.Connection,
|
||||
table: str,
|
||||
count: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Load rate-like data from a SQLite table or view.
|
||||
|
||||
Args:
|
||||
connection: Open SQLite connection.
|
||||
table: Source table or view name.
|
||||
count: Optional number of most recent rows to load.
|
||||
|
||||
Returns:
|
||||
DataFrame indexed by ascending ``time``.
|
||||
|
||||
Raises:
|
||||
ValueError: If inputs, schema, timestamps are invalid, or the table
|
||||
or view contains no rows.
|
||||
"""
|
||||
table_name = _validate_rate_load_request(table, count)
|
||||
columns = get_table_columns(connection, table_name)
|
||||
_ensure_rate_columns(columns, table_name)
|
||||
quoted_table = quote_sqlite_identifier(table_name)
|
||||
if count is None:
|
||||
frame = cast(
|
||||
"pd.DataFrame",
|
||||
pd.read_sql_query( # type: ignore[reportUnknownMemberType]
|
||||
f"SELECT * FROM {quoted_table} ORDER BY time ASC", # noqa: S608
|
||||
connection,
|
||||
),
|
||||
)
|
||||
else:
|
||||
frame = cast(
|
||||
"pd.DataFrame",
|
||||
pd.read_sql_query( # type: ignore[reportUnknownMemberType]
|
||||
f"SELECT * FROM {quoted_table} ORDER BY time DESC LIMIT ?", # noqa: S608
|
||||
connection,
|
||||
params=(count,),
|
||||
),
|
||||
)
|
||||
if frame.empty:
|
||||
msg = f"SQLite table or view {table_name!r} contains no rows."
|
||||
raise ValueError(msg)
|
||||
return _parse_rate_time_index(frame, table_name)
|
||||
|
||||
|
||||
def load_rate_data(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
table: str,
|
||||
count: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Load rate-like data from a SQLite database path or connection.
|
||||
|
||||
Args:
|
||||
conn_or_path: SQLite database path or open connection.
|
||||
table: Source table or view name.
|
||||
count: Optional number of most recent rows to load.
|
||||
|
||||
Returns:
|
||||
DataFrame indexed by ascending ``time``.
|
||||
|
||||
"""
|
||||
conn, should_close = _open_existing_sqlite_database(conn_or_path)
|
||||
try:
|
||||
return load_rate_data_from_connection(conn, table, count=count)
|
||||
finally:
|
||||
if should_close:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_rates_timeframe_counts(conn: sqlite3.Connection) -> dict[str, int] | None:
|
||||
"""Return distinct timeframe counts per symbol from the normalized rates table."""
|
||||
columns = get_table_columns(conn, Dataset.rates.table_name)
|
||||
@@ -241,7 +380,7 @@ def _resolve_rate_view_name_from_context(
|
||||
|
||||
|
||||
def resolve_rate_view_name(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
conn_or_path: SqliteConnOrPath | None,
|
||||
symbol: str,
|
||||
granularity: str,
|
||||
*,
|
||||
@@ -250,7 +389,9 @@ def resolve_rate_view_name(
|
||||
"""Resolve the mt5cli-managed rate compatibility view name.
|
||||
|
||||
Args:
|
||||
conn_or_path: SQLite database path or open connection.
|
||||
conn_or_path: SQLite database path or open connection. When None or a
|
||||
non-existing path and ``require_existing`` is False, the deterministic
|
||||
default view name is returned without creating a database file.
|
||||
symbol: Symbol stored in the normalized ``rates`` table.
|
||||
granularity: Timeframe name (for example ``M1``) or integer string.
|
||||
require_existing: When True, require the database and a managed view to exist.
|
||||
@@ -294,7 +435,7 @@ def resolve_rate_view_name(
|
||||
|
||||
|
||||
def resolve_rate_view_names(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
conn_or_path: SqliteConnOrPath | None,
|
||||
symbols: Sequence[str],
|
||||
granularities: Sequence[str],
|
||||
*,
|
||||
@@ -303,7 +444,9 @@ def resolve_rate_view_names(
|
||||
"""Resolve rate compatibility view names for symbol and granularity pairs.
|
||||
|
||||
Args:
|
||||
conn_or_path: SQLite database path or open connection.
|
||||
conn_or_path: SQLite database path or open connection. When None or a
|
||||
non-existing path and ``require_existing`` is False, deterministic
|
||||
default view names are returned without creating a database file.
|
||||
symbols: Symbols stored in the normalized ``rates`` table.
|
||||
granularities: Timeframe names (for example ``M1``) or integer strings.
|
||||
require_existing: When True, require the database and managed views to exist.
|
||||
@@ -347,9 +490,226 @@ def resolve_rate_view_names(
|
||||
conn.close()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateTarget:
|
||||
"""A single rate series identified by symbol and timeframe.
|
||||
|
||||
Attributes:
|
||||
symbol: MT5 symbol name, or None when the rate series is addressed only
|
||||
by an explicit table (for example a custom SQLite view).
|
||||
timeframe: MT5 timeframe as an integer or name (for example ``M1``).
|
||||
"""
|
||||
|
||||
symbol: str | None
|
||||
timeframe: int | str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalize accepted timeframe aliases to the stored integer value."""
|
||||
if not isinstance(self.timeframe, int):
|
||||
object.__setattr__(self, "timeframe", parse_timeframe(self.timeframe))
|
||||
|
||||
@property
|
||||
def timeframe_int(self) -> int:
|
||||
"""Return the timeframe as its integer MT5 value."""
|
||||
return cast("int", self.timeframe)
|
||||
|
||||
|
||||
def build_rate_targets(
|
||||
symbols: Sequence[str],
|
||||
timeframes: Sequence[int | str],
|
||||
*,
|
||||
allow_missing_symbol: bool = False,
|
||||
) -> list[RateTarget]:
|
||||
"""Build rate targets for every symbol and timeframe combination.
|
||||
|
||||
Args:
|
||||
symbols: MT5 symbol names. May be empty when ``allow_missing_symbol``.
|
||||
timeframes: MT5 timeframes as integers or names (for example ``M1``).
|
||||
allow_missing_symbol: When True and ``symbols`` is empty, build targets
|
||||
with ``symbol=None`` for each timeframe instead of raising.
|
||||
|
||||
Returns:
|
||||
Targets in row-major order: every timeframe for the first symbol, then
|
||||
every timeframe for the next symbol, and so on.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``timeframes`` is empty, or ``symbols`` is empty and
|
||||
``allow_missing_symbol`` is False.
|
||||
"""
|
||||
if not timeframes:
|
||||
msg = "At least one timeframe is required."
|
||||
raise ValueError(msg)
|
||||
if not symbols:
|
||||
if not allow_missing_symbol:
|
||||
msg = "At least one symbol is required."
|
||||
raise ValueError(msg)
|
||||
return [RateTarget(symbol=None, timeframe=tf) for tf in timeframes]
|
||||
return [
|
||||
RateTarget(symbol=symbol, timeframe=tf)
|
||||
for symbol in symbols
|
||||
for tf in timeframes
|
||||
]
|
||||
|
||||
|
||||
def resolve_rate_tables(
|
||||
conn_or_path: SqliteConnOrPath | None,
|
||||
targets: Sequence[RateTarget],
|
||||
explicit_tables: Sequence[str] | None = None,
|
||||
*,
|
||||
require_existing: bool = False,
|
||||
) -> list[str]:
|
||||
"""Resolve SQLite table or view names for rate targets.
|
||||
|
||||
Args:
|
||||
conn_or_path: SQLite database path or open connection. May be None when
|
||||
``explicit_tables`` is provided, or when ``require_existing`` is
|
||||
False and deterministic default view names are sufficient.
|
||||
targets: Rate targets to resolve.
|
||||
explicit_tables: Optional explicit table or view names. When provided,
|
||||
they are used as-is and must match the number of targets.
|
||||
require_existing: When True, require the database and managed views to
|
||||
exist for each symbol target. Ignored when ``explicit_tables`` is
|
||||
provided.
|
||||
|
||||
Returns:
|
||||
Table or view names aligned with ``targets``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``targets`` is empty, ``explicit_tables`` length does not
|
||||
match the target count, a target without a symbol is resolved
|
||||
without an explicit table, or ``require_existing`` is True and the
|
||||
database or a managed view is missing.
|
||||
"""
|
||||
target_list = list(targets)
|
||||
if not target_list:
|
||||
msg = "At least one rate target is required."
|
||||
raise ValueError(msg)
|
||||
if explicit_tables is not None:
|
||||
tables = list(explicit_tables)
|
||||
if len(tables) != len(target_list):
|
||||
msg = (
|
||||
f"Expected {len(target_list)} explicit table(s) "
|
||||
f"to match the targets, got {len(tables)}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return tables
|
||||
if any(target.symbol is None for target in target_list):
|
||||
msg = (
|
||||
"Cannot resolve a rate table for a target without a symbol; "
|
||||
"provide explicit_tables."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
conn, should_close = _open_history_connection(conn_or_path)
|
||||
try:
|
||||
if conn is None:
|
||||
if require_existing:
|
||||
path = (
|
||||
conn_or_path
|
||||
if isinstance(conn_or_path, (Path, str))
|
||||
else "database"
|
||||
)
|
||||
msg = f"SQLite database not found: {path}"
|
||||
raise ValueError(msg)
|
||||
timeframe_counts = None
|
||||
existing_views: set[str] = set()
|
||||
else:
|
||||
timeframe_counts = _load_rates_timeframe_counts(conn)
|
||||
existing_views = _load_existing_rate_views(conn)
|
||||
resolved: list[str] = []
|
||||
for target in target_list:
|
||||
symbol = cast("str", target.symbol)
|
||||
timeframe = target.timeframe_int
|
||||
resolved.append(
|
||||
_resolve_rate_view_name_from_context(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
granularity_name=resolve_granularity_name(timeframe),
|
||||
timeframe_counts=timeframe_counts,
|
||||
existing_views=existing_views,
|
||||
require_existing=require_existing,
|
||||
),
|
||||
)
|
||||
return resolved
|
||||
finally:
|
||||
if should_close and conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_rate_series_from_sqlite(
|
||||
conn_or_path: SqliteConnOrPath,
|
||||
targets: Sequence[RateTarget],
|
||||
count: int,
|
||||
explicit_tables: Sequence[str] | None = None,
|
||||
) -> dict[tuple[str | None, int], pd.DataFrame]:
|
||||
"""Load multiple rate series from a SQLite database.
|
||||
|
||||
Args:
|
||||
conn_or_path: SQLite database path or open connection.
|
||||
targets: Rate targets to load. Each ``(symbol, timeframe_int)`` pair
|
||||
must be unique.
|
||||
count: Number of most recent rows to load per series.
|
||||
explicit_tables: Optional explicit table or view names matching targets.
|
||||
When omitted, managed ``rate_*`` compatibility views must already
|
||||
exist in the database.
|
||||
|
||||
Returns:
|
||||
Mapping keyed by ``(symbol, timeframe_int)`` to each rate DataFrame.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``count`` is not positive, targets are empty, duplicate
|
||||
``(symbol, timeframe_int)`` pairs are present, or table resolution
|
||||
fails.
|
||||
"""
|
||||
if count <= 0:
|
||||
msg = "count must be positive."
|
||||
raise ValueError(msg)
|
||||
target_list = list(targets)
|
||||
if not target_list:
|
||||
msg = "At least one rate target is required."
|
||||
raise ValueError(msg)
|
||||
if explicit_tables is None and any(target.symbol is None for target in target_list):
|
||||
msg = (
|
||||
"Cannot resolve a rate table for a target without a symbol; "
|
||||
"provide explicit_tables."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
seen_keys: set[tuple[str | None, int]] = set()
|
||||
for target in target_list:
|
||||
key = (target.symbol, target.timeframe_int)
|
||||
if key in seen_keys:
|
||||
symbol_repr = repr(target.symbol)
|
||||
msg = f"Duplicate rate target: ({symbol_repr}, {target.timeframe_int})"
|
||||
raise ValueError(msg)
|
||||
seen_keys.add(key)
|
||||
tables = (
|
||||
resolve_rate_tables(None, target_list, explicit_tables)
|
||||
if explicit_tables is not None
|
||||
else None
|
||||
)
|
||||
conn, should_close = _open_existing_sqlite_database(conn_or_path)
|
||||
try:
|
||||
resolved_tables = tables or resolve_rate_tables(
|
||||
conn,
|
||||
target_list,
|
||||
require_existing=True,
|
||||
)
|
||||
return {
|
||||
(target.symbol, target.timeframe_int): load_rate_data_from_connection(
|
||||
conn,
|
||||
table,
|
||||
count=count,
|
||||
)
|
||||
for target, table in zip(target_list, resolved_tables, strict=True)
|
||||
}
|
||||
finally:
|
||||
if should_close:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
"""Return existing SQLite columns for a table."""
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
quoted_table = quote_sqlite_identifier(table)
|
||||
rows = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall()
|
||||
return {str(row[1]) for row in rows}
|
||||
|
||||
|
||||
|
||||
+371
-5
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Self, TypeVar
|
||||
from typing import TYPE_CHECKING, Self, TypeVar, cast
|
||||
|
||||
import pandas as pd
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
@@ -39,10 +40,13 @@ 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",
|
||||
@@ -51,10 +55,15 @@ __all__ = [
|
||||
"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",
|
||||
@@ -78,6 +87,22 @@ def _coerce_tick_flags(flags: int | str) -> int:
|
||||
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
|
||||
@@ -90,6 +115,31 @@ def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
||||
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
|
||||
@@ -230,6 +280,26 @@ def _run_with_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."""
|
||||
|
||||
@@ -242,6 +312,7 @@ class Mt5CliClient:
|
||||
server: str | None = None,
|
||||
timeout: int | None = None,
|
||||
config: Mt5Config | None = None,
|
||||
client: Mt5DataClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize the SDK client.
|
||||
|
||||
@@ -252,6 +323,8 @@ class Mt5CliClient:
|
||||
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,
|
||||
@@ -260,7 +333,20 @@ class Mt5CliClient:
|
||||
server=server,
|
||||
timeout=timeout,
|
||||
)
|
||||
self._client: Mt5DataClient | None = None
|
||||
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:
|
||||
@@ -273,6 +359,8 @@ class Mt5CliClient:
|
||||
Returns:
|
||||
This client instance.
|
||||
"""
|
||||
if self._client is not None:
|
||||
return self
|
||||
client = Mt5DataClient(config=self._config)
|
||||
try:
|
||||
client.initialize_and_login_mt5()
|
||||
@@ -280,6 +368,7 @@ class Mt5CliClient:
|
||||
client.shutdown()
|
||||
raise
|
||||
self._client = client
|
||||
self._owns_client = True # only set when this method created the client
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
@@ -289,15 +378,18 @@ class Mt5CliClient:
|
||||
tb: object,
|
||||
) -> None:
|
||||
"""Shut down the persistent MT5 connection."""
|
||||
if self._client is not None:
|
||||
if self._client is not None and self._owns_client:
|
||||
self._client.shutdown()
|
||||
self._client = None
|
||||
|
||||
def _fetch(self, fetch_fn: Callable[[Mt5DataClient], pd.DataFrame]) -> pd.DataFrame:
|
||||
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,
|
||||
@@ -335,6 +427,54 @@ class Mt5CliClient:
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -486,6 +626,24 @@ class Mt5CliClient:
|
||||
),
|
||||
)
|
||||
|
||||
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())
|
||||
@@ -553,6 +711,39 @@ class Mt5CliClient:
|
||||
"""
|
||||
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],
|
||||
@@ -873,6 +1064,154 @@ def copy_rates_from_pos(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -1024,6 +1363,23 @@ def history_deals(
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -1084,3 +1440,13 @@ def minimum_margins(
|
||||
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()
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.4.3"
|
||||
version = "0.5.1"
|
||||
description = "Command-line tool for MetaTrader 5"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
|
||||
@@ -93,6 +93,10 @@ def mock_client(mocker: MockerFixture) -> MagicMock:
|
||||
client.market_book_get_as_df.return_value = sample_df
|
||||
client.order_check_as_df.return_value = sample_df
|
||||
client.order_send_as_df.return_value = sample_df
|
||||
client.version.return_value = (5, 0, 1)
|
||||
client.terminal_info.return_value = {"connected": True, "paths": ["terminal.exe"]}
|
||||
client.account_info.return_value = {"login": 123, "limits": {"modes": ["demo"]}}
|
||||
client.symbols_total.return_value = 42
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
return client
|
||||
|
||||
@@ -223,6 +227,37 @@ class TestCommands:
|
||||
count=50,
|
||||
)
|
||||
|
||||
def test_latest_rates(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test latest-rates command."""
|
||||
output = tmp_path / "out.csv"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"latest-rates",
|
||||
"--symbol",
|
||||
"GBPUSD",
|
||||
"--timeframe",
|
||||
"H1",
|
||||
"--count",
|
||||
"50",
|
||||
"--start-pos",
|
||||
"2",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.copy_rates_from_pos_as_df.assert_called_once_with(
|
||||
symbol="GBPUSD",
|
||||
timeframe=16385,
|
||||
start_pos=2,
|
||||
count=50,
|
||||
)
|
||||
|
||||
def test_rates_range(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@@ -451,6 +486,84 @@ class TestCommands:
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.history_deals_get_as_df.assert_called_once()
|
||||
|
||||
def test_recent_history_deals(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test recent-history-deals command."""
|
||||
output = tmp_path / "out.csv"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"-o",
|
||||
str(output),
|
||||
"recent-history-deals",
|
||||
"--hours",
|
||||
"6",
|
||||
"--date-to",
|
||||
"2024-01-02",
|
||||
"--symbol",
|
||||
"EURUSD",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.history_deals_get_as_df.assert_called_once_with(
|
||||
date_from=datetime(2024, 1, 1, 18, tzinfo=UTC),
|
||||
date_to=datetime(2024, 1, 2, tzinfo=UTC),
|
||||
group=None,
|
||||
symbol="EURUSD",
|
||||
ticket=None,
|
||||
position=None,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "reader"),
|
||||
[
|
||||
("summary.csv", "csv"),
|
||||
("summary.json", "json"),
|
||||
("summary.db", "sqlite3"),
|
||||
("summary.parquet", "parquet"),
|
||||
],
|
||||
)
|
||||
def test_mt5_summary_export_formats(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mock_client: MagicMock,
|
||||
filename: str,
|
||||
reader: str,
|
||||
) -> None:
|
||||
"""Test mt5-summary writes export-safe files for supported formats."""
|
||||
output = tmp_path / filename
|
||||
result = runner.invoke(app, ["-o", str(output), "mt5-summary"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
mock_client.version.assert_called_once()
|
||||
mock_client.terminal_info.assert_called_once()
|
||||
mock_client.account_info.assert_called_once()
|
||||
mock_client.symbols_total.assert_called_once()
|
||||
if reader == "csv":
|
||||
frame = pd.read_csv(output)
|
||||
elif reader == "json":
|
||||
with output.open() as f:
|
||||
records = json.load(f)
|
||||
frame = pd.DataFrame(records)
|
||||
elif reader == "sqlite3":
|
||||
with sqlite3.connect(output) as conn:
|
||||
frame = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT * FROM data",
|
||||
conn,
|
||||
)
|
||||
else:
|
||||
frame = pd.read_parquet(output)
|
||||
assert len(frame) == 1
|
||||
assert frame.iloc[0].to_dict() == {
|
||||
"version": "[5,0,1]",
|
||||
"terminal_info": '{"connected":true,"paths":["terminal.exe"]}',
|
||||
"account_info": '{"limits":{"modes":["demo"]},"login":123}',
|
||||
"symbols_total": 42,
|
||||
}
|
||||
|
||||
def test_version(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -10,14 +10,18 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture # noqa: TC002
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import history
|
||||
from mt5cli.history import (
|
||||
DEFAULT_HISTORY_TIMEFRAMES,
|
||||
RateTarget,
|
||||
append_dataframe,
|
||||
augment_written_columns_from_sqlite,
|
||||
build_rate_targets,
|
||||
build_rate_view_name,
|
||||
create_cash_events_view,
|
||||
create_history_indexes,
|
||||
@@ -31,6 +35,9 @@ from mt5cli.history import (
|
||||
get_incremental_start_datetime,
|
||||
get_table_columns,
|
||||
load_incremental_start_datetimes,
|
||||
load_rate_data,
|
||||
load_rate_data_from_connection,
|
||||
load_rate_series_from_sqlite,
|
||||
parse_sqlite_timestamp,
|
||||
quote_sqlite_identifier,
|
||||
record_written_columns,
|
||||
@@ -38,6 +45,7 @@ from mt5cli.history import (
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
resolve_rate_tables,
|
||||
resolve_rate_view_name,
|
||||
resolve_rate_view_names,
|
||||
write_collected_datasets,
|
||||
@@ -58,6 +66,21 @@ class TestResolveRateViewName:
|
||||
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
assert not db_path.exists()
|
||||
|
||||
def test_none_path_returns_default_name(self) -> None:
|
||||
"""Test a None connection or path returns the deterministic default."""
|
||||
assert resolve_rate_view_name(None, "EURUSD", "M1") == "rate_EURUSD__1"
|
||||
assert resolve_rate_view_names(None, ["EURUSD"], ["M1", "H1"]) == [
|
||||
"rate_EURUSD__1",
|
||||
"rate_EURUSD__16385",
|
||||
]
|
||||
|
||||
def test_none_path_with_require_existing_raises(self) -> None:
|
||||
"""Test a None path under strict mode raises a clear error."""
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
resolve_rate_view_name(None, "EURUSD", "M1", require_existing=True)
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
resolve_rate_view_names(None, ["EURUSD"], ["M1"], require_existing=True)
|
||||
|
||||
def test_no_rates_table_falls_back_to_single_timeframe_name(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@@ -338,6 +361,147 @@ class TestQuoteSqliteIdentifier:
|
||||
assert quoted.endswith('"')
|
||||
|
||||
|
||||
class TestLoadRateData:
|
||||
"""Tests for SQLite rate-like table and view loading."""
|
||||
|
||||
def test_loads_close_rates_from_path_with_count(self, tmp_path: Path) -> None:
|
||||
"""Test loading the latest close-based rates in ascending time order."""
|
||||
db_path = tmp_path / "rates.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:02:00+00:00", 1.2),
|
||||
("2024-01-01T00:01:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
frame = load_rate_data(db_path, "rates", count=2)
|
||||
assert list(frame["close"]) == [1.1, 1.2]
|
||||
assert isinstance(frame.index, pd.DatetimeIndex)
|
||||
assert frame.index.name == "time"
|
||||
assert frame.index.is_monotonic_increasing
|
||||
|
||||
def test_loads_ask_bid_tick_like_rates_from_connection(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test loading tick-like tables with bid and ask columns."""
|
||||
db_path = tmp_path / "ticks.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE ticks(time TEXT, bid REAL, ask REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO ticks(time, bid, ask) VALUES (?, ?, ?)",
|
||||
("2024-01-01T00:00:00+00:00", 1.0, 1.1),
|
||||
)
|
||||
frame = load_rate_data_from_connection(conn, "ticks")
|
||||
path_frame = load_rate_data(conn, "ticks")
|
||||
assert frame.iloc[0].to_dict() == {"bid": 1.0, "ask": 1.1}
|
||||
assert path_frame.iloc[0].to_dict() == {"bid": 1.0, "ask": 1.1}
|
||||
|
||||
def test_loads_from_view(self, tmp_path: Path) -> None:
|
||||
"""Test loading from a SQLite view."""
|
||||
db_path = tmp_path / "view.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE rates(time TEXT, close REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO rates(time, close) VALUES (?, ?)",
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
conn.execute("CREATE VIEW rate_view AS SELECT time, close FROM rates")
|
||||
frame = load_rate_data_from_connection(conn, "rate_view")
|
||||
assert list(frame["close"]) == [1.0]
|
||||
|
||||
def test_loads_quoted_identifier(self, tmp_path: Path) -> None:
|
||||
"""Test table names are quoted safely."""
|
||||
db_path = tmp_path / "quoted.db"
|
||||
table = 'rate "quoted"'
|
||||
quoted = quote_sqlite_identifier(table)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(f"CREATE TABLE {quoted}(time TEXT, close REAL)")
|
||||
conn.execute(
|
||||
f"INSERT INTO {quoted}(time, close) VALUES (?, ?)", # noqa: S608
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
frame = load_rate_data_from_connection(conn, table)
|
||||
assert list(frame["close"]) == [1.0]
|
||||
|
||||
def test_rejects_missing_database_and_non_file(self, tmp_path: Path) -> None:
|
||||
"""Test path validation for SQLite database inputs."""
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
load_rate_data(tmp_path / "missing.db", "rates")
|
||||
with pytest.raises(ValueError, match="not a file"):
|
||||
load_rate_data(tmp_path, "rates")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("table", "count", "match"),
|
||||
[
|
||||
("", None, "must not be empty"),
|
||||
("rates", 0, "count must be positive"),
|
||||
("rates", -1, "count must be positive"),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_inputs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
table: str,
|
||||
count: int | None,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test request validation."""
|
||||
db_path = tmp_path / "invalid-inputs.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE rates(time TEXT, close REAL)")
|
||||
with pytest.raises(ValueError, match=match):
|
||||
load_rate_data_from_connection(conn, table, count=count)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ddl", "match"),
|
||||
[
|
||||
("CREATE TABLE rates(time TEXT, close REAL)", "contains no rows"),
|
||||
("CREATE TABLE rates(close REAL)", "time column"),
|
||||
("CREATE TABLE rates(time TEXT, open REAL)", "close, or both ask and bid"),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_tables(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
ddl: str,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test missing table, empty table, and invalid schemas."""
|
||||
db_path = tmp_path / "invalid-tables.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(ddl)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
load_rate_data_from_connection(conn, "rates")
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
load_rate_data_from_connection(conn, "missing")
|
||||
|
||||
def test_rejects_invalid_timestamp(self, tmp_path: Path) -> None:
|
||||
"""Test unparsable timestamps fail clearly."""
|
||||
db_path = tmp_path / "invalid-time.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE rates(time TEXT, close REAL)")
|
||||
conn.execute("INSERT INTO rates(time, close) VALUES (?, ?)", ("bad", 1.0))
|
||||
with pytest.raises(ValueError, match="unparsable time"):
|
||||
load_rate_data_from_connection(conn, "rates")
|
||||
|
||||
def test_loads_numeric_mt5_epoch_seconds(self, tmp_path: Path) -> None:
|
||||
"""Test MT5-native integer timestamps are parsed as epoch seconds."""
|
||||
db_path = tmp_path / "epoch-rates.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE rates(time INTEGER, close REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO rates(time, close) VALUES (?, ?)",
|
||||
(1_704_067_200, 1.0),
|
||||
)
|
||||
frame = load_rate_data_from_connection(conn, "rates")
|
||||
assert frame.index[0] == pd.Timestamp("2024-01-01", tz="UTC")
|
||||
assert list(frame["close"]) == [1.0]
|
||||
|
||||
|
||||
class TestResolveHistorySettings:
|
||||
"""Tests for history dataset and timeframe resolution."""
|
||||
|
||||
@@ -1772,3 +1936,305 @@ class TestWriteHelpers:
|
||||
)
|
||||
assert get_table_columns(conn, "rates") == {"time", "open"}
|
||||
create_history_indexes(conn, written_columns)
|
||||
|
||||
|
||||
class TestRateSourceHelpers:
|
||||
"""Tests for generic rate-source SDK helpers."""
|
||||
|
||||
def test_rate_target_timeframe_int(self) -> None:
|
||||
"""Test RateTarget resolves named and integer timeframes."""
|
||||
target = RateTarget(symbol="EURUSD", timeframe="M1")
|
||||
assert target.timeframe == 1
|
||||
assert target.timeframe_int == 1
|
||||
assert RateTarget(symbol="EURUSD", timeframe=16385).timeframe_int == 16385
|
||||
|
||||
def test_build_rate_targets_row_major(self) -> None:
|
||||
"""Test targets are built in row-major symbol/timeframe order."""
|
||||
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
||||
assert [(t.symbol, t.timeframe) for t in targets] == [
|
||||
("EURUSD", 1),
|
||||
("EURUSD", 16385),
|
||||
("GBPUSD", 1),
|
||||
("GBPUSD", 16385),
|
||||
]
|
||||
|
||||
def test_build_rate_targets_allows_missing_symbol(self) -> None:
|
||||
"""Test missing symbols produce None-symbol targets when allowed."""
|
||||
targets = build_rate_targets([], ["M1", "H1"], allow_missing_symbol=True)
|
||||
assert [(t.symbol, t.timeframe) for t in targets] == [
|
||||
(None, 1),
|
||||
(None, 16385),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("symbols", "timeframes", "match"),
|
||||
[
|
||||
(["EURUSD"], [], "At least one timeframe"),
|
||||
([], ["M1"], "At least one symbol"),
|
||||
],
|
||||
)
|
||||
def test_build_rate_targets_rejects_empty(
|
||||
self,
|
||||
symbols: list[str],
|
||||
timeframes: list[str],
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test target building input validation."""
|
||||
with pytest.raises(ValueError, match=match):
|
||||
build_rate_targets(symbols, timeframes)
|
||||
|
||||
def test_resolve_rate_tables_uses_explicit_tables(self) -> None:
|
||||
"""Test explicit tables bypass view resolution when counts match."""
|
||||
targets = build_rate_targets([], ["M1", "H1"], allow_missing_symbol=True)
|
||||
assert resolve_rate_tables(None, targets, ["t1", "t2"]) == ["t1", "t2"]
|
||||
|
||||
def test_resolve_rate_tables_rejects_mismatched_explicit_count(self) -> None:
|
||||
"""Test explicit table count must match the number of targets."""
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
with pytest.raises(ValueError, match="Expected 1 explicit table"):
|
||||
resolve_rate_tables(None, targets, ["t1", "t2"])
|
||||
|
||||
def test_resolve_rate_tables_rejects_empty_targets(self) -> None:
|
||||
"""Test resolving requires at least one target."""
|
||||
with pytest.raises(ValueError, match="At least one rate target"):
|
||||
resolve_rate_tables(None, [])
|
||||
|
||||
def test_resolve_rate_tables_requires_symbol_without_explicit(self) -> None:
|
||||
"""Test None-symbol targets require explicit tables."""
|
||||
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
|
||||
with pytest.raises(ValueError, match="without a symbol"):
|
||||
resolve_rate_tables(None, targets)
|
||||
|
||||
def test_resolve_rate_tables_resolves_view_names(self) -> None:
|
||||
"""Test symbol targets resolve to default view names without a database."""
|
||||
targets = build_rate_targets(["EURUSD"], ["M1", "H1"])
|
||||
assert resolve_rate_tables(None, targets) == [
|
||||
"rate_EURUSD__1",
|
||||
"rate_EURUSD__16385",
|
||||
]
|
||||
|
||||
def test_resolve_rate_tables_none_path_with_require_existing_raises(self) -> None:
|
||||
"""Test strict mode rejects a missing database path."""
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
resolve_rate_tables(None, targets, require_existing=True)
|
||||
|
||||
def test_resolve_rate_tables_missing_db_with_require_existing_raises(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test strict mode rejects a non-existing database path."""
|
||||
db_path = tmp_path / "missing.db"
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
with pytest.raises(ValueError, match="SQLite database not found"):
|
||||
resolve_rate_tables(db_path, targets, require_existing=True)
|
||||
|
||||
def test_resolve_rate_tables_missing_view_with_require_existing_raises(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test strict mode rejects databases without managed rate views."""
|
||||
db_path = tmp_path / "no-views.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
||||
resolve_rate_tables(db_path, targets, require_existing=True)
|
||||
|
||||
def test_resolve_rate_tables_with_require_existing_resolves_views(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test strict mode resolves existing managed rate views."""
|
||||
db_path = tmp_path / "strict-views.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
assert resolve_rate_tables(db_path, targets, require_existing=True) == [
|
||||
"rate_EURUSD__1",
|
||||
]
|
||||
|
||||
def test_resolve_rate_tables_batches_sqlite_metadata(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test resolving multiple targets loads SQLite metadata once."""
|
||||
db_path = tmp_path / "batch-rate-tables.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
("EURUSD", 16385, "2024-01-01T01:00:00+00:00", 1.1),
|
||||
("GBPUSD", 1, "2024-01-01T00:00:00+00:00", 1.2),
|
||||
],
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
counts_spy = mocker.spy(history, "_load_rates_timeframe_counts")
|
||||
views_spy = mocker.spy(history, "_load_existing_rate_views")
|
||||
|
||||
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
|
||||
assert resolve_rate_tables(db_path, targets) == [
|
||||
"rate_EURUSD__M1_1",
|
||||
"rate_EURUSD__H1_16385",
|
||||
"rate_GBPUSD__1",
|
||||
"rate_GBPUSD__16385",
|
||||
]
|
||||
assert counts_spy.call_count == 1
|
||||
assert views_spy.call_count == 1
|
||||
|
||||
def test_load_rate_series_from_sqlite(self, tmp_path: Path) -> None:
|
||||
"""Test loading multiple rate series keyed by symbol and timeframe."""
|
||||
db_path = tmp_path / "series.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
("EURUSD", 1, "2024-01-01T00:01:00+00:00", 1.1),
|
||||
],
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
result = load_rate_series_from_sqlite(db_path, targets, count=2)
|
||||
assert set(result) == {("EURUSD", 1)}
|
||||
assert len(result["EURUSD", 1]) == 2
|
||||
|
||||
def test_load_rate_series_reuses_path_connection(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test loading from a path opens SQLite once for resolve and reads."""
|
||||
db_path = tmp_path / "single-open-series.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
create_rate_compatibility_views(conn)
|
||||
connect_spy = mocker.spy(history.sqlite3, "connect")
|
||||
|
||||
result = load_rate_series_from_sqlite(
|
||||
db_path,
|
||||
build_rate_targets(["EURUSD"], ["M1"]),
|
||||
count=1,
|
||||
)
|
||||
|
||||
assert set(result) == {("EURUSD", 1)}
|
||||
assert connect_spy.call_count == 1
|
||||
|
||||
def test_load_rate_series_with_explicit_tables(self, tmp_path: Path) -> None:
|
||||
"""Test explicit tables and None-symbol targets load series."""
|
||||
db_path = tmp_path / "explicit.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO custom_view(time, close) VALUES (?, ?)",
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
|
||||
result = load_rate_series_from_sqlite(
|
||||
db_path,
|
||||
targets,
|
||||
count=1,
|
||||
explicit_tables=["custom_view"],
|
||||
)
|
||||
assert set(result) == {(None, 1)}
|
||||
|
||||
def test_load_rate_series_rejects_non_positive_count(self) -> None:
|
||||
"""Test loading requires a positive count."""
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
with pytest.raises(ValueError, match="count must be positive"):
|
||||
load_rate_series_from_sqlite("unused.db", targets, count=0)
|
||||
|
||||
def test_load_rate_series_rejects_empty_targets(self) -> None:
|
||||
"""Test loading requires at least one target before opening SQLite."""
|
||||
with pytest.raises(ValueError, match="At least one rate target"):
|
||||
load_rate_series_from_sqlite("unused.db", [], count=1)
|
||||
|
||||
def test_load_rate_series_requires_symbol_without_explicit_tables(self) -> None:
|
||||
"""Test None-symbol targets require explicit tables before opening SQLite."""
|
||||
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
|
||||
with pytest.raises(ValueError, match="without a symbol"):
|
||||
load_rate_series_from_sqlite("unused.db", targets, count=1)
|
||||
|
||||
def test_load_rate_series_requires_existing_managed_views(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test loading without explicit tables requires managed rate views."""
|
||||
db_path = tmp_path / "no-managed-views.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE rates("
|
||||
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
|
||||
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
targets = build_rate_targets(["EURUSD"], ["M1"])
|
||||
with pytest.raises(ValueError, match="No rate compatibility view exists"):
|
||||
load_rate_series_from_sqlite(db_path, targets, count=1)
|
||||
|
||||
def test_load_rate_series_rejects_duplicate_targets(self) -> None:
|
||||
"""Test duplicate (symbol, timeframe) targets are rejected."""
|
||||
targets = [
|
||||
RateTarget("EURUSD", 1),
|
||||
RateTarget("EURUSD", "M1"),
|
||||
]
|
||||
with pytest.raises(ValueError, match=r"Duplicate rate target: \('EURUSD', 1\)"):
|
||||
load_rate_series_from_sqlite("unused.db", targets, count=1)
|
||||
|
||||
def test_load_rate_series_rejects_duplicate_targets_with_explicit_tables(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test duplicate targets are rejected even with explicit tables."""
|
||||
db_path = tmp_path / "duplicate-explicit.db"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
|
||||
conn.execute(
|
||||
"INSERT INTO custom_view(time, close) VALUES (?, ?)",
|
||||
("2024-01-01T00:00:00+00:00", 1.0),
|
||||
)
|
||||
targets = [
|
||||
RateTarget("EURUSD", 1),
|
||||
RateTarget("EURUSD", 1),
|
||||
]
|
||||
with pytest.raises(ValueError, match=r"Duplicate rate target: \('EURUSD', 1\)"):
|
||||
load_rate_series_from_sqlite(
|
||||
db_path,
|
||||
targets,
|
||||
count=1,
|
||||
explicit_tables=["custom_view", "custom_view"],
|
||||
)
|
||||
|
||||
+447
-2
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -15,13 +15,18 @@ from pytest_mock import MockerFixture # noqa: TC002
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
|
||||
from mt5cli import sdk
|
||||
from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES
|
||||
from mt5cli.sdk import (
|
||||
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,
|
||||
@@ -30,10 +35,15 @@ from mt5cli.sdk import (
|
||||
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,
|
||||
@@ -45,6 +55,32 @@ from mt5cli.sdk import (
|
||||
)
|
||||
from mt5cli.utils import Dataset
|
||||
|
||||
|
||||
class _TerminalInfo(NamedTuple):
|
||||
connected: bool
|
||||
path: str
|
||||
|
||||
|
||||
class _AccountInfo(NamedTuple):
|
||||
login: int
|
||||
limits: dict[str, object]
|
||||
|
||||
|
||||
class _MissingSummaryMethodClient:
|
||||
def version(self) -> tuple[int, int, int]:
|
||||
return (5, 0, 1)
|
||||
|
||||
def terminal_info(self) -> dict[str, bool]:
|
||||
return {"connected": True}
|
||||
|
||||
def symbols_total(self) -> int:
|
||||
return 42
|
||||
|
||||
|
||||
class _NonCallableSummaryMethodClient:
|
||||
version = (5, 0, 1)
|
||||
|
||||
|
||||
_DEALS_FIXTURE: dict[str, list[object]] = {
|
||||
"ticket": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
|
||||
"position_id": [100, 100, 100, 0, 200, 200, 300, 400, 400, 500, 500, 600, 600, 600],
|
||||
@@ -232,6 +268,32 @@ class TestConnectionLifecycle:
|
||||
client = Mt5CliClient()
|
||||
client.__exit__(None, None, None)
|
||||
|
||||
def test_injected_client_is_reused_and_not_shutdown(self) -> None:
|
||||
"""Test injected connected clients are not initialized or shut down."""
|
||||
connected = MagicMock()
|
||||
connected.account_info_as_df.return_value = pd.DataFrame({"a": [1]})
|
||||
connected.terminal_info_as_df.return_value = pd.DataFrame({"b": [2]})
|
||||
with Mt5CliClient.from_connected_client(connected) as client:
|
||||
result = client.account_info()
|
||||
assert result.to_dict("list") == {"a": [1]}
|
||||
connected.initialize_and_login_mt5.assert_not_called()
|
||||
connected.shutdown.assert_not_called()
|
||||
connected.account_info_as_df.assert_called_once()
|
||||
after_exit = client.terminal_info()
|
||||
assert after_exit.to_dict("list") == {"b": [2]}
|
||||
connected.terminal_info_as_df.assert_called_once()
|
||||
|
||||
def test_constructor_injected_client_is_reused_and_not_shutdown(self) -> None:
|
||||
"""Test constructor injection has the same non-owning lifecycle."""
|
||||
connected = MagicMock()
|
||||
connected.terminal_info_as_df.return_value = pd.DataFrame({"b": [2]})
|
||||
client = Mt5CliClient(client=connected)
|
||||
with client:
|
||||
result = client.terminal_info()
|
||||
assert result.to_dict("list") == {"b": [2]}
|
||||
connected.initialize_and_login_mt5.assert_not_called()
|
||||
connected.shutdown.assert_not_called()
|
||||
|
||||
|
||||
class TestModuleFunctions:
|
||||
"""Tests for module-level SDK wrappers."""
|
||||
@@ -271,6 +333,7 @@ class TestModuleFunctions:
|
||||
(last_error, (), "last_error_as_df"),
|
||||
(symbol_info_tick, ("EURUSD",), "symbol_info_tick_as_df"),
|
||||
(market_book, ("EURUSD",), "market_book_get_as_df"),
|
||||
(latest_rates, ("EURUSD", "M1", 10), "copy_rates_from_pos_as_df"),
|
||||
],
|
||||
)
|
||||
def test_module_functions_delegate(
|
||||
@@ -354,6 +417,214 @@ class TestMt5CliClient:
|
||||
assert isinstance(df, pd.DataFrame)
|
||||
mock_client.copy_rates_range_as_df.assert_called_once()
|
||||
|
||||
def test_latest_rates_delegates_to_copy_rates_from_pos(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test latest_rates is a convenience wrapper for positional rates."""
|
||||
Mt5CliClient().latest_rates("EURUSD", "M1", 5, start_pos=2)
|
||||
mock_client.copy_rates_from_pos_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
timeframe=1,
|
||||
start_pos=2,
|
||||
count=5,
|
||||
)
|
||||
|
||||
def test_latest_rates_rejects_non_positive_count(self) -> None:
|
||||
"""Test latest_rates validates count."""
|
||||
with pytest.raises(ValueError, match="count must be positive"):
|
||||
Mt5CliClient().latest_rates("EURUSD", "M1", 0)
|
||||
|
||||
def test_collect_latest_rates_returns_mapping(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test multi-target latest rate collection."""
|
||||
result = collect_latest_rates(["EURUSD", "GBPUSD"], ["M1", "H1"], count=3)
|
||||
assert set(result) == {
|
||||
("EURUSD", 1),
|
||||
("EURUSD", 16385),
|
||||
("GBPUSD", 1),
|
||||
("GBPUSD", 16385),
|
||||
}
|
||||
assert mock_client.copy_rates_from_pos_as_df.call_count == 4
|
||||
|
||||
def test_collect_latest_rates_uses_single_transient_connection(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test module helper opens one connection for all target pairs."""
|
||||
mt5_data_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
collect_latest_rates(["EURUSD", "GBPUSD"], ["M1", "H1"], count=3)
|
||||
|
||||
mt5_data_client.assert_called_once()
|
||||
mock_client.initialize_and_login_mt5.assert_called_once()
|
||||
mock_client.shutdown.assert_called_once()
|
||||
assert mock_client.copy_rates_from_pos_as_df.call_count == 4
|
||||
mock_client.copy_rates_from_pos_as_df.assert_has_calls(
|
||||
[
|
||||
call(symbol="EURUSD", timeframe=1, start_pos=0, count=3),
|
||||
call(symbol="EURUSD", timeframe=16385, start_pos=0, count=3),
|
||||
call(symbol="GBPUSD", timeframe=1, start_pos=0, count=3),
|
||||
call(symbol="GBPUSD", timeframe=16385, start_pos=0, count=3),
|
||||
],
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("symbols", "timeframes", "match"),
|
||||
[
|
||||
([], ["M1"], "At least one symbol"),
|
||||
(["EURUSD"], [], "At least one timeframe"),
|
||||
],
|
||||
)
|
||||
def test_collect_latest_rates_rejects_empty_inputs(
|
||||
self,
|
||||
symbols: list[str],
|
||||
timeframes: list[str],
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test multi-target latest rate input validation."""
|
||||
with pytest.raises(ValueError, match=match):
|
||||
Mt5CliClient().collect_latest_rates(symbols, timeframes, count=1)
|
||||
|
||||
def test_recent_history_deals_uses_trailing_window(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test recent_history_deals calculates date_from from hours."""
|
||||
result = recent_history_deals(
|
||||
6,
|
||||
date_to="2024-01-02T00:00:00+00:00",
|
||||
group="*",
|
||||
symbol="EURUSD",
|
||||
)
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
mock_client.history_deals_get_as_df.assert_called_once_with(
|
||||
date_from=datetime(2024, 1, 1, 18, tzinfo=UTC),
|
||||
date_to=datetime(2024, 1, 2, tzinfo=UTC),
|
||||
group="*",
|
||||
symbol="EURUSD",
|
||||
ticket=None,
|
||||
position=None,
|
||||
)
|
||||
|
||||
def test_recent_history_deals_defaults_date_to_now(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test recent_history_deals uses current UTC time when date_to is omitted."""
|
||||
before = datetime.now(UTC)
|
||||
recent_history_deals(1.0)
|
||||
after = datetime.now(UTC)
|
||||
call_kwargs = mock_client.history_deals_get_as_df.call_args.kwargs
|
||||
assert before <= call_kwargs["date_to"] <= after
|
||||
assert call_kwargs["date_from"] == call_kwargs["date_to"] - timedelta(hours=1)
|
||||
|
||||
def test_recent_history_deals_rejects_non_positive_hours(self) -> None:
|
||||
"""Test recent_history_deals validates hours."""
|
||||
with pytest.raises(ValueError, match="hours must be positive"):
|
||||
Mt5CliClient().recent_history_deals(0)
|
||||
|
||||
def test_mt5_summary_returns_status_mapping(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test mt5_summary calls raw terminal/account status methods."""
|
||||
mock_client.version.return_value = (5, 0, 1)
|
||||
mock_client.terminal_info.return_value = {"connected": True}
|
||||
mock_client.account_info.return_value = {"login": 123}
|
||||
mock_client.symbols_total.return_value = 42
|
||||
assert mt5_summary() == {
|
||||
"version": [5, 0, 1],
|
||||
"terminal_info": {"connected": True},
|
||||
"account_info": {"login": 123},
|
||||
"symbols_total": 42,
|
||||
}
|
||||
|
||||
def test_mt5_summary_normalizes_namedtuple_values(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test mt5_summary returns structured plain Python values."""
|
||||
mock_client.version.return_value = (5, 0, 1)
|
||||
mock_client.terminal_info.return_value = _TerminalInfo(
|
||||
connected=True,
|
||||
path="terminal.exe",
|
||||
)
|
||||
mock_client.account_info.return_value = _AccountInfo(
|
||||
login=123,
|
||||
limits={"modes": ("netting", "hedging"), "servers": ["demo"]},
|
||||
)
|
||||
mock_client.symbols_total.return_value = 42
|
||||
|
||||
assert mt5_summary() == {
|
||||
"version": [5, 0, 1],
|
||||
"terminal_info": {"connected": True, "path": "terminal.exe"},
|
||||
"account_info": {
|
||||
"login": 123,
|
||||
"limits": {"modes": ["netting", "hedging"], "servers": ["demo"]},
|
||||
},
|
||||
"symbols_total": 42,
|
||||
}
|
||||
|
||||
def test_mt5_summary_as_df_stringifies_nested_values(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test mt5_summary_as_df returns export-safe tabular values."""
|
||||
mock_client.version.return_value = (5, 0, 1)
|
||||
mock_client.terminal_info.return_value = _TerminalInfo(
|
||||
connected=True,
|
||||
path="terminal.exe",
|
||||
)
|
||||
mock_client.account_info.return_value = _AccountInfo(
|
||||
login=123,
|
||||
limits={"modes": ("netting", "hedging"), "servers": ["demo"]},
|
||||
)
|
||||
mock_client.symbols_total.return_value = 42
|
||||
|
||||
result = mt5_summary_as_df()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result.iloc[0].to_dict() == {
|
||||
"version": "[5,0,1]",
|
||||
"terminal_info": '{"connected":true,"path":"terminal.exe"}',
|
||||
"account_info": (
|
||||
'{"limits":{"modes":["netting","hedging"],'
|
||||
'"servers":["demo"]},"login":123}'
|
||||
),
|
||||
"symbols_total": 42,
|
||||
}
|
||||
|
||||
def test_mt5_summary_missing_method_raises_clear_error(self) -> None:
|
||||
"""Test mt5_summary fails clearly when a required method is missing."""
|
||||
client = Mt5CliClient(
|
||||
client=cast("Mt5DataClient", _MissingSummaryMethodClient()),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
AttributeError,
|
||||
match="MT5 client is missing required method: account_info",
|
||||
):
|
||||
client.mt5_summary()
|
||||
|
||||
def test_mt5_summary_non_callable_method_raises_clear_error(self) -> None:
|
||||
"""Test mt5_summary fails clearly when a required method is not callable."""
|
||||
client = Mt5CliClient(
|
||||
client=cast("Mt5DataClient", _NonCallableSummaryMethodClient()),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="MT5 client attribute is not callable: version",
|
||||
):
|
||||
client.mt5_summary()
|
||||
|
||||
|
||||
class TestCollectHistory:
|
||||
"""Tests for collect_history SDK function."""
|
||||
@@ -980,3 +1251,177 @@ class TestMinimumMargins:
|
||||
)
|
||||
client.order_calc_margin.assert_any_call(0, "EURUSD", 0.01, 1.1010)
|
||||
client.order_calc_margin.assert_any_call(1, "EURUSD", 0.01, 1.1000)
|
||||
|
||||
|
||||
class TestMt5Session:
|
||||
"""Tests for the mt5_session context manager."""
|
||||
|
||||
def test_yields_connected_client_and_shuts_down(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test mt5_session connects, yields a client wrapper, and shuts down."""
|
||||
mock_client = MagicMock()
|
||||
mt5_data_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
with mt5_session(build_config(path="/opt/mt5/terminal64.exe")) as client:
|
||||
mock_client.initialize_and_login_mt5.assert_called_once()
|
||||
assert isinstance(client, Mt5CliClient)
|
||||
|
||||
config = mt5_data_client.call_args.kwargs["config"]
|
||||
assert config.path == "/opt/mt5/terminal64.exe"
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_default_config_attaches_to_running_terminal(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test mt5_session builds a default config when none is supplied."""
|
||||
mock_client = MagicMock()
|
||||
mt5_data_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
with mt5_session():
|
||||
pass
|
||||
|
||||
mt5_data_client.assert_called_once()
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
|
||||
class TestAccountSpec:
|
||||
"""Tests for account configuration helpers."""
|
||||
|
||||
def test_repr_omits_password(self) -> None:
|
||||
"""Test AccountSpec repr does not expose plaintext passwords."""
|
||||
spec = AccountSpec(symbols=["EURUSD"], login=123, password="secret")
|
||||
|
||||
assert "secret" not in repr(spec)
|
||||
assert "password" not in repr(spec)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("login", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
(123, 123),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("456", 456),
|
||||
],
|
||||
)
|
||||
def test_coerce_login(
|
||||
self,
|
||||
login: int | str | None,
|
||||
expected: int | None,
|
||||
) -> None:
|
||||
"""Test login values are normalized for account configs."""
|
||||
assert sdk._coerce_login(login) == expected # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_coerce_login_rejects_non_numeric_string(self) -> None:
|
||||
"""Test non-numeric login strings raise ValueError."""
|
||||
with pytest.raises(ValueError, match="invalid literal"):
|
||||
sdk._coerce_login("abc") # type: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
class TestCollectLatestRatesForAccounts:
|
||||
"""Tests for collect_latest_rates_for_accounts."""
|
||||
|
||||
def test_merges_results_across_accounts(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test rates are collected and merged for each account group."""
|
||||
mt5_data_client = mocker.patch(
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
accounts = [
|
||||
AccountSpec(symbols=["EURUSD"], login="123"),
|
||||
AccountSpec(symbols=["GBPUSD"], login=456),
|
||||
]
|
||||
|
||||
result = collect_latest_rates_for_accounts(accounts, ["M1"], count=2)
|
||||
|
||||
assert set(result) == {("EURUSD", 1), ("GBPUSD", 1)}
|
||||
assert mt5_data_client.call_count == 2
|
||||
assert mock_client.initialize_and_login_mt5.call_count == 2
|
||||
assert mock_client.shutdown.call_count == 2
|
||||
|
||||
def test_builds_config_from_account_and_base(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test account fields override base_config, empty login falls back."""
|
||||
configs: list[object] = []
|
||||
|
||||
def _record_config(*, config: object) -> MagicMock:
|
||||
configs.append(config)
|
||||
return mock_client
|
||||
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", side_effect=_record_config)
|
||||
base = build_config(login=999, server="Base-Server", timeout=5000)
|
||||
accounts = [
|
||||
AccountSpec(symbols=["EURUSD"], login="", server="Acct-Server"),
|
||||
]
|
||||
|
||||
collect_latest_rates_for_accounts(accounts, ["M1"], count=1, base_config=base)
|
||||
|
||||
assert len(configs) == 1
|
||||
config = cast("Mt5Config", configs[0])
|
||||
assert config.login == 999
|
||||
assert config.server == "Acct-Server"
|
||||
assert config.timeout == 5000
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("accounts", "timeframes", "count", "match"),
|
||||
[
|
||||
([], ["M1"], 1, "At least one account"),
|
||||
([AccountSpec(symbols=["EURUSD"])], [], 1, "At least one timeframe"),
|
||||
(
|
||||
[AccountSpec(symbols=[])],
|
||||
["M1"],
|
||||
1,
|
||||
"Each account requires at least one symbol",
|
||||
),
|
||||
(
|
||||
[AccountSpec(symbols=["EURUSD"])],
|
||||
["M1"],
|
||||
0,
|
||||
"count must be positive",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_inputs(
|
||||
self,
|
||||
accounts: list[AccountSpec],
|
||||
timeframes: list[str],
|
||||
count: int,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test input validation for account-level rate collection."""
|
||||
with pytest.raises(ValueError, match=match):
|
||||
collect_latest_rates_for_accounts(accounts, timeframes, count)
|
||||
|
||||
def test_rejects_empty_symbols_before_connecting(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test all account symbols are validated before any MT5 connection."""
|
||||
mt5_data_client = mocker.patch("mt5cli.sdk.Mt5DataClient")
|
||||
accounts = [
|
||||
AccountSpec(symbols=["EURUSD"], login=123),
|
||||
AccountSpec(symbols=[], login=456),
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Each account requires at least one symbol"
|
||||
):
|
||||
collect_latest_rates_for_accounts(accounts, ["M1"], count=1)
|
||||
|
||||
mt5_data_client.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user